Reworks the screenshot addon into the flow the user asked for: the dock icon opens a small dropdown menu (Visible viewport / Full page / Region…) instead of the sidebar picker, and each capture opens a full browser tab hosting an editor. Two new addon-host capabilities land alongside: - toolbar-menu: the addon declares an icon + item list in its manifest; the chrome dock renders a button that, on click, opens a small menu and dispatches the selection to the addon via addon-menu-select IPC. - open-tab: api.openTab(path) opens a browser tab whose URL is the addon's local file. Origin-gated per addon; the editor uses a dedicated addon-tab-preload for its main → renderer bridge. Editor page (editor.html/js/css): - Crop, arrow, rectangle, circle, freehand pen, text, blur - Colour swatches (red / yellow / acid / white / black), 3 stroke widths - Undo/redo command stack, zoom controls - Save PNG (goes through the download pipeline, chip picks it up) - Copy to clipboard via ClipboardItem
129 lines
6.5 KiB
JavaScript
129 lines
6.5 KiB
JavaScript
// 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.
|
|
//
|
|
// 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).
|
|
|
|
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
|
|
|
|
module.exports = {
|
|
activate(api) {
|
|
// 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.
|
|
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.
|
|
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) {
|
|
// 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 {} }
|
|
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 };
|
|
}
|
|
|
|
// 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}`);
|
|
}
|
|
// 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 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 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 = pruneRecent(recent);
|
|
api.storage.set("recent", recent);
|
|
api.log(`captured ${id} → ${name} (${written.bytes} bytes)`);
|
|
// Region mode = land in the crop tool immediately so the user can trim
|
|
// further if the freehand rect was rough.
|
|
const tool = id === "region" ? "crop" : "";
|
|
api.openTab("editor.html", { query: { src: fileUrl(written.path), name, tool } });
|
|
return { ok: true, name };
|
|
});
|
|
|
|
// 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.
|
|
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 }));
|
|
});
|
|
|
|
api.onMessage("openRecent", ({ id } = {}) => {
|
|
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 };
|
|
});
|
|
|
|
api.onMessage("clearRecent", ({ id } = {}) => {
|
|
let recent = api.storage.get("recent", []) || [];
|
|
if (id) {
|
|
const hit = recent.find((r) => r.id === id);
|
|
if (hit) { try { fs.unlinkSync(hit.path); } catch {} }
|
|
recent = recent.filter((r) => r.id !== id);
|
|
} 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 toolbar-menu (visible / full / region)");
|
|
},
|
|
};
|