diff --git a/main.js b/main.js index 6e6c78b2..63edaebc 100644 --- a/main.js +++ b/main.js @@ -503,9 +503,12 @@ const SETTINGS_DEFAULTS = { // service shortcuts; click opens the URL in a new tab. Toggle via settings. quickLinksShow: true, quickLinks: [ - { id: "x", url: "https://x.com/", title: "X" }, - { id: "telegram", url: "https://web.telegram.org/", title: "Telegram" }, - { id: "whatsapp", url: "https://web.whatsapp.com/", title: "WhatsApp" }, + { id: "messenger", url: "https://www.messenger.com/", title: "Messenger" }, + { id: "whatsapp", url: "https://web.whatsapp.com/", title: "WhatsApp" }, + { id: "telegram", url: "https://web.telegram.org/k/", title: "Telegram" }, + { id: "x", url: "https://x.com/", title: "X" }, + { id: "youtube", url: "https://www.youtube.com/", title: "YouTube" }, + { id: "spotify", url: "https://open.spotify.com/", title: "Spotify" }, ], // Toolbar bar sizes. The URL bar is flex:1 by default so it eats all // remaining space; `compact`/`medium` cap it so the extension dock has @@ -1920,8 +1923,11 @@ let linkStatusW = 100, linkStatusH = 22; // nothing until the user actively opens it, so the perf cost of an unused // add-on is nil. let sidebar, sidebarVisible = false, sidebarActivePanelId = null; -let quicklinks; // left-edge quick-links strip (Opera-style) -const QUICKLINKS_W = 44; // fixed width in px — just big enough for 32-icon tiles +let quicklinks; // left-edge quick-links strip (Opera-style) +const QUICKLINKS_W = 44; // fixed width in px — just big enough for 32-icon tiles +let quickPanel; // the mini dedicated view for the active quick-link +const QUICK_PANEL_W = 380; // Opera's sidebar panel sits around this width +let activeQuickLinkId = null; // id of the quick-link whose panel is open, or null // Sidebar width is user-adjustable via a drag grip on the panel's left edge. // The value below is the default; settings.sidebarWidth overrides it once // loadSettings() runs and persists any drag adjustment made by the user. @@ -2736,16 +2742,24 @@ function layout() { quicklinks.setBounds({ x: 0, y: chromeH, width: leftW, height: bodyH }); try { quicklinks.setVisible(leftW > 0); } catch {} } + // Quick-link panel: a narrow mini-view just right of the strip, showing the + // active quick-link's web app. Only takes space when something is open. + const panelW = (quickPanel && activeQuickLinkId && !fsTab) ? QUICK_PANEL_W : 0; + if (quickPanel) { + quickPanel.setBounds({ x: leftW, y: chromeH, width: panelW, height: bodyH }); + try { quickPanel.setVisible(panelW > 0); } catch {} + } // Sidebar (when visible) claims a fixed slice on the right; the tab views // shrink to fit alongside it. When hidden, tabs get the full width. const sideW = sidebarVisible && !fsTab ? sidebarW : 0; - const tabW = Math.max(0, width - leftW - sideW); - for (const t of tabs) t.view.setBounds({ x: leftW, y: chromeH, width: tabW, height: bodyH }); - if (sidebar) sidebar.setBounds({ x: leftW + tabW, y: chromeH, width: sideW, height: bodyH }); + const tabX = leftW + panelW; + const tabW = Math.max(0, width - tabX - sideW); + for (const t of tabs) t.view.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); + if (sidebar) sidebar.setBounds({ x: tabX + tabW, y: chromeH, width: sideW, height: bodyH }); // Approval overlay sits exactly over the tab area — the page underneath // keeps running; only pointer input is intercepted. - if (approvalPop) approvalPop.setBounds({ x: leftW, y: chromeH, width: tabW, height: bodyH }); - if (jsDialogPop) jsDialogPop.setBounds({ x: leftW, y: chromeH, width: tabW, height: bodyH }); + if (approvalPop) approvalPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); + if (jsDialogPop) jsDialogPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); positionPopover(); positionEnginePicker(); positionDownloads(); @@ -3788,12 +3802,23 @@ function createWindow() { sidebar.setVisible(false); // Opera-style quick-links strip on the left edge: a thin vertical column // with icons for a few web apps (X, Telegram, WhatsApp by default). Clicking - // one opens its URL in a new tab (or focuses an existing tab for that URL). + // one opens that service in a dedicated mini-tab (the quickPanel next door) + // rather than a full-sized tab. quicklinks = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "quicklinks-preload.js") } }); try { quicklinks.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0e131c" : "#eceff3"); } catch {} win.contentView.addChildView(quicklinks); styleScrollbars(quicklinks.webContents); quicklinks.webContents.loadFile("quicklinks.html"); + // Dedicated mini-view that renders the currently-active quick-link in its + // own narrow column, Opera-style. Lives permanently in the window; shown / + // hidden via activeQuickLinkId. Separate browsing context from any tab, + // so a Facebook sidebar visit doesn't share cookies with a Facebook tab + // the user opened — matching how Opera's sidebar panels feel. + quickPanel = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "home-preload.js") } }); + try { quickPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {} + win.contentView.addChildView(quickPanel); + styleScrollbars(quickPanel.webContents); + quickPanel.setVisible(false); // Add-on approval overlay (approval-modal capability). Transparent view // over the tab area, loaded once, shown per request. approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } }); @@ -6282,11 +6307,15 @@ ipcMain.handle("settings-set", (_e, key, val) => { const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null; try { wc && wc.reload(); } catch {} } - // Quick-links strip: re-layout + push the new list to the strip's view so - // show/hide and link edits take effect without a relaunch. + // Quick-links strip: re-layout + push the new state to the strip's view so + // show/hide and link edits take effect without a relaunch. If the active + // panel's link was removed, close the panel. if (key === "quickLinksShow" || key === "quickLinks") { + if (activeQuickLinkId && !(settings.quickLinks || []).some((L) => L.id === activeQuickLinkId)) { + activeQuickLinkId = null; + } layout(); - try { quicklinks?.webContents.send("quicklinks-update", settings.quickLinks || []); } catch {} + emitQuickLinksState(); } // Broadcast to every renderer that watches the live settings — the chrome // (toolbar sizes, URL-bar language chip) and every open settings tab (so @@ -6295,29 +6324,43 @@ ipcMain.handle("settings-set", (_e, key, val) => { broadcastSettings(); return settings; }); -// Quick-links IPC: read the saved list, open one in a new tab (focus an -// existing tab for that host if present), or prompt for a URL to add. -ipcMain.handle("quicklinks-get", () => settings.quickLinks || []); -ipcMain.handle("quicklinks-open", (_e, id) => { +// Quick-links IPC. getState: list + whichever one is currently open. open: a +// toggle — click to open the panel on that link, click the active one again +// to close it, click a different one to switch. The strip subscribes via +// onState so it can highlight the active icon. +function emitQuickLinksState() { + const payload = { links: settings.quickLinks || [], openId: activeQuickLinkId }; + try { quicklinks?.webContents.send("quicklinks-state", payload); } catch {} +} +function openQuickPanel(id) { const L = (settings.quickLinks || []).find((x) => x.id === id); - if (!L || !L.url) return false; + if (!quickPanel || !L || !L.url) return; + activeQuickLinkId = id; try { - // Focus an existing tab whose current URL is on the same host — avoids - // stacking a new tab every click. - const u = new URL(L.url); - const existing = tabs.find((t) => { try { return t.url && new URL(t.url).host === u.host; } catch { return false; } }); - if (existing) { setActive(existing.id); return true; } - } catch {} - createTab(L.url); - return true; -}); -ipcMain.handle("quicklinks-add", async () => { - // Minimal add flow for now: prompt via a native input dialog isn't ideal - // in Electron, so route the user to Settings where they can edit the list - // directly. (A proper picker UI can land later.) - openSettingsTab("general"); + const cur = quickPanel.webContents.getURL(); + // Only re-navigate when the panel isn't already showing this origin — + // avoids blowing away the user's state (open chat, scroll position) + // when they click the icon to reopen the panel they just closed. + const sameHost = (() => { try { return cur && new URL(cur).host === new URL(L.url).host; } catch { return false; } })(); + if (!sameHost) quickPanel.webContents.loadURL(L.url); + } catch (e) { console.warn("[quicklinks] loadURL failed:", e?.message); } + layout(); + emitQuickLinksState(); +} +function closeQuickPanel() { + activeQuickLinkId = null; + layout(); + emitQuickLinksState(); +} +ipcMain.handle("quicklinks-get-state", () => ({ links: settings.quickLinks || [], openId: activeQuickLinkId })); +ipcMain.handle("quicklinks-open", (_e, id) => { + if (!id) return false; + if (id === activeQuickLinkId) { closeQuickPanel(); return true; } + openQuickPanel(String(id)); return true; }); +ipcMain.handle("quicklinks-close", () => { closeQuickPanel(); return true; }); +ipcMain.handle("quicklinks-add", () => { openSettingsTab("general"); return true; }); function broadcastSettings() { try { chrome?.webContents.send("settings-update", settings); } catch {} for (const t of tabs) { diff --git a/package.json b/package.json index f927deb2..407ed3ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.67", + "version": "0.3.68", "license": "MPL-2.0", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode", diff --git a/quicklinks-preload.js b/quicklinks-preload.js index a0ccea8a..0033f6cc 100644 --- a/quicklinks-preload.js +++ b/quicklinks-preload.js @@ -1,7 +1,8 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("quicklinks", { - getLinks: () => ipcRenderer.invoke("quicklinks-get"), + getState: () => ipcRenderer.invoke("quicklinks-get-state"), open: (id) => ipcRenderer.invoke("quicklinks-open", String(id || "")), + close: () => ipcRenderer.invoke("quicklinks-close"), add: () => ipcRenderer.invoke("quicklinks-add"), - onLinks: (cb) => ipcRenderer.on("quicklinks-update", (_e, d) => cb(d)), + onState: (cb) => ipcRenderer.on("quicklinks-state", (_e, d) => cb(d)), }); diff --git a/quicklinks.html b/quicklinks.html index 81b81700..2eb21b9e 100644 --- a/quicklinks.html +++ b/quicklinks.html @@ -5,78 +5,90 @@ :root { color-scheme: light dark; --bg:#0e131c; --ink:#e7eaf1; --mut:#8b98a9; --line2:rgba(255,255,255,.13); - --acid:#d6ff3d; + --acid:#d6ff3d; --acid-tint: rgb(from var(--acid) r g b / .14); } @media (prefers-color-scheme: light) { - :root { --bg:#eceff3; --ink:#1a1f28; --mut:#5c6676; --line2:rgba(0,0,0,.10); --acid:#0AC18E; } + :root { --bg:#eceff3; --ink:#1a1f28; --mut:#5c6676; --line2:rgba(0,0,0,.10); --acid:#0AC18E; --acid-tint: rgb(from var(--acid) r g b / .14); } } html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow: hidden; user-select: none; } + /* Icons sit in a vertically-centered column: the two spacers split the + remaining space above and below so the stack reads as "quick access" + centered in the strip, Opera-style. */ .strip { display: flex; flex-direction: column; align-items: center; - padding: 10px 0; gap: 4px; height: 100%; box-sizing: border-box; + padding: 6px 0; height: 100%; box-sizing: border-box; border-right: 1px solid var(--line2); } - .icon { width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center; - cursor: pointer; background: transparent; border: none; padding: 0; color: var(--mut); + .icons { display: flex; flex-direction: column; align-items: center; gap: 4px; + flex: none; } + .spacer { flex: 1 1 auto; min-height: 10px; } + .icon { width: 34px; height: 34px; border-radius: 8px; display: grid; place-items: center; + cursor: pointer; background: transparent; border: none; padding: 0; color: var(--ink); position: relative; } - .icon:hover { background: var(--line2); color: var(--ink); } + .icon:hover { background: var(--line2); } .icon:active { transform: scale(0.94); } - .icon svg { width: 20px; height: 20px; display: block; } - .icon img { width: 22px; height: 22px; display: block; border-radius: 4px; } + .icon.on { background: var(--acid-tint); box-shadow: inset 2px 0 0 var(--acid); } + .icon svg, .icon img { width: 20px; height: 20px; display: block; } + .icon.letter { font: 600 13px/1 system-ui, sans-serif; color: var(--mut); } .icon .tt { position: absolute; left: 44px; top: 50%; transform: translateY(-50%); background: var(--bg); color: var(--ink); padding: 4px 8px; border-radius: 6px; border: 1px solid var(--line2); white-space: nowrap; font-size: 12px; pointer-events: none; opacity: 0; transition: opacity .1s; z-index: 10; } .icon:hover .tt { opacity: 1; transition-delay: .4s; } - .sep { width: 24px; height: 1px; background: var(--line2); margin: 6px 0; flex: none; } - .spacer { flex: 1 1 auto; } - /* Add button — small plus to add a custom link. Prompts for URL via main. */ .add { color: var(--mut); font: 500 20px/1 system-ui, sans-serif; } .add:hover { color: var(--acid); }
- +