// Screenshot editor v2. Lives inside the sidebar view; no separate tab. // // Load path (idempotent — this is the key change from v1's __pending drain): // editor.html?name= // → silentmode.invoke("getBytes", {name}) // → addon reads scratch/.png from disk and returns // { name, dataUrl:"data:image/png;base64,…", bytes, at, mode } // → we set an src to that data URL and draw it onto #base // // Two canvases: #base holds the committed image; #over is a preview layer // that hosts the live drag preview for each drawing tool. On mouseup the // tool commits its result by drawing #over onto #base, clearing #over, // and pushing a fresh ImageData snapshot onto the undo stack. // // No cross-origin img loading (data: URLs are same-origin in Chromium), so // the canvas never gets tainted — getImageData / toBlob keep working. const $ = (id) => document.getElementById(id); const UNDO_MAX = 25; // ---- audio ----------------------------------------------------------- // Kept in sync with the sidebar panel: preference stored under // silentmode.storage as "soundOn" (default true), synthesized on the fly // so we don't ship any .wav in the tarball. let soundOn = true; let _audio = null; function _actx() { if (!_audio) { try { _audio = new (window.AudioContext || window.webkitAudioContext)(); } catch {} } if (_audio && _audio.state === "suspended") _audio.resume().catch(() => {}); return _audio; } function _tone(freq, dur, type, vol, at) { if (!soundOn) return; const c = _actx(); if (!c) return; const t0 = c.currentTime + (at || 0); const osc = c.createOscillator(); const g = c.createGain(); osc.type = type || "sine"; osc.frequency.setValueAtTime(freq, t0); g.gain.setValueAtTime(vol || 0.15, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); osc.connect(g); g.connect(c.destination); osc.start(t0); osc.stop(t0 + dur + 0.02); } function playSave() { _tone(700, 0.06, "sine", 0.16, 0); _tone(1100, 0.08, "sine", 0.16, 0.05); _tone(1500, 0.09, "sine", 0.16, 0.10); } function playCopy() { _tone(900, 0.06, "sine", 0.15, 0); _tone(1400, 0.08, "sine", 0.15, 0.05); } function playDiscard() { _tone(500, 0.06, "sine", 0.18, 0); _tone(280, 0.10, "sine", 0.14, 0.05); } function playShutter() { _tone(1500, 0.05, "square", 0.20, 0); _tone(600, 0.06, "square", 0.14, 0.03); } const base = $("base"); const over = $("over"); const stage = $("stage"); const board = $("board"); const empty = $("empty"); const nameEl = $("name"); const undoBtn = $("undo"); const redoBtn = $("redo"); const toastEl = $("toast"); const dl = $("download-link"); const bctx = base.getContext("2d"); const octx = over.getContext("2d"); const params = new URLSearchParams(location.search); const NAME = params.get("name") || ""; let state = { tool: "select", color: "#ff3b30", width: 5, drag: null, // {x0,y0,x,y} pen: null, // [{x,y}, …] textInput: null, // {x, y, el} }; let undo = []; let redo = []; // -- utilities -------------------------------------------------------------- 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"), 1800); } function updateUndoRedo() { undoBtn.disabled = undo.length <= 1; // baseline snapshot always at index 0 redoBtn.disabled = redo.length === 0; } function pushSnapshot() { try { const snap = bctx.getImageData(0, 0, base.width, base.height); undo.push(snap); if (undo.length > UNDO_MAX) undo.shift(); redo = []; updateUndoRedo(); } catch (e) { console.warn("snapshot failed:", e); } } function restoreSnapshot(snap) { if (!snap) return; if (base.width !== snap.width || base.height !== snap.height) { sizeCanvases(snap.width, snap.height); } bctx.putImageData(snap, 0, 0); } function sizeCanvases(w, h) { for (const c of [base, over]) { c.width = w; c.height = h; c.style.width = w + "px"; c.style.height = h + "px"; } stage.style.width = w + "px"; stage.style.height = h + "px"; } // Scale the stage down to fit inside the board when the image is larger // than the visible area. Purely visual — drawing math stays in natural px. function fitBoard() { const availW = Math.max(50, board.clientWidth - 24); const availH = Math.max(50, board.clientHeight - 24); const s = Math.min(1, availW / base.width, availH / base.height); const scale = s > 0 && Number.isFinite(s) ? s : 1; stage.style.transform = `scale(${scale})`; stage.style.width = (base.width * scale) + "px"; stage.style.height = (base.height * scale) + "px"; stage._scale = scale; } // Convert a viewport-relative pointer event into natural canvas coords. function pointToCanvas(ev) { const rect = base.getBoundingClientRect(); const scale = stage._scale || 1; const cssW = rect.width, cssH = rect.height; const x = (ev.clientX - rect.left) * (base.width / (cssW || 1)); const y = (ev.clientY - rect.top) * (base.height / (cssH || 1)); return { x, y }; } // -- loading --------------------------------------------------------------- async function loadFromAddon(name) { if (!window.silentmode?.invoke) { throw new Error("silentmode API not available in this view"); } const res = await window.silentmode.invoke("getBytes", { name }); if (!res || !res.dataUrl) throw new Error("addon returned no data URL"); return res; } function loadImage(dataUrl) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error("image decode failed")); // NB: no crossOrigin — data: URLs are same-origin, and setting it // to anonymous would require CORS headers that a data URL can't carry, // which is precisely what tripped up v1. img.src = dataUrl; }); } async function init() { document.title = NAME ? NAME + " — editor" : "Screenshot editor"; nameEl.textContent = NAME || "screenshot"; if (!NAME) { empty.classList.add("err"); empty.innerHTML = "
Missing ?name — open a capture from the Screenshot sidebar.
"; return; } let res; try { res = await loadFromAddon(NAME); } catch (e) { console.warn("editor load failed:", e); empty.classList.add("err"); empty.innerHTML = `
Couldn't load capture: ${(e && e.message || e)}
`; return; } let img; try { img = await loadImage(res.dataUrl); } catch (e) { empty.classList.add("err"); empty.innerHTML = `
Couldn't decode capture bytes (${res.bytes || 0} B)
`; return; } sizeCanvases(img.naturalWidth, img.naturalHeight); bctx.drawImage(img, 0, 0); undo = []; redo = []; pushSnapshot(); empty.hidden = true; stage.hidden = false; fitBoard(); } window.addEventListener("resize", () => { if (!stage.hidden) fitBoard(); }); // -- tool selection -------------------------------------------------------- function setTool(name) { state.tool = name; stage.dataset.tool = name; for (const b of document.querySelectorAll(".tool")) { b.classList.toggle("active", b.dataset.tool === name); } cancelTextInput(); } for (const b of document.querySelectorAll(".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) || 5; for (const x of document.querySelectorAll(".width")) x.classList.toggle("active", x === b); }); } // -- drawing --------------------------------------------------------------- function clearOver() { octx.clearRect(0, 0, over.width, over.height); } function strokeStyle() { octx.strokeStyle = state.color; octx.fillStyle = state.color; octx.lineWidth = state.width; octx.lineCap = "round"; octx.lineJoin = "round"; } function drawArrow(a, b) { strokeStyle(); octx.beginPath(); octx.moveTo(a.x, a.y); octx.lineTo(b.x, b.y); octx.stroke(); // arrowhead const dx = b.x - a.x, dy = b.y - a.y; const len = Math.hypot(dx, dy); if (len < 1) return; const head = Math.max(10, state.width * 3); const ang = Math.atan2(dy, dx); const spread = Math.PI / 6; octx.beginPath(); octx.moveTo(b.x, b.y); octx.lineTo(b.x - head * Math.cos(ang - spread), b.y - head * Math.sin(ang - spread)); octx.moveTo(b.x, b.y); octx.lineTo(b.x - head * Math.cos(ang + spread), b.y - head * Math.sin(ang + spread)); octx.stroke(); } function drawRect(a, b) { strokeStyle(); const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y); const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y); octx.strokeRect(x, y, w, h); } function drawEllipse(a, b) { strokeStyle(); const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2; const rx = Math.abs(b.x - a.x) / 2, ry = Math.abs(b.y - a.y) / 2; octx.beginPath(); octx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); octx.stroke(); } function drawPen(points) { if (!points || points.length < 2) return; strokeStyle(); octx.beginPath(); octx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i++) octx.lineTo(points[i].x, points[i].y); octx.stroke(); } function commitOver() { bctx.drawImage(over, 0, 0); clearOver(); pushSnapshot(); } // -- input -------------------------------------------------------------- over.style.pointerEvents = "none"; // draw layer never blocks input; base receives base.addEventListener("pointerdown", (ev) => { if (state.tool === "select") return; if (state.tool === "text") { openTextInput(pointToCanvas(ev)); return; } base.setPointerCapture(ev.pointerId); const p = pointToCanvas(ev); if (state.tool === "pen") { state.pen = [p]; } else { state.drag = { x0: p.x, y0: p.y, x: p.x, y: p.y }; } }); base.addEventListener("pointermove", (ev) => { const p = pointToCanvas(ev); if (state.tool === "pen" && state.pen) { state.pen.push(p); clearOver(); drawPen(state.pen); return; } if (!state.drag) return; state.drag.x = p.x; state.drag.y = p.y; clearOver(); const a = { x: state.drag.x0, y: state.drag.y0 }, b = { x: p.x, y: p.y }; if (state.tool === "arrow") drawArrow(a, b); else if (state.tool === "rect") drawRect(a, b); else if (state.tool === "ellipse") drawEllipse(a, b); }); base.addEventListener("pointerup", (ev) => { try { base.releasePointerCapture(ev.pointerId); } catch {} if (state.tool === "pen" && state.pen) { if (state.pen.length >= 2) commitOver(); else clearOver(); state.pen = null; return; } if (!state.drag) return; const dx = state.drag.x - state.drag.x0, dy = state.drag.y - state.drag.y0; if (Math.hypot(dx, dy) < 2) { clearOver(); state.drag = null; return; } commitOver(); state.drag = null; }); // -- text tool ----------------------------------------------------------- function openTextInput(pt) { cancelTextInput(); const el = document.createElement("input"); el.type = "text"; el.className = "text-input"; el.placeholder = "text…"; el.style.color = state.color; el.style.font = `${Math.max(14, state.width * 4)}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`; const rect = base.getBoundingClientRect(); const scale = stage._scale || 1; const cssX = pt.x * scale + rect.left; const cssY = pt.y * scale + rect.top; el.style.left = cssX + "px"; el.style.top = (cssY - 14) + "px"; document.body.appendChild(el); el.focus(); state.textInput = { x: pt.x, y: pt.y, el }; el.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { commitTextInput(); ev.preventDefault(); } else if (ev.key === "Escape") { cancelTextInput(); ev.preventDefault(); } }); el.addEventListener("blur", commitTextInput); } function commitTextInput() { const ti = state.textInput; if (!ti) return; const value = ti.el.value.trim(); ti.el.remove(); state.textInput = null; if (!value) return; const size = Math.max(14, state.width * 4); bctx.fillStyle = state.color; bctx.font = `${size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`; bctx.textBaseline = "alphabetic"; bctx.fillText(value, ti.x, ti.y); pushSnapshot(); } function cancelTextInput() { if (!state.textInput) return; state.textInput.el.remove(); state.textInput = null; } // -- undo / redo ------------------------------------------------------- function doUndo() { if (undo.length <= 1) return; const cur = undo.pop(); redo.push(cur); restoreSnapshot(undo[undo.length - 1]); updateUndoRedo(); } function doRedo() { const snap = redo.pop(); if (!snap) return; undo.push(snap); restoreSnapshot(snap); updateUndoRedo(); } undoBtn.addEventListener("click", doUndo); redoBtn.addEventListener("click", doRedo); // -- save / copy ------------------------------------------------------ function canvasBlob() { return new Promise((resolve, reject) => base.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob returned null")), "image/png")); } async function save() { try { const blob = await canvasBlob(); const url = URL.createObjectURL(blob); dl.href = url; dl.download = NAME || "screenshot.png"; dl.click(); setTimeout(() => URL.revokeObjectURL(url), 5000); playSave(); toast(`Saved ${dl.download} (${(blob.size / 1024).toFixed(1)} KB)`); } catch (e) { toast("Save failed: " + (e && e.message || e), true); } } async function copy() { try { const blob = await canvasBlob(); await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); playCopy(); toast("Copied to clipboard"); } catch (e) { toast("Copy failed: " + (e && e.message || e), true); } } $("save").addEventListener("click", save); $("copy").addEventListener("click", copy); // -- top-bar navigation ---------------------------------------------- $("back").addEventListener("click", () => { playDiscard(); location.href = "panel.html"; }); // Sound-toggle button — mirrors the sidebar panel's, both persist via // silentmode.storage so opening either surface reflects the same setting. const soundBtn = $("toggle-sound"); function paintSoundIcon() { if (!soundBtn) return; soundBtn.title = soundOn ? "Sounds on — click to mute" : "Sounds off — click to enable"; soundBtn.innerHTML = soundOn ? '' : ''; } (async () => { try { const v = await window.silentmode?.storage?.get("soundOn", true); soundOn = v !== false; } catch {} paintSoundIcon(); })(); if (soundBtn) soundBtn.addEventListener("click", async () => { soundOn = !soundOn; paintSoundIcon(); try { await window.silentmode?.storage?.set("soundOn", soundOn); } catch {} if (soundOn) playSave(); }); const maxBtn = $("toggle-max"); async function paintMaxIcon(isMax) { maxBtn.title = isMax ? "Restore sidebar width" : "Expand the sidebar to full window"; maxBtn.innerHTML = isMax ? '' : ''; } if (window.silentmode?.sidebar) { maxBtn.addEventListener("click", async () => { try { await window.silentmode.sidebar.toggleMax(); } catch (e) { console.warn("toggleMax failed:", e); } }); window.silentmode.sidebar.onMaxChange(paintMaxIcon); window.silentmode.sidebar.isMax().then(paintMaxIcon).catch(() => {}); } // -- keyboard shortcuts --------------------------------------------- window.addEventListener("keydown", (ev) => { if (state.textInput) return; const meta = ev.ctrlKey || ev.metaKey; if (meta && ev.key.toLowerCase() === "z" && !ev.shiftKey) { doUndo(); ev.preventDefault(); return; } if (meta && ev.key.toLowerCase() === "z" && ev.shiftKey) { doRedo(); ev.preventDefault(); return; } if (meta && ev.key.toLowerCase() === "s") { save(); ev.preventDefault(); return; } if (meta && ev.key.toLowerCase() === "c") { copy(); ev.preventDefault(); return; } if (ev.key === "a") setTool("arrow"); else if (ev.key === "r") setTool("rect"); else if (ev.key === "o") setTool("ellipse"); else if (ev.key === "p") setTool("pen"); else if (ev.key === "t") setTool("text"); else if (ev.key === "Escape") setTool("select"); }); init();