theseus/bundled-addons/screenshot/index.js
Local Dev 7b8539fb8d feat(theseus/screenshot): 0.6.4 — Polaroid sounds, trash icon, centred cluster, filename footer + open-in-folder
Rolling every user report from the 0.6.3 rollout into one bundle:

Sounds — the Web-Audio synth palette matches the metaphor now:
- Screenshot: Polaroid shutter — sharp metallic tick + curtain-close click
  chained to a film-advance whir (band-passed noise sweeping 900→400 Hz).
- Copy: printer "chika-chika-chika" — three descending percussive noise
  bursts pinned by short sine ticks. Reads as a print-head sweep.
- Discard: paper crumple — three overlapping band-limited noise beds
  with per-sample random-amplitude crackle, descending centre freq. No
  more descending sine "boop".
- Save: soft "photo dispensing" hiss (Polaroid ejects) + a small click.
- Both the panel and editor share the design so nothing sounds different
  depending on which surface fired it.

UI polish:
- Discard button now carries a trash-can icon so it's obviously not the
  same as the close-sidebar X (they both used to be plain X's).
- Toolbar drawing tools centre themselves via a new .tool-cluster
  wrapper (flex:1 1 auto, justify-content:center); the Copy/Save actions
  stay right-anchored via margin-left:auto on their own tgroup. Fixes
  the maximized-sidebar case where the drawing groups all crowded the
  left with a big empty gap before Copy/Save on the right.
- Filename moves out of the topbar into a dedicated footer strip under
  the canvas board, alongside a new "Open in folder" button. The topbar
  is now flex-wrap:nowrap and holds only fixed-width window controls,
  so a long filename can never push discard / sound / max / close onto
  a second row (the filename ellipsises instead).
- "Open in folder" invokes a new "openFolder" addon message that calls
  Electron's shell.showItemInFolder() to open the OS file explorer with
  the specific scratch PNG highlighted (falls back to shell.openPath()
  on the scratch dir when no capture is named).

Version bump so the OTA update endpoint picks it up on the next tick.
2026-09-09 22:34:47 +02:00

162 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,
};
});
// (The old "arm" handler that pre-armed a __pending storage entry is
// gone: editor.html now pulls its bytes via getBytes on load, so there
// is nothing to hand off in advance. Idempotent — reopening the editor
// in the sidebar just re-fetches the bytes.)
// 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 };
});
// Show the scratch dir (or a specific capture inside it) in the OS
// file explorer. `api.require("electron").shell.openPath` opens the
// folder; `showItemInFolder` opens the folder AND highlights the
// file — used when the panel/editor knows a specific capture.
api.onMessage("openFolder", (payload) => {
const { shell } = api.require("electron");
const wantedName = payload && payload.name ? String(payload.name) : "";
if (wantedName) {
const recent = api.storage.get("recent", []) || [];
const hit = recent.find((r) => r.id === wantedName);
if (hit) { shell.showItemInFolder(hit.path); return { ok: true, path: hit.path }; }
}
shell.openPath(scratchDir);
return { ok: true, path: scratchDir };
});
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.5.0 sidebar panel");
},
};