feat(theseus): extension dock — drag to reorder, right-click menu
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.
This commit is contained in:
parent
70ee7fc39b
commit
6adabca85a
3 changed files with 185 additions and 13 deletions
54
chrome.html
54
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 <img> instead — same size
|
||||
// slot, no layout change. Anything else is HTML-escaped and stays
|
||||
// text so the emoji case keeps working.
|
||||
return `<button class="extbtn${cls}" ${dataAttr} title="${b.title.replace(/"/g,""")}">${addonIconHtml(b.icon)}</button>`;
|
||||
return `<button class="extbtn${cls}" ${dataAttr} data-key="${b.key.replace(/"/g,""")}" draggable="true" title="${b.title.replace(/"/g,""")}">${addonIconHtml(b.icon)}</button>`;
|
||||
}).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,
|
||||
|
|
|
|||
140
main.js
140
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:<panelId>" /
|
||||
// "m:<addonId>", 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:<panelId>" for a sidebar panel button,
|
||||
// "m:<addonId>" 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 -----------------------------------------
|
||||
|
|
|
|||
|
|
@ -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:<panelId>" | "m:<addonId>") 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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue