Theseus: sidebar panels open over the page or push it aside
An open sidebar panel (a web app or an extension panel) now has a bar on top with its name, Maximize/Restore (it covers the page, up to the right sidebar) and Close. How panels sit next to the page is a choice, in the new gear above + on the strip and in Settings > Sidebar: over the page, where it floats on the left of a page that keeps its width and a click in the page closes it; or pushing the page aside, where it stays open until closed. That choice replaces the pin, which only existed to keep a panel open on a page click. Also: a window laid out while minimized reads a zero-size area, so every view was left at 0x0 and the window came back blank until resized. Layout now skips a minimized window and runs again on restore.
This commit is contained in:
parent
f803491844
commit
110d0157de
7 changed files with 195 additions and 69 deletions
133
main.js
133
main.js
|
|
@ -594,7 +594,11 @@ const SETTINGS_DEFAULTS = {
|
|||
// "p:<panelId>"), 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/**/*",
|
||||
|
|
|
|||
8
panel-header-preload.js
Normal file
8
panel-header-preload.js
Normal file
|
|
@ -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)),
|
||||
});
|
||||
48
panel-header.html
Normal file
48
panel-header.html
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<style>
|
||||
/* The bar over an open sidebar panel (a web app or an extension's panel):
|
||||
its name, then Maximize/Restore and Close at the top right. */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg:#121826; --ink:#e7eaf1; --mut:#8b98a9; --line2:rgba(255,255,255,.10);
|
||||
--acid:#d6ff3d; --hover: rgba(255,255,255,.08);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg:#e4e8ee; --ink:#1a1f28; --mut:#5c6676; --line2:rgba(0,0,0,.10); --acid:#0AC18E; --hover: rgba(0,0,0,.06); }
|
||||
}
|
||||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); overflow: hidden; user-select: none;
|
||||
font: 600 12.5px/1 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
.bar { display: flex; align-items: center; height: 100%; padding: 0 4px 0 12px; box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--line2); gap: 2px; }
|
||||
.title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
button { width: 26px; height: 24px; border: none; border-radius: 6px; background: transparent; color: var(--mut);
|
||||
display: grid; place-items: center; cursor: pointer; padding: 0; }
|
||||
button:hover { background: var(--hover); color: var(--ink); }
|
||||
button svg { width: 14px; height: 14px; display: block; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="bar">
|
||||
<span class="title" id="title"></span>
|
||||
<button id="max" title="Maximize"></button>
|
||||
<button id="close" title="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
||||
</div>
|
||||
<script>
|
||||
const H = window.panelHeader;
|
||||
const ICON_MAX = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>';
|
||||
const ICON_RESTORE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="12" height="12" rx="2"/><path d="M8 8V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2"/></svg>';
|
||||
const title = document.getElementById("title"), max = document.getElementById("max");
|
||||
function render(s) {
|
||||
title.textContent = (s && s.title) || "";
|
||||
const isMax = !!(s && s.max);
|
||||
max.innerHTML = isMax ? ICON_RESTORE : ICON_MAX;
|
||||
max.title = isMax ? "Restore" : "Maximize";
|
||||
}
|
||||
max.onclick = () => H && H.toggleMax();
|
||||
document.getElementById("close").onclick = () => H && H.close();
|
||||
// Double-click the bar: maximize / restore, like a window title bar.
|
||||
document.querySelector(".bar").addEventListener("dblclick", (e) => { if (e.target.closest("button")) return; H && H.toggleMax(); });
|
||||
H && H.getState().then(render).catch(() => {});
|
||||
H && H.onState(render);
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
@ -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)),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="strip">
|
||||
<div class="spacer"></div>
|
||||
<div class="icons" id="icons"></div>
|
||||
<div class="spacer"></div>
|
||||
<button class="icon pin" id="pinBtn" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6z"/><path d="M12 15v6"/></svg><span class="tt" id="pinTt">Keep open</span></button>
|
||||
<button class="icon gear" id="gearBtn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span class="tt">Sidebar options</span></button>
|
||||
<button class="icon add" id="addBtn" title="Add link">+<span class="tt">Add link</span></button>
|
||||
</div>
|
||||
<script>
|
||||
|
|
@ -59,10 +57,11 @@
|
|||
const icons = document.getElementById("icons");
|
||||
const addBtn = document.getElementById("addBtn");
|
||||
addBtn.onclick = () => Q && Q.add();
|
||||
const pinBtn = document.getElementById("pinBtn");
|
||||
const pinTt = document.getElementById("pinTt");
|
||||
let pinned = false;
|
||||
pinBtn.onclick = () => Q && Q.pin && Q.pin(!pinned);
|
||||
const gearBtn = document.getElementById("gearBtn");
|
||||
gearBtn.onclick = () => {
|
||||
const r = gearBtn.getBoundingClientRect();
|
||||
Q && Q.menu && Q.menu({ x: r.left, y: r.top, width: r.width, height: r.height });
|
||||
};
|
||||
// Bundled brand marks. Each SVG is tight (20×20 viewBox, no padding), the
|
||||
// colour is baked in so icons read at a glance in both themes. Unknown ids
|
||||
// fall back to a letter chip. All marks are purely to identify services.
|
||||
|
|
@ -80,10 +79,6 @@
|
|||
function render(data) {
|
||||
const links = (data && data.links) || [];
|
||||
const openId = (data && data.openId) || null;
|
||||
pinned = !!(data && data.pinned);
|
||||
pinBtn.hidden = !openId;
|
||||
pinBtn.classList.toggle("on", pinned);
|
||||
pinTt.textContent = pinned ? "Pinned: stays open when you click the page" : "Keep open when you click the page";
|
||||
icons.innerHTML = "";
|
||||
// Extension panels (registered with side: "left") come first.
|
||||
const panels = (data && data.panels) || [];
|
||||
|
|
|
|||
|
|
@ -939,11 +939,19 @@
|
|||
</section>
|
||||
<section id="sidebar" hidden>
|
||||
<h1>Sidebar</h1>
|
||||
<p class="lede">The strip on the left edge: web apps that open in a panel beside the page, and extension panels. Drag a panel's right edge to resize it (double-click the edge to reset). The pin keeps a panel open when you click the page.</p>
|
||||
<p class="lede">The strip on the left edge: web apps that open in a panel beside the page, and extension panels. Drag a panel's right edge to resize it (double-click the edge to reset). The bar over an open panel maximizes it over the page, restores it, or closes it.</p>
|
||||
<div class="row">
|
||||
<div class="txt"><div class="t">Show the strip</div><div class="d">A thin vertical column on the left side of every page.</div></div>
|
||||
<label class="sw"><input type="checkbox" id="quickLinksShow"><span class="track"><span class="knob"></span></span></label>
|
||||
</div>
|
||||
<h2 class="sub">How panels open</h2>
|
||||
<p class="subd">Also in the gear on the strip, above +.</p>
|
||||
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<label class="polrow"><input type="radio" name="sidebarPanelMode" value="overlay"><span><b>Over the page</b> <span class="pmuted">— the panel floats over the left of the page, which keeps its width. A click in the page closes it.</span></span></label>
|
||||
<label class="polrow"><input type="radio" name="sidebarPanelMode" value="push"><span><b>Push the page aside</b> <span class="pmuted">— the page narrows to make room, and the panel stays open until you close it.</span></span></label>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="sub">Web apps</h2>
|
||||
<p class="subd">Sites that open in the panel. A site you installed as an app can be added from Apps.</p>
|
||||
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
|
||||
|
|
@ -970,7 +978,7 @@
|
|||
<label class="sw"><input type="checkbox" id="sidebarHideOnRightPanel"><span class="track"><span class="knob"></span></span></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="txt"><div class="t">You click the page</div><div class="d">Pinned panels stay open.</div></div>
|
||||
<div class="txt"><div class="t">You click the page</div><div class="d">Panels that open over the page. A panel that pushes the page aside stays open.</div></div>
|
||||
<label class="sw"><input type="checkbox" id="sidebarHideOnPageClick"><span class="track"><span class="knob"></span></span></label>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1291,12 +1299,11 @@
|
|||
const addBtn = document.getElementById("qlAdd");
|
||||
if (!list || !addBtn) return;
|
||||
const esc = (v) => String(v || "").replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">","\"":""","'":"'" })[c]);
|
||||
// A row's pin and width share their key with the strip: "q:<id>".
|
||||
const pinBtnHtml = (on, key) => `<button class="btn ghost" data-pin="${esc(key)}" title="${on ? "Pinned: stays open when you click the page" : "Not pinned: closes when you click the page"}" style="${on ? "color:var(--acid-text)" : ""}">${on ? "Pinned" : "Pin"}</button>`;
|
||||
// A row's width shares its key with the strip: "q:<id>".
|
||||
const widthBtnHtml = (w, key) => w ? `<button class="btn ghost" data-width="${esc(key)}" title="Panel width ${w} px">Reset width</button>` : "";
|
||||
let cur = s;
|
||||
function render(links) {
|
||||
const pinned = cur.leftPanelPinned || {}, widths = cur.leftPanelWidths || {};
|
||||
const widths = cur.leftPanelWidths || {};
|
||||
list.innerHTML = "";
|
||||
if (!links.length) {
|
||||
const empty = document.createElement("div");
|
||||
|
|
@ -1314,7 +1321,7 @@
|
|||
row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px";
|
||||
row.innerHTML = `<span class="grip" title="Drag to reorder">⠿</span><div style="width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,.06);display:grid;place-items:center;font-weight:600;color:var(--acid-text);flex:none">${esc((L.title || "?").slice(0,1).toUpperCase())}</div>
|
||||
<div style="flex:1;min-width:140px"><div style="font-weight:600">${esc(L.title || L.url)}</div><div style="font-size:12px;color:var(--mut);word-break:break-all">${esc(L.url)}${widths[key] ? " · " + Number(widths[key]) + " px" : ""}</div></div>
|
||||
${pinBtnHtml(!!pinned[key], key)}${widthBtnHtml(widths[key], key)}
|
||||
${widthBtnHtml(widths[key], key)}
|
||||
<button class="btn ghost" data-del="${esc(L.id)}">Remove</button>`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
|
@ -1340,23 +1347,18 @@
|
|||
C.set("quickLinks", now);
|
||||
});
|
||||
});
|
||||
wirePinWidth(list);
|
||||
wireWidth(list);
|
||||
}
|
||||
// Shared with the extension-panel list below.
|
||||
window.__sbWirePinWidth = wirePinWidth;
|
||||
function wirePinWidth(root) {
|
||||
root.querySelectorAll("[data-pin]").forEach((b) => b.onclick = async () => {
|
||||
const p = { ...((await C.get()).leftPanelPinned || {}) };
|
||||
if (p[b.dataset.pin]) delete p[b.dataset.pin]; else p[b.dataset.pin] = true;
|
||||
C.set("leftPanelPinned", p);
|
||||
});
|
||||
window.__sbWireWidth = wireWidth;
|
||||
function wireWidth(root) {
|
||||
root.querySelectorAll("[data-width]").forEach((b) => b.onclick = async () => {
|
||||
const w = { ...((await C.get()).leftPanelWidths || {}) };
|
||||
delete w[b.dataset.width];
|
||||
C.set("leftPanelWidths", w);
|
||||
});
|
||||
}
|
||||
window.__sbButtons = { pinBtnHtml, widthBtnHtml };
|
||||
window.__sbButtons = { widthBtnHtml };
|
||||
render(s.quickLinks || []);
|
||||
addBtn.onclick = async () => {
|
||||
const t = String(titleIn.value || "").trim();
|
||||
|
|
@ -1381,7 +1383,7 @@
|
|||
let cur = s;
|
||||
async function render() {
|
||||
let panels = []; try { panels = (await C.sidebarLeftPanels()) || []; } catch {}
|
||||
const pinned = cur.leftPanelPinned || {}, widths = cur.leftPanelWidths || {};
|
||||
const widths = cur.leftPanelWidths || {};
|
||||
const B = window.__sbButtons;
|
||||
if (!panels.length) { box.innerHTML = `<div class="pmuted" style="font-size:12.5px">No extension opens from the strip.</div>`; return; }
|
||||
box.innerHTML = panels.map((p) => {
|
||||
|
|
@ -1389,7 +1391,7 @@
|
|||
return `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px">
|
||||
<div style="width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,.06);display:grid;place-items:center;flex:none">${icon}</div>
|
||||
<div style="flex:1;min-width:140px"><div style="font-weight:600">${esc(p.title)}</div><div style="font-size:12px;color:var(--mut)">${p.hidden ? "Not on the strip" : "On the strip"}${widths[p.id] ? " · " + Number(widths[p.id]) + " px" : ""}</div></div>
|
||||
${B ? B.pinBtnHtml(!!pinned[p.id], p.id) + B.widthBtnHtml(widths[p.id], p.id) : ""}
|
||||
${B ? B.widthBtnHtml(widths[p.id], p.id) : ""}
|
||||
<button class="btn ghost" data-show="${esc(p.id)}">${p.hidden ? "Show" : "Hide"}</button></div>`;
|
||||
}).join("");
|
||||
box.querySelectorAll("[data-show]").forEach((b) => b.onclick = async () => {
|
||||
|
|
@ -1397,7 +1399,7 @@
|
|||
if (h.has(b.dataset.show)) h.delete(b.dataset.show); else h.add(b.dataset.show);
|
||||
C.set("leftPanelsHidden", [...h]);
|
||||
});
|
||||
if (window.__sbWirePinWidth) window.__sbWirePinWidth(box);
|
||||
if (window.__sbWireWidth) window.__sbWireWidth(box);
|
||||
}
|
||||
render();
|
||||
document.addEventListener("section", (e) => { if (String(e.detail) === "sidebar") render(); });
|
||||
|
|
@ -1991,6 +1993,13 @@
|
|||
// The active radio reflects the current setting; changing it just calls
|
||||
// C.set — the F12 handler in main.js reads settings.devToolsDock each
|
||||
// time DevTools opens, so no restart is needed for the choice to apply.
|
||||
// Sidebar › How panels open. The gear on the strip changes it too.
|
||||
{
|
||||
const sync = (mode) => document.querySelectorAll('input[name="sidebarPanelMode"]').forEach((r) => { r.checked = (r.value === (mode === "push" ? "push" : "overlay")); });
|
||||
sync(s.sidebarPanelMode);
|
||||
document.querySelectorAll('input[name="sidebarPanelMode"]').forEach((r) => r.addEventListener("change", () => { if (r.checked) C.set("sidebarPanelMode", r.value); }));
|
||||
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => sync(next.sidebarPanelMode));
|
||||
}
|
||||
const devToolsDock = s.devToolsDock || "bottom";
|
||||
document.querySelectorAll('input[name="devToolsDock"]').forEach((r) => {
|
||||
r.checked = (r.value === devToolsDock);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue