From 5e5212a8fd4d9837f1170280fbcfbf2fc96d7b7a Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 8 Sep 2026 20:26:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(theseus/screenshot):=200.3.0=20=E2=80=94?= =?UTF-8?q?=20sidebar-first=20flow=20with=20explicit=20"open=20in=20tab"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.3.33 still ships blank screenshots because the whole toolbar-menu → auto- open-editor path can't be made race-free: the moment the editor tab opens it becomes the active tab, and a snapshot of the editor's own tab (before its canvas has drawn from storage) is a valid-looking 24 KB all-white PNG. The lastCapturableTabId fallback I added in a279864 catches the second click, but the first click can still land on the addon-owned tab whenever the user re-triggers before setActive has settled. Rebuild the UX so this class of race can't happen at all: - Drop the toolbar-menu capability. Manifest is back to sidebar-panel + capture-tab + open-tab, so the dock icon opens the panel (never the editor directly). No dropdown, no clip-under-tab-view issue, no auto- jump into an addon-owned tab. - Sidebar has the three capture buttons + a preview + a "Open in editor tab" button. The preview is fed a data:image/png URL returned straight from api.captureTab, rendered inside the sidebar's own document — same origin, no file:// cross-directory gotcha, and the user can see immediately whether the shot actually landed. - Editor.html tab opens only on an explicit "Open in editor tab" click. The addon rewrites __pending at that moment (so the editor always sees the just-selected capture even if a prior editor tab drained the entry), then api.openTab("editor.html", {name}). The editor's storage-based load path is unchanged. - Recent captures ring is kept and now exposed as a horizontal thumbnail strip in the sidebar; clicking a tile re-previews that capture and arms "Open in editor tab" for it. Editor page (editor.html/js/css) unchanged — same crop / arrow / rect / ellipse / pen / text / mosaic-redact / undo / redo / copy / save. Bundled but not shipped — leaving version bump + deploy to the parent session. --- bundled-addons/screenshot/addon.json | 17 +- bundled-addons/screenshot/index.js | 148 ++++++++------ bundled-addons/screenshot/panel.html | 277 +++++++++++++++++++++++++++ 3 files changed, 366 insertions(+), 76 deletions(-) create mode 100644 bundled-addons/screenshot/panel.html diff --git a/bundled-addons/screenshot/addon.json b/bundled-addons/screenshot/addon.json index 7b12a983..247a4aa2 100644 --- a/bundled-addons/screenshot/addon.json +++ b/bundled-addons/screenshot/addon.json @@ -1,20 +1,11 @@ { "id": "screenshot", "name": "Screenshot", - "version": "0.2.4", - "description": "Capture the current tab — visible viewport, entire scrollable page, or a rectangle you draw. Pick a mode from a toolbar dropdown; the capture opens in a full-tab editor (crop, annotate, redact, save).", + "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).", "author": "Silent Mode", "icon": "📸", "main": "index.js", - "capabilities": ["toolbar-menu", "capture-tab", "open-tab"], - "updateURL": "https://navigate.st/bns/theseus.x/extensions/screenshot/updates.json", - "toolbar-menu": { - "title": "Screenshot", - "icon": "📸", - "items": [ - { "id": "visible", "label": "Visible viewport", "icon": "🖼️" }, - { "id": "full", "label": "Full page", "icon": "📄" }, - { "id": "region", "label": "Region…", "icon": "✂️" } - ] - } + "capabilities": ["sidebar-panel", "capture-tab", "open-tab"], + "updateURL": "https://navigate.st/bns/theseus.x/extensions/screenshot/updates.json" } diff --git a/bundled-addons/screenshot/index.js b/bundled-addons/screenshot/index.js index 22512a1a..03805bf6 100644 --- a/bundled-addons/screenshot/index.js +++ b/bundled-addons/screenshot/index.js @@ -1,37 +1,47 @@ -// Screenshot — capture the active tab, then hand the raw PNG to a full-tab -// editor page (editor.html) for annotate / redact / crop / save. There is -// no sidebar panel; the whole flow is toolbar dropdown → capture → editor tab. +// Screenshot — capture the active tab, preview in the sidebar, then hand +// off to a full-tab editor only when the user asks for it. The old toolbar- +// dropdown flow auto-opened editor.html the instant a capture completed; +// that turned the editor tab into the ACTIVE tab, so any second click of +// the dropdown snapshotted the editor's still-blank canvas and every follow- +// up produced a white PNG. Sidebar-first breaks that loop entirely: the +// preview is served from a data URL inside the sidebar's own document, and +// the editor tab is opened only on an explicit "Open in editor" click. // -// Flow: chrome dispatches "menu-select" with {id: "visible"|"full"|"region"} -// → we call api.captureTab({mode, ...}) → write the raw PNG to a per-add-on -// scratch dir → record it in api.storage under "recent" (small ring buffer) -// → open editor.html?src=&name=…&tool=… as a full Theseus tab. -// The editor loads the PNG onto a and, when the user hits Save, -// downloads the modified image through Chromium's normal path -// (caught by Theseus's will-download tracker — no download capability needed). +// Flow: +// panel invokes "capture" → api.captureTab → write PNG to per-add-on +// scratch dir → return {name, dataUrl, width, height, host, bytes} to +// panel → panel renders in a preview area. +// panel invokes "openInTab" → we stash the raw PNG in api.storage under +// "__pending" and openTab("editor.html") — the editor's preload lets it +// pull that entry out via silentmode.storage.get(). const fs = require("node:fs"); const path = require("node:path"); -const MAX_RECENT = 6; // keep the last N in the ring; older files pruned -const SCRATCH_DIR = "screenshot-scratch"; // sibling of the add-on's storage json +const MAX_RECENT = 6; // ring size for the recent-captures list +const SCRATCH_DIR = "screenshot-scratch"; // sibling of the add-on's storage JSON module.exports = { activate(api) { + api.registerSidebarPanel({ + id: "main", + title: "Screenshot", + icon: "📸", + page: "panel.html", + }); + // Per-add-on scratch dir under /addons-data/. We don't touch - // the add-on folder itself — that would confuse users editing their own - // copy in the file browser. api.folder is /addons/screenshot/, - // so dirname twice + "addons-data" gets us to the sibling of the storage - // json api.storage writes. + // the add-on folder itself — editing it there would confuse users who + // are inspecting the shipped source. api.folder = /addons/ + // screenshot/, so dirname twice lands on . const dataParent = path.dirname(path.join(api.folder, "..")); const scratchDir = path.join(dataParent, "addons-data", SCRATCH_DIR); try { fs.mkdirSync(scratchDir, { recursive: true }); } catch (e) { api.log("scratch mkdir failed:", e?.message); } // Region-select overlay source, loaded once at activation. Region mode - // triggers this; main injects it into the tab and awaits the rect it - // resolves with. Keeping the DOM code in a sibling file (not a JS string - // in main) lets someone edit the overlay UX without touching browser core. + // hands this to capture-tab, which injects it into the target tab and + // awaits the {x,y,w,h} the overlay resolves with. let overlaySource = ""; try { overlaySource = fs.readFileSync(path.join(api.folder, "panel-preload.js"), "utf8"); } catch (e) { api.log("panel-preload.js not readable:", e?.message); } @@ -45,10 +55,7 @@ module.exports = { return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } function pruneRecent(recent) { - // Drop entries whose scratch file no longer exists so the ring doesn't - // reference broken paths after a manual cleanup. const alive = recent.filter((r) => { try { fs.accessSync(r.path); return true; } catch { return false; } }); - // Cap size and delete the files we're about to forget. const trimmed = alive.slice(0, MAX_RECENT); const dropped = alive.slice(MAX_RECENT); for (const r of dropped) { try { fs.unlinkSync(r.path); } catch {} } @@ -62,69 +69,84 @@ module.exports = { fs.writeFileSync(file, buf); return { path: file, bytes: buf.length }; } + function readAsDataUrl(absPath) { + const buf = fs.readFileSync(absPath); + return `data:image/png;base64,${buf.toString("base64")}`; + } - // Toolbar dropdown item click: chrome sends {id: "visible"|"full"|"region"}. - api.onMessage("menu-select", async (payload) => { - const id = String(payload && payload.id || "visible"); - if (id !== "visible" && id !== "full" && id !== "region") { - throw new Error(`unknown menu item: ${id}`); + // panel invokes "capture": take the shot, write to scratch, hand the + // data URL straight back so the sidebar can preview it immediately. + api.onMessage("capture", async (payload) => { + const mode = String(payload && payload.mode || "visible"); + if (mode !== "visible" && mode !== "full" && mode !== "region") { + throw new Error(`unknown capture mode: ${mode}`); } - // capture-tab uses the same mode strings as our menu ids. Region mode - // needs the overlay source so the user can draw the rect in-page. - const opts = { mode: id, format: "png" }; - if (id === "region") opts.overlaySource = overlaySource; + const opts = { mode, format: "png" }; + if (mode === "region") opts.overlaySource = overlaySource; const cap = await api.captureTab(opts); if (cap.cancelled) { api.log(`region capture cancelled`); return { ok: false, cancelled: true }; } - const name = `screenshot-${nowStamp()}${id === "full" ? "-fullpage" : id === "region" ? "-region" : ""}.png`; + const name = `screenshot-${nowStamp()}${mode === "full" ? "-fullpage" : mode === "region" ? "-region" : ""}.png`; const written = writeScratch(cap.dataUrl, name); - // Recent-captures ring buffer. let recent = api.storage.get("recent", []); if (!Array.isArray(recent)) recent = []; - recent.unshift({ id: name, name, path: written.path, url: fileUrl(written.path), bytes: written.bytes, at: Date.now(), mode: id }); + recent.unshift({ id: name, name, path: written.path, bytes: written.bytes, at: Date.now(), mode }); recent = pruneRecent(recent); api.storage.set("recent", recent); - // Hand the raw data URL to the editor tab through addon storage. We - // used to pass `?src=file://…` and let the editor load the scratch - // file, but Chromium's file:// origin policy blocks from - // cross-directory file loads: editor.html lives under - // /addons/screenshot/ and scratch PNGs land under - // /addons-data/screenshot-scratch/, which is a sibling of - // the addon dir, and the load quietly fails — the editor initializes - // but the image never draws, so every subsequent tool (draw, save, - // discard) operates on a still-empty canvas. Storage isn't - // origin-scoped, so the editor can pull the data URL out and paint - // it locally. - api.storage.set("__pending", { name, dataUrl: cap.dataUrl, tool: id === "region" ? "crop" : "", at: Date.now() }); - api.log(`captured ${id} → ${name} (${written.bytes} bytes)`); - api.openTab("editor.html", { query: { name } }); - return { ok: true, name }; + api.log(`captured ${mode} → ${name} (${written.bytes} bytes)`); + return { + ok: true, name, mode, + dataUrl: cap.dataUrl, + width: cap.width, height: cap.height, + host: cap.host, bytes: written.bytes, + }; }); - // Read the ring for a future "recent captures" surface — the editor may - // grow a "past captures" strip, and Settings can display them too. Not - // wired to any UI in 0.2.0; kept so the ring is inspectable. + // 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) => { + 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"); + 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}`); + return { ok: true, name: hit.name }; + }); + + // Read the ring for a "recent captures" strip in the sidebar. Bytes are + // reported; the actual images are pulled through "getBytes" on demand + // so we don't ship every thumbnail through IPC on every panel open. api.onMessage("listRecent", () => { let recent = api.storage.get("recent", []); if (!Array.isArray(recent)) recent = []; recent = pruneRecent(recent); api.storage.set("recent", recent); - return recent.map((r) => ({ id: r.id, name: r.name, url: r.url, bytes: r.bytes, at: r.at, mode: r.mode })); + return recent.map((r) => ({ id: r.id, name: r.name, bytes: r.bytes, at: r.at, mode: r.mode })); }); - api.onMessage("openRecent", ({ id } = {}) => { + // Panel asks for a specific past capture's bytes so it can preview it. + api.onMessage("getBytes", (payload) => { + const wantedName = String(payload && payload.name || ""); const recent = api.storage.get("recent", []) || []; - const hit = recent.find((r) => r.id === id); - if (!hit) throw new Error(`no recent capture "${id}"`); - api.openTab("editor.html", { query: { src: hit.url, name: hit.name, tool: "" } }); - return { ok: true }; + const hit = recent.find((r) => r.id === wantedName); + if (!hit) throw new Error(`no recent capture "${wantedName}"`); + return { name: hit.name, dataUrl: readAsDataUrl(hit.path), bytes: hit.bytes, at: hit.at, mode: hit.mode }; }); - api.onMessage("clearRecent", ({ id } = {}) => { + api.onMessage("clearRecent", (payload) => { + const wantedName = payload && payload.name ? String(payload.name) : ""; let recent = api.storage.get("recent", []) || []; - if (id) { - const hit = recent.find((r) => r.id === id); + if (wantedName) { + const hit = recent.find((r) => r.id === wantedName); if (hit) { try { fs.unlinkSync(hit.path); } catch {} } - recent = recent.filter((r) => r.id !== id); + recent = recent.filter((r) => r.id !== wantedName); } else { for (const r of recent) { try { fs.unlinkSync(r.path); } catch {} } recent = []; @@ -133,6 +155,6 @@ module.exports = { return { ok: true }; }); - api.log("registered screenshot toolbar-menu (visible / full / region)"); + api.log("registered screenshot v0.3.0 sidebar panel"); }, }; diff --git a/bundled-addons/screenshot/panel.html b/bundled-addons/screenshot/panel.html new file mode 100644 index 00000000..b3bd8440 --- /dev/null +++ b/bundled-addons/screenshot/panel.html @@ -0,0 +1,277 @@ + + + + +Screenshot + + + +
+
📸 Screenshot
+
+
+
+
+ + + +
+ +
+ No capture yet. Pick a mode above. + +
+ +
+ — + +
+ +
+ + +
+ +
+ +
+
Recent + +
+
+
+
+ + + +