// 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 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 ? '' : ''; }; 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();