Theseus: a page click closes the left panel unless it is pinned

Opera's other sidebar rule. A click in the active tab's page now hides the
left panel (web app or add-on panel), so a chat opened for a quick look
gets out of the way on its own. A pin button in the strip, shown while a
panel is open, keeps that app open instead; pins are per app and survive
restarts (settings.leftPanelPinned). The click is reported by
home-preload, which every web tab already runs; main ignores it unless a
panel is open and the click came from the active tab.
This commit is contained in:
Local Dev 2026-10-04 20:58:03 +02:00
parent 29b346250f
commit 7907451b24
4 changed files with 44 additions and 0 deletions

View file

@ -5,6 +5,9 @@
// origin-mismatched call, so a third-party page can inspect the API's
// SHAPE but can't invoke it against local user data.
const { contextBridge, ipcRenderer } = require("electron");
// A click in the page closes the left panel (web app / add-on) unless it is
// pinned. Main ignores this unless a panel is open and the tab is active.
window.addEventListener("pointerdown", () => { ipcRenderer.send("tab-pointerdown"); }, true);
contextBridge.exposeInMainWorld("home", {
getCards: () => ipcRenderer.invoke("home-cards-get"),
setCards: (cards) => ipcRenderer.invoke("home-cards-set", cards),

25
main.js
View file

@ -7951,6 +7951,7 @@ function quickLinksPayload() {
return {
links: settings.quickLinks || [],
openId: leftPanelId ? "p:" + leftPanelId : activeQuickLinkId,
pinned: leftPanelPinned(),
panels: leftPanels().map((p) => ({ id: "p:" + p.panelId, title: p.addonName || p.title, icon: p.icon || "" })),
};
}
@ -7977,6 +7978,30 @@ function raisePanelGrip() {
// 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;
});
// home-preload reports a pointerdown in a web tab's page.
ipcMain.on("tab-pointerdown", (e) => {
if (!leftPanelKey() || leftPanelPinned()) return;
const t = tabs.find((x) => x.view?.webContents === e.sender);
if (!t || t.id !== activeId) return;
hideLeftPanels();
});
function hideLeftPanels() {
if (activeQuickLinkId) closeQuickPanel();
if (leftPanelId) closeLeftPanel();

View file

@ -3,6 +3,7 @@ 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),
add: () => ipcRenderer.invoke("quicklinks-add"),
onState: (cb) => ipcRenderer.on("quicklinks-state", (_e, d) => cb(d)),
});

View file

@ -39,12 +39,19 @@
.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; }
</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 add" id="addBtn" title="Add link">+<span class="tt">Add link</span></button>
</div>
<script>
@ -52,6 +59,10 @@
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);
// 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.
@ -69,6 +80,10 @@
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) || [];