theseus/bundled-addons/screenshot/editor.js
Local Dev 523832cd72 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

621 lines
22 KiB
JavaScript

// 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() {
// 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;
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();
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;
}
}
});
// 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; }
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);
$("discard").addEventListener("click", discard);
// 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.
async function discard() {
try { if (window.silentmode?.storage) await window.silentmode.storage.set("__pending", null); } catch {}
location.href = "panel.html";
}
init();