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.
160 lines
7.6 KiB
JavaScript
160 lines
7.6 KiB
JavaScript
// 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 <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; // 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 — 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
|
|
// 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 "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 edit — take one first");
|
|
const dataUrl = readAsDataUrl(hit.path);
|
|
api.storage.set("__pending", { name: hit.name, dataUrl, at: Date.now() });
|
|
api.log(`arm → 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");
|
|
},
|
|
};
|