feat(theseus/screenshot): 0.6.0 — crop + mosaic redaction, right-anchored sidebar controls, real shutter+print sounds

Editor:
- Crop tool restored — drag to select, marquee sits with a dashed acid
  border and a dimmed backdrop for the area you'll discard, then the
  topbar shows Apply crop / Cancel. Applying trims #base to the rect,
  resets undo (dimensions changed), and drops back into the select tool.
  Enter / Esc keyboard shortcuts while a crop is pending.
- Blur / mosaic redaction tool back — drag a rectangle, editor
  downsamples that region of #base to ~12-block granularity and paints
  the blocks back nearest-neighbour. Commits directly (no confirm step).
- Sidebar-window controls (Back, name, Copy, Save, Sound, Maximize)
  reflow: Back + name on the left, Copy + Save + Sound + Maximize on
  the right so the "put the sidebar back to normal size" affordance
  lives where users expect it. Toolbar's drawing tools stay centred.
- Back arrow icon swapped from a chevron to a proper flat arrow
  (line + arrowhead), matching the new browser back/forward glyphs.

Sounds — modeled on Firefox Screenshots' feedback rather than beeps:
- Shutter is now a real photoshoot click: two mirror-slaps built from a
  band-passed noise burst (metallic ping) plus a very short square-wave
  thud each. Sounds like a camera, not a beep.
- Copy is a two-chirp "printer feed" — filtered noise burst on top of a
  sine chirp per beat, staccato ascending pair. Same shape Firefox Easy
  Screenshot uses for "copied to clipboard".
- Save keeps its ascending triad; Discard keeps its descending pair;
  new small ascending pair for Apply crop.

Chrome:
- Browser Back / Forward chevrons (M10 3 L5 8 L10 13 — two segments
  meeting at a point, no shaft) replaced with straight-arrow glyphs
  (line + arrowhead). Reads as a navigation arrow, not an angle bracket.

Bundled but not shipped separately — parent session OTA-signs and pushes.
This commit is contained in:
Local Dev 2026-09-09 10:24:03 +02:00
parent 882de1654f
commit 7405e444e7
6 changed files with 241 additions and 21 deletions

View file

@ -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",

View file

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

View file

@ -8,13 +8,22 @@
<body>
<div class="topbar">
<button class="btn" id="back" title="Back to sidebar panel">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 8H3M6 5L3 8l3 3"/></svg>
</button>
<button class="btn" id="toggle-max" title="Expand the sidebar to full window / restore">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/></svg>
</button>
<button class="btn" id="toggle-sound" title="Toggle sound"></button>
<span class="name" id="name">screenshot</span>
<!-- Crop mode splits the topbar into a confirm/cancel prompt while the
user is dragging a crop rectangle. Everything else in the topbar
fades in and out around these two buttons. -->
<button class="btn wide" id="apply-crop" hidden title="Apply the crop (Enter)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8l3 3 7-7"/></svg>
<span>Apply crop</span>
</button>
<button class="btn wide" id="cancel-crop" hidden title="Cancel crop (Esc)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
<span>Cancel</span>
</button>
<span class="spacer"></span>
<button class="btn wide" id="copy" title="Copy PNG to clipboard (Ctrl+C)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="4" width="9" height="10"/><path d="M3 12V3h9"/></svg>
@ -24,12 +33,19 @@
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg>
<span>Save</span>
</button>
<button class="btn" id="toggle-sound" title="Toggle sound"></button>
<button class="btn" id="toggle-max" title="Expand the sidebar to full window / restore">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/></svg>
</button>
</div>
<div class="toolbar">
<button class="tool active" data-tool="select" title="Select (no tool)">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 2l10 5-4 1-1 4z"/></svg>
</button>
<button class="tool" data-tool="crop" title="Crop (C)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 1v11h11M1 4h11v11"/></svg>
</button>
<button class="tool" data-tool="arrow" title="Arrow (A)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13L13 3M13 3H8M13 3v5"/></svg>
</button>
@ -45,6 +61,9 @@
<button class="tool" data-tool="text" title="Text (T)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 3h10M8 3v10M6 13h4"/></svg>
</button>
<button class="tool" data-tool="blur" title="Blur / mosaic redaction (B)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="2.5" width="11" height="11"/><path d="M5 6h1M8 6h1M11 6h1M5 9h1M8 9h1M11 9h1M5 12h1M8 12h1M11 12h1"/></svg>
</button>
<span class="sep"></span>

View file

@ -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
// ~<blockSize> 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");
});

View file

@ -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";

View file

@ -394,8 +394,8 @@
<div class="tabs" id="tabs"></div>
<div class="bar" id="bar">
<div class="nav">
<button class="ic" id="back" title="Back"><svg viewBox="0 0 16 16"><path d="M10 3 L5 8 L10 13"/></svg></button>
<button class="ic" id="fwd" title="Forward"><svg viewBox="0 0 16 16"><path d="M6 3 L11 8 L6 13"/></svg></button>
<button class="ic" id="back" title="Back"><svg viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round"><path d="M13 8H3M6 5L3 8l3 3"/></svg></button>
<button class="ic" id="fwd" title="Forward"><svg viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8h10M10 5l3 3-3 3"/></svg></button>
<button class="ic" id="reload" title="Reload"><svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg></button>
<button class="ic" id="home" title="Home"><svg viewBox="0 0 16 16"><path d="M2.5 7.5 L8 3 L13.5 7.5"/><path d="M4 6.7 L4 13 L12 13 L12 6.7"/></svg></button>
</div>