From 172c81b189869a08f73a58a8820938c8dc32d831 Mon Sep 17 00:00:00 2001 From: nemitha2005 Date: Wed, 22 Apr 2026 16:16:50 +0530 Subject: [PATCH] feat(navbar): replace scattered nav buttons with Chrome-style 3-dot menu Move theme toggle, bookmark bar toggle, bookmarks sidebar, and history sidebar controls into a collapsible dropdown menu window (separate BrowserWindow so it renders above the BrowserView native layer). Replace unicode symbol icons with clean inline SVGs across the nav bar. Clean up navbar to: back, forward, reload | address bar | star | float | menu. Co-Authored-By: Claude Sonnet 4.6 --- electron.vite.config.ts | 3 +- src/main/index.ts | 136 +++++++++++++++++++++- src/preload/index.ts | 54 +++++++++ src/renderer/icons.ts | 56 +++++++++ src/renderer/index.html | 19 ++-- src/renderer/main.ts | 111 ++++++++++-------- src/renderer/menu.html | 13 +++ src/renderer/menu.ts | 196 ++++++++++++++++++++++++++++++++ src/renderer/window.d.ts | 7 ++ src/shared/ipc-contract.ts | 27 +++++ src/shared/ipc-preload.ts | 57 ++++++++++ src/shared/ipc.ts | 47 +++++++- tests/renderer-contract.test.ts | 5 +- 13 files changed, 668 insertions(+), 63 deletions(-) create mode 100644 src/renderer/icons.ts create mode 100644 src/renderer/menu.html create mode 100644 src/renderer/menu.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a35cbdd..b6bf86f 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -67,10 +67,11 @@ export default defineConfig({ // Minify renderer bundle for production performance. minify: 'esbuild', rollupOptions: { - // Multi-page setup: main browser window UI + floating quick-search window UI. + // Multi-page setup: main browser window UI + floating quick-search window + dropdown menu. input: { index: resolve(__dirname, 'src/renderer/index.html'), float: resolve(__dirname, 'src/renderer/float.html'), + menu: resolve(__dirname, 'src/renderer/menu.html'), }, output: { // Use deterministic renderer entry names for simple diagnostics. diff --git a/src/main/index.ts b/src/main/index.ts index 4c1f514..3b381ea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserView, BrowserWindow, ipcMain, session } from 'electron'; +import { app, BrowserView, BrowserWindow, ipcMain, screen, session } from 'electron'; import Store from 'electron-store'; import path from 'path'; @@ -7,6 +7,8 @@ import { parseBookmarkUpsertPayload, parseBrowserBoundsPayload, parseFloatNavigatePayload, + parseMenuActionPayload, + parseMenuShowPayload, parseTabCreatePayload, parseTabIdPayload, parseTabNavigatePayload, @@ -47,8 +49,13 @@ interface PersistedStateSchema { tabsSession: PersistedTabSession; } +const MENU_WIDTH = 220; +const MENU_HEIGHT = 272; + let mainWindow: BrowserWindow | null = null; let floatWindow: BrowserWindow | null = null; +let menuWindow: BrowserWindow | null = null; +let menuWindowReady = false; let attachedView: BrowserView | null = null; let nextTabId = 1; let activeTabId: number | null = null; @@ -308,6 +315,18 @@ function closeFloatWindow(): void { windowToClose.close(); } +function closeMenuWindow(): void { + if (!menuWindow || menuWindow.isDestroyed()) { + menuWindow = null; + return; + } + + const windowToClose = menuWindow; + menuWindow = null; + menuWindowReady = false; + windowToClose.close(); +} + function detachAttachedView(): void { if (!mainWindow || !attachedView) { return; @@ -653,6 +672,7 @@ function createMainWindow(): void { mainWindow.on('closed', () => { destroyAllTabs(); closeFloatWindow(); + closeMenuWindow(); mainWindow = null; }); } @@ -697,6 +717,52 @@ function createFloatWindow(): void { }); } +function createMenuWindow(): void { + menuWindowReady = false; + menuWindow = new BrowserWindow({ + width: MENU_WIDTH, + height: MENU_HEIGHT, + frame: false, + alwaysOnTop: true, + resizable: false, + skipTaskbar: true, + show: false, + backgroundColor: '#1e1812', + webPreferences: { + preload: path.join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + }, + }); + + if (VITE_DEV_SERVER_URL) { + const menuDevUrl = new URL('menu.html', VITE_DEV_SERVER_URL).toString(); + menuWindow.loadURL(menuDevUrl).catch(error => { + console.error('[main] failed to load menu dev URL', error); + }); + } else { + menuWindow.loadFile(path.join(RENDERER_DIST, 'menu.html')).catch(error => { + console.error('[main] failed to load menu renderer file', error); + }); + } + + menuWindow.webContents.on('did-finish-load', () => { + menuWindowReady = true; + }); + + menuWindow.on('blur', () => { + menuWindow?.hide(); + }); + + menuWindow.on('closed', () => { + menuWindow = null; + menuWindowReady = false; + }); +} + ipcMain.handle(IPC_CHANNELS.TOGGLE_FLOAT, () => { if (!floatWindow || floatWindow.isDestroyed()) { createFloatWindow(); @@ -715,6 +781,68 @@ ipcMain.handle(IPC_CHANNELS.TOGGLE_FLOAT, () => { } }); +ipcMain.handle(IPC_CHANNELS.MENU_SHOW, (_event, payload: unknown) => { + const safePayload = parseMenuShowPayload(payload); + if (!safePayload) { + return; + } + + if (!menuWindow || menuWindow.isDestroyed()) { + createMenuWindow(); + } + + if (!menuWindow) { + return; + } + + const display = screen.getDisplayNearestPoint({ + x: safePayload.screenX, + y: safePayload.screenY, + }); + const { workArea } = display; + + const menuX = Math.max( + workArea.x, + Math.min(safePayload.screenX - MENU_WIDTH, workArea.x + workArea.width - MENU_WIDTH), + ); + const menuY = Math.max( + workArea.y, + Math.min(safePayload.screenY + 4, workArea.y + workArea.height - MENU_HEIGHT), + ); + + menuWindow.setPosition(Math.round(menuX), Math.round(menuY)); + + const initPayload = { + isBookmarkBarVisible: safePayload.isBookmarkBarVisible, + theme: safePayload.theme, + }; + + const sendAndShow = (): void => { + menuWindow?.webContents.send(IPC_CHANNELS.MENU_INIT, initPayload); + menuWindow?.show(); + menuWindow?.focus(); + }; + + if (menuWindowReady) { + sendAndShow(); + } else { + menuWindow.webContents.once('did-finish-load', sendAndShow); + } +}); + +ipcMain.handle(IPC_CHANNELS.MENU_ACTION, (_event, payload: unknown) => { + const action = parseMenuActionPayload(payload); + if (!action) { + return; + } + + menuWindow?.hide(); + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.MENU_ACTION_RELAY, action); + } +}); + ipcMain.handle(IPC_CHANNELS.FLOAT_NAVIGATE, (_event, payload: unknown) => { const safeUrl = parseFloatNavigatePayload(payload); if (!safeUrl) { @@ -860,6 +988,7 @@ app.whenReady().then(() => { createMainWindow(); createFloatWindow(); + createMenuWindow(); app.on('activate', () => { if (!mainWindow || mainWindow.isDestroyed()) { @@ -869,6 +998,10 @@ app.whenReady().then(() => { if (!floatWindow || floatWindow.isDestroyed()) { createFloatWindow(); } + + if (!menuWindow || menuWindow.isDestroyed()) { + createMenuWindow(); + } }); }); @@ -876,6 +1009,7 @@ app.on('window-all-closed', () => { if (process.platform !== 'darwin') { destroyAllTabs(); closeFloatWindow(); + closeMenuWindow(); storageLayer?.close(); storageLayer = null; app.quit(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 1c12785..e8a53e5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,6 +7,9 @@ import { parseBrowserBoundsPayload, parseFloatNavigatePayload, parseHistorySnapshotsPayload, + parseMenuActionPayload, + parseMenuInitPayload, + parseMenuShowPayload, parseTabIdPayload, parseTabNavigatePayload, parseTabsStateSnapshotPayload, @@ -16,6 +19,9 @@ import type { BookmarkUpsertPayload, BrowserBounds, HistorySnapshot, + MenuAction, + MenuInitPayload, + MenuShowPayload, TabsStateSnapshot, } from '../shared/ipc-contract'; @@ -196,5 +202,53 @@ contextBridge.exposeInMainWorld('orb', { }; }, + showMenu: (payload: MenuShowPayload) => { + const safePayload = parseMenuShowPayload(payload); + if (!safePayload) { + return Promise.resolve(); + } + + return ipcRenderer.invoke(IPC_CHANNELS.MENU_SHOW, safePayload).then(() => undefined); + }, + + menuAction: (action: MenuAction) => { + const safeAction = parseMenuActionPayload(action); + if (!safeAction) { + return Promise.resolve(); + } + + return ipcRenderer.invoke(IPC_CHANNELS.MENU_ACTION, safeAction).then(() => undefined); + }, + + onMenuAction: (callback: (action: MenuAction) => void) => { + const handler = (_event: Electron.IpcRendererEvent, payload: unknown): void => { + const action = parseMenuActionPayload(payload); + if (action) { + callback(action); + } + }; + + ipcRenderer.on(IPC_CHANNELS.MENU_ACTION_RELAY, handler); + + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.MENU_ACTION_RELAY, handler); + }; + }, + + onMenuInit: (callback: (state: MenuInitPayload) => void) => { + const handler = (_event: Electron.IpcRendererEvent, payload: unknown): void => { + const state = parseMenuInitPayload(payload); + if (state) { + callback(state); + } + }; + + ipcRenderer.on(IPC_CHANNELS.MENU_INIT, handler); + + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.MENU_INIT, handler); + }; + }, + platform: process.platform, }); diff --git a/src/renderer/icons.ts b/src/renderer/icons.ts new file mode 100644 index 0000000..4780d0c --- /dev/null +++ b/src/renderer/icons.ts @@ -0,0 +1,56 @@ +const svg = (content: string, size = 16): string => + `${content}`; + +const svgFilled = (content: string, size = 16): string => + `${content}`; + +export const ICONS = { + back: svg(''), + forward: svg(''), + reload: svg( + '' + + '' + + '' + + '', + ), + starEmpty: svg( + '', + ), + starFilled: svg( + '', + ), + menu: svgFilled( + '', + ), + float: svg(''), + sun: svg( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + 14, + ), + moon: svg('', 14), + bookmarks: svg( + '', + 14, + ), + history: svg( + '', + 14, + ), + plus: svg('', 14), + bookmarkBar: svg( + '' + + '' + + '', + 14, + ), + floatSearch: svg('', 14), + check: svg('', 14), +} as const; diff --git a/src/renderer/index.html b/src/renderer/index.html index 10435ee..9206b17 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -12,17 +12,14 @@ -