theseus/bundled-addons/screenshot/editor.js
Local Dev 7405e444e7 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.
2026-09-09 10:24:03 +02:00

640 lines
23 KiB
JavaScript

// 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=<scratchfile.png>
// → silentmode.invoke("getBytes", {name})
// → addon reads scratch/<name>.png from disk and returns
// { name, dataUrl:"data:image/png;base64,…", bytes, at, mode }
// → we set an <img> 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 = "<div>Missing <code>?name</code> — open a capture from the Screenshot sidebar.</div>";
return;
}
let res;
try { res = await loadFromAddon(NAME); }
catch (e) {
console.warn("editor load failed:", e);
empty.classList.add("err");
empty.innerHTML = `<div>Couldn't load capture: ${(e && e.message || e)}</div>`;
return;
}
let img;
try { img = await loadImage(res.dataUrl); }
catch (e) {
empty.classList.add("err");
empty.innerHTML = `<div>Couldn't decode capture bytes (${res.bytes || 0} B)</div>`;
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
// ~<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();
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
? '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h3l3.5 3V3L5.5 6z"/><path d="M11 5.5c1 .8 1 4.2 0 5"/><path d="M12.5 4c2 1.5 2 6.5 0 8"/></svg>'
: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h3l3.5 3V3L5.5 6z"/><path d="M11 5l4 6M15 5l-4 6"/></svg>';
}
(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
? '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2v4H2M10 2v4h4M6 14v-4H2M10 14v-4h4"/></svg>'
: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/></svg>';
}
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();