feat(theseus/screenshot): 0.4.0 — editor lives inside the sidebar, maximizable
User report: the sidebar preview lands correctly, but the moment the editor
opens in its own tab the picture is blank. Rather than chase that class of
handoff race again, put the editor in the same webContents as the panel:
the sidebar view navigates panel.html ↔ editor.html in place. Same
document object, same silentmode.storage surface, no cross-tab __pending
transfer at all.
- panel.html "Edit" button now calls silentmode.invoke("arm", …) — the
add-on rewrites __pending with the currently-previewed capture's bytes,
and the panel does location.href = "editor.html?name=…". Sidebar view
loads the editor with the same preload; editor.js's storage-based load
path pulls the pending entry out and paints.
- editor.html gains a "Back" arrow (returns to panel.html) and a
maximize / restore icon.
- discard() now navigates to panel.html instead of closeTab() — there is
no tab to close.
- Manifest drops the "open-tab" capability entirely (no more full-tab
editor); keeps sidebar-panel + capture-tab.
Framework: new silentmode.sidebar.{maximize, restore, toggleMax, isMax,
onMaxChange}. main.js honours them via new sidebar-maximize / -restore /
-toggle-max / -is-max IPCs, remembering the pre-maximize width so a
restore drops back exactly. The sidebar drag-grip auto-exits maximize
mode on any user drag, so pulling the edge always lands on the pre-max
value plus/minus the delta. sidebar-preload exposes the surface;
chrome.html renderer is untouched — this is a per-panel affordance.
Editor tools (crop / arrow / rect / ellipse / pen / text / mosaic /
undo / redo / copy / save) unchanged. Save still goes through Chromium's
<a download> path, so the file lands in Downloads and appears in the
download chip like any other save.
Bundled but not shipped — leaving version bump + deploy to parent session.
This commit is contained in:
parent
1b74195298
commit
523832cd72
7 changed files with 142 additions and 58 deletions
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"id": "screenshot",
|
||||
"name": "Screenshot",
|
||||
"version": "0.3.0",
|
||||
"description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview lands in the sidebar; open in a new tab for the full editor (crop, annotate, redact, save).",
|
||||
"version": "0.4.0",
|
||||
"description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + full editor (crop, annotate, redact, save) live inside the sidebar. Expand the sidebar to full window for a canvas-sized editor.",
|
||||
"author": "Silent Mode",
|
||||
"icon": "📸",
|
||||
"main": "index.js",
|
||||
"capabilities": ["sidebar-panel", "capture-tab", "open-tab"],
|
||||
"capabilities": ["sidebar-panel", "capture-tab"],
|
||||
"updateURL": "https://navigate.st/bns/theseus.x/extensions/screenshot/updates.json"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@
|
|||
</head>
|
||||
<body>
|
||||
<div class="toolbar" id="toolbar">
|
||||
<button class="tool" id="back" title="Back to sidebar panel">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M10 3l-5 5 5 5"/></svg>
|
||||
</button>
|
||||
<button class="tool" id="toggle-max" title="Expand the sidebar to full window / restore">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/></svg>
|
||||
</button>
|
||||
<span class="name" id="name">screenshot</span>
|
||||
<span class="sep"></span>
|
||||
|
||||
|
|
|
|||
|
|
@ -588,24 +588,34 @@ $("save").addEventListener("click", save);
|
|||
$("copy").addEventListener("click", copy);
|
||||
$("discard").addEventListener("click", discard);
|
||||
|
||||
// Drop the working screenshot and close the editor tab. Used by the
|
||||
// Discard button and the top-level Escape shortcut. window.close() on a
|
||||
// tab opened via api.openTab lands as the tab's close request; Theseus's
|
||||
// tab manager honors it just like a normal cross-origin window.close().
|
||||
async function discard() {
|
||||
// Preferred path: ask main to close this tab. window.close() on a page
|
||||
// Chromium didn't open via script is a no-op by default, and we don't
|
||||
// want the editor stuck if the user has changed nothing to save.
|
||||
try {
|
||||
if (window.silentmode?.closeTab) {
|
||||
const ok = await window.silentmode.closeTab();
|
||||
if (ok) return;
|
||||
// Back button — navigate the sidebar view back to panel.html. Same
|
||||
// webContents, so it's just a location swap; no IPC needed.
|
||||
const backBtn = $("back");
|
||||
if (backBtn) backBtn.addEventListener("click", () => { location.href = "panel.html"; });
|
||||
|
||||
// Maximize / restore — asks the sidebar host to widen its view to the full
|
||||
// window and back. Icon reflects state via silentmode.sidebar.onMaxChange.
|
||||
const maxBtn = $("toggle-max");
|
||||
if (maxBtn && window.silentmode?.sidebar) {
|
||||
maxBtn.addEventListener("click", async () => {
|
||||
try { await window.silentmode.sidebar.toggleMax(); }
|
||||
catch (e) { console.warn("toggleMax failed:", e); }
|
||||
});
|
||||
const paint = (isMax) => {
|
||||
maxBtn.title = isMax ? "Restore sidebar width" : "Expand the sidebar to full window";
|
||||
maxBtn.innerHTML = isMax
|
||||
? '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2v4H2M10 2v4h4M6 14v-4H2M10 14v-4h4"/></svg>'
|
||||
: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/></svg>';
|
||||
};
|
||||
window.silentmode.sidebar.onMaxChange(paint);
|
||||
window.silentmode.sidebar.isMax().then(paint).catch(() => {});
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
// Fallbacks: window.close() (works when the tab was opened via JS), then
|
||||
// a hard navigation so at minimum the stale screenshot is gone.
|
||||
try { window.close(); } catch {}
|
||||
location.replace("about:blank");
|
||||
|
||||
// Drop the working screenshot and go back to the sidebar panel. Used by
|
||||
// the Discard button and the top-level Escape shortcut.
|
||||
async function discard() {
|
||||
try { if (window.silentmode?.storage) await window.silentmode.storage.set("__pending", null); } catch {}
|
||||
location.href = "panel.html";
|
||||
}
|
||||
|
||||
init();
|
||||
|
|
|
|||
|
|
@ -101,22 +101,22 @@ module.exports = {
|
|||
};
|
||||
});
|
||||
|
||||
// panel invokes "openInTab" once the user is happy with the preview.
|
||||
// We rewrite __pending fresh (the editor consumes it on load) so the
|
||||
// editor is always populated from the most-recent capture — even if a
|
||||
// prior editor tab already drained the pending entry.
|
||||
api.onMessage("openInTab", (payload) => {
|
||||
// panel invokes "arm" right before it navigates itself to editor.html
|
||||
// (in-sidebar navigation — SAME webContents, so the editor's storage-
|
||||
// based load path Just Works). We rewrite __pending fresh here so
|
||||
// whichever capture is currently previewed becomes the one the editor
|
||||
// draws, even if a previous edit session already drained the entry.
|
||||
api.onMessage("arm", (payload) => {
|
||||
const wantedName = payload && payload.name ? String(payload.name) : "";
|
||||
let recent = api.storage.get("recent", []);
|
||||
if (!Array.isArray(recent)) recent = [];
|
||||
recent = pruneRecent(recent);
|
||||
api.storage.set("recent", recent);
|
||||
const hit = wantedName ? recent.find((r) => r.id === wantedName) : recent[0];
|
||||
if (!hit) throw new Error("no capture to open — take one first");
|
||||
if (!hit) throw new Error("no capture to edit — take one first");
|
||||
const dataUrl = readAsDataUrl(hit.path);
|
||||
api.storage.set("__pending", { name: hit.name, dataUrl, at: Date.now() });
|
||||
api.openTab("editor.html", { query: { name: hit.name } });
|
||||
api.log(`openInTab → editor.html?name=${hit.name}`);
|
||||
api.log(`arm → editor.html?name=${hit.name}`);
|
||||
return { ok: true, name: hit.name };
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,10 +20,16 @@
|
|||
display: flex; flex-direction: column; }
|
||||
header { display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
||||
background: var(--panel); }
|
||||
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; }
|
||||
background: var(--panel); gap: 8px; }
|
||||
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; min-width: 0; }
|
||||
header .t .em { font-size: 15px; }
|
||||
header .m { color: var(--dim); font-size: 11.5px; min-height: 15px; }
|
||||
header .m { color: var(--dim); font-size: 11.5px; min-height: 15px; flex: 1; text-align: right; }
|
||||
header .max {
|
||||
border: 1px solid var(--line); border-radius: 6px; background: var(--btn);
|
||||
color: var(--ink); cursor: pointer; padding: 4px 6px; font: inherit; line-height: 0;
|
||||
}
|
||||
header .max:hover { background: var(--btn-h); }
|
||||
header .max svg { width: 14px; height: 14px; display: block; }
|
||||
main { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.modes { display: flex; gap: 6px; }
|
||||
button.mode {
|
||||
|
|
@ -67,7 +73,7 @@
|
|||
.status.ok { color: var(--acid); }
|
||||
.recent { display: none; }
|
||||
.recent.on { display: block; }
|
||||
.recent .lbl { color: var(--dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: .5px; margin: 4px 0 6px; }
|
||||
.recent .lbl { color: var(--dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: .5px; margin: 4px 0 6px; display: flex; justify-content: space-between; }
|
||||
.recent .strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
|
||||
.recent .tile {
|
||||
flex: 0 0 auto; width: 78px; height: 52px; border-radius: 4px;
|
||||
|
|
@ -77,7 +83,7 @@
|
|||
}
|
||||
.recent .tile img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
|
||||
.recent .tile:hover { border-color: var(--acid); }
|
||||
.recent .clear { background: none; border: 0; color: var(--dim); font: inherit; cursor: pointer; float: right; }
|
||||
.recent .clear { background: none; border: 0; color: var(--dim); font: inherit; cursor: pointer; text-transform: none; letter-spacing: 0; }
|
||||
.recent .clear:hover { color: var(--err); }
|
||||
</style>
|
||||
</head>
|
||||
|
|
@ -85,6 +91,11 @@
|
|||
<header>
|
||||
<div class="t"><span class="em">📸</span> <span>Screenshot</span></div>
|
||||
<div class="m" id="hdr-status"></div>
|
||||
<button class="max" id="btn-max" title="Expand the sidebar to full window width" aria-label="Expand sidebar">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<main>
|
||||
<div class="modes">
|
||||
|
|
@ -111,13 +122,13 @@
|
|||
|
||||
<div class="actions">
|
||||
<button class="act" id="btn-discard" disabled>Discard</button>
|
||||
<button class="act primary" id="btn-open-tab" disabled title="Open the capture in a full editor tab (crop, annotate, redact, save)">Open in editor tab</button>
|
||||
<button class="act primary" id="btn-edit" disabled title="Edit the capture — crop, annotate, redact, save">Edit</button>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<div class="recent" id="recent">
|
||||
<div class="lbl">Recent
|
||||
<div class="lbl"><span>Recent</span>
|
||||
<button class="clear" id="clear-recent" title="Delete every past capture">clear all</button>
|
||||
</div>
|
||||
<div class="strip" id="recent-strip"></div>
|
||||
|
|
@ -131,8 +142,9 @@
|
|||
const status = $("status");
|
||||
const metaSize = $("meta-size");
|
||||
const metaHost = $("meta-host");
|
||||
const btnOpenTab = $("btn-open-tab");
|
||||
const btnEdit = $("btn-edit");
|
||||
const btnDiscard = $("btn-discard");
|
||||
const btnMax = $("btn-max");
|
||||
const hdrStatus = $("hdr-status");
|
||||
const recentBox = $("recent");
|
||||
const recentStrip = $("recent-strip");
|
||||
|
|
@ -156,7 +168,7 @@
|
|||
previewEmpty.hidden = false;
|
||||
metaSize.textContent = "—";
|
||||
metaHost.textContent = "";
|
||||
btnOpenTab.disabled = true;
|
||||
btnEdit.disabled = true;
|
||||
btnDiscard.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
|
@ -165,7 +177,7 @@
|
|||
previewEmpty.hidden = true;
|
||||
metaSize.textContent = `${last.width}×${last.height} · ${fmt(last.bytes || 0)}`;
|
||||
metaHost.textContent = last.host || "";
|
||||
btnOpenTab.disabled = false;
|
||||
btnEdit.disabled = false;
|
||||
btnDiscard.disabled = false;
|
||||
}
|
||||
|
||||
|
|
@ -179,24 +191,19 @@
|
|||
}
|
||||
recentBox.classList.add("on");
|
||||
recentStrip.innerHTML = list.map((r) =>
|
||||
`<div class="tile" data-name="${r.name.replace(/"/g,""")}" title="${r.name.replace(/"/g,""")} · ${fmt(r.bytes || 0)}">
|
||||
<span class="dim">${r.mode ? r.mode.charAt(0).toUpperCase() + r.mode.slice(1) : ""}</span>
|
||||
</div>`).join("");
|
||||
`<div class="tile" data-name="${r.name.replace(/"/g,""")}" title="${r.name.replace(/"/g,""")} · ${fmt(r.bytes || 0)}"></div>`
|
||||
).join("");
|
||||
for (const tile of recentStrip.querySelectorAll(".tile")) {
|
||||
// Lazy-load thumbnail on hover / first paint.
|
||||
const name = tile.dataset.name;
|
||||
try {
|
||||
const b = await window.silentmode.invoke("getBytes", { name });
|
||||
if (b && b.dataUrl) {
|
||||
tile.innerHTML = `<img src="${b.dataUrl}" alt="">`;
|
||||
}
|
||||
if (b && b.dataUrl) tile.innerHTML = `<img src="${b.dataUrl}" alt="">`;
|
||||
} catch {}
|
||||
tile.addEventListener("click", async () => {
|
||||
try {
|
||||
const b = await window.silentmode.invoke("getBytes", { name });
|
||||
if (!b) return;
|
||||
last = { name: b.name, dataUrl: b.dataUrl, width: 0, height: 0, host: "", bytes: b.bytes };
|
||||
// Fill in dims from the loaded image.
|
||||
const im = new Image();
|
||||
im.onload = () => { last.width = im.naturalWidth; last.height = im.naturalHeight; renderPreview(); };
|
||||
im.src = b.dataUrl;
|
||||
|
|
@ -227,7 +234,7 @@
|
|||
} else if (res && res.dataUrl) {
|
||||
last = res;
|
||||
renderPreview();
|
||||
setStatus("Captured. Preview above; open in editor to annotate.", "ok");
|
||||
setStatus("Captured. Click Edit to annotate.", "ok");
|
||||
refreshRecent();
|
||||
} else {
|
||||
setStatus("Nothing captured.", "err");
|
||||
|
|
@ -242,29 +249,38 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function doOpenTab() {
|
||||
async function doEdit() {
|
||||
if (!last || !last.dataUrl || busy) return;
|
||||
busy = true;
|
||||
btnOpenTab.disabled = true;
|
||||
hdrStatus.textContent = "opening…";
|
||||
btnEdit.disabled = true;
|
||||
hdrStatus.textContent = "opening editor…";
|
||||
try {
|
||||
await window.silentmode.invoke("openInTab", { name: last.name });
|
||||
setStatus("Opened in editor tab.", "ok");
|
||||
// Arm __pending with the currently-previewed capture, then navigate
|
||||
// this sidebar view to editor.html — same webContents, same preload,
|
||||
// so the editor keeps talking to the add-on through silentmode.*.
|
||||
const armed = await window.silentmode.invoke("arm", { name: last.name });
|
||||
const name = (armed && armed.name) || last.name;
|
||||
location.href = "editor.html?name=" + encodeURIComponent(name);
|
||||
} catch (e) {
|
||||
console.warn("openInTab failed:", e);
|
||||
setStatus("Open failed: " + (e && e.message || e), "err");
|
||||
} finally {
|
||||
busy = false;
|
||||
btnOpenTab.disabled = !last;
|
||||
console.warn("arm failed:", e);
|
||||
setStatus("Edit failed: " + (e && e.message || e), "err");
|
||||
btnEdit.disabled = !last;
|
||||
hdrStatus.textContent = "";
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMax() {
|
||||
try { await window.silentmode.sidebar.toggleMax(); }
|
||||
catch (e) { console.warn("toggleMax failed:", e); }
|
||||
}
|
||||
|
||||
for (const b of document.querySelectorAll("button.mode")) {
|
||||
b.addEventListener("click", () => doCapture(b.dataset.mode));
|
||||
}
|
||||
btnOpenTab.addEventListener("click", doOpenTab);
|
||||
btnEdit.addEventListener("click", doEdit);
|
||||
btnDiscard.addEventListener("click", () => { last = null; renderPreview(); setStatus(""); });
|
||||
btnMax.addEventListener("click", toggleMax);
|
||||
clearBtn.addEventListener("click", async () => {
|
||||
if (!confirm("Delete every past capture?")) return;
|
||||
try { await window.silentmode.invoke("clearRecent", {}); await refreshRecent(); }
|
||||
|
|
|
|||
42
main.js
42
main.js
|
|
@ -1299,6 +1299,17 @@ let sidebar, sidebarVisible = false, sidebarActivePanelId = null;
|
|||
// loadSettings() runs and persists any drag adjustment made by the user.
|
||||
const SIDEBAR_W_MIN = 200, SIDEBAR_W_MAX = 800, SIDEBAR_W_DEFAULT = 340;
|
||||
let sidebarW = SIDEBAR_W_DEFAULT;
|
||||
// When a panel asks for "maximize" (screenshot editor wanting the full canvas
|
||||
// area), we widen the sidebar to fill the window and remember the previous
|
||||
// width so restore drops us back exactly. Non-persisted: closing/reopening
|
||||
// Theseus always starts un-maximized.
|
||||
let sidebarMaximized = false;
|
||||
let sidebarPreMaxW = SIDEBAR_W_DEFAULT;
|
||||
function sidebarMaxWidth() {
|
||||
if (!win) return SIDEBAR_W_MAX;
|
||||
const { width } = win.getContentBounds();
|
||||
return Math.max(SIDEBAR_W_MIN, width);
|
||||
}
|
||||
// The add-on host is the single point of truth for what's installed and
|
||||
// active. Populated by initAddons() at app-ready time.
|
||||
let addonHost = null;
|
||||
|
|
@ -2653,6 +2664,14 @@ ipcMain.handle("sidebar-toggle", () => { toggleSidebar(); return sidebarVisible;
|
|||
let _sidebarSaveTimer = null;
|
||||
ipcMain.handle("sidebar-drag", (_e, deltaPx) => {
|
||||
const d = Number(deltaPx) || 0;
|
||||
// Drag-to-resize exits maximize mode — the user is asking for a specific
|
||||
// width. We snap out of maximize first so the delta lands on the pre-max
|
||||
// width rather than on the (huge) maximized value.
|
||||
if (sidebarMaximized) {
|
||||
sidebarMaximized = false;
|
||||
sidebarW = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarPreMaxW || SIDEBAR_W_DEFAULT));
|
||||
try { sidebar?.webContents.send("sidebar-max-change", false); } catch {}
|
||||
}
|
||||
const next = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarW + d));
|
||||
if (next === sidebarW) return sidebarW;
|
||||
sidebarW = next;
|
||||
|
|
@ -2664,6 +2683,29 @@ ipcMain.handle("sidebar-drag", (_e, deltaPx) => {
|
|||
});
|
||||
ipcMain.handle("sidebar-open", (_e, panelId) => { setSidebar(true, panelId); return sidebarVisible; });
|
||||
ipcMain.handle("sidebar-close", () => { setSidebar(false); return false; });
|
||||
// Panel-driven sidebar maximize: fills the window with the sidebar (tab
|
||||
// area shrinks to zero-width), remembering the pre-max width so restore
|
||||
// returns cleanly. Doesn't persist across sessions — a fresh launch
|
||||
// always starts at the saved settings.sidebarWidth. Layout runs so tab
|
||||
// views and other floating popovers reposition against the new bounds.
|
||||
function setSidebarMaximized(next) {
|
||||
const wanted = !!next;
|
||||
if (wanted === sidebarMaximized) return sidebarMaximized;
|
||||
if (wanted) {
|
||||
sidebarPreMaxW = sidebarW;
|
||||
sidebarW = sidebarMaxWidth();
|
||||
} else {
|
||||
sidebarW = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarPreMaxW || SIDEBAR_W_DEFAULT));
|
||||
}
|
||||
sidebarMaximized = wanted;
|
||||
layout();
|
||||
try { sidebar?.webContents.send("sidebar-max-change", sidebarMaximized); } catch {}
|
||||
return sidebarMaximized;
|
||||
}
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@ contextBridge.exposeInMainWorld("silentmode", {
|
|||
invoke: (msg, payload) => ipcRenderer.invoke("addon-msg", String(msg), payload),
|
||||
// Events the add-on pushes via api.emit while this panel is open.
|
||||
on: (msg, cb) => ipcRenderer.on("addon-event", (_e, name, payload) => { if (name === msg) cb(payload); }),
|
||||
// Sidebar sizing controls a panel may want (e.g. the screenshot editor
|
||||
// wants a full-window canvas). Widen fills the window minus a thin strip
|
||||
// for the tab area behind it; restore returns to the pre-widen width.
|
||||
sidebar: {
|
||||
maximize: () => ipcRenderer.invoke("sidebar-maximize"),
|
||||
restore: () => ipcRenderer.invoke("sidebar-restore"),
|
||||
toggleMax:() => ipcRenderer.invoke("sidebar-toggle-max"),
|
||||
isMax: () => ipcRenderer.invoke("sidebar-is-max"),
|
||||
onMaxChange: (cb) => ipcRenderer.on("sidebar-max-change", (_e, max) => cb(!!max)),
|
||||
},
|
||||
});
|
||||
|
||||
// Panel picker strip was here — a 32-px tab bar injected at the top of
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue