// 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 ----------------------------------------------------------- // Same synth engine as the sidebar panel: preference stored under // silentmode.storage as "soundOn" (default true), no .wav ships in the // tarball. Photoshoot-style shutter for capture, quick "printer chirp" // for copy — modeled on Firefox's Screenshots feedback tones. 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); } // A short filtered noise burst — the mechanical component of the shutter // click and (paired lower down) the "paper feed" texture of the print // sound. `bp` is the band-pass centre frequency, `q` how tight the band. function _noise(dur, vol, bp, q, at) { if (!soundOn) return; const c = _actx(); if (!c) return; const t0 = c.currentTime + (at || 0); const n = Math.max(1, Math.floor(c.sampleRate * dur)); const buf = c.createBuffer(1, n, c.sampleRate); const d = buf.getChannelData(0); for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1; const src = c.createBufferSource(); src.buffer = buf; const flt = c.createBiquadFilter(); flt.type = "bandpass"; flt.frequency.setValueAtTime(bp || 2000, t0); flt.Q.setValueAtTime(q || 4, t0); const g = c.createGain(); g.gain.setValueAtTime(vol || 0.2, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); src.connect(flt); flt.connect(g); g.connect(c.destination); src.start(t0); src.stop(t0 + dur + 0.02); } // Photoshoot: two clicks — mirror-slap up, mirror-slap down. A short // high-band noise burst is the metallic ping, a low-band burst is the // mechanical thud, and a very short low tone fills in the body of the // shutter closing. function playShutter() { _noise(0.03, 0.28, 4200, 6, 0); // first click, up _tone(180, 0.04, "square", 0.10, 0.005); _noise(0.04, 0.32, 3600, 5, 0.08); // second click, down _tone(140, 0.05, "square", 0.12, 0.09); } // Copy: two-chirp "printer feed" — matches Firefox's Easy Screenshot copy // feedback (staccato ascending pair, no residual hum). function playCopy() { _noise(0.02, 0.14, 3200, 8, 0); _tone(1400, 0.05, "sine", 0.14, 0); _noise(0.02, 0.14, 4200, 8, 0.06); _tone(2000, 0.06, "sine", 0.14, 0.06); } 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 playDiscard() { _tone(500, 0.06, "sine", 0.18, 0); _tone(280, 0.10, "sine", 0.14, 0.05); } function playCropApply() { _tone(1200, 0.05, "sine", 0.14, 0); _tone(1600, 0.07, "sine", 0.14, 0.04); } 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} cropRect: null, // committed-crop rectangle in canvas coords, {x,y,w,h} }; 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) { // Leaving crop mode with an unapplied marquee drops it. if (state.tool === "crop" && name !== "crop" && state.cropRect) { state.cropRect = null; clearOver(); } state.tool = name; stage.dataset.tool = name; for (const b of document.querySelectorAll(".tool")) { b.classList.toggle("active", b.dataset.tool === name); } cancelTextInput(); updateCropUI(); } // Show/hide the Apply crop / Cancel crop buttons in the top bar. Visible // only while the tool is "crop" AND a rectangle has been drawn. function updateCropUI() { const on = state.tool === "crop" && !!state.cropRect; const ac = document.getElementById("apply-crop"); const cc = document.getElementById("cancel-crop"); if (ac) ac.hidden = !on; if (cc) cc.hidden = !on; } // Apply the crop: resize the base canvas to the rect's size, draw the // cropped region onto it, then clear undo/redo (the coordinate system has // changed — pre-crop snapshots would restore into the wrong dimensions). // Baseline snapshot for the cropped canvas becomes the new floor. function applyCrop() { const r = state.cropRect; if (!r) return; const w = Math.max(1, Math.round(r.w)); const h = Math.max(1, Math.round(r.h)); const x = Math.max(0, Math.round(r.x)); const y = Math.max(0, Math.round(r.y)); const tmp = document.createElement("canvas"); tmp.width = w; tmp.height = h; tmp.getContext("2d").drawImage(base, x, y, w, h, 0, 0, w, h); sizeCanvases(w, h); bctx.drawImage(tmp, 0, 0); state.cropRect = null; clearOver(); undo = []; redo = []; pushSnapshot(); fitBoard(); updateCropUI(); playCropApply(); setTool("select"); } function cancelCrop() { state.cropRect = null; clearOver(); updateCropUI(); } 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(); } // Crop overlay — dim the area outside the rect, thin dashed border. This // is a live preview during drag AND the settled marquee while apply-crop // is pending. function drawCropRect(a, b) { 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); clearOver(); octx.save(); octx.fillStyle = "rgba(10,13,19,0.55)"; octx.fillRect(0, 0, over.width, over.height); octx.clearRect(x, y, w, h); octx.strokeStyle = "#d6ff3d"; octx.lineWidth = 1.5; octx.setLineDash([6, 4]); octx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); octx.restore(); return { x, y, w, h }; } // Mosaic redaction: downsample the selected region of #base to blocks of // ~ px, then paint the blocks back. The whole thing commits // straight to #base (there's no live preview — the drag rect uses the // crop-style marquee to show the target region). function commitBlur(a, b) { const x = Math.max(0, Math.min(a.x, b.x)) | 0; const y = Math.max(0, Math.min(a.y, b.y)) | 0; const w = Math.min(base.width - x, Math.abs(b.x - a.x) | 0); const h = Math.min(base.height - y, Math.abs(b.y - a.y) | 0); if (w < 4 || h < 4) return false; const block = Math.max(6, Math.round(Math.min(w, h) / 12)); const tmp = document.createElement("canvas"); const scaleW = Math.max(1, Math.floor(w / block)); const scaleH = Math.max(1, Math.floor(h / block)); tmp.width = scaleW; tmp.height = scaleH; const tctx = tmp.getContext("2d"); tctx.imageSmoothingEnabled = true; tctx.drawImage(base, x, y, w, h, 0, 0, scaleW, scaleH); bctx.imageSmoothingEnabled = false; bctx.drawImage(tmp, 0, 0, scaleW, scaleH, x, y, w, h); bctx.imageSmoothingEnabled = true; return true; } 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; const a = { x: state.drag.x0, y: state.drag.y0 }, b = { x: p.x, y: p.y }; if (state.tool === "arrow") { clearOver(); drawArrow(a, b); } else if (state.tool === "rect") { clearOver(); drawRect(a, b); } else if (state.tool === "ellipse") { clearOver(); drawEllipse(a, b); } else if (state.tool === "crop" || state.tool === "blur") { drawCropRect(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; state.cropRect = null; updateCropUI(); return; } const a = { x: state.drag.x0, y: state.drag.y0 }, b = { x: state.drag.x, y: state.drag.y }; if (state.tool === "crop") { // Don't commit yet — the marquee stays up until the user hits Apply. state.cropRect = drawCropRect(a, b); state.drag = null; updateCropUI(); return; } if (state.tool === "blur") { if (commitBlur(a, b)) { clearOver(); pushSnapshot(); } else 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); const applyCropBtn = document.getElementById("apply-crop"); const cancelCropBtn = document.getElementById("cancel-crop"); if (applyCropBtn) applyCropBtn.addEventListener("click", applyCrop); if (cancelCropBtn) cancelCropBtn.addEventListener("click", cancelCrop); // -- 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 (state.tool === "crop" && state.cropRect) { if (ev.key === "Enter") { applyCrop(); ev.preventDefault(); return; } if (ev.key === "Escape") { cancelCrop(); 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 === "c") setTool("crop"); else if (ev.key === "b") setTool("blur"); else if (ev.key === "Escape") setTool("select"); }); init();