From 59d1c5ad5641b90dae7706d4b58b758d5f113e05 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Thu, 8 Oct 2026 01:30:33 +0200 Subject: [PATCH] Theseus: drag sidebar icons into any order The strip always drew extension panels first and web apps after them, in a fixed order. Web apps and panels now share one order (stripOrder) that the icons can be dragged into; reordering web apps in Settings moves them on the strip too, and a new app or extension goes to the end. Until the strip is rearranged, Proton Mail leads, above the panels. --- main.js | 40 ++++++++++++++++++++++++++++++++++++++ quicklinks-preload.js | 2 ++ quicklinks.html | 45 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/main.js b/main.js index 620af390..c5fd2b9a 100644 --- a/main.js +++ b/main.js @@ -600,6 +600,9 @@ const SETTINGS_DEFAULTS = { // and a click in the page closes the panel; "push" moves the page aside and // the panel stays until it is closed. sidebarPanelMode: "overlay", + // The strip's order, web apps ("q:") and extension panels ("p:") + // mixed, as the user dragged them. Empty: Proton Mail, the panels, the rest. + stripOrder: [], leftPanelsHidden: [], sidebarHideOnNewTab: true, sidebarHideOnRightPanel: true, @@ -9156,6 +9159,14 @@ ipcMain.handle("settings-set", (_e, key, val) => { // show/hide and link edits take effect without a relaunch. If the active // panel's link was removed, close the panel. if (key === "sidebarPanelMode") raisePanelGrip(); + if (key === "quickLinks" && Array.isArray(settings.stripOrder) && settings.stripOrder.length) { + const slots = settings.stripOrder.map((k, i) => (k.startsWith("q:") ? i : -1)).filter((i) => i >= 0); + const next = settings.stripOrder.slice(); + const ids = (settings.quickLinks || []).map((L) => "q:" + L.id); + slots.forEach((slot, n) => { next[slot] = ids[n]; }); + settings.stripOrder = next.filter(Boolean); + saveSettings(); + } if (key === "quickLinksShow" || key === "quickLinks" || key === "leftPanelWidths" || key === "sidebarPanelMode" || key === "leftPanelsHidden") { if (activeQuickLinkId && !(settings.quickLinks || []).some((L) => L.id === activeQuickLinkId)) { activeQuickLinkId = null; @@ -9177,8 +9188,37 @@ ipcMain.handle("settings-set", (_e, key, val) => { // toggle — click to open the panel on that link, click the active one again // to close it, click a different one to switch. The strip subscribes via // onState so it can highlight the active icon. +// The strip, top to bottom. A saved order keeps what the user dragged; an +// item it does not know yet (a new web app or extension) goes at the end. +function stripOrder() { + const links = (settings.quickLinks || []).map((L) => "q:" + L.id); + const panels = leftPanels().filter((p) => !leftPanelHidden(p.panelId)).map((p) => "p:" + p.panelId); + const all = new Set([...links, ...panels]); + const saved = Array.isArray(settings.stripOrder) ? settings.stripOrder.filter((k) => all.has(k)) : []; + if (saved.length) { + for (const k of [...links, ...panels]) if (!saved.includes(k)) saved.push(k); + return [...new Set(saved)]; + } + const top = links.includes("q:proton") ? ["q:proton"] : []; + return [...top, ...panels, ...links.filter((k) => !top.includes(k))]; +} +ipcMain.handle("quicklinks-reorder", (e, order) => { + if (!quicklinks || e.sender !== quicklinks.webContents || !Array.isArray(order)) return false; + const current = stripOrder(); + const want = order.map(String).filter((k, i, a) => current.includes(k) && a.indexOf(k) === i); + if (want.length !== current.length) return false; + settings.stripOrder = want; + // Settings › Sidebar lists the web apps in quickLinks order: keep it the same. + const pos = new Map(want.filter((k) => k.startsWith("q:")).map((k, i) => [k.slice(2), i])); + settings.quickLinks = (settings.quickLinks || []).slice().sort((a, b) => (pos.get(a.id) ?? 1e9) - (pos.get(b.id) ?? 1e9)); + saveSettings(); + emitQuickLinksState(); + broadcastSettings(); + return true; +}); function quickLinksPayload() { return { + order: stripOrder(), links: settings.quickLinks || [], openId: leftPanelId ? "p:" + leftPanelId : activeQuickLinkId, panels: leftPanels().filter((p) => !leftPanelHidden(p.panelId)).map((p) => ({ id: "p:" + p.panelId, title: p.addonName || p.title, icon: p.icon || "" })), diff --git a/quicklinks-preload.js b/quicklinks-preload.js index 274ae094..555173b9 100644 --- a/quicklinks-preload.js +++ b/quicklinks-preload.js @@ -5,6 +5,8 @@ contextBridge.exposeInMainWorld("quicklinks", { close: () => ipcRenderer.invoke("quicklinks-close"), // The gear: rect is the button's box in the strip, for placing the menu. menu: (rect) => ipcRenderer.invoke("quicklinks-menu", rect), + // The strip's new order after a drag: keys "q:" / "p:". + reorder: (order) => ipcRenderer.invoke("quicklinks-reorder", order), add: () => ipcRenderer.invoke("quicklinks-add"), onState: (cb) => ipcRenderer.on("quicklinks-state", (_e, d) => cb(d)), }); diff --git a/quicklinks.html b/quicklinks.html index bad0d11e..74133283 100644 --- a/quicklinks.html +++ b/quicklinks.html @@ -28,6 +28,9 @@ .icon:hover { background: var(--line2); } .icon:active { transform: scale(0.94); } .icon.on { background: var(--acid-tint); box-shadow: inset 2px 0 0 var(--acid); } + .icon.dragging { opacity: .4; } + .icon.dropbefore { box-shadow: inset 0 2px 0 var(--acid); } + .icon.dropafter { box-shadow: inset 0 -2px 0 var(--acid); } .icon svg, .icon img { width: 20px; height: 20px; display: block; } .icon.letter { font: 600 13px/1 system-ui, sans-serif; color: var(--mut); } .icon .tt { position: absolute; left: 44px; top: 50%; transform: translateY(-50%); @@ -95,12 +98,39 @@ } // Any other site: its own favicon, and the first letter if it has none. function letterIcon(b, L) { b.classList.add("letter"); b.prepend(document.createTextNode((L.title || "?").slice(0, 1).toUpperCase())); } + // One order for web apps and extension panels (main keeps it); drag an + // icon up or down the strip to move it. + let order = []; + let dragKey = null; + function wireDrag(b, key) { + b.draggable = true; + b.dataset.key = key; + b.addEventListener("dragstart", (e) => { dragKey = key; b.classList.add("dragging"); try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("application/x-theseus-strip", key); } catch {} }); + b.addEventListener("dragend", () => { dragKey = null; b.classList.remove("dragging"); icons.querySelectorAll(".icon").forEach((x) => x.classList.remove("dropbefore", "dropafter")); }); + b.addEventListener("dragover", (e) => { + if (!dragKey || dragKey === key) return; + e.preventDefault(); e.dataTransfer.dropEffect = "move"; + const r = b.getBoundingClientRect(); const before = (e.clientY - r.top) < r.height / 2; + b.classList.toggle("dropbefore", before); b.classList.toggle("dropafter", !before); + }); + b.addEventListener("dragleave", () => b.classList.remove("dropbefore", "dropafter")); + b.addEventListener("drop", (e) => { + e.preventDefault(); + if (!dragKey || dragKey === key) return; + const r = b.getBoundingClientRect(); const before = (e.clientY - r.top) < r.height / 2; + const next = order.filter((k) => k !== dragKey); + next.splice(next.indexOf(key) + (before ? 0 : 1), 0, dragKey); + dragKey = null; + Q && Q.reorder && Q.reorder(next); + }); + } function render(data) { const links = (data && data.links) || []; const openId = (data && data.openId) || null; icons.innerHTML = ""; - // Extension panels (registered with side: "left") come first. const panels = (data && data.panels) || []; + order = Array.isArray(data && data.order) ? data.order : [...panels.map((P) => P.id), ...links.map((L) => "q:" + L.id)]; + const made = new Map(); for (const P of panels) { const b = document.createElement("button"); b.className = "icon"; @@ -119,12 +149,8 @@ tt.className = "tt"; tt.textContent = P.title; b.appendChild(tt); b.onclick = () => Q && Q.open(P.id); - icons.appendChild(b); - } - if (panels.length && links.length) { - const sep = document.createElement("div"); - sep.className = "sep"; - icons.appendChild(sep); + wireDrag(b, P.id); + made.set(P.id, b); } for (const L of links) { const b = document.createElement("button"); @@ -148,8 +174,11 @@ tt.className = "tt"; tt.textContent = L.title || L.url; b.appendChild(tt); b.onclick = () => Q && Q.open(L.id); - icons.appendChild(b); + wireDrag(b, "q:" + L.id); + made.set("q:" + L.id, b); } + for (const k of order) { const b = made.get(k); if (b) { icons.appendChild(b); made.delete(k); } } + for (const b of made.values()) icons.appendChild(b); } Q && Q.getState && Q.getState().then(render).catch(() => render(null)); Q && Q.onState && Q.onState(render);