diff --git a/bundled-addons/screenshot/addon.json b/bundled-addons/screenshot/addon.json index c6aea6a2..c7d41da2 100644 --- a/bundled-addons/screenshot/addon.json +++ b/bundled-addons/screenshot/addon.json @@ -1,8 +1,8 @@ { "id": "screenshot", "name": "Screenshot", - "version": "0.5.1", - "description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + annotate editor (arrow, rect, ellipse, pen, text, undo, copy, save) live inside the sidebar. Expand the sidebar to full window for a canvas-sized editor.", + "version": "0.6.0", + "description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + annotate editor (crop, arrow, rect, ellipse, pen, text, blur/mosaic redaction, undo, copy, save) live inside the sidebar. Expand the sidebar to full window for a canvas-sized editor.", "author": "Silent Mode", "icon": "📸", "main": "index.js", diff --git a/bundled-addons/screenshot/editor.css b/bundled-addons/screenshot/editor.css index 75f420c4..4591a120 100644 --- a/bundled-addons/screenshot/editor.css +++ b/bundled-addons/screenshot/editor.css @@ -99,6 +99,15 @@ body { background: var(--bg); color: var(--ink); .stage[data-tool="ellipse"]{ cursor: crosshair; } .stage[data-tool="pen"] { cursor: crosshair; } .stage[data-tool="text"] { cursor: text; } +.stage[data-tool="crop"] { cursor: crosshair; } +.stage[data-tool="blur"] { cursor: crosshair; } + +/* Apply-crop is the primary action while a crop marquee is up. */ +#apply-crop { + background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; +} +#apply-crop:hover { filter: brightness(1.06); background: var(--acid); border-color: transparent; } +#cancel-crop:hover { border-color: rgba(255,91,91,.5); color: var(--danger); } .text-input { position: absolute; z-index: 10; diff --git a/bundled-addons/screenshot/editor.html b/bundled-addons/screenshot/editor.html index 5e4f97f4..3d1e1767 100644 --- a/bundled-addons/screenshot/editor.html +++ b/bundled-addons/screenshot/editor.html @@ -8,13 +8,22 @@
- - screenshot + + + + + + +
+ @@ -45,6 +61,9 @@ + diff --git a/bundled-addons/screenshot/editor.js b/bundled-addons/screenshot/editor.js index 44e9c567..902743f7 100644 --- a/bundled-addons/screenshot/editor.js +++ b/bundled-addons/screenshot/editor.js @@ -19,9 +19,10 @@ 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. +// 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() { @@ -43,10 +44,49 @@ function _tone(freq, dur, type, vol, at) { 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 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); } +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"); @@ -72,6 +112,7 @@ let state = { 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 = []; @@ -203,12 +244,59 @@ 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)); @@ -281,6 +369,49 @@ function drawPen(points) { 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(); @@ -307,11 +438,12 @@ base.addEventListener("pointermove", (ev) => { } 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); + 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 {} @@ -323,7 +455,21 @@ base.addEventListener("pointerup", (ev) => { } 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; } + 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; }); @@ -421,6 +567,10 @@ async function copy() { } $("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"; }); @@ -473,11 +623,17 @@ window.addEventListener("keydown", (ev) => { 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"); }); diff --git a/bundled-addons/screenshot/panel.html b/bundled-addons/screenshot/panel.html index 86c9cc6f..f2038db2 100644 --- a/bundled-addons/screenshot/panel.html +++ b/bundled-addons/screenshot/panel.html @@ -216,8 +216,44 @@ osc.start(t0); osc.stop(t0 + dur + 0.02); } - function playShutter() { tone(1500, 0.05, "square", 0.20, 0); tone(600, 0.06, "square", 0.14, 0.03); } - function playCopy() { tone(900, 0.06, "sine", 0.15, 0); tone(1400, 0.08, "sine", 0.15, 0.05); } + // Filtered noise burst — the metallic "click" of the shutter and the + // paper-feed texture of the print sound. Band-pass around `bp` with + // sharpness `q` picks a resonance so we don't just play white noise. + 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 shutter — mirror-slap up (metallic ping + soft thud), then + // mirror-slap down. Reads as a real camera click rather than a beep. + function playShutter() { + noise(0.03, 0.28, 4200, 6, 0); + tone(180, 0.04, "square", 0.10, 0.005); + noise(0.04, 0.32, 3600, 5, 0.08); + tone(140, 0.05, "square", 0.12, 0.09); + } + // Copy — two-chirp "printer feed", modeled on Firefox Easy Screenshot's + // copy feedback: fast 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 playDiscard() { tone(500, 0.06, "sine", 0.18, 0); tone(280, 0.10, "sine", 0.14, 0.05); } function paintSound() { btnSound.title = soundOn ? "Sounds on — click to mute" : "Sounds off — click to enable"; diff --git a/chrome.html b/chrome.html index dc0b6d1f..d848578c 100644 --- a/chrome.html +++ b/chrome.html @@ -394,8 +394,8 @@