feat(theseus/screenshot): bundled screenshot add-on (visible / full page / region)

New capture-tab capability on the addon-host, and the screenshot add-on
uses it to expose three modes in a sidebar launcher panel:

- Visible viewport: Electron's WebContents.capturePage() on the active tab
- Full scrollable page: temp-resize the tab view to document.scrollHeight,
  capturePage, restore
- Region: preload overlays a translucent selection div, tracks mousedown /
  move / up, sends the rect back; main takes the visible capture and
  crops via nativeImage.crop({x,y,width,height})

Saves land in the user's Downloads folder via session.downloadURL — same
pipeline as any file download, so the download chip picks them up.
Filename: theseus-screenshot-<host>-<ISO date>.png. JPEG option for
smaller files.

A follow-up task (task_b9608dc6) reworks this to open captures in a
full-tab editor with crop / draw / annotate / undo / copy-to-clipboard
instead of the current bare launcher.
This commit is contained in:
Local Dev 2026-09-07 00:18:48 +02:00
parent 0cf6ca10b4
commit 5642959eca
4 changed files with 404 additions and 0 deletions

View file

@ -0,0 +1,10 @@
{
"id": "screenshot",
"name": "Screenshot",
"version": "0.1.0",
"description": "Capture the current tab — visible viewport, entire scrollable page, or a rectangle you draw. Saves to Downloads as PNG (or JPEG for smaller files).",
"author": "Silent Mode",
"icon": "📸",
"main": "index.js",
"capabilities": ["sidebar-panel", "capture-tab"]
}

View file

@ -0,0 +1,49 @@
// Screenshot — capture the active tab. The panel drives everything through
// two messages ("capture" and "save"); main-side capture-tab does the actual
// pixel work. This module is a thin router.
const fs = require("node:fs");
const path = require("node:path");
module.exports = {
activate(api) {
api.registerSidebarPanel({
id: "main",
title: "Screenshot",
icon: "📸",
page: "panel.html",
});
// Region-select overlay source, loaded once at activation. The panel
// triggers region mode; main injects this 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
// the 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); }
api.onMessage("capture", async (payload) => {
const mode = String(payload?.mode || "visible");
const format = payload?.format === "jpeg" ? "jpeg" : "png";
const quality = Number(payload?.quality) || 90;
const opts = { mode, format, quality };
if (mode === "region") opts.overlaySource = overlaySource;
return api.captureTab(opts);
});
api.onMessage("save", async (payload) => {
const dataUrl = String(payload?.dataUrl || "");
const host = String(payload?.host || "tab");
const format = payload?.format === "jpeg" ? "jpeg" : "png";
const ext = format === "jpeg" ? "jpg" : "png";
// ISO date, but with the colons Windows won't accept in filenames swapped
// out. Second precision is enough; the filename is human-oriented.
const iso = new Date().toISOString().replace(/[:.]/g, "-").replace(/-\d{3}Z$/, "Z");
const safeHost = (host || "tab").replace(/[^a-z0-9._-]+/gi, "_") || "tab";
const filename = `theseus-screenshot-${safeHost}-${iso}.${ext}`;
return api.saveCapture({ dataUrl, filename });
});
api.log("registered screenshot panel");
},
};

View file

@ -0,0 +1,115 @@
// Region-select overlay. Injected into the target tab by main.js via
// wc.executeJavaScript(); the last expression is what executeJavaScript
// resolves with, so the whole file is an IIFE that returns a Promise for
// {x, y, w, h} — or null when the user cancels.
//
// Coordinates are CSS pixels relative to the viewport (not the document),
// which is exactly what WebContents.capturePage(rect) wants.
//
// Runs in the page's world, so it shares globals with the page. That's
// acceptable here: the overlay lives for a few seconds while the user
// drags, then removes itself and its listeners.
(() => {
return new Promise((resolve) => {
// If a previous overlay is still up (double-click on the panel button),
// tear it down before installing a fresh one.
const prev = document.getElementById("__theseus_screenshot_overlay__");
if (prev) prev.remove();
const overlay = document.createElement("div");
overlay.id = "__theseus_screenshot_overlay__";
overlay.style.cssText = [
"position:fixed", "inset:0", "z-index:2147483647",
"cursor:crosshair",
"background:rgba(11,14,20,0.35)",
"user-select:none", "-webkit-user-select:none",
].join(";");
const box = document.createElement("div");
box.style.cssText = [
"position:absolute", "left:0", "top:0", "width:0", "height:0",
"border:1px solid #d6ff3d",
"box-shadow:0 0 0 9999px rgba(11,14,20,0.35)",
"background:transparent",
"pointer-events:none",
].join(";");
box.hidden = true;
overlay.appendChild(box);
const hint = document.createElement("div");
hint.textContent = "Drag to select a region — Esc to cancel";
hint.style.cssText = [
"position:absolute", "top:12px", "left:50%", "transform:translateX(-50%)",
"padding:6px 12px", "border-radius:999px",
"background:rgba(11,14,20,0.92)", "color:#e7eaf1",
"font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif",
"border:1px solid rgba(214,255,61,0.30)", "pointer-events:none",
].join(";");
overlay.appendChild(hint);
document.documentElement.appendChild(overlay);
let startX = 0, startY = 0, dragging = false;
let curX = 0, curY = 0;
const dpr = window.devicePixelRatio || 1;
function updateBox(x, y, w, h) {
box.style.left = x + "px";
box.style.top = y + "px";
box.style.width = w + "px";
box.style.height = h + "px";
box.hidden = false;
}
function finish(result) {
cleanup();
resolve(result);
}
function cleanup() {
overlay.removeEventListener("mousedown", onDown, true);
window.removeEventListener("mousemove", onMove, true);
window.removeEventListener("mouseup", onUp, true);
window.removeEventListener("keydown", onKey, true);
overlay.removeEventListener("contextmenu", onCtx, true);
try { overlay.remove(); } catch {}
}
function onDown(e) {
if (e.button !== 0) return;
e.preventDefault(); e.stopPropagation();
dragging = true;
startX = e.clientX; startY = e.clientY;
curX = startX; curY = startY;
updateBox(startX, startY, 0, 0);
}
function onMove(e) {
if (!dragging) return;
curX = e.clientX; curY = e.clientY;
const x = Math.min(startX, curX), y = Math.min(startY, curY);
const w = Math.abs(curX - startX), h = Math.abs(curY - startY);
updateBox(x, y, w, h);
}
function onUp(e) {
if (!dragging) return;
dragging = false;
const x = Math.min(startX, curX), y = Math.min(startY, curY);
const w = Math.abs(curX - startX), h = Math.abs(curY - startY);
// A click-with-no-drag is treated as cancel — capturing a 0px region
// isn't useful, and it lets the user bail without hunting for Esc.
if (w < 4 || h < 4) { finish(null); return; }
finish({ x, y, w, h, dpr });
}
function onKey(e) {
if (e.key === "Escape") { e.preventDefault(); finish(null); }
}
function onCtx(e) { e.preventDefault(); finish(null); }
overlay.addEventListener("mousedown", onDown, true);
window.addEventListener("mousemove", onMove, true);
window.addEventListener("mouseup", onUp, true);
window.addEventListener("keydown", onKey, true);
overlay.addEventListener("contextmenu", onCtx, true);
});
})()

View file

@ -0,0 +1,230 @@
<!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; }
@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; }
}
* { 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; }
main { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; }
.row { display: flex; gap: 6px; }
.row.stack { flex-direction: column; }
button.mode {
flex: 1; min-width: 0;
padding: 10px 8px; 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: 12px; }
.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;
}
.prev-wrap img { max-width: 100%; max-height: 320px; border-radius: 4px; display: block; }
.format { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--mut); }
.format label { display: flex; gap: 4px; align-items: center; cursor: pointer; }
.format input[type="radio"] { accent-color: var(--acid); }
.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; }
.meta { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
.status { color: var(--mut); font-size: 12px; min-height: 16px; }
.status.err { color: #ff9081; }
.status.ok { color: var(--acid); }
</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="row">
<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 — fixed headers may repeat">
<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 id="meta-host"></span>
</div>
<div class="format">
<span>Format:</span>
<label><input type="radio" name="fmt" value="png" checked> PNG</label>
<label><input type="radio" name="fmt" value="jpeg"> JPEG</label>
</div>
<div class="actions">
<button class="act" id="btn-clear" disabled>Discard</button>
<button class="act primary" id="btn-save" disabled>Save to Downloads</button>
</div>
<div class="status" id="status"></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 btnSave = $("btn-save");
const btnClear = $("btn-clear");
const hdrStatus = $("hdr-status");
let last = null; // { dataUrl, width, height, host, format }
let busy = false;
const bytesFromDataUrl = (u) => {
const i = u.indexOf(",");
if (i < 0) return 0;
// base64 length → decoded byte count.
const b64 = u.slice(i + 1);
return Math.floor(b64.length * 3 / 4) - (b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0);
};
const fmt = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + " MB" : n >= 1e3 ? (n / 1e3).toFixed(0) + " KB" : n + " B";
function currentFormat() {
const el = document.querySelector('input[name="fmt"]:checked');
return el && el.value === "jpeg" ? "jpeg" : "png";
}
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 = "";
btnSave.disabled = true;
btnClear.disabled = true;
return;
}
previewImg.src = last.dataUrl;
previewImg.hidden = false;
previewEmpty.hidden = true;
metaSize.textContent = `${last.width}×${last.height} · ${fmt(bytesFromDataUrl(last.dataUrl))}`;
metaHost.textContent = last.host || "";
btnSave.disabled = false;
btnClear.disabled = false;
}
async function doCapture(mode) {
if (busy) return;
busy = true;
hdrStatus.textContent = "capturing…";
setStatus("");
// For region mode the user has to interact with the page; encourage them.
if (mode === "region") setStatus("Drag on the page. Esc to cancel.");
// For full-page the layout flicker is visible; explain.
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, format: currentFormat() });
if (res && res.cancelled) {
setStatus("Region capture cancelled.");
} else if (res && res.dataUrl) {
last = res;
renderPreview();
setStatus("Captured. Review, then save.", "ok");
} 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 doSave() {
if (!last || !last.dataUrl || busy) return;
busy = true;
btnSave.disabled = true;
hdrStatus.textContent = "saving…";
try {
const res = await window.silentmode.invoke("save", {
dataUrl: last.dataUrl,
host: last.host,
format: last.format,
});
setStatus("Saved: " + (res && res.savePath ? res.savePath : "Downloads"), "ok");
} catch (e) {
console.warn("save failed:", e);
setStatus("Save failed: " + String(e && e.message || e), "err");
} finally {
busy = false;
btnSave.disabled = !last;
hdrStatus.textContent = "";
}
}
for (const b of document.querySelectorAll("button.mode")) {
b.addEventListener("click", () => doCapture(b.dataset.mode));
}
btnSave.addEventListener("click", doSave);
btnClear.addEventListener("click", () => { last = null; renderPreview(); setStatus(""); });
// Changing format after a capture: reset preview so the meta size reflects
// the format that will actually be saved next.
for (const r of document.querySelectorAll('input[name="fmt"]')) {
r.addEventListener("change", () => {
if (last) setStatus("Format changed. Re-capture to see the new file size.");
});
}
</script>
</body>
</html>