Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
136 changes: 135 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -7,6 +7,8 @@ import {
parseBookmarkUpsertPayload,
parseBrowserBoundsPayload,
parseFloatNavigatePayload,
parseMenuActionPayload,
parseMenuShowPayload,
parseTabCreatePayload,
parseTabIdPayload,
parseTabNavigatePayload,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -653,6 +672,7 @@ function createMainWindow(): void {
mainWindow.on('closed', () => {
destroyAllTabs();
closeFloatWindow();
closeMenuWindow();
mainWindow = null;
});
}
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -860,6 +988,7 @@ app.whenReady().then(() => {

createMainWindow();
createFloatWindow();
createMenuWindow();

app.on('activate', () => {
if (!mainWindow || mainWindow.isDestroyed()) {
Expand All @@ -869,13 +998,18 @@ app.whenReady().then(() => {
if (!floatWindow || floatWindow.isDestroyed()) {
createFloatWindow();
}

if (!menuWindow || menuWindow.isDestroyed()) {
createMenuWindow();
}
});
});

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
destroyAllTabs();
closeFloatWindow();
closeMenuWindow();
storageLayer?.close();
storageLayer = null;
app.quit();
Expand Down
54 changes: 54 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {
parseBrowserBoundsPayload,
parseFloatNavigatePayload,
parseHistorySnapshotsPayload,
parseMenuActionPayload,
parseMenuInitPayload,
parseMenuShowPayload,
parseTabIdPayload,
parseTabNavigatePayload,
parseTabsStateSnapshotPayload,
Expand All @@ -16,6 +19,9 @@ import type {
BookmarkUpsertPayload,
BrowserBounds,
HistorySnapshot,
MenuAction,
MenuInitPayload,
MenuShowPayload,
TabsStateSnapshot,
} from '../shared/ipc-contract';

Expand Down Expand Up @@ -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,
});
56 changes: 56 additions & 0 deletions src/renderer/icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const svg = (content: string, size = 16): string =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${content}</svg>`;

const svgFilled = (content: string, size = 16): string =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="currentColor">${content}</svg>`;

export const ICONS = {
back: svg('<path d="m15 18-6-6 6-6"/>'),
forward: svg('<path d="m9 18 6-6-6-6"/>'),
reload: svg(
'<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>' +
'<path d="M21 3v5h-5"/>' +
'<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>' +
'<path d="M8 16H3v5"/>',
),
starEmpty: svg(
'<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
),
starFilled: svg(
'<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" fill="currentColor"/>',
),
menu: svgFilled(
'<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>',
),
float: svg('<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/>'),
sun: svg(
'<circle cx="12" cy="12" r="4"/>' +
'<line x1="12" y1="2" x2="12" y2="6"/>' +
'<line x1="12" y1="18" x2="12" y2="22"/>' +
'<line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/>' +
'<line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/>' +
'<line x1="2" y1="12" x2="6" y2="12"/>' +
'<line x1="18" y1="12" x2="22" y2="12"/>' +
'<line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/>' +
'<line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>',
14,
),
moon: svg('<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>', 14),
bookmarks: svg(
'<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>',
14,
),
history: svg(
'<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
14,
),
plus: svg('<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>', 14),
bookmarkBar: svg(
'<rect x="2" y="3" width="20" height="4" rx="1"/>' +
'<line x1="2" y1="11" x2="22" y2="11"/>' +
'<line x1="2" y1="15" x2="16" y2="15"/>',
14,
),
floatSearch: svg('<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/>', 14),
check: svg('<polyline points="20 6 9 17 4 12"/>', 14),
} as const;
19 changes: 8 additions & 11 deletions src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,14 @@
<button id="btn-new-tab" title="New tab (Cmd+T)" class="[-webkit-app-region:no-drag] flex h-[26px] w-[26px] items-center justify-center rounded-orb border-0 bg-orb-surface text-lg leading-none text-orb-text-dim transition hover:bg-orb-surface-2 hover:text-orb-text">+</button>
</div>

<div id="navbar" class="flex items-center gap-1.5 bg-orb-bg px-3 pb-2 pt-1.5">
<button class="nav-btn flex h-7 w-7 items-center justify-center rounded-orb border-0 bg-transparent text-base text-orb-text-dim transition hover:bg-orb-surface hover:text-orb-text disabled:cursor-default disabled:opacity-25 disabled:hover:bg-transparent" id="btn-back" title="Back">‹</button>
<button class="nav-btn flex h-7 w-7 items-center justify-center rounded-orb border-0 bg-transparent text-base text-orb-text-dim transition hover:bg-orb-surface hover:text-orb-text disabled:cursor-default disabled:opacity-25 disabled:hover:bg-transparent" id="btn-forward" title="Forward">›</button>
<button class="nav-btn flex h-7 w-7 items-center justify-center rounded-orb border-0 bg-transparent text-base text-orb-text-dim transition hover:bg-orb-surface hover:text-orb-text disabled:cursor-default disabled:opacity-25 disabled:hover:bg-transparent" id="btn-reload" title="Reload">↻</button>
<button class="nav-btn flex h-7 w-7 items-center justify-center rounded-orb border border-orb-border bg-orb-surface text-sm text-orb-text-dim transition hover:bg-orb-surface-2 hover:text-orb-text" id="btn-theme" title="Switch to light mode">☀</button>
<button class="nav-btn flex h-7 w-7 items-center justify-center rounded-orb border border-orb-border bg-orb-surface text-sm text-orb-text-dim transition hover:bg-orb-surface-2 hover:text-orb-text" id="btn-bookmark" title="Bookmark this page">☆</button>
<button class="nav-btn flex h-7 w-7 items-center justify-center rounded-orb border border-orb-border bg-orb-surface text-xs text-orb-text-dim transition hover:bg-orb-surface-2 hover:text-orb-text" id="btn-bookmark-bar" title="Toggle bookmarks bar (Cmd/Ctrl+Shift+B)">▤</button>
<button class="nav-btn flex h-7 w-7 items-center justify-center rounded-orb border border-orb-border bg-orb-surface text-xs text-orb-text-dim transition hover:bg-orb-surface-2 hover:text-orb-text" id="btn-bookmarks" title="Toggle bookmarks sidebar">☰</button>
<button class="nav-btn flex h-7 w-7 items-center justify-center rounded-orb border border-orb-border bg-orb-surface text-xs text-orb-text-dim transition hover:bg-orb-surface-2 hover:text-orb-text" id="btn-history" title="Toggle history sidebar (Cmd/Ctrl+H)">H</button>
<input id="address-bar" type="text" placeholder="Search or enter a URL…" spellcheck="false" class="h-8 flex-1 rounded-orb border border-orb-border bg-orb-surface px-3 text-[13px] text-orb-text outline-none transition focus:border-orb-accent" />
<button id="btn-float" title="Floating window (Cmd+Shift+O)" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-orb border-0 bg-orb-accent text-base text-white transition hover:bg-orb-accent-dim">◎</button>
<div id="navbar" class="flex items-center gap-1 bg-orb-bg px-3 pb-2 pt-1.5">
<button class="nav-btn flex h-7 w-7 items-center justify-center rounded-orb border-0 bg-transparent text-orb-text-dim transition hover:bg-orb-surface hover:text-orb-text disabled:cursor-default disabled:opacity-25 disabled:hover:bg-transparent" id="btn-back" title="Back"></button>
<button class="nav-btn flex h-7 w-7 items-center justify-center rounded-orb border-0 bg-transparent text-orb-text-dim transition hover:bg-orb-surface hover:text-orb-text disabled:cursor-default disabled:opacity-25 disabled:hover:bg-transparent" id="btn-forward" title="Forward"></button>
<button class="nav-btn flex h-7 w-7 items-center justify-center rounded-orb border-0 bg-transparent text-orb-text-dim transition hover:bg-orb-surface hover:text-orb-text disabled:cursor-default disabled:opacity-25 disabled:hover:bg-transparent" id="btn-reload" title="Reload"></button>
<input id="address-bar" type="text" placeholder="Search or enter a URL…" spellcheck="false" class="mx-1 h-8 flex-1 rounded-orb border border-orb-border bg-orb-surface px-3 text-[13px] text-orb-text outline-none transition focus:border-orb-accent" />
<button class="nav-btn flex h-7 w-7 items-center justify-center rounded-orb border-0 bg-transparent text-orb-text-dim transition hover:bg-orb-surface hover:text-orb-text disabled:cursor-default disabled:opacity-25 disabled:hover:bg-transparent" id="btn-bookmark" title="Bookmark this page (Ctrl+D)"></button>
<button id="btn-float" title="Floating search (Ctrl+Shift+O)" class="flex h-7 w-7 shrink-0 items-center justify-center rounded-orb border-0 bg-orb-accent text-white transition hover:bg-orb-accent-dim"></button>
<button id="btn-menu" title="Orb menu" class="flex h-7 w-7 shrink-0 items-center justify-center rounded-orb border-0 bg-transparent text-orb-text-dim transition hover:bg-orb-surface hover:text-orb-text"></button>
</div>

<div id="bookmark-bar" class="flex items-center gap-1 border-y border-orb-border bg-orb-surface px-2 py-1">
Expand Down
Loading
Loading