diff --git a/main.js b/main.js index 22957c31..a71487b3 100644 --- a/main.js +++ b/main.js @@ -594,7 +594,11 @@ const SETTINGS_DEFAULTS = { // "p:"), extension panels the user took off the strip, and when // the open panel steps aside (Opera's rules, each one switchable). leftPanelWidths: {}, - leftPanelPinned: {}, + leftPanelPinned: {}, // retired with the pin button (0.3.86); kept so old files load cleanly + // How an open sidebar panel sits next to the page: "overlay" floats over it + // and a click in the page closes the panel; "push" moves the page aside and + // the panel stays until it is closed. + sidebarPanelMode: "overlay", leftPanelsHidden: [], sidebarHideOnNewTab: true, sidebarHideOnRightPanel: true, @@ -2528,7 +2532,7 @@ function winOfWebContents(wc) { const t = tabs.find((x) => x.view.webContents === wc); if (t) return t.w || null; for (const [view, w] of hostOf) if (view.webContents === wc) return windows.includes(w) ? w : null; - for (const v of [sidebar, quicklinks, quickPanel, leftPanel, panelGrip]) if (v && v.webContents === wc) return primaryW; + for (const v of [sidebar, quicklinks, quickPanel, leftPanel, panelGrip, panelHeader]) if (v && v.webContents === wc) return primaryW; return null; } // Shared overlays (site info, pickers, downloads, password fill, link pill, @@ -2620,6 +2624,8 @@ const LEFT_PANEL_W = 400; const LEFT_PANEL_W_MIN = 280; const TAB_AREA_MIN = 320; // the panel never squeezes the page below this let panelGrip; // panel-grip.html, laid over the panel's right edge +let panelHeader; // panel-header.html: name, Maximize/Restore, Close over the open panel +const PANEL_HEADER_H = 30; let panelGripDragging = false; const PANEL_GRIP_W = 6; // Sidebar width is user-adjustable via a drag grip on the panel's left edge. @@ -3697,6 +3703,11 @@ function layout() { } function layoutHere() { if (!winAlive()) return; + // A minimized window reports a zero-size content area: laying out then left + // every view at 0x0, and nothing laid it out again on restore, so the + // window came back blank until it was resized. Keep the last layout; the + // "restore" event redoes it. + try { if (win.isMinimized()) return; } catch {} const primary = !curW || curW === primaryW; if (fsTabId == null && !userFullscreen) { try { if (win.isFullScreen()) scheduleFullscreenCheck(); } catch {} } const { width, height } = win.getContentBounds(); @@ -3720,32 +3731,43 @@ function layoutHere() { // 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 quickW = (primary && quickPanel && activeQuickLinkId && !fsTab) ? leftPanelWidth(QUICK_PANEL_W, width - leftW) : 0; - if (quickPanel && primary) { - quickPanel.setBounds({ x: leftW, y: chromeH, width: quickW, height: bodyH }); - try { quickPanel.setVisible(quickW > 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 = primary && sidebarVisible && !fsTab ? sidebarW : 0; + // An open panel has its header bar on top. Maximized, it covers the tab + // area like a page of its own, up to the right sidebar. + const headH = (quickW || (primary && leftPanel && leftPanelId && !fsTab)) ? PANEL_HEADER_H : 0; + const quickShownW = quickW && leftPanelMax ? Math.max(quickW, width - leftW - sideW) : quickW; + if (quickPanel && primary) { + quickPanel.setBounds({ x: leftW, y: chromeH + headH, width: quickShownW, height: Math.max(0, bodyH - headH) }); + try { quickPanel.setVisible(quickShownW > 0); } catch {} + } // An add-on's left panel. Widened, it covers the tab area like a page of // its own, up to the right sidebar, which keeps its width: the tabs keep // their narrow-panel bounds underneath instead of being squeezed aside. const addonLeftW = (primary && leftPanel && leftPanelId && !fsTab) ? leftPanelWidth(LEFT_PANEL_W, width - leftW) : 0; const addonLeftShownW = addonLeftW && leftPanelMax ? Math.max(addonLeftW, width - leftW - sideW) : addonLeftW; if (leftPanel && primary) { - leftPanel.setBounds({ x: leftW, y: chromeH, width: addonLeftShownW, height: bodyH }); + leftPanel.setBounds({ x: leftW, y: chromeH + headH, width: addonLeftShownW, height: Math.max(0, bodyH - headH) }); try { leftPanel.setVisible(addonLeftShownW > 0); } catch {} } const panelW = quickW || addonLeftW; + if (panelHeader && primary) { + const headW = quickShownW || addonLeftShownW; + panelHeader.setBounds({ x: leftW, y: chromeH, width: headW, height: headH }); + try { panelHeader.setVisible(headH > 0 && headW > 0); } catch {} + } if (panelGrip && primary) { // At rest a thin strip on the boundary; while dragging, the whole body, // so the drag keeps its events wherever the pointer goes. - const showGrip = panelW > 0 && !(leftPanelId && leftPanelMax); + const showGrip = panelW > 0 && !leftPanelMax; if (panelGripDragging) panelGrip.setBounds({ x: 0, y: chromeH, width, height: bodyH }); else panelGrip.setBounds({ x: leftW + panelW - PANEL_GRIP_W / 2, y: chromeH, width: PANEL_GRIP_W, height: bodyH }); try { panelGrip.setVisible(showGrip || panelGripDragging); } catch {} } - const tabX = leftW + panelW; + // "push": the page moves aside for the panel. "overlay": the page keeps its + // width and the panel floats over its left edge. + const tabX = leftW + (settings.sidebarPanelMode === "push" ? panelW : 0); const tabW = Math.max(0, width - tabX - sideW); for (const t of winTabs()) t.view.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); if (sidebar && primary) sidebar.setBounds({ x: tabX + tabW, y: chromeH, width: sideW, height: bodyH }); @@ -4638,6 +4660,8 @@ function createTab(initial, opts = {}) { tabs.splice(at, 0, tab); } win.contentView.addChildView(view); + // A panel floating over the page stays above the new tab. + if (curW === primaryW && leftPanelKey() && settings.sidebarPanelMode !== "push") raisePanelGrip(); wc.on("page-title-updated", (_e, title) => { tab.title = title; emitTabs(); // Keep the top-of-history title in sync when a page's title loads late. @@ -5218,6 +5242,7 @@ function makeBrowserWindow({ bounds } = {}) { bw.on("focus", () => focusWin(w)); bw.on("blur", closePopupsOnWindowBlur); bw.on("resize", layout); + bw.on("restore", layout); bw.on("enter-full-screen", layout); // Fullscreen left from outside (the OS, or Electron on the page's behalf) // while a page still held it: put the toolbar back and tell the page. @@ -5277,7 +5302,7 @@ function onWindowClose(w) { if (w === primaryW) primaryW = heir; for (const [view, host] of [...hostOf]) { if (host !== w) continue; - const sidePanel = [sidebar, quicklinks, quickPanel, leftPanel, panelGrip].includes(view); + const sidePanel = [sidebar, quicklinks, quickPanel, leftPanel, panelGrip, panelHeader].includes(view); if (!sidePanel) try { view.setVisible(false); } catch {} raise(view, heir); } @@ -5434,6 +5459,11 @@ function createWindow() { raise(panelGrip); panelGrip.webContents.loadFile("panel-grip.html"); panelGrip.setVisible(false); + panelHeader = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "panel-header-preload.js") } }); + try { panelHeader.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#121826" : "#e4e8ee"); } catch {} + raise(panelHeader); + deferOverlayLoad(panelHeader, "panel-header.html"); + panelHeader.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") } }); @@ -5480,7 +5510,7 @@ function onLastWindowClosed() { // pumpApproval (and every later dapp request) until a full restart. if (approvalCurrent) { const c = approvalCurrent; approvalCurrent = null; try { c.resolve("cancel"); } catch {} } for (const q of approvalQueue.splice(0)) { try { q.resolve("cancel"); } catch {} } - addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; leftPanel = null; panelGrip = null; panelGripDragging = false; approvalPop = null; unlockPop = null; jsDialogPop = null; + addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; leftPanel = null; panelGrip = null; panelHeader = null; panelGripDragging = false; approvalPop = null; unlockPop = null; jsDialogPop = null; linkStatusVisible = false; } } @@ -6281,10 +6311,11 @@ function setLeftPanelMax(next) { // One wide view at a time: a maximized right sidebar steps back, and the // panel goes on top of tab views created since it opened. if (sidebarMaximized) setSidebarMaximized(false); - raise(leftPanel, primaryW); } + if (leftPanelMax) raisePanelGrip(); layout(); try { leftPanel?.webContents.send("sidebar-max-change", leftPanelMax); } catch {} + emitPanelHeader(); return leftPanelMax; } ipcMain.handle("sidebar-maximize", (e) => isLeftSender(e) ? setLeftPanelMax(true) : setSidebarMaximized(true)); @@ -9000,6 +9031,7 @@ ipcMain.handle("settings-get", () => settings); ipcMain.handle("settings-set", (_e, key, val) => { // The default engine must be one that is enabled and not frozen, whichever path sets it. if (key === "searchEngine" && !enabledEnginesList().some((e) => e.id === val)) return settings; + if (key === "sidebarPanelMode" && val !== "overlay" && val !== "push") return settings; if (key in SETTINGS_DEFAULTS) { settings[key] = val; saveSettings(); } if (key === "webrtcMode") applyWebRTCPolicy(); if (key === "dohMode" || key === "dohProvider" || key === "dohCustom") applyDoh(); @@ -9038,7 +9070,8 @@ ipcMain.handle("settings-set", (_e, key, val) => { // 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" || key === "leftPanelWidths" || key === "leftPanelPinned" || key === "leftPanelsHidden") { + if (key === "sidebarPanelMode") raisePanelGrip(); + if (key === "quickLinksShow" || key === "quickLinks" || key === "leftPanelWidths" || key === "sidebarPanelMode" || key === "leftPanelsHidden") { if (activeQuickLinkId && !(settings.quickLinks || []).some((L) => L.id === activeQuickLinkId)) { activeQuickLinkId = null; } @@ -9063,13 +9096,52 @@ function quickLinksPayload() { return { links: settings.quickLinks || [], openId: leftPanelId ? "p:" + leftPanelId : activeQuickLinkId, - pinned: leftPanelPinned(), panels: leftPanels().filter((p) => !leftPanelHidden(p.panelId)).map((p) => ({ id: "p:" + p.panelId, title: p.addonName || p.title, icon: p.icon || "" })), }; } function emitQuickLinksState() { try { quicklinks?.webContents.send("quicklinks-state", quickLinksPayload()); } catch {} + emitPanelHeader(); } +// ---- the bar over an open panel ------------------------------------------- +function panelHeaderState() { + let title = ""; + if (activeQuickLinkId) { const L = (settings.quickLinks || []).find((x) => x.id === activeQuickLinkId); title = L ? (L.title || L.url || "") : ""; } + else if (leftPanelId) { const p = leftPanels().find((x) => x.panelId === leftPanelId); title = p ? (p.addonName || p.title || "") : ""; } + return { title, max: !!leftPanelMax, open: !!leftPanelKey() }; +} +function emitPanelHeader() { + try { if (panelHeader && overlayLoaded(panelHeader)) panelHeader.webContents.send("panel-header-state", panelHeaderState()); } catch {} +} +const isHeaderSender = (e) => !!panelHeader && e.sender === panelHeader.webContents; +ipcMain.handle("panel-header-state", (e) => isHeaderSender(e) ? panelHeaderState() : null); +ipcMain.handle("panel-header-max", (e) => isHeaderSender(e) && leftPanelKey() ? setLeftPanelMax(!leftPanelMax) : false); +ipcMain.handle("panel-header-close", (e) => { if (isHeaderSender(e)) hideLeftPanels(); return true; }); +// The gear above "+" on the strip: how panels sit next to the page, and the +// Sidebar settings. A native menu, so it can open past the strip's edge. +ipcMain.handle("quicklinks-menu", (e, rect) => { + if (!quicklinks || e.sender !== quicklinks.webContents || !primaryW?.win || primaryW.win.isDestroyed()) return false; + const setMode = (mode) => { + if (settings.sidebarPanelMode === mode) return; + settings.sidebarPanelMode = mode; + saveSettings(); + raisePanelGrip(); + layout(); + broadcastSettings(); + }; + const push = settings.sidebarPanelMode === "push"; + const menu = Menu.buildFromTemplate([ + { label: "Panels open over the page", type: "radio", checked: !push, click: () => setMode("overlay") }, + { label: "Panels push the page aside", type: "radio", checked: push, click: () => setMode("push") }, + { type: "separator" }, + { label: "Reset panel widths", enabled: !!(settings.leftPanelWidths && Object.keys(settings.leftPanelWidths).length), click: () => { settings.leftPanelWidths = {}; saveSettings(); layout(); } }, + { label: "Sidebar settings…", click: () => openSettingsTab("sidebar") }, + ]); + const r = rect || {}; + const y = Math.round(Number(r.y) || 0) + (primaryW.win === win ? CHROME_H : primaryW.chromeH || CHROME_H); + menu.popup({ window: primaryW.win, x: Math.round((Number(r.x) || 0) + (Number(r.width) || 0)), y }); + return true; +}); function leftPanelHidden(panelId) { return Array.isArray(settings.leftPanelsHidden) && settings.leftPanelsHidden.includes("p:" + panelId); } @@ -9089,34 +9161,22 @@ function leftPanelWidth(def, room) { const max = Math.max(LEFT_PANEL_W_MIN, (room || 0) - TAB_AREA_MIN); return Math.max(LEFT_PANEL_W_MIN, Math.min(max, saved || def)); } -// The grip has to stay above whichever panel view was raised last. +// The open panel, its header and the grip go on top of the tab views (over +// the page in "overlay" mode, and above tabs created since it opened). function raisePanelGrip() { - if (!panelGrip) return; - raise(panelGrip, primaryW); + if (activeQuickLinkId && quickPanel) raise(quickPanel, primaryW); + if (leftPanelId && leftPanel) raise(leftPanel, primaryW); + if (panelHeader && leftPanelKey()) { loadOverlay(panelHeader); raise(panelHeader, primaryW); } + if (panelGrip) raise(panelGrip, primaryW); } // Opera-style: the left panel steps aside when the user opens something else // (a new tab, the right sidebar). It is only hidden — the app keeps its // state, so reopening it is instant and lands where the user left off. -// Opera's other rule: a click in the page closes the panel, unless the user -// pinned that app (settings.leftPanelPinned, keyed like leftPanelWidths). -function leftPanelPinned() { - const k = leftPanelKey(); - return !!(k && settings.leftPanelPinned && settings.leftPanelPinned[k]); -} -ipcMain.handle("quicklinks-pin", (e, on) => { - if (!quicklinks || e.sender !== quicklinks.webContents) return false; - const k = leftPanelKey(); - if (!k) return false; - const next = { ...(settings.leftPanelPinned || {}) }; - if (on) next[k] = true; else delete next[k]; - settings.leftPanelPinned = next; - saveSettings(); - emitQuickLinksState(); - return !!on; -}); +// Opera's other rule: a panel floating over the page closes when the page is +// clicked. A panel that pushed the page aside stays until it is closed. // home-preload reports a pointerdown in a web tab's page. ipcMain.on("tab-pointerdown", (e) => { - if (settings.sidebarHideOnPageClick === false || !leftPanelKey() || leftPanelPinned()) return; + if (settings.sidebarPanelMode === "push" || settings.sidebarHideOnPageClick === false || !leftPanelKey()) return; const t = tabs.find((x) => x.view?.webContents === e.sender); if (!t || !isActiveId(t.id)) return; hideLeftPanels(); @@ -9186,6 +9246,7 @@ function openLeftPanel(panelId) { function closeLeftPanel() { if (!leftPanelId) return; leftPanelId = null; + if (leftPanelMax) { leftPanelMax = false; try { leftPanel?.webContents.send("sidebar-max-change", false); } catch {} } layout(); emitQuickLinksState(); try { leftPanel?.webContents.send("sidebar-visibility", false); } catch {} @@ -9194,6 +9255,7 @@ function openQuickPanel(id) { const L = (settings.quickLinks || []).find((x) => x.id === id); if (!quickPanel || !L || !L.url) return; if (leftPanelId) closeLeftPanel(); + if (activeQuickLinkId !== id) leftPanelMax = false; activeQuickLinkId = id; try { const cur = quickPanel.webContents.getURL(); @@ -9209,6 +9271,7 @@ function openQuickPanel(id) { } function closeQuickPanel() { activeQuickLinkId = null; + if (leftPanelMax && !leftPanelId) leftPanelMax = false; layout(); emitQuickLinksState(); } diff --git a/package.json b/package.json index dcf78573..e1962efd 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,8 @@ "quicklinks.html", "quicklinks-preload.js", "panel-grip-preload.js", + "panel-header.html", + "panel-header-preload.js", "panel-grip.html", "engine-icons/**/*", "lib/**/*", diff --git a/panel-header-preload.js b/panel-header-preload.js new file mode 100644 index 00000000..4d427c79 --- /dev/null +++ b/panel-header-preload.js @@ -0,0 +1,8 @@ +// Preload for panel-header.html, the bar over an open sidebar panel. +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("panelHeader", { + getState: () => ipcRenderer.invoke("panel-header-state"), + toggleMax: () => ipcRenderer.invoke("panel-header-max"), + close: () => ipcRenderer.invoke("panel-header-close"), + onState: (cb) => ipcRenderer.on("panel-header-state", (_e, s) => cb(s)), +}); diff --git a/panel-header.html b/panel-header.html new file mode 100644 index 00000000..d3f65d50 --- /dev/null +++ b/panel-header.html @@ -0,0 +1,48 @@ + + + + +
+ + + +
+ + diff --git a/quicklinks-preload.js b/quicklinks-preload.js index a39811ed..274ae094 100644 --- a/quicklinks-preload.js +++ b/quicklinks-preload.js @@ -3,7 +3,8 @@ contextBridge.exposeInMainWorld("quicklinks", { getState: () => ipcRenderer.invoke("quicklinks-get-state"), open: (id) => ipcRenderer.invoke("quicklinks-open", String(id || "")), close: () => ipcRenderer.invoke("quicklinks-close"), - pin: (on) => ipcRenderer.invoke("quicklinks-pin", !!on), + // The gear: rect is the button's box in the strip, for placing the menu. + menu: (rect) => ipcRenderer.invoke("quicklinks-menu", rect), add: () => ipcRenderer.invoke("quicklinks-add"), onState: (cb) => ipcRenderer.on("quicklinks-state", (_e, d) => cb(d)), }); diff --git a/quicklinks.html b/quicklinks.html index 6b9ee357..c16a2c0a 100644 --- a/quicklinks.html +++ b/quicklinks.html @@ -39,19 +39,17 @@ .sep { width: 22px; height: 1px; background: var(--line2); margin: 4px 0; } .icon.emoji { font-size: 18px; } .add:hover { color: var(--acid); } - /* Pin: keeps the open panel when the page is clicked. Shown only while a - panel is open; lit when that app is pinned. */ - .pin { color: var(--mut); margin-bottom: 4px; } - .pin[hidden] { display: none; } - .pin.on { color: var(--acid); background: var(--acid-tint); } - .pin svg { width: 16px; height: 16px; } + /* Gear: how panels sit next to the page, and the Sidebar settings. */ + .gear { color: var(--mut); margin-bottom: 2px; } + .gear:hover { color: var(--ink); } + .gear svg { width: 17px; height: 17px; }
- +