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 @@
+
@@ -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 @@