feat(theseus/screenshot): 0.3.0 — sidebar-first flow with explicit "open in tab"

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 <img> + 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.
This commit is contained in:
Local Dev 2026-09-08 20:26:48 +02:00
parent ed67c4719a
commit 5e5212a8fd
3 changed files with 366 additions and 76 deletions

View file

@ -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"
}

View file

@ -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=<file url>&name=…&tool=… as a full Theseus tab.
// The editor loads the PNG onto a <canvas> and, when the user hits Save,
// downloads the modified image through Chromium's normal <a download> 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 <img src="data:…"> 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 <userData>/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 <userData>/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 = <userData>/addons/
// screenshot/, so dirname twice lands on <userData>.
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 <img> from
// cross-directory file loads: editor.html lives under
// <userData>/addons/screenshot/ and scratch PNGs land under
// <userData>/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");
},
};

View file

@ -0,0 +1,277 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Screenshot</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
--btn:#1c2432; --btn-h:#242e40; --err:#ff9081; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
--btn:#f0f3fa; --btn-h:#e3e8f2; --acid:#088A66; --err:#c81f0f; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--ink);
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
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; }
header .t .em { font-size: 15px; }
header .m { color: var(--dim); font-size: 11.5px; min-height: 15px; }
main { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; }
.modes { display: flex; gap: 6px; }
button.mode {
flex: 1; min-width: 0;
padding: 10px 6px; border: 1px solid var(--line); border-radius: 8px;
background: var(--btn); color: var(--ink); cursor: pointer;
font: inherit; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 4px;
transition: background 120ms;
}
button.mode:hover:not(:disabled) { background: var(--btn-h); }
button.mode:disabled { opacity: .5; cursor: default; }
button.mode .em { font-size: 18px; line-height: 1; }
button.mode .lbl { font-size: 11.5px; }
.prev-wrap {
border: 1px dashed var(--line); border-radius: 8px;
background:
repeating-conic-gradient(rgba(255,255,255,.02) 0% 25%, transparent 0% 50%)
50%/16px 16px;
padding: 6px; min-height: 120px;
display: flex; align-items: center; justify-content: center;
color: var(--dim); font-size: 12px; text-align: center;
}
.prev-wrap img {
max-width: 100%; max-height: 320px; border-radius: 4px; display: block;
background: #fff;
}
.meta { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
.meta .host { color: var(--mut); }
.actions { display: flex; gap: 6px; }
button.act {
flex: 1; padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
}
button.act.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
button.act:hover:not(:disabled) { background: var(--btn-h); }
button.act.primary:hover:not(:disabled) { filter: brightness(1.05); }
button.act:disabled { opacity: .45; cursor: default; }
.status { color: var(--mut); font-size: 12px; min-height: 16px; }
.status.err { color: var(--err); }
.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 .strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
.recent .tile {
flex: 0 0 auto; width: 78px; height: 52px; border-radius: 4px;
border: 1px solid var(--line); background: var(--btn);
display: flex; align-items: center; justify-content: center;
cursor: pointer; overflow: hidden; position: relative;
}
.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:hover { color: var(--err); }
</style>
</head>
<body>
<header>
<div class="t"><span class="em">📸</span> <span>Screenshot</span></div>
<div class="m" id="hdr-status"></div>
</header>
<main>
<div class="modes">
<button class="mode" data-mode="visible" title="Capture the visible part of the current tab">
<span class="em">🖼️</span><span class="lbl">Visible</span>
</button>
<button class="mode" data-mode="full" title="Capture the entire scrollable page">
<span class="em">📄</span><span class="lbl">Full page</span>
</button>
<button class="mode" data-mode="region" title="Drag a rectangle on the page to select what to capture">
<span class="em">✂️</span><span class="lbl">Region</span>
</button>
</div>
<div class="prev-wrap" id="prev-wrap">
<span id="prev-empty">No capture yet. Pick a mode above.</span>
<img id="prev-img" alt="" hidden>
</div>
<div class="meta">
<span id="meta-size">—</span>
<span class="host" id="meta-host"></span>
</div>
<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>
</div>
<div class="status" id="status"></div>
<div class="recent" id="recent">
<div class="lbl">Recent
<button class="clear" id="clear-recent" title="Delete every past capture">clear all</button>
</div>
<div class="strip" id="recent-strip"></div>
</div>
</main>
<script>
const $ = (id) => document.getElementById(id);
const previewImg = $("prev-img");
const previewEmpty = $("prev-empty");
const status = $("status");
const metaSize = $("meta-size");
const metaHost = $("meta-host");
const btnOpenTab = $("btn-open-tab");
const btnDiscard = $("btn-discard");
const hdrStatus = $("hdr-status");
const recentBox = $("recent");
const recentStrip = $("recent-strip");
const clearBtn = $("clear-recent");
let last = null; // { name, dataUrl, width, height, host, bytes, mode }
let busy = false;
const fmt = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + " MB"
: n >= 1e3 ? (n / 1e3).toFixed(0) + " KB"
: n + " B";
function setStatus(text, cls = "") {
status.textContent = text || "";
status.className = "status" + (cls ? " " + cls : "");
}
function renderPreview() {
if (!last || !last.dataUrl) {
previewImg.hidden = true; previewImg.src = "";
previewEmpty.hidden = false;
metaSize.textContent = "—";
metaHost.textContent = "";
btnOpenTab.disabled = true;
btnDiscard.disabled = true;
return;
}
previewImg.src = last.dataUrl;
previewImg.hidden = false;
previewEmpty.hidden = true;
metaSize.textContent = `${last.width}×${last.height} · ${fmt(last.bytes || 0)}`;
metaHost.textContent = last.host || "";
btnOpenTab.disabled = false;
btnDiscard.disabled = false;
}
async function refreshRecent() {
try {
const list = await window.silentmode.invoke("listRecent", {});
if (!Array.isArray(list) || list.length === 0) {
recentBox.classList.remove("on");
recentStrip.innerHTML = "";
return;
}
recentBox.classList.add("on");
recentStrip.innerHTML = list.map((r) =>
`<div class="tile" data-name="${r.name.replace(/"/g,"&quot;")}" title="${r.name.replace(/"/g,"&quot;")} · ${fmt(r.bytes || 0)}">
<span class="dim">${r.mode ? r.mode.charAt(0).toUpperCase() + r.mode.slice(1) : ""}</span>
</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="">`;
}
} 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;
renderPreview();
setStatus(`Loaded ${b.name}.`, "ok");
} catch (e) {
setStatus("Couldn't load: " + (e && e.message || e), "err");
}
});
}
} catch (e) {
console.warn("refreshRecent failed:", e);
}
}
async function doCapture(mode) {
if (busy) return;
busy = true;
hdrStatus.textContent = "capturing…";
setStatus("");
if (mode === "region") setStatus("Drag on the page. Esc to cancel.");
else if (mode === "full") setStatus("Rendering full page — this can take a moment.");
for (const b of document.querySelectorAll("button.mode")) b.disabled = true;
try {
const res = await window.silentmode.invoke("capture", { mode });
if (res && res.cancelled) {
setStatus("Region capture cancelled.");
} else if (res && res.dataUrl) {
last = res;
renderPreview();
setStatus("Captured. Preview above; open in editor to annotate.", "ok");
refreshRecent();
} else {
setStatus("Nothing captured.", "err");
}
} catch (e) {
console.warn("capture failed:", e);
setStatus(String(e && e.message || e), "err");
} finally {
busy = false;
hdrStatus.textContent = "";
for (const b of document.querySelectorAll("button.mode")) b.disabled = false;
}
}
async function doOpenTab() {
if (!last || !last.dataUrl || busy) return;
busy = true;
btnOpenTab.disabled = true;
hdrStatus.textContent = "opening…";
try {
await window.silentmode.invoke("openInTab", { name: last.name });
setStatus("Opened in editor tab.", "ok");
} catch (e) {
console.warn("openInTab failed:", e);
setStatus("Open failed: " + (e && e.message || e), "err");
} finally {
busy = false;
btnOpenTab.disabled = !last;
hdrStatus.textContent = "";
}
}
for (const b of document.querySelectorAll("button.mode")) {
b.addEventListener("click", () => doCapture(b.dataset.mode));
}
btnOpenTab.addEventListener("click", doOpenTab);
btnDiscard.addEventListener("click", () => { last = null; renderPreview(); setStatus(""); });
clearBtn.addEventListener("click", async () => {
if (!confirm("Delete every past capture?")) return;
try { await window.silentmode.invoke("clearRecent", {}); await refreshRecent(); }
catch (e) { setStatus("Clear failed: " + (e && e.message || e), "err"); }
});
refreshRecent();
</script>
</body>
</html>