// 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: // 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; // 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 — 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 // 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); } function fileUrl(abs) { return "file:///" + abs.replace(/\\/g, "/").replace(/^\/+/, "").replace(/#/g, "%23").replace(/\?/g, "%3F"); } function nowStamp() { const d = new Date(); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } function pruneRecent(recent) { const alive = recent.filter((r) => { try { fs.accessSync(r.path); return true; } catch { return false; } }); const trimmed = alive.slice(0, MAX_RECENT); const dropped = alive.slice(MAX_RECENT); for (const r of dropped) { try { fs.unlinkSync(r.path); } catch {} } return trimmed; } function writeScratch(dataUrl, name) { const m = /^data:image\/png;base64,(.+)$/.exec(String(dataUrl)); if (!m) throw new Error("expected image/png data URL from capture"); const buf = Buffer.from(m[1], "base64"); const file = path.join(scratchDir, name); 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")}`; } // 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}`); } 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()}${mode === "full" ? "-fullpage" : mode === "region" ? "-region" : ""}.png`; const written = writeScratch(cap.dataUrl, name); let recent = api.storage.get("recent", []); if (!Array.isArray(recent)) recent = []; recent.unshift({ id: name, name, path: written.path, bytes: written.bytes, at: Date.now(), mode }); recent = pruneRecent(recent); api.storage.set("recent", recent); 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, }; }); // 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, bytes: r.bytes, at: r.at, mode: r.mode })); }); // 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 === 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", (payload) => { const wantedName = payload && payload.name ? String(payload.name) : ""; let recent = api.storage.get("recent", []) || []; if (wantedName) { const hit = recent.find((r) => r.id === wantedName); if (hit) { try { fs.unlinkSync(hit.path); } catch {} } recent = recent.filter((r) => r.id !== wantedName); } else { for (const r of recent) { try { fs.unlinkSync(r.path); } catch {} } recent = []; } api.storage.set("recent", recent); return { ok: true }; }); api.log("registered screenshot v0.3.0 sidebar panel"); }, };