From 6adabca85a4e379c59004442a226acda678d88f8 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 22 Sep 2026 08:30:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(theseus):=20extension=20dock=20=E2=80=94?= =?UTF-8?q?=20drag=20to=20reorder,=20right-click=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension buttons sat in registration order with no way to change it, hide one, or switch an add-on off without opening Settings. Buttons are now draggable (drop side follows the pointer, same feedback as tabs and bookmark chips) and the order is persisted per profile. Right-click opens a native menu: open/close the panel, move left/right, hide from the toolbar, turn the add-on off, and Manage extensions; the dock's own right-click offers Show hidden. Main owns the prefs (dockOrder, dockHidden) and the actions, so the chrome only renders. Add-on rediscovery now pushes a fresh dock state to the chrome, which it never did before — turning an add-on off or installing one from a page updates the toolbar at once. --- chrome.html | 54 +++++++++++++++++++- main.js | 140 +++++++++++++++++++++++++++++++++++++++++++++++----- preload.js | 4 ++ 3 files changed, 185 insertions(+), 13 deletions(-) diff --git a/chrome.html b/chrome.html index ad07070b..8c8ce46b 100644 --- a/chrome.html +++ b/chrome.html @@ -171,6 +171,10 @@ .extbtn:hover { background: var(--line2); color: var(--ink); } .extbtn.active { background: rgb(from var(--acid) r g b / .12); color: var(--acid); box-shadow: inset 0 -2px 0 var(--acid); } .extbtn.soon { opacity: .45; cursor: help; } + /* drag-reorder feedback, same language as tabs and bookmark chips */ + .extbtn.dragging { opacity: .45; } + .extbtn.dropbefore { box-shadow: -2px 0 0 var(--acid); } + .extbtn.dropafter { box-shadow: 2px 0 0 var(--acid); } .extbtn.soon:hover { opacity: .7; background: var(--line2); } /* downloads button — a small badge sits over the icon when there's activity */ .dlbtn { position: relative; } @@ -680,7 +684,7 @@ // an addon that declares a toolbar-menu both get one entry here. panels // stay clickable-toggle; menus open a floating dropdown positioned under // the icon and dispatch the picked item back to the add-on. - let lastPanels = [], lastMenus = [], lastButtons = [], lastAllPanels = [], lastAllMenus = []; + let lastPanels = [], lastMenus = [], lastButtons = [], lastAllPanels = [], lastAllMenus = [], lastDock = { order: [], hidden: [] }; let sidebarOpen = false, activePanelId = null; let openMenuAddonId = null; // which addon's menu dropdown is currently open function renderSidebarState(d) { @@ -704,6 +708,15 @@ for (const m of lastMenus) { lastButtons.push({ kind: "menu", key: "m:" + m.addonId, addonId: m.addonId, icon: String(m.icon || "•"), title: m.title || m.addonId, items: m.items || [] }); } + // User prefs: drag-reorder order and buttons hidden via the right-click + // menu. Unknown keys keep registration order after the ordered ones. + if (d.dock) lastDock = d.dock; + const order = Array.isArray(lastDock.order) ? lastDock.order : []; + const hidden = new Set(Array.isArray(lastDock.hidden) ? lastDock.hidden : []); + const rank = (k) => { const i = order.indexOf(k); return i < 0 ? order.length : i; }; + lastButtons = lastButtons.filter((b) => !hidden.has(b.key)).map((b, i) => ({ ...b, i })).sort((a, b) => rank(a.key) - rank(b.key) || a.i - b.i); + lastPanels = lastPanels.filter((p) => !hidden.has("p:" + p.panelId)); + lastMenus = lastMenus.filter((m) => !hidden.has("m:" + m.addonId)); // Hide the dock entirely on a fresh install with no add-ons registered // — no empty-slot artifact next to the download button. extDock.hidden = lastButtons.length === 0; @@ -719,8 +732,12 @@ // URI (svg or png) and we render it as an instead — same size // slot, no layout change. Anything else is HTML-escaped and stays // text so the emoji case keeps working. - return ``; + return ``; }).join(""); + // Drag-reorder: drop side is the half of the target button the pointer + // is on; main persists the order and re-emits state, which re-renders. + let dragKey = null; + const clearDrop = () => extButtons.querySelectorAll(".dropbefore,.dropafter").forEach((x) => x.classList.remove("dropbefore", "dropafter")); extButtons.querySelectorAll(".extbtn").forEach((btn) => { btn.onclick = () => { if (btn.dataset.panel) { @@ -732,6 +749,32 @@ openToolbarMenu(btn.dataset.menu, btn); } }; + btn.addEventListener("dragstart", (e) => { + dragKey = btn.dataset.key; btn.classList.add("dragging"); + try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", dragKey); } catch {} + }); + btn.addEventListener("dragend", () => { btn.classList.remove("dragging"); clearDrop(); dragKey = null; }); + btn.addEventListener("dragover", (e) => { + if (!dragKey || btn.dataset.key === dragKey) return; + e.preventDefault(); + const r = btn.getBoundingClientRect(); const after = e.clientX > r.left + r.width / 2; + clearDrop(); btn.classList.add(after ? "dropafter" : "dropbefore"); + }); + btn.addEventListener("dragleave", () => btn.classList.remove("dropbefore", "dropafter")); + btn.addEventListener("drop", (e) => { + if (!dragKey || btn.dataset.key === dragKey) return; + e.preventDefault(); + const r = btn.getBoundingClientRect(); const after = e.clientX > r.left + r.width / 2; + const from = dragKey; clearDrop(); dragKey = null; + T.dockMove && T.dockMove(from, btn.dataset.key, after ? "after" : "before"); + }); + // Right-click: native menu owned by main (open/close, move, hide, turn off…). + btn.addEventListener("contextmenu", (e) => { + e.preventDefault(); e.stopPropagation(); + closeToolbarMenu(); + const r = btn.getBoundingClientRect(); + T.dockMenu && T.dockMenu(btn.dataset.key, { x: r.left, y: r.bottom }); + }); }); // Collapsed dock button: show the first button's icon (falling back // to 🧩 if nothing is registered). Clicking it opens a dropdown of @@ -892,6 +935,13 @@ // adds an extension, or a wider-icon extension gets registered. const extButtonsEl = $("extbuttons"); const extDockEl = $("extdock"); + // Right-click on the dock's empty space (or the collapsed button): the + // same menu without a per-button section — "Show hidden" + "Manage". + extDockEl.addEventListener("contextmenu", (e) => { + e.preventDefault(); + closeToolbarMenu(); + T.dockMenu && T.dockMenu(null, { x: e.clientX, y: e.clientY }); + }); // Cache the natural width of the buttons row while expanded. When the dock // is currently collapsed, #extbuttons has display:none so scrollWidth is // 0 — measuring it then would falsely decide "plenty of room" and expand, diff --git a/main.js b/main.js index 66ff328b..c0099cca 100644 --- a/main.js +++ b/main.js @@ -346,6 +346,11 @@ const SETTINGS_DEFAULTS = { // Add-on framework: ids the user has explicitly turned off. Installed but // disabled add-ons are still discovered — they just never activate. disabledAddons: [], + // Toolbar extension dock: user-chosen button order (keys "p:" / + // "m:", unknown keys keep registration order after these) and + // buttons hidden from the toolbar via the dock's right-click menu. + dockOrder: [], + dockHidden: [], // Sidebar width in px. Adjusted by dragging the grip on the panel's left // edge; persisted across launches. Clamped to [200, 800] on load. sidebarWidth: 340, @@ -2005,6 +2010,15 @@ function initAddons() { }, approvalModal: (opts, addonId) => showApprovalModal(opts, addonId), emitToPanel: (addonId, msg, payload) => { + // Full-tab pages of the same add-on hear it too. addon-tab-preload has + // always exposed silentmode.on(), but nothing ever delivered to a tab, + // so an add-on had no way to tell its own open editor anything — which + // is what a second document needs in order to land in the editor that + // is already up instead of a new tab. + for (const t of tabs) { + if (t.addonId !== addonId) continue; + try { t.view?.webContents?.send("addon-event", msg, payload); } catch {} + } if (!sidebar || !sidebarActivePanelId || !sidebarActivePanelId.startsWith(addonId + ":")) return; try { sidebar.webContents.send("addon-event", msg, payload); } catch {} }, @@ -2422,6 +2436,23 @@ function showLinkStatus(url) { // Open (or close) the sidebar. Loading the panel HTML is lazy — the first // open triggers loadFile; subsequent opens just flip visibility. function toggleSidebar() { setSidebar(!sidebarVisible); } +// Everything the chrome needs to draw the extension dock + plug-in row: +// panels, toolbar menus, which panel is open, and the user's dock prefs. +function sidebarStatePayload() { + return { + visible: sidebarVisible, + active: sidebarActivePanelId, + panels: addonHost ? addonHost.getSidebarPanels() : [], + toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [], + dock: { + order: Array.isArray(settings.dockOrder) ? settings.dockOrder : [], + hidden: Array.isArray(settings.dockHidden) ? settings.dockHidden : [], + }, + }; +} +function emitSidebarState() { + try { chrome?.webContents.send("sidebar-state", sidebarStatePayload()); } catch {} +} function setSidebar(show, panelId) { if (!sidebar) return; const panels = addonHost ? addonHost.getSidebarPanels() : []; @@ -2442,13 +2473,13 @@ function setSidebar(show, panelId) { try { win.contentView.removeChildView(sidebar); win.contentView.addChildView(sidebar); } catch {} layout(); try { sidebar.webContents.send("sidebar-visibility", true); } catch {} - try { chrome?.webContents.send("sidebar-state", { visible: true, active: sidebarActivePanelId, panels, toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [] }); } catch {} + emitSidebarState(); } else { sidebarVisible = false; sidebar.setVisible(false); layout(); try { sidebar.webContents.send("sidebar-visibility", false); } catch {} - try { chrome?.webContents.send("sidebar-state", { visible: false, active: sidebarActivePanelId, panels, toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [] }); } catch {} + emitSidebarState(); } } function showPwFill(show, matches) { @@ -3602,12 +3633,83 @@ ipcMain.handle("sidebar-maximize", () => setSidebarMaximized(true)); ipcMain.handle("sidebar-restore", () => setSidebarMaximized(false)); ipcMain.handle("sidebar-toggle-max", () => setSidebarMaximized(!sidebarMaximized)); ipcMain.handle("sidebar-is-max", () => sidebarMaximized); -ipcMain.handle("sidebar-state", () => ({ - visible: sidebarVisible, - active: sidebarActivePanelId, - panels: addonHost ? addonHost.getSidebarPanels() : [], - toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [], -})); +ipcMain.handle("sidebar-state", () => sidebarStatePayload()); +// ---- extension dock: reorder by drag, right-click menu -------------------- +// Keys match the chrome's: "p:" for a sidebar panel button, +// "m:" for a toolbar-menu button. The full key list in the order +// the chrome would draw it (prefs applied) is computed here so a move is +// resolved against what the user actually sees. +function dockKeys() { + const keys = []; + if (addonHost) { + for (const p of addonHost.getSidebarPanels()) if (!p.plugin) keys.push("p:" + p.panelId); + for (const m of addonHost.getToolbarMenus()) if (!m.plugin) keys.push("m:" + m.addonId); + } + const order = Array.isArray(settings.dockOrder) ? settings.dockOrder : []; + const rank = (k) => { const i = order.indexOf(k); return i < 0 ? order.length : i; }; + return keys.map((k, i) => ({ k, i })).sort((a, b) => rank(a.k) - rank(b.k) || a.i - b.i).map((x) => x.k); +} +function dockLabel(key) { + if (!addonHost) return key; + if (key.startsWith("p:")) { const p = addonHost.getSidebarPanels().find((x) => "p:" + x.panelId === key); return p ? (p.addonName || p.title || key) : key.slice(2); } + const m = addonHost.getToolbarMenus().find((x) => "m:" + x.addonId === key); + return m ? (m.title || m.addonId) : key.slice(2); +} +const dockAddonId = (key) => key.startsWith("p:") ? key.slice(2).split(":")[0] : key.slice(2); +function dockMove(key, targetKey, place) { + const keys = dockKeys(); + if (!keys.includes(key) || !keys.includes(targetKey) || key === targetKey) return false; + const rest = keys.filter((k) => k !== key); + const at = rest.indexOf(targetKey) + (place === "after" ? 1 : 0); + rest.splice(at, 0, key); + settings.dockOrder = rest; + saveSettings(); + emitSidebarState(); + return true; +} +function dockSetHidden(key, hide) { + const hidden = new Set(Array.isArray(settings.dockHidden) ? settings.dockHidden : []); + if (hide) hidden.add(key); else hidden.delete(key); + settings.dockHidden = [...hidden]; + saveSettings(); + emitSidebarState(); +} +ipcMain.handle("dock-move", (e, key, targetKey, place) => { + if (chrome && e.sender !== chrome.webContents) throw new Error("dock-move: untrusted sender"); + if (typeof key !== "string" || typeof targetKey !== "string") return false; + return dockMove(key, targetKey, place === "after" ? "after" : "before"); +}); +ipcMain.handle("dock-menu", (e, key, rect) => { + if (chrome && e.sender !== chrome.webContents) throw new Error("dock-menu: untrusted sender"); + const keys = dockKeys(); + const visible = keys.filter((k) => !(settings.dockHidden || []).includes(k)); + const hiddenKeys = (settings.dockHidden || []).filter((k) => keys.includes(k)); + const template = []; + if (typeof key === "string" && keys.includes(key)) { + const name = dockLabel(key); + const pos = visible.indexOf(key); + if (key.startsWith("p:")) { + const pid = key.slice(2); + const open = sidebarVisible && sidebarActivePanelId === pid; + template.push({ label: open ? `Close ${name}` : `Open ${name}`, click: () => setSidebar(!open, pid) }); + } + template.push({ type: "separator" }); + template.push({ label: "Move left", enabled: pos > 0, click: () => dockMove(key, visible[pos - 1], "before") }); + template.push({ label: "Move right", enabled: pos >= 0 && pos < visible.length - 1, click: () => dockMove(key, visible[pos + 1], "after") }); + template.push({ type: "separator" }); + template.push({ label: "Hide from toolbar", click: () => dockSetHidden(key, true) }); + template.push({ label: `Turn off ${name}`, click: () => setAddonEnabled(dockAddonId(key), false) }); + template.push({ type: "separator" }); + } + if (hiddenKeys.length) { + template.push({ label: "Show hidden", submenu: hiddenKeys.map((k) => ({ label: dockLabel(k), click: () => dockSetHidden(k, false) })) }); + } + template.push({ label: "Manage extensions…", click: () => openSettingsTab("addons") }); + const popup = Menu.buildFromTemplate(template); + const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 }; + popup.popup({ window: win, x: Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0))), y: Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0))) }); + return true; +}); // Toolbar-menu click: chrome sends the {addonId, itemId} of the item the // user picked. Route to the add-on's registered "menu-select" handler. We // trust chrome as the sender (same convention as sidebar-toggle et al) — @@ -3687,6 +3789,19 @@ ipcMain.handle("addon-tab-close", (e) => { closeTab(tab.id); return true; }); +// The counterpart: an add-on page asking for its own tab to be brought to +// the front. Needed when the add-on hands an already-open page something new +// to show and the click that caused it happened somewhere else — the sidebar, +// say — so the result would otherwise land in a tab nobody is looking at. +// Same confinement as the close handler: derived from the sender, so a page +// can only front the tab it is itself in. +ipcMain.handle("addon-tab-focus", (e) => { + const senderId = e.sender.id; + const tab = tabs.find((t) => t.view?.webContents?.id === senderId); + if (!tab) return false; + setActive(tab.id); + return true; +}); // Read-side of Settings' Add-ons tab. ipcMain.handle("addons-list", () => { if (!addonHost) return { installed: [], sidebarPanels: [] }; @@ -3711,7 +3826,7 @@ ipcMain.handle("addons-list", () => { // Toggle an add-on's enabled state. Discovery re-runs so newly-enabled // add-ons activate immediately and newly-disabled ones drop out — no // restart required. -ipcMain.handle("addons-set-enabled", (_e, id, enabled) => { +function setAddonEnabled(id, enabled) { if (!id || typeof id !== "string") return false; const disabled = new Set(Array.isArray(settings.disabledAddons) ? settings.disabledAddons : []); if (enabled) disabled.delete(id); else disabled.add(id); @@ -3726,8 +3841,10 @@ ipcMain.handle("addons-set-enabled", (_e, id, enabled) => { sidebarActivePanelId = null; setSidebar(false); } + emitSidebarState(); return true; -}); +} +ipcMain.handle("addons-set-enabled", (_e, id, enabled) => setAddonEnabled(id, enabled)); // Reveal an add-on's folder in the OS file manager — the primary way users // edit / uninstall add-ons. ipcMain.handle("addons-reveal", (_e, folder) => { @@ -3800,7 +3917,7 @@ async function installCommunityById(id) { verifyPublisher: verifyPublisherEntry, log: (...a) => console.log("[addons]", ...a), }); - if (r.ok && addonHost) addonHost.discoverAndActivate(); + if (r.ok && addonHost) { addonHost.discoverAndActivate(); emitSidebarState(); } return r; } catch (e) { return { ok: false, error: e?.message || String(e) }; } } @@ -3906,6 +4023,7 @@ function listStagedAddons(stagedDir) { ipcMain.handle("addons-reload", () => { if (!addonHost) return false; addonHost.discoverAndActivate(); + emitSidebarState(); return true; }); // --- Add-on messaging + capabilities ----------------------------------------- diff --git a/preload.js b/preload.js index 92e4840c..715a43a6 100644 --- a/preload.js +++ b/preload.js @@ -81,6 +81,10 @@ contextBridge.exposeInMainWorld("theseus", { closeSidebar: () => ipcRenderer.invoke("sidebar-close"), sidebarState: () => ipcRenderer.invoke("sidebar-state"), onSidebarState: (cb) => ipcRenderer.on("sidebar-state", (_e, d) => cb(d)), + // Extension dock: drag-reorder (key = "p:" | "m:") and + // the right-click menu (native; main owns the actions). + dockMove: (key, targetKey, place) => ipcRenderer.invoke("dock-move", key, targetKey, place), + dockMenu: (key, rect) => ipcRenderer.invoke("dock-menu", key, rect), // Toolbar-menu (dropdown from an add-on's dock icon): dispatch the picked // item id to the add-on's "menu-select" handler. addonMenuSelect: (addonId, itemId) => ipcRenderer.invoke("addon-menu-select", addonId, itemId),