theseus/bundled-addons/screenshot/editor.js

622 lines
22 KiB
JavaScript
Raw Normal View History

// Screenshot editor. Three stacked canvases:
// #committed — pristine bitmap after every applied edit
// #draw — receives pointer events; hosts the live preview during a drag
// #overlay — the crop/blur selection chrome (dashed rect, dim mask)
//
// Undo/redo is snapshot-based for correctness over cleverness: each committed
// edit pushes an ImageData onto an undo stack. Redo stack is cleared as soon
// as a new edit lands. Memory footprint is width * height * 4 * (stack depth);
// for a 1920x1080 image at depth 20 that's ~170 MB, so we cap the stack.
const UNDO_MAX = 25;
const $ = (id) => document.getElementById(id);
const committed = $("committed");
const draw = $("draw");
const overlay = $("overlay");
const stage = $("stage");
const board = $("board");
const nameEl = $("name");
const undoBtn = $("undo");
const redoBtn = $("redo");
const applyCropBtn = $("apply-crop");
const cancelCropBtn = $("cancel-crop");
const hintEl = $("hint");
const toastEl = $("toast");
const cctx = committed.getContext("2d");
const dctx = draw.getContext("2d");
const octx = overlay.getContext("2d");
// URL params tell us what to load and (optionally) which tool to preselect.
const params = new URLSearchParams(location.search);
const srcUrl = params.get("src") || "";
const baseName = params.get("name") || "screenshot.png";
const initialTool = params.get("tool") || "";
nameEl.textContent = baseName;
document.title = baseName + " — editor";
let state = {
tool: "select",
color: "#d6ff3d",
width: 4,
dragging: false,
start: null, // {x,y} in canvas coords (not CSS pixels)
end: null,
path: null, // pen points
cropRect: null, // {x,y,w,h} in canvas coords
textInput: null, // {x,y, el}
};
let undo = []; // ImageData
let redo = [];
// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------
function toast(msg, err) {
toastEl.textContent = msg;
toastEl.classList.toggle("err", !!err);
toastEl.classList.add("on");
clearTimeout(toast._t);
toast._t = setTimeout(() => toastEl.classList.remove("on"), 1600);
}
function showHint(msg) {
hintEl.textContent = msg || "";
hintEl.classList.toggle("on", !!msg);
}
function updateUndoButtons() {
undoBtn.disabled = undo.length <= 1; // one entry = the base image
redoBtn.disabled = redo.length === 0;
}
function pushSnapshot() {
try {
const snap = cctx.getImageData(0, 0, committed.width, committed.height);
undo.push(snap);
if (undo.length > UNDO_MAX) undo.splice(0, undo.length - UNDO_MAX);
redo.length = 0;
updateUndoButtons();
} catch (e) { console.warn("snapshot failed:", e); }
}
function restoreSnapshot(snap) {
if (!snap) return;
// Resize canvases to match the snapshot (crop is destructive to size).
if (committed.width !== snap.width || committed.height !== snap.height) {
sizeCanvases(snap.width, snap.height);
}
cctx.putImageData(snap, 0, 0);
}
function sizeCanvases(w, h) {
for (const c of [committed, draw, overlay]) {
c.width = w;
c.height = h;
// Match CSS size so 1 canvas px = 1 CSS px unless the board scales it.
c.style.width = w + "px";
c.style.height = h + "px";
}
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = (e) => reject(new Error("failed to load image"));
img.src = url;
});
}
async function init() {
fix(theseus/screenshot): 0.2.4 — deliver capture via addon storage, not a cross-origin file:// Blank editor + broken buttons root cause: index.js was writing the capture to <userData>/addons-data/screenshot-scratch/<name>.png and passing "?src=file://<that path>" to editor.html. The editor lives at file:///<userData>/addons/screenshot/editor.html — different directory tree under file://. Chromium's file:// origin policy treats those as different origins and quietly refuses the <img> load, so init()'s loadImage() rejects, the canvas never gets an image, and every tool after that operates on a still-empty 300×150 default canvas — the tools appear to work but produce no visible output because the base image never landed. The sidebar version we replaced set `previewImg.src = dataUrl` (a base64 data URL) directly, which has no origin and just worked; the tab version regressed by adding the file hop. Fix keeps the scratch file for the recent-captures ring but hands the raw capture through the add-on's per-add-on kv store (`__pending` key). Same store, same origin scoping, no cross-directory read: index.js writes via api.storage.set from main; editor.js reads via window.silentmode.storage.get through the tab preload (packaged since 0.3.27). Fallback path retained for "openRecent" callers still passing ?src=… — those will need their own fix in a follow-up. Bumped to 0.2.4 and signed for the OTA endpoint — first real independent add-on ship: no Theseus release needed to fix this, 0.3.27 installs pick up 0.2.4 via the boot-time signed-update poll.
2026-09-08 12:57:46 +02:00
// Preferred source: a pending capture handed to us through addon storage
// (the toolbar-menu capture path). A file:// src won't load — Chromium
// treats the editor at file:///…/addons/screenshot/editor.html as a
// different origin from the scratch PNG at file:///…/addons-data/…,
// so <img> quietly errors. Storage-based delivery sidesteps the origin
// entirely.
let dataUrl = null;
let toolFromPending = "";
let pendingName = baseName;
try {
if (window.silentmode?.storage) {
const pending = await window.silentmode.storage.get("__pending", null);
if (pending && pending.dataUrl) {
dataUrl = pending.dataUrl;
toolFromPending = pending.tool || "";
if (pending.name) { pendingName = pending.name; nameEl.textContent = pending.name; document.title = pending.name + " — editor"; }
// Clear so a later editor open (e.g. "recent captures") doesn't
// accidentally reload the same capture.
await window.silentmode.storage.set("__pending", null);
}
}
} catch (e) { console.warn("editor: storage read failed:", e); }
// Fallback path — retained so the "openRecent" flow (which currently
// still passes ?src=file://) keeps working after we teach it to use
// storage too.
if (!dataUrl) {
if (!srcUrl) { toast("No capture to edit", true); return; }
dataUrl = srcUrl;
}
let img;
fix(theseus/screenshot): 0.2.4 — deliver capture via addon storage, not a cross-origin file:// Blank editor + broken buttons root cause: index.js was writing the capture to <userData>/addons-data/screenshot-scratch/<name>.png and passing "?src=file://<that path>" to editor.html. The editor lives at file:///<userData>/addons/screenshot/editor.html — different directory tree under file://. Chromium's file:// origin policy treats those as different origins and quietly refuses the <img> load, so init()'s loadImage() rejects, the canvas never gets an image, and every tool after that operates on a still-empty 300×150 default canvas — the tools appear to work but produce no visible output because the base image never landed. The sidebar version we replaced set `previewImg.src = dataUrl` (a base64 data URL) directly, which has no origin and just worked; the tab version regressed by adding the file hop. Fix keeps the scratch file for the recent-captures ring but hands the raw capture through the add-on's per-add-on kv store (`__pending` key). Same store, same origin scoping, no cross-directory read: index.js writes via api.storage.set from main; editor.js reads via window.silentmode.storage.get through the tab preload (packaged since 0.3.27). Fallback path retained for "openRecent" callers still passing ?src=… — those will need their own fix in a follow-up. Bumped to 0.2.4 and signed for the OTA endpoint — first real independent add-on ship: no Theseus release needed to fix this, 0.3.27 installs pick up 0.2.4 via the boot-time signed-update poll.
2026-09-08 12:57:46 +02:00
try { img = await loadImage(dataUrl); }
catch (e) { toast("Couldn't load capture: " + e.message, true); return; }
sizeCanvases(img.naturalWidth, img.naturalHeight);
cctx.drawImage(img, 0, 0);
undo = [];
redo = [];
pushSnapshot(); // baseline so the very first edit is undoable
updateUndoButtons();
fitBoard();
fix(theseus/screenshot): 0.2.4 — deliver capture via addon storage, not a cross-origin file:// Blank editor + broken buttons root cause: index.js was writing the capture to <userData>/addons-data/screenshot-scratch/<name>.png and passing "?src=file://<that path>" to editor.html. The editor lives at file:///<userData>/addons/screenshot/editor.html — different directory tree under file://. Chromium's file:// origin policy treats those as different origins and quietly refuses the <img> load, so init()'s loadImage() rejects, the canvas never gets an image, and every tool after that operates on a still-empty 300×150 default canvas — the tools appear to work but produce no visible output because the base image never landed. The sidebar version we replaced set `previewImg.src = dataUrl` (a base64 data URL) directly, which has no origin and just worked; the tab version regressed by adding the file hop. Fix keeps the scratch file for the recent-captures ring but hands the raw capture through the add-on's per-add-on kv store (`__pending` key). Same store, same origin scoping, no cross-directory read: index.js writes via api.storage.set from main; editor.js reads via window.silentmode.storage.get through the tab preload (packaged since 0.3.27). Fallback path retained for "openRecent" callers still passing ?src=… — those will need their own fix in a follow-up. Bumped to 0.2.4 and signed for the OTA endpoint — first real independent add-on ship: no Theseus release needed to fix this, 0.3.27 installs pick up 0.2.4 via the boot-time signed-update poll.
2026-09-08 12:57:46 +02:00
const t = initialTool || toolFromPending;
if (t) setTool(t);
}
// Scale the stage to fit within the board when the image is bigger than
// the viewport, so users see the whole shot without scrolling. We scale
// visually (CSS transform); drawing math still uses natural canvas
// coordinates.
let stageScale = 1;
function fitBoard() {
const availW = board.clientWidth - 40;
const availH = board.clientHeight - 40;
const s = Math.min(1, availW / committed.width, availH / committed.height);
stageScale = s > 0 ? s : 1;
stage.style.transform = `scale(${stageScale})`;
stage.style.transformOrigin = "top left";
// Reserve room so the scaled stage isn't clipped by the flex layout.
stage.style.width = (committed.width * stageScale) + "px";
stage.style.height = (committed.height * stageScale) + "px";
// Undo the reservation on the inner canvases — they must stay at natural
// size so the transform can scale them uniformly.
for (const c of [committed, draw, overlay]) {
c.style.width = committed.width + "px";
c.style.height = committed.height + "px";
}
// Keep the "reserved" outer wrapper's natural children visible.
stage.style.position = "relative";
committed.style.position = "static";
}
window.addEventListener("resize", () => fitBoard());
// ---------------------------------------------------------------------------
// Tool selection
// ---------------------------------------------------------------------------
function setTool(name) {
state.tool = name;
stage.dataset.tool = name;
for (const b of document.querySelectorAll(".tool[data-tool]")) {
b.classList.toggle("active", b.dataset.tool === name);
}
// Crop has a two-step commit; show its buttons when relevant.
const isCrop = name === "crop";
applyCropBtn.hidden = !isCrop || !state.cropRect;
cancelCropBtn.hidden = !isCrop || !state.cropRect;
if (!isCrop) { state.cropRect = null; clearOverlay(); }
clearDraw();
const hints = {
crop: "Drag a rectangle, then Apply crop",
arrow: "Drag to draw an arrow",
rect: "Drag to draw a rectangle",
ellipse: "Drag to draw an ellipse",
pen: "Draw freehand",
text: "Click to place a label",
blur: "Drag a rectangle to pixelate",
select: "",
};
showHint(hints[name] || "");
}
for (const b of document.querySelectorAll(".tool[data-tool]")) {
b.addEventListener("click", () => setTool(b.dataset.tool));
}
for (const b of document.querySelectorAll(".swatch")) {
b.addEventListener("click", () => {
state.color = b.dataset.color;
for (const x of document.querySelectorAll(".swatch")) x.classList.toggle("active", x === b);
});
}
for (const b of document.querySelectorAll(".width")) {
b.addEventListener("click", () => {
state.width = Number(b.dataset.width);
for (const x of document.querySelectorAll(".width")) x.classList.toggle("active", x === b);
});
}
// ---------------------------------------------------------------------------
// Drawing primitives on ANY 2D context — used for both the live preview
// and the committed bake. Coordinates are in natural canvas px.
// ---------------------------------------------------------------------------
function drawArrow(ctx, x1, y1, x2, y2, color, width) {
ctx.save();
ctx.strokeStyle = color; ctx.fillStyle = color;
ctx.lineWidth = width; ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1;
const head = Math.max(10, width * 3);
const ux = dx / len, uy = dy / len;
const px = -uy, py = ux;
const tipX = x2, tipY = y2;
const baseX = x2 - ux * head, baseY = y2 - uy * head;
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(baseX + px * head * 0.5, baseY + py * head * 0.5);
ctx.lineTo(baseX - px * head * 0.5, baseY - py * head * 0.5);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawRect(ctx, x, y, w, h, color, width) {
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = width;
// Half-pixel offset for crisp 1px lines is not worth the branching at
// small width; the visible fuzz is negligible past width 2.
ctx.strokeRect(x, y, w, h);
ctx.restore();
}
function drawEllipse(ctx, x, y, w, h, color, width) {
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = width;
ctx.beginPath();
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
function drawPen(ctx, points, color, width) {
if (!points || points.length < 2) return;
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = width;
ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.stroke();
ctx.restore();
}
function drawTextLabel(ctx, x, y, text, color) {
if (!text) return;
ctx.save();
ctx.font = `600 18px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
ctx.textBaseline = "top";
const metrics = ctx.measureText(text);
const w = Math.ceil(metrics.width) + 8, h = 22;
// Backdrop for legibility over any background.
ctx.fillStyle = "rgba(0,0,0,.65)";
ctx.fillRect(x - 4, y - 2, w, h);
ctx.fillStyle = color;
ctx.fillText(text, x, y);
ctx.restore();
}
// Mosaic pixelation: sample the region into a small offscreen, then draw
// back at full size with imageSmoothingEnabled off so each sample lands as
// a chunky square. Block size scales with stroke width for a "coarser /
// finer" knob on the same tool.
function applyMosaic(ctx, x, y, w, h, width) {
if (w <= 0 || h <= 0) return;
const block = Math.max(6, Math.min(40, width * 3));
const sw = Math.max(1, Math.round(w / block));
const sh = Math.max(1, Math.round(h / block));
const tmp = document.createElement("canvas");
tmp.width = sw; tmp.height = sh;
const tctx = tmp.getContext("2d");
tctx.imageSmoothingEnabled = false;
tctx.drawImage(ctx.canvas, x, y, w, h, 0, 0, sw, sh);
ctx.save();
ctx.imageSmoothingEnabled = false;
ctx.drawImage(tmp, 0, 0, sw, sh, x, y, w, h);
ctx.restore();
}
function clearDraw() { dctx.clearRect(0, 0, draw.width, draw.height); }
function clearOverlay() { octx.clearRect(0, 0, overlay.width, overlay.height); }
function drawSelectionChrome(rect) {
clearOverlay();
if (!rect) return;
// Dim the surrounding area so the crop rect stands out.
octx.save();
octx.fillStyle = "rgba(0,0,0,.45)";
octx.fillRect(0, 0, overlay.width, overlay.height);
octx.clearRect(rect.x, rect.y, rect.w, rect.h);
octx.strokeStyle = "#d6ff3d";
octx.lineWidth = 1.5;
octx.setLineDash([6, 4]);
octx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1);
octx.restore();
}
// ---------------------------------------------------------------------------
// Pointer wiring
// ---------------------------------------------------------------------------
function pointerToCanvas(ev) {
const r = draw.getBoundingClientRect();
// r.width / draw.width gives us CSS px per canvas px, i.e. our current
// stageScale — computing it from the rect keeps us honest even if the
// fit-to-board math ever drifts.
const sx = draw.width / r.width;
const sy = draw.height / r.height;
return { x: (ev.clientX - r.left) * sx, y: (ev.clientY - r.top) * sy };
}
function normRect(a, b) {
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
const w = Math.abs(a.x - b.x), h = Math.abs(a.y - b.y);
return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
}
draw.addEventListener("pointerdown", (ev) => {
if (state.tool === "select") return;
if (state.tool === "text") {
beginText(ev);
return;
}
draw.setPointerCapture(ev.pointerId);
state.dragging = true;
state.start = pointerToCanvas(ev);
state.end = state.start;
if (state.tool === "pen") state.path = [state.start];
});
draw.addEventListener("pointermove", (ev) => {
if (!state.dragging) return;
state.end = pointerToCanvas(ev);
if (state.tool === "pen") {
state.path.push(state.end);
// Live-render the whole path each move; simpler than incremental and
// fine at freehand cadence.
clearDraw();
drawPen(dctx, state.path, state.color, state.width);
return;
}
const r = normRect(state.start, state.end);
if (state.tool === "crop" || state.tool === "blur") {
drawSelectionChrome(r);
return;
}
clearDraw();
if (state.tool === "arrow") drawArrow(dctx, state.start.x, state.start.y, state.end.x, state.end.y, state.color, state.width);
if (state.tool === "rect") drawRect(dctx, r.x, r.y, r.w, r.h, state.color, state.width);
if (state.tool === "ellipse") drawEllipse(dctx, r.x, r.y, r.w, r.h, state.color, state.width);
});
draw.addEventListener("pointerup", (ev) => {
if (!state.dragging) return;
state.dragging = false;
try { draw.releasePointerCapture(ev.pointerId); } catch {}
const r = normRect(state.start, state.end);
if (state.tool === "pen") {
if (state.path && state.path.length > 1) {
drawPen(cctx, state.path, state.color, state.width);
pushSnapshot();
}
state.path = null;
clearDraw();
return;
}
if (state.tool === "arrow") {
if (Math.hypot(state.end.x - state.start.x, state.end.y - state.start.y) > 3) {
drawArrow(cctx, state.start.x, state.start.y, state.end.x, state.end.y, state.color, state.width);
pushSnapshot();
}
clearDraw();
return;
}
if (state.tool === "rect" || state.tool === "ellipse") {
if (r.w > 3 && r.h > 3) {
(state.tool === "rect" ? drawRect : drawEllipse)(cctx, r.x, r.y, r.w, r.h, state.color, state.width);
pushSnapshot();
}
clearDraw();
return;
}
if (state.tool === "blur") {
if (r.w > 3 && r.h > 3) {
applyMosaic(cctx, r.x, r.y, r.w, r.h, state.width);
pushSnapshot();
}
clearOverlay();
return;
}
if (state.tool === "crop") {
if (r.w > 3 && r.h > 3) {
state.cropRect = r;
applyCropBtn.hidden = false;
cancelCropBtn.hidden = false;
} else {
state.cropRect = null;
clearOverlay();
applyCropBtn.hidden = true;
cancelCropBtn.hidden = true;
}
}
});
feat(theseus/addons): CDP capture + editor Discard + manual update controls Three tied-together fixes: 1) captureTab moves from WebContents.capturePage() to CDP Page.captureScreenshot for every mode (visible / full / region). Blank-screenshot symptom: after a toolbar-menu selection, the OS popup teardown left the tab view marked occluded for a few frames on some Windows setups, so capturePage() snapshotted a stale/transparent frame at the correct dimensions — no 0x0, no retry hit. CDP forces a fresh composite regardless of occlusion state (same path the "Full page" mode was already using) and returns a base64 PNG directly; PNG dimensions come out of the IHDR chunk (bytes 16-24). Attach only when nothing else has, and detach after only if WE attached, so an open DevTools stays attached. 2) Editor gets a Discard button. Toolbar picks up an "×" glyph next to Save/Copy that closes the editor tab and drops the working screenshot. Top-level Escape now falls through the same path after unwinding an in-flight text placement or crop rectangle. A new "addon-tab-close" IPC lets an add-on's own tab close itself (main matches the sender's webContents id against the tab list, so a page can only close its own tab); window.silentmode.closeTab() exposes it from addon-tab-preload.js. 3) Manual update controls in Settings > Extensions. New "Check for updates" button at the top of the Extensions surface calls the same signed-update polling the boot timer runs; the result is surfaced inline ("All extensions are up to date" / "N updates staged; restart Theseus to apply"). A "Pending updates" box below lists what's in <userData>/addons-updates-staged/ so the user knows what will be promoted on next restart. Toolbar-menu popup settle bumped from 120 ms to 250 ms with an explicit win.focus() in the popup close callback — the previous window wasn't enough on slower Windows setups. CDP capture no longer depends on this delay anyway, but the settle still helps any add-on that does DOM work in its click handler before capture. Screenshot add-on bumped 0.2.2 → 0.2.3 (Discard button; capture fixes come from the host, not the add-on).
2026-09-08 02:27:36 +02:00
// Escape unwinds progressively: in-flight text placement → in-flight
// crop rectangle → whole editor (drops the screenshot and closes the tab).
window.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") {
if (state.textInput) { cancelText(); ev.preventDefault(); return; }
feat(theseus/addons): CDP capture + editor Discard + manual update controls Three tied-together fixes: 1) captureTab moves from WebContents.capturePage() to CDP Page.captureScreenshot for every mode (visible / full / region). Blank-screenshot symptom: after a toolbar-menu selection, the OS popup teardown left the tab view marked occluded for a few frames on some Windows setups, so capturePage() snapshotted a stale/transparent frame at the correct dimensions — no 0x0, no retry hit. CDP forces a fresh composite regardless of occlusion state (same path the "Full page" mode was already using) and returns a base64 PNG directly; PNG dimensions come out of the IHDR chunk (bytes 16-24). Attach only when nothing else has, and detach after only if WE attached, so an open DevTools stays attached. 2) Editor gets a Discard button. Toolbar picks up an "×" glyph next to Save/Copy that closes the editor tab and drops the working screenshot. Top-level Escape now falls through the same path after unwinding an in-flight text placement or crop rectangle. A new "addon-tab-close" IPC lets an add-on's own tab close itself (main matches the sender's webContents id against the tab list, so a page can only close its own tab); window.silentmode.closeTab() exposes it from addon-tab-preload.js. 3) Manual update controls in Settings > Extensions. New "Check for updates" button at the top of the Extensions surface calls the same signed-update polling the boot timer runs; the result is surfaced inline ("All extensions are up to date" / "N updates staged; restart Theseus to apply"). A "Pending updates" box below lists what's in <userData>/addons-updates-staged/ so the user knows what will be promoted on next restart. Toolbar-menu popup settle bumped from 120 ms to 250 ms with an explicit win.focus() in the popup close callback — the previous window wasn't enough on slower Windows setups. CDP capture no longer depends on this delay anyway, but the settle still helps any add-on that does DOM work in its click handler before capture. Screenshot add-on bumped 0.2.2 → 0.2.3 (Discard button; capture fixes come from the host, not the add-on).
2026-09-08 02:27:36 +02:00
if (state.tool === "crop" && state.cropRect) { state.cropRect = null; clearOverlay(); applyCropBtn.hidden = true; cancelCropBtn.hidden = true; return; }
discard(); ev.preventDefault(); return;
}
// Undo / redo shortcuts.
const meta = ev.ctrlKey || ev.metaKey;
if (meta && !ev.shiftKey && ev.key.toLowerCase() === "z") { doUndo(); ev.preventDefault(); return; }
if (meta && ev.shiftKey && ev.key.toLowerCase() === "z") { doRedo(); ev.preventDefault(); return; }
if (meta && ev.key.toLowerCase() === "y") { doRedo(); ev.preventDefault(); return; }
if (meta && ev.key.toLowerCase() === "s") { save(); ev.preventDefault(); return; }
if (meta && ev.key.toLowerCase() === "c" && !state.textInput) { copy(); ev.preventDefault(); return; }
});
// ---------------------------------------------------------------------------
// Text tool: click places an input; blur/Enter commits, Escape cancels.
// ---------------------------------------------------------------------------
function beginText(ev) {
if (state.textInput) commitText();
const p = pointerToCanvas(ev);
const inp = document.createElement("input");
inp.type = "text";
inp.className = "text-input";
inp.placeholder = "text";
// Place using viewport coords — body isn't positioned, so absolute
// left/top match clientX/Y as long as the board isn't scrolled.
inp.style.left = (ev.clientX + board.scrollLeft) + "px";
inp.style.top = (ev.clientY + board.scrollTop) + "px";
inp.style.color = state.color;
document.body.appendChild(inp);
inp.focus();
state.textInput = { x: p.x, y: p.y, el: inp, color: state.color };
inp.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); commitText(); }
else if (e.key === "Escape") { e.preventDefault(); cancelText(); }
});
inp.addEventListener("blur", () => setTimeout(commitText, 0));
}
function commitText() {
const t = state.textInput; if (!t) return;
const text = t.el.value.trim();
t.el.remove();
state.textInput = null;
if (!text) return;
drawTextLabel(cctx, t.x, t.y, text, t.color);
pushSnapshot();
}
function cancelText() {
const t = state.textInput; if (!t) return;
t.el.remove();
state.textInput = null;
}
// ---------------------------------------------------------------------------
// Undo / redo
// ---------------------------------------------------------------------------
function doUndo() {
if (undo.length <= 1) return;
const cur = undo.pop();
redo.push(cur);
const prev = undo[undo.length - 1];
restoreSnapshot(prev);
clearDraw(); clearOverlay();
state.cropRect = null;
applyCropBtn.hidden = true;
cancelCropBtn.hidden = true;
updateUndoButtons();
}
function doRedo() {
if (!redo.length) return;
const snap = redo.pop();
undo.push(snap);
restoreSnapshot(snap);
updateUndoButtons();
}
undoBtn.addEventListener("click", doUndo);
redoBtn.addEventListener("click", doRedo);
// ---------------------------------------------------------------------------
// Crop apply
// ---------------------------------------------------------------------------
applyCropBtn.addEventListener("click", () => {
const r = state.cropRect; if (!r) return;
// Clamp to canvas.
const x = Math.max(0, r.x), y = Math.max(0, r.y);
const w = Math.min(r.w, committed.width - x);
const h = Math.min(r.h, committed.height - y);
if (w <= 0 || h <= 0) { toast("Crop out of bounds", true); return; }
const tmp = document.createElement("canvas");
tmp.width = w; tmp.height = h;
tmp.getContext("2d").drawImage(committed, x, y, w, h, 0, 0, w, h);
sizeCanvases(w, h);
cctx.drawImage(tmp, 0, 0);
state.cropRect = null;
clearOverlay(); clearDraw();
applyCropBtn.hidden = true;
cancelCropBtn.hidden = true;
pushSnapshot();
fitBoard();
});
cancelCropBtn.addEventListener("click", () => {
state.cropRect = null;
clearOverlay();
applyCropBtn.hidden = true;
cancelCropBtn.hidden = true;
});
// ---------------------------------------------------------------------------
// Save & copy
// ---------------------------------------------------------------------------
function canvasBlob() {
return new Promise((resolve, reject) => {
committed.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob returned null")), "image/png");
});
}
async function save() {
try {
const blob = await canvasBlob();
// Chromium's will-download listener catches this via the download attr;
// no separate capability needed.
const url = URL.createObjectURL(blob);
const a = $("download-link");
a.href = url;
a.download = baseName || "screenshot.png";
a.click();
// Blob URLs are cheap but leak; release once the browser has had a beat
// to start the download.
setTimeout(() => URL.revokeObjectURL(url), 4000);
toast(`Saved ${baseName} (${(blob.size / 1024).toFixed(1)} KB)`);
} catch (e) {
toast("Save failed: " + e.message, true);
}
}
async function copy() {
try {
const blob = await canvasBlob();
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
toast("Copied to clipboard");
} catch (e) {
toast("Copy failed: " + e.message, true);
}
}
$("save").addEventListener("click", save);
$("copy").addEventListener("click", copy);
feat(theseus/addons): CDP capture + editor Discard + manual update controls Three tied-together fixes: 1) captureTab moves from WebContents.capturePage() to CDP Page.captureScreenshot for every mode (visible / full / region). Blank-screenshot symptom: after a toolbar-menu selection, the OS popup teardown left the tab view marked occluded for a few frames on some Windows setups, so capturePage() snapshotted a stale/transparent frame at the correct dimensions — no 0x0, no retry hit. CDP forces a fresh composite regardless of occlusion state (same path the "Full page" mode was already using) and returns a base64 PNG directly; PNG dimensions come out of the IHDR chunk (bytes 16-24). Attach only when nothing else has, and detach after only if WE attached, so an open DevTools stays attached. 2) Editor gets a Discard button. Toolbar picks up an "×" glyph next to Save/Copy that closes the editor tab and drops the working screenshot. Top-level Escape now falls through the same path after unwinding an in-flight text placement or crop rectangle. A new "addon-tab-close" IPC lets an add-on's own tab close itself (main matches the sender's webContents id against the tab list, so a page can only close its own tab); window.silentmode.closeTab() exposes it from addon-tab-preload.js. 3) Manual update controls in Settings > Extensions. New "Check for updates" button at the top of the Extensions surface calls the same signed-update polling the boot timer runs; the result is surfaced inline ("All extensions are up to date" / "N updates staged; restart Theseus to apply"). A "Pending updates" box below lists what's in <userData>/addons-updates-staged/ so the user knows what will be promoted on next restart. Toolbar-menu popup settle bumped from 120 ms to 250 ms with an explicit win.focus() in the popup close callback — the previous window wasn't enough on slower Windows setups. CDP capture no longer depends on this delay anyway, but the settle still helps any add-on that does DOM work in its click handler before capture. Screenshot add-on bumped 0.2.2 → 0.2.3 (Discard button; capture fixes come from the host, not the add-on).
2026-09-08 02:27:36 +02:00
$("discard").addEventListener("click", discard);
feat(theseus/screenshot): 0.4.0 — editor lives inside the sidebar, maximizable 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.
2026-09-08 22:18:41 +02:00
// Back button — navigate the sidebar view back to panel.html. Same
// webContents, so it's just a location swap; no IPC needed.
const backBtn = $("back");
if (backBtn) backBtn.addEventListener("click", () => { location.href = "panel.html"; });
// Maximize / restore — asks the sidebar host to widen its view to the full
// window and back. Icon reflects state via silentmode.sidebar.onMaxChange.
const maxBtn = $("toggle-max");
if (maxBtn && window.silentmode?.sidebar) {
maxBtn.addEventListener("click", async () => {
try { await window.silentmode.sidebar.toggleMax(); }
catch (e) { console.warn("toggleMax failed:", e); }
});
const paint = (isMax) => {
maxBtn.title = isMax ? "Restore sidebar width" : "Expand the sidebar to full window";
maxBtn.innerHTML = isMax
? '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2v4H2M10 2v4h4M6 14v-4H2M10 14v-4h4"/></svg>'
: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/></svg>';
};
window.silentmode.sidebar.onMaxChange(paint);
window.silentmode.sidebar.isMax().then(paint).catch(() => {});
}
// Drop the working screenshot and go back to the sidebar panel. Used by
// the Discard button and the top-level Escape shortcut.
feat(theseus/addons): CDP capture + editor Discard + manual update controls Three tied-together fixes: 1) captureTab moves from WebContents.capturePage() to CDP Page.captureScreenshot for every mode (visible / full / region). Blank-screenshot symptom: after a toolbar-menu selection, the OS popup teardown left the tab view marked occluded for a few frames on some Windows setups, so capturePage() snapshotted a stale/transparent frame at the correct dimensions — no 0x0, no retry hit. CDP forces a fresh composite regardless of occlusion state (same path the "Full page" mode was already using) and returns a base64 PNG directly; PNG dimensions come out of the IHDR chunk (bytes 16-24). Attach only when nothing else has, and detach after only if WE attached, so an open DevTools stays attached. 2) Editor gets a Discard button. Toolbar picks up an "×" glyph next to Save/Copy that closes the editor tab and drops the working screenshot. Top-level Escape now falls through the same path after unwinding an in-flight text placement or crop rectangle. A new "addon-tab-close" IPC lets an add-on's own tab close itself (main matches the sender's webContents id against the tab list, so a page can only close its own tab); window.silentmode.closeTab() exposes it from addon-tab-preload.js. 3) Manual update controls in Settings > Extensions. New "Check for updates" button at the top of the Extensions surface calls the same signed-update polling the boot timer runs; the result is surfaced inline ("All extensions are up to date" / "N updates staged; restart Theseus to apply"). A "Pending updates" box below lists what's in <userData>/addons-updates-staged/ so the user knows what will be promoted on next restart. Toolbar-menu popup settle bumped from 120 ms to 250 ms with an explicit win.focus() in the popup close callback — the previous window wasn't enough on slower Windows setups. CDP capture no longer depends on this delay anyway, but the settle still helps any add-on that does DOM work in its click handler before capture. Screenshot add-on bumped 0.2.2 → 0.2.3 (Discard button; capture fixes come from the host, not the add-on).
2026-09-08 02:27:36 +02:00
async function discard() {
feat(theseus/screenshot): 0.4.0 — editor lives inside the sidebar, maximizable 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.
2026-09-08 22:18:41 +02:00
try { if (window.silentmode?.storage) await window.silentmode.storage.set("__pending", null); } catch {}
location.href = "panel.html";
feat(theseus/addons): CDP capture + editor Discard + manual update controls Three tied-together fixes: 1) captureTab moves from WebContents.capturePage() to CDP Page.captureScreenshot for every mode (visible / full / region). Blank-screenshot symptom: after a toolbar-menu selection, the OS popup teardown left the tab view marked occluded for a few frames on some Windows setups, so capturePage() snapshotted a stale/transparent frame at the correct dimensions — no 0x0, no retry hit. CDP forces a fresh composite regardless of occlusion state (same path the "Full page" mode was already using) and returns a base64 PNG directly; PNG dimensions come out of the IHDR chunk (bytes 16-24). Attach only when nothing else has, and detach after only if WE attached, so an open DevTools stays attached. 2) Editor gets a Discard button. Toolbar picks up an "×" glyph next to Save/Copy that closes the editor tab and drops the working screenshot. Top-level Escape now falls through the same path after unwinding an in-flight text placement or crop rectangle. A new "addon-tab-close" IPC lets an add-on's own tab close itself (main matches the sender's webContents id against the tab list, so a page can only close its own tab); window.silentmode.closeTab() exposes it from addon-tab-preload.js. 3) Manual update controls in Settings > Extensions. New "Check for updates" button at the top of the Extensions surface calls the same signed-update polling the boot timer runs; the result is surfaced inline ("All extensions are up to date" / "N updates staged; restart Theseus to apply"). A "Pending updates" box below lists what's in <userData>/addons-updates-staged/ so the user knows what will be promoted on next restart. Toolbar-menu popup settle bumped from 120 ms to 250 ms with an explicit win.focus() in the popup close callback — the previous window wasn't enough on slower Windows setups. CDP capture no longer depends on this delay anyway, but the settle still helps any add-on that does DOM work in its click handler before capture. Screenshot add-on bumped 0.2.2 → 0.2.3 (Discard button; capture fixes come from the host, not the add-on).
2026-09-08 02:27:36 +02:00
}
init();