// 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); } // Filtered noise burst. `bp` = band-pass centre freq, `q` = sharpness of // the band. Optional `pan` gives a random-amplitude micro-modulation // texture — used by the paper-crumple pass. function _noise(dur, vol, bp, q, at, texture) { 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++) { let s = Math.random() * 2 - 1; if (texture) s *= 0.4 + Math.random() * 0.6; // crackle d[i] = s; } 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); } // Slowly-sweeping band-pass noise — used as the film-advance "whir" tail // under the shutter click. function _sweep(dur, vol, fromHz, toHz, 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(fromHz, t0); flt.frequency.exponentialRampToValueAtTime(toHz, t0 + dur); flt.Q.setValueAtTime(q || 8, t0); const g = c.createGain(); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(vol, t0 + dur * 0.2); 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); } // Polaroid: sharp mechanical click (shutter mirror + curtain) followed by // a short film-advance whir. Reads unmistakably as "camera taking a // picture" rather than a UI beep. function playShutter() { _noise(0.02, 0.32, 5200, 8, 0); // sharp metallic tick _tone(160, 0.03, "square", 0.14, 0.003); // mirror thud _noise(0.03, 0.24, 3200, 5, 0.03); // curtain close _tone(120, 0.04, "square", 0.10, 0.035); _sweep(0.28, 0.10, 900, 400, 12, 0.07); // film-advance whir tail } // Printer chika-chika-chika — three descending percussive bursts modelled // on a print head sweeping across a page. Fast and unmistakably "action // happened", no residual hum. function playCopy() { _noise(0.028, 0.22, 3800, 9, 0.00); _tone(1200, 0.03, "sine", 0.10, 0.00); _noise(0.028, 0.22, 3200, 9, 0.06); _tone(1000, 0.03, "sine", 0.10, 0.06); _noise(0.028, 0.22, 2600, 9, 0.12); _tone(800, 0.03, "sine", 0.10, 0.12); } // Photo dispensing — a soft pneumatic hiss with a small final click, the // way a Polaroid ejects its print. function playSave() { _sweep(0.18, 0.14, 3800, 1600, 3, 0); _noise(0.02, 0.20, 2400, 8, 0.18); _tone(900, 0.04, "sine", 0.12, 0.20); } // Paper crumple — a long band-limited noise with texture crackle and a // descending centre frequency, reading as "a page being scrunched". function playDiscard() { _noise(0.16, 0.28, 2600, 3, 0.00, true); // main body _noise(0.12, 0.22, 1800, 3, 0.06, true); // trailing scrunch _noise(0.08, 0.16, 1200, 3, 0.12, true); } 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(); } // Clicking an already-active tool toggles it off (back to "select" = no // tool). The dedicated "Select" button used to live here but read as // broken to users — clicking it did nothing visible. Toggle-off gives the // same "put the pen down" affordance without a mystery button. for (const b of document.querySelectorAll(".tool")) { b.addEventListener("click", () => { setTool(state.tool === b.dataset.tool ? "select" : 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(); } // Plain line — same drag flow as arrow, minus the head. Useful for // underlines / separators / crossing-out without pointing at anything. function drawLine(a, b) { strokeStyle(); octx.beginPath(); octx.moveTo(a.x, a.y); octx.lineTo(b.x, b.y); 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") { ev.preventDefault(); 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 === "line") { clearOver(); drawLine(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 ----------------------------------------------------------- // A little floats over the canvas at the click point; Enter commits // as fillText, Escape drops. Chases a few browser quirks: // - Focus after the DOM mutation, not before: some Chromium builds // drop focus() when the element hasn't yet been laid out. // - Contain the input's own pointer events so a mousedown inside it // doesn't bubble to the canvas and immediately re-fire openTextInput, // spawning a fresh empty box. // - Track height instead of a hard-coded 14 px offset so tall fonts sit // on the click's baseline rather than 14 px above it. function openTextInput(pt) { cancelTextInput(); const size = Math.max(16, state.width * 4); // Textarea (not input) so the user can drag the corner to resize the // box, and so text can wrap / span multiple lines. Enter commits; // Shift+Enter inserts a newline. const el = document.createElement("textarea"); el.className = "text-input"; el.placeholder = "type, then Enter (Shift+Enter for newline · drag corner to resize)"; el.autocomplete = "off"; el.setAttribute("autocorrect", "off"); el.setAttribute("spellcheck", "false"); el.rows = 1; // Visual weight — a bright acid halo around the box + the actual // ink colour on the text itself, so users see something clearly // happened when they clicked. el.style.color = state.color; el.style.font = `${size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`; el.style.lineHeight = "1.15"; el.style.background = "rgba(11,14,20,0.94)"; el.style.border = "2px solid var(--acid, #d6ff3d)"; el.style.boxShadow = "0 0 0 3px rgba(214,255,61,.25), 0 4px 14px rgba(0,0,0,.45)"; el.style.resize = "both"; el.style.overflow = "auto"; el.style.whiteSpace = "pre"; 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 - size) + "px"; document.body.appendChild(el); // Three-way focus attempt — Chromium is racy about focusing an element // that appeared mid-pointer-event. Synchronous focus() first (works on // most builds), then a paint tick, then a short timer as belt-and-braces. el.focus(); requestAnimationFrame(() => el.focus()); setTimeout(() => { if (state.textInput && state.textInput.el === el) el.focus(); }, 30); state.textInput = { x: pt.x, y: pt.y, size, el }; // Don't let the box's own pointer events bubble to the canvas — // otherwise every click-through re-fires openTextInput on the base and // spawns duplicate boxes, and drag-to-resize on the corner handle would // start a canvas drag on the tool underneath. const swallow = (ev) => ev.stopPropagation(); for (const t of ["pointerdown", "mousedown", "click", "pointerup", "pointermove"]) { el.addEventListener(t, swallow); } el.addEventListener("keydown", (ev) => { if (ev.key === "Enter" && !ev.shiftKey) { commitTextInput(); ev.preventDefault(); ev.stopPropagation(); } else if (ev.key === "Escape") { cancelTextInput(); ev.preventDefault(); ev.stopPropagation(); } // Every other key stays inside the input — belt against a stray // document-level keydown handler stealing focus. else ev.stopPropagation(); }); el.addEventListener("blur", commitTextInput); } function commitTextInput() { const ti = state.textInput; if (!ti) return; const value = ti.el.value; const trimmed = value.trim(); ti.el.remove(); state.textInput = null; if (!trimmed) return; bctx.fillStyle = state.color; bctx.font = `${ti.size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`; bctx.textBaseline = "alphabetic"; // Multi-line rendering — one fillText per line, stepping down by the // font's line height so the visible box's layout matches the baked-in // pixels. const lineH = ti.size * 1.15; const lines = value.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { bctx.fillText(lines[i], ti.x, ti.y + i * lineH); } 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", () => { location.href = "panel.html"; }); // "Open in folder" — sends the current capture's name to the addon's // openFolder handler, which calls shell.showItemInFolder() so the file // explorer opens with the exact scratch PNG highlighted. const openFolderBtn = document.getElementById("open-folder"); if (openFolderBtn) openFolderBtn.addEventListener("click", async () => { try { if (window.silentmode?.invoke) { await window.silentmode.invoke("openFolder", { name: NAME || "" }); } } catch (e) { console.warn("open-folder failed:", e); toast("Couldn't open folder: " + (e && e.message || e), true); } }); // Discard — throw away this capture entirely (delete it from the Recent // ring) and return to the panel. Back is non-destructive; Discard is not. const discardBtn = document.getElementById("discard"); if (discardBtn) discardBtn.addEventListener("click", async () => { playDiscard(); try { if (NAME && window.silentmode?.invoke) { await window.silentmode.invoke("clearRecent", { name: NAME }); } } catch (e) { console.warn("discard: clearRecent failed:", e); } location.href = "panel.html"; }); // Close the sidebar entirely — same IPC the dock icon toggles. We stop // on the panel side (see panel.html); the editor mirrors the affordance // so users don't need to navigate back before hiding the addon. const closeSbBtn = document.getElementById("close-sidebar"); if (closeSbBtn && window.silentmode?.sidebar) { closeSbBtn.addEventListener("click", () => { try { window.silentmode.sidebar.close(); } catch {} }); } // 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 === "l") setTool("line"); 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();