feat(theseus/screenshot): 0.5.0 — sidebar-first editor, direct save/copy, sounds

Two problems the old editor kept hitting:
- __pending drain race: opening the editor a second time (refresh, back-and-
  forth navigation) found the storage entry already consumed and bailed to
  a blank canvas silently.
- Cross-origin img loading: editor.html at file:///…/addons/screenshot/
  loading a scratch PNG at file:///…/addons-data/ counts as cross-origin
  under Chromium's file-URL policy; setting crossOrigin="anonymous" made
  the load fail outright.

Rebuilt editor v2:
- Load path is idempotent: silentmode.invoke("getBytes", {name}) → addon
  reads the scratch file and returns a data URL. No __pending drain, no
  cross-origin trickery — data: URLs are same-origin and never taint the
  canvas, so getImageData / toBlob keep working.
- Two-canvas model (#base + #over, over is pointer-events:none) so live
  previews don't cost a full re-composite per mousemove.
- Tools: cursor, arrow, rect, ellipse, pen, text. 6 swatches, 3 widths,
  undo / redo (25-deep). Copy + Save at the top bar. Back and Maximize
  buttons in the same top bar so navigation controls stay reachable when
  the toolbar wraps at narrow widths.
- Keyboard: A/R/O/P/T select tool, Esc = cursor, Ctrl+Z/Shift+Z undo/redo,
  Ctrl+S save, Ctrl+C copy.
- Toast surface for save/copy/error feedback.

Sidebar panel gains a direct raw-save path so the user can copy or save the
capture without entering the editor:
- Two-row actions: [Copy] [Save] on top, [Discard] [Edit] below.
- Copy uses navigator.clipboard.write(ClipboardItem); Save uses
  <a download> with a Blob URL — same path Chromium's will-download
  tracker already handles, so the file lands in Downloads and the chip
  updates like any other save.

Inline "clear all" confirmation replaces the native confirm() — the old
system-modal opened over the tab area (out of the sidebar's visual
context) and looked like Windows 95. Now a compact red strip appears
under the Recent header with Cancel / Delete buttons.

Sounds + a sound-on/off toggle in both surfaces:
- Web Audio oscillator-synthesized (no .wav shipped): shutter click on
  capture, two-tone bloop on copy, descending pair on discard/back,
  ascending triad on save.
- Preference stored in silentmode.storage under "soundOn" (default on),
  shared between the panel and the editor.

Simplifications:
- Dropped the addon's "arm" onMessage handler (superseded by getBytes).
- Manifest capabilities: sidebar-panel + capture-tab (no open-tab,
  no toolbar-menu).

Bundled but not shipped — parent session handles the OTA sign + push.
This commit is contained in:
Local Dev 2026-09-09 00:56:41 +02:00
parent a65dc0a153
commit 81d276f655
6 changed files with 733 additions and 768 deletions

View file

@ -1,8 +1,8 @@
{
"id": "screenshot",
"name": "Screenshot",
"version": "0.4.0",
"description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + full editor (crop, annotate, redact, save) live inside the sidebar. Expand the sidebar to full window for a canvas-sized editor.",
"version": "0.5.0",
"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.",
"author": "Silent Mode",
"icon": "📸",
"main": "index.js",

View file

@ -1,5 +1,6 @@
/* Screenshot editor full-tab page. Matches the Theseus dark aesthetic
from the sidebar. Light-mode swap is symmetric. */
/* Screenshot editor v2 sidebar-embedded. Compact but not cramped; scales
the canvas to fit whatever width the sidebar has, up to full window when
the user hits the maximize button in the topbar. */
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
@ -15,76 +16,97 @@ body { background: var(--bg); color: var(--ink);
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; flex-direction: column; overflow: hidden; }
/* Toolbar ---------------------------------------------------------------- */
.toolbar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
padding: 8px 10px; border-bottom: 1px solid var(--line);
background: var(--panel); user-select: none;
font: 12px ui-monospace, "Cascadia Code", Consolas, monospace; }
.toolbar .name { color: var(--dim); font-size: 11.5px; margin-right: 4px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; max-width: 240px; }
.toolbar .sep { width: 1px; height: 22px; background: var(--line); margin: 0 4px; }
.toolbar .spacer { flex: 1; }
.tool { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
.topbar, .toolbar { display: flex; align-items: center; gap: 4px; padding: 6px 8px;
border-bottom: 1px solid var(--line); background: var(--panel);
user-select: none; flex-wrap: wrap; }
.topbar .name { color: var(--dim); font-size: 11.5px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; max-width: 200px; margin: 0 4px; }
.topbar .spacer, .toolbar .spacer { flex: 1; }
.btn, .tool {
border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
width: 30px; height: 30px; border-radius: 6px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
font: inherit; padding: 0; transition: background .1s, border-color .1s; }
.tool:hover { border-color: rgb(from var(--acid) r g b / .5); }
.tool.active { background: rgb(from var(--acid) r g b / .15); border-color: rgb(from var(--acid) r g b / .7); color: var(--acid); }
.tool:disabled { opacity: .4; cursor: default; }
.tool svg { width: 16px; height: 16px; display: block; }
.tool.wide { width: auto; padding: 0 10px; gap: 6px; }
.tool.danger { color: var(--danger); }
.tool.danger:hover { border-color: rgba(255,91,91,.5); }
padding: 0; transition: background 100ms, border-color 100ms;
font: inherit; line-height: 0;
}
.btn:hover, .tool:hover { border-color: rgb(from var(--acid) r g b / .5); }
.btn:disabled, .tool:disabled { opacity: .4; cursor: default; }
.btn:disabled:hover, .tool:disabled:hover { border-color: var(--line); }
.tool.active {
background: rgb(from var(--acid) r g b / .15);
border-color: rgb(from var(--acid) r g b / .7);
color: var(--acid);
}
.btn svg, .tool svg { width: 16px; height: 16px; display: block; }
.btn.wide {
width: auto; padding: 0 10px; gap: 6px; line-height: 1;
}
.btn.wide span { font-size: 12px; }
.btn.primary {
background: var(--acid); color: #101418; border-color: transparent; font-weight: 600;
}
.btn.primary:hover { filter: brightness(1.06); border-color: transparent; }
.swatch { width: 22px; height: 22px; border-radius: 50%; padding: 0;
border: 2px solid var(--line); cursor: pointer; }
.swatch.active { border-color: var(--acid); outline: 1px solid rgb(from var(--acid) r g b / .35); outline-offset: 1px; }
.sep { width: 1px; height: 20px; background: var(--line); margin: 0 3px; }
.width { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
.swatch {
width: 22px; height: 22px; border-radius: 50%; padding: 0;
border: 2px solid var(--line); cursor: pointer;
}
.swatch.active {
border-color: var(--acid);
outline: 1px solid rgb(from var(--acid) r g b / .35); outline-offset: 1px;
}
.width {
border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
width: 30px; height: 30px; border-radius: 6px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
padding: 0; }
.width.active { border-color: rgb(from var(--acid) r g b / .7); background: rgb(from var(--acid) r g b / .15); }
display: inline-flex; align-items: center; justify-content: center; padding: 0;
}
.width.active {
border-color: rgb(from var(--acid) r g b / .7);
background: rgb(from var(--acid) r g b / .15);
}
.width .dot { border-radius: 50%; background: currentColor; color: var(--ink); }
.width.active .dot { color: var(--acid); }
/* Canvas board -------------------------------------------------------- */
.board {
flex: 1; overflow: auto; background: var(--board);
display: flex; align-items: flex-start; justify-content: center;
padding: 12px;
}
.empty { color: var(--dim); font-size: 13px; padding: 40px 20px; text-align: center;
display: flex; align-items: center; justify-content: center; height: 100%; }
.empty.err { color: var(--danger); }
.stage { position: relative; transform-origin: top left;
box-shadow: 0 2px 12px rgba(0,0,0,.35); background: #fff; }
.stage canvas { display: block; position: absolute; left: 0; top: 0; }
.stage #base { position: static; }
.stage #over { pointer-events: none; }
.stage[data-tool="arrow"] { cursor: crosshair; }
.stage[data-tool="rect"] { cursor: crosshair; }
.stage[data-tool="ellipse"]{ cursor: crosshair; }
.stage[data-tool="pen"] { cursor: crosshair; }
.stage[data-tool="text"] { cursor: text; }
.text-input {
position: absolute; z-index: 10;
background: rgba(20,26,36,.92); color: var(--ink); border: 1px solid var(--acid);
padding: 2px 6px; font: 15px/1.2 system-ui, sans-serif; outline: none;
border-radius: 3px; min-width: 80px;
background: rgba(20,26,36,.92); color: var(--ink);
border: 1px solid var(--acid); padding: 2px 6px;
font: 15px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
outline: none; border-radius: 3px; min-width: 80px;
}
/* Canvas board ----------------------------------------------------------- */
.board { flex: 1; overflow: auto; background: var(--board);
display: flex; align-items: flex-start; justify-content: center;
padding: 20px; }
.stage { position: relative; box-shadow: 0 0 0 1px var(--line), 0 8px 30px rgba(0,0,0,.35); }
.stage canvas { display: block; background: #fff; }
.stage canvas.draw { position: absolute; left: 0; top: 0; }
.stage canvas.overlay { position: absolute; left: 0; top: 0; pointer-events: none; }
/* When a drawing tool is active, put the drawing canvas above overlay */
.stage[data-tool="select"] canvas.draw { cursor: default; }
.stage[data-tool="crop"] canvas.draw { cursor: crosshair; }
.stage[data-tool="rect"] canvas.draw,
.stage[data-tool="ellipse"] canvas.draw,
.stage[data-tool="arrow"] canvas.draw,
.stage[data-tool="blur"] canvas.draw { cursor: crosshair; }
.stage[data-tool="pen"] canvas.draw { cursor: crosshair; }
.stage[data-tool="text"] canvas.draw { cursor: text; }
.hint { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
background: rgba(11,14,20,.9); border: 1px solid var(--line);
color: var(--ink); padding: 6px 10px; border-radius: 6px;
font-size: 12px; pointer-events: none; opacity: 0; transition: opacity .15s; }
.hint.on { opacity: 1; }
.toast { position: fixed; right: 16px; bottom: 16px;
background: rgb(from var(--acid) r g b / .15); color: var(--acid);
border: 1px solid rgb(from var(--acid) r g b / .5);
padding: 8px 12px; border-radius: 6px; font-size: 12.5px;
opacity: 0; transform: translateY(6px);
transition: opacity .2s, transform .2s; pointer-events: none; }
.toast.on { opacity: 1; transform: translateY(0); }
.toast.err { color: #ffb0b0; background: rgba(255,60,60,.15); border-color: rgba(255,60,60,.5); }
.toast {
position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%) translateY(20px);
padding: 8px 14px; border-radius: 6px; background: rgba(11,14,20,.94);
color: var(--ink); font-size: 12.5px; border: 1px solid var(--line);
opacity: 0; pointer-events: none; transition: opacity 180ms, transform 180ms;
z-index: 100; max-width: 80vw; text-align: center;
}
.toast.on { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.err { border-color: rgba(255,91,91,.5); color: var(--danger); }
</style>

View file

@ -6,117 +6,82 @@
<link rel="stylesheet" href="editor.css">
</head>
<body>
<div class="toolbar" id="toolbar">
<button class="tool" id="back" title="Back to sidebar panel">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M10 3l-5 5 5 5"/></svg>
<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>
</button>
<button class="tool" id="toggle-max" title="Expand the sidebar to full window / restore">
<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>
<span class="sep"></span>
<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>
<span>Copy</span>
</button>
<button class="btn wide primary" id="save" title="Save PNG (Ctrl+S)">
<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>
</div>
<!-- Tool selector. Each button carries data-tool; buttons for the
drawing shapes and crop go here. -->
<button class="tool active" data-tool="select" title="Select / no tool">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2l10 5-4 1-1 4z"/></svg>
<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">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 1v11h11M1 4h11v11"/></svg>
<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>
<button class="tool" data-tool="arrow" title="Arrow">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 13L13 3M13 3H8M13 3v5"/></svg>
</button>
<button class="tool" data-tool="rect" title="Rectangle">
<button class="tool" data-tool="rect" title="Rectangle (R)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/></svg>
</button>
<button class="tool" data-tool="ellipse" title="Ellipse">
<button class="tool" data-tool="ellipse" title="Ellipse (O)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><ellipse cx="8" cy="8" rx="5.5" ry="4"/></svg>
</button>
<button class="tool" data-tool="pen" title="Pen (freehand)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 14c1-3 3-6 6-8s5-3 6-3l-3 4c-2 1-4 3-6 4s-2 2-3 3z"/></svg>
<button class="tool" data-tool="pen" title="Pen (P)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14c1-3 3-6 6-8s5-3 6-3l-3 4c-2 1-4 3-6 4s-2 2-3 3z"/></svg>
</button>
<button class="tool" data-tool="text" title="Text label">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3h10M8 3v10M6 13h4"/></svg>
</button>
<button class="tool" data-tool="blur" title="Blur / mosaic redaction">
<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 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>
<span class="sep"></span>
<!-- Palette -->
<button class="swatch active" data-color="var(--acid)" style="background:var(--acid)" title="Acid"></button>
<button class="swatch" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button>
<button class="swatch" data-color="#ff9500" style="background:#ff9500" title="Orange"></button>
<button class="swatch active" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button>
<button class="swatch" data-color="#d6ff3d" style="background:#d6ff3d" title="Acid"></button>
<button class="swatch" data-color="#ffcc00" style="background:#ffcc00" title="Yellow"></button>
<button class="swatch" data-color="#0a84ff" style="background:#0a84ff" title="Blue"></button>
<button class="swatch" data-color="#bf5af2" style="background:#bf5af2" title="Purple"></button>
<button class="swatch" data-color="#0aa8ff" style="background:#0aa8ff" title="Blue"></button>
<button class="swatch" data-color="#ffffff" style="background:#ffffff" title="White"></button>
<button class="swatch" data-color="#000000" style="background:#000000" title="Black"></button>
<span class="sep"></span>
<!-- Stroke width -->
<button class="width" data-width="2" title="Thin">
<span class="dot" style="width:4px;height:4px"></span>
</button>
<button class="width active" data-width="4" title="Medium">
<span class="dot" style="width:7px;height:7px"></span>
</button>
<button class="width" data-width="8" title="Thick">
<span class="dot" style="width:10px;height:10px"></span>
</button>
<button class="width" data-width="3" title="Thin"><span class="dot" style="width:4px;height:4px"></span></button>
<button class="width active" data-width="5" title="Medium"><span class="dot" style="width:7px;height:7px"></span></button>
<button class="width" data-width="9" title="Thick"><span class="dot" style="width:11px;height:11px"></span></button>
<span class="sep"></span>
<button class="tool" id="undo" title="Undo (Ctrl+Z)" disabled>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 8c0-3 2-5 5-5s5 2 5 5-2 5-5 5"/><path d="M6 5L3 8l3 3"/></svg>
<button class="btn" id="undo" title="Undo (Ctrl+Z)" disabled>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 8c0-3 2-5 5-5s5 2 5 5-2 5-5 5"/><path d="M6 5L3 8l3 3"/></svg>
</button>
<button class="tool" id="redo" title="Redo (Ctrl+Shift+Z)" disabled>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M13 8c0-3-2-5-5-5S3 5 3 8s2 5 5 5"/><path d="M10 5l3 3-3 3"/></svg>
</button>
<span class="spacer"></span>
<button class="tool wide" id="apply-crop" title="Apply the current crop rectangle" hidden>
<span>Apply crop</span>
</button>
<button class="tool wide" id="cancel-crop" title="Cancel crop" hidden>
<span>Cancel</span>
</button>
<button class="tool wide" id="copy" title="Copy PNG to clipboard">
<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>
<span>Copy</span>
</button>
<button class="tool wide" id="save" title="Save PNG">
<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="tool wide" id="discard" title="Discard and close this tab (Esc)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
<span>Discard</span>
<button class="btn" id="redo" title="Redo (Ctrl+Shift+Z)" disabled>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M13 8c0-3-2-5-5-5S3 5 3 8s2 5 5 5"/><path d="M10 5l3 3-3 3"/></svg>
</button>
</div>
<div class="board" id="board">
<div class="stage" id="stage" data-tool="select">
<!-- committed pixels (the "real" image after every applied edit) -->
<canvas id="committed"></canvas>
<!-- live preview during a drag (arrow / rect / etc.) -->
<canvas id="draw" class="draw"></canvas>
<!-- crop / blur selection rectangle chrome -->
<canvas id="overlay" class="overlay"></canvas>
<div class="empty" id="empty">
<div>Loading capture…</div>
</div>
<div class="stage" id="stage" hidden>
<canvas id="base"></canvas>
<canvas id="over"></canvas>
</div>
</div>
<div class="hint" id="hint"></div>
<div class="toast" id="toast"></div>
<a id="download-link" style="display:none"></a>
<script src="editor.js"></script>

View file

@ -1,218 +1,218 @@
// Screenshot editor. Three stacked canvases:
// #committed — pristine bitmap after every applied edit
// #draw — receives pointer events; hosts the live preview during a drag
// #overlay — the crop/blur selection chrome (dashed rect, dim mask)
// Screenshot editor v2. Lives inside the sidebar view; no separate tab.
//
// Undo/redo is snapshot-based for correctness over cleverness: each committed
// edit pushes an ImageData onto an undo stack. Redo stack is cleared as soon
// as a new edit lands. Memory footprint is width * height * 4 * (stack depth);
// for a 1920x1080 image at depth 20 that's ~170 MB, so we cap the stack.
const UNDO_MAX = 25;
// 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 committed = $("committed");
const draw = $("draw");
const overlay = $("overlay");
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.
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);
}
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); }
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 applyCropBtn = $("apply-crop");
const cancelCropBtn = $("cancel-crop");
const hintEl = $("hint");
const toastEl = $("toast");
const dl = $("download-link");
const cctx = committed.getContext("2d");
const dctx = draw.getContext("2d");
const octx = overlay.getContext("2d");
const bctx = base.getContext("2d");
const octx = over.getContext("2d");
// URL params tell us what to load and (optionally) which tool to preselect.
const params = new URLSearchParams(location.search);
const srcUrl = params.get("src") || "";
const baseName = params.get("name") || "screenshot.png";
const initialTool = params.get("tool") || "";
nameEl.textContent = baseName;
document.title = baseName + " — editor";
const NAME = params.get("name") || "";
let state = {
tool: "select",
color: "#d6ff3d",
width: 4,
dragging: false,
start: null, // {x,y} in canvas coords (not CSS pixels)
end: null,
path: null, // pen points
cropRect: null, // {x,y,w,h} in canvas coords
color: "#ff3b30",
width: 5,
drag: null, // {x0,y0,x,y}
pen: null, // [{x,y}, …]
textInput: null, // {x, y, el}
};
let undo = []; // ImageData
let undo = [];
let redo = [];
// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------
// -- 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"), 1600);
toast._t = setTimeout(() => toastEl.classList.remove("on"), 1800);
}
function showHint(msg) {
hintEl.textContent = msg || "";
hintEl.classList.toggle("on", !!msg);
}
function updateUndoButtons() {
undoBtn.disabled = undo.length <= 1; // one entry = the base image
function updateUndoRedo() {
undoBtn.disabled = undo.length <= 1; // baseline snapshot always at index 0
redoBtn.disabled = redo.length === 0;
}
function pushSnapshot() {
try {
const snap = cctx.getImageData(0, 0, committed.width, committed.height);
const snap = bctx.getImageData(0, 0, base.width, base.height);
undo.push(snap);
if (undo.length > UNDO_MAX) undo.splice(0, undo.length - UNDO_MAX);
redo.length = 0;
updateUndoButtons();
} catch (e) { console.warn("snapshot failed:", e); }
if (undo.length > UNDO_MAX) undo.shift();
redo = [];
updateUndoRedo();
} catch (e) {
console.warn("snapshot failed:", e);
}
}
function restoreSnapshot(snap) {
if (!snap) return;
// Resize canvases to match the snapshot (crop is destructive to size).
if (committed.width !== snap.width || committed.height !== snap.height) {
if (base.width !== snap.width || base.height !== snap.height) {
sizeCanvases(snap.width, snap.height);
}
cctx.putImageData(snap, 0, 0);
bctx.putImageData(snap, 0, 0);
}
function sizeCanvases(w, h) {
for (const c of [committed, draw, overlay]) {
for (const c of [base, over]) {
c.width = w;
c.height = h;
// Match CSS size so 1 canvas px = 1 CSS px unless the board scales it.
c.style.width = w + "px";
c.style.height = h + "px";
}
stage.style.width = w + "px";
stage.style.height = h + "px";
}
function loadImage(url) {
// 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.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = (e) => reject(new Error("failed to load image"));
img.src = url;
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() {
// Preferred source: a pending capture handed to us through addon storage
// (the toolbar-menu capture path). A file:// src won't load — Chromium
// treats the editor at file:///…/addons/screenshot/editor.html as a
// different origin from the scratch PNG at file:///…/addons-data/…,
// so <img> quietly errors. Storage-based delivery sidesteps the origin
// entirely.
let dataUrl = null;
let toolFromPending = "";
let pendingName = baseName;
try {
if (window.silentmode?.storage) {
const pending = await window.silentmode.storage.get("__pending", null);
if (pending && pending.dataUrl) {
dataUrl = pending.dataUrl;
toolFromPending = pending.tool || "";
if (pending.name) { pendingName = pending.name; nameEl.textContent = pending.name; document.title = pending.name + " — editor"; }
// Clear so a later editor open (e.g. "recent captures") doesn't
// accidentally reload the same capture.
await window.silentmode.storage.set("__pending", null);
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;
}
}
} catch (e) { console.warn("editor: storage read failed:", e); }
// Fallback path — retained so the "openRecent" flow (which currently
// still passes ?src=file://) keeps working after we teach it to use
// storage too.
if (!dataUrl) {
if (!srcUrl) { toast("No capture to edit", true); return; }
dataUrl = srcUrl;
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(dataUrl); }
catch (e) { toast("Couldn't load capture: " + e.message, true); return; }
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);
cctx.drawImage(img, 0, 0);
bctx.drawImage(img, 0, 0);
undo = [];
redo = [];
pushSnapshot(); // baseline so the very first edit is undoable
updateUndoButtons();
pushSnapshot();
empty.hidden = true;
stage.hidden = false;
fitBoard();
const t = initialTool || toolFromPending;
if (t) setTool(t);
}
window.addEventListener("resize", () => { if (!stage.hidden) fitBoard(); });
// Scale the stage to fit within the board when the image is bigger than
// the viewport, so users see the whole shot without scrolling. We scale
// visually (CSS transform); drawing math still uses natural canvas
// coordinates.
let stageScale = 1;
function fitBoard() {
const availW = board.clientWidth - 40;
const availH = board.clientHeight - 40;
const s = Math.min(1, availW / committed.width, availH / committed.height);
stageScale = s > 0 ? s : 1;
stage.style.transform = `scale(${stageScale})`;
stage.style.transformOrigin = "top left";
// Reserve room so the scaled stage isn't clipped by the flex layout.
stage.style.width = (committed.width * stageScale) + "px";
stage.style.height = (committed.height * stageScale) + "px";
// Undo the reservation on the inner canvases — they must stay at natural
// size so the transform can scale them uniformly.
for (const c of [committed, draw, overlay]) {
c.style.width = committed.width + "px";
c.style.height = committed.height + "px";
}
// Keep the "reserved" outer wrapper's natural children visible.
stage.style.position = "relative";
committed.style.position = "static";
}
window.addEventListener("resize", () => fitBoard());
// ---------------------------------------------------------------------------
// Tool selection
// ---------------------------------------------------------------------------
// -- tool selection --------------------------------------------------------
function setTool(name) {
state.tool = name;
stage.dataset.tool = name;
for (const b of document.querySelectorAll(".tool[data-tool]")) {
for (const b of document.querySelectorAll(".tool")) {
b.classList.toggle("active", b.dataset.tool === name);
}
// Crop has a two-step commit; show its buttons when relevant.
const isCrop = name === "crop";
applyCropBtn.hidden = !isCrop || !state.cropRect;
cancelCropBtn.hidden = !isCrop || !state.cropRect;
if (!isCrop) { state.cropRect = null; clearOverlay(); }
clearDraw();
const hints = {
crop: "Drag a rectangle, then Apply crop",
arrow: "Drag to draw an arrow",
rect: "Drag to draw a rectangle",
ellipse: "Drag to draw an ellipse",
pen: "Draw freehand",
text: "Click to place a label",
blur: "Drag a rectangle to pixelate",
select: "",
};
showHint(hints[name] || "");
cancelTextInput();
}
for (const b of document.querySelectorAll(".tool[data-tool]")) {
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;
@ -221,401 +221,264 @@ for (const b of document.querySelectorAll(".swatch")) {
}
for (const b of document.querySelectorAll(".width")) {
b.addEventListener("click", () => {
state.width = Number(b.dataset.width);
state.width = Number(b.dataset.width) || 5;
for (const x of document.querySelectorAll(".width")) x.classList.toggle("active", x === b);
});
}
// ---------------------------------------------------------------------------
// Drawing primitives on ANY 2D context — used for both the live preview
// and the committed bake. Coordinates are in natural canvas px.
// ---------------------------------------------------------------------------
function drawArrow(ctx, x1, y1, x2, y2, color, width) {
ctx.save();
ctx.strokeStyle = color; ctx.fillStyle = color;
ctx.lineWidth = width; ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1;
const head = Math.max(10, width * 3);
const ux = dx / len, uy = dy / len;
const px = -uy, py = ux;
const tipX = x2, tipY = y2;
const baseX = x2 - ux * head, baseY = y2 - uy * head;
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(baseX + px * head * 0.5, baseY + py * head * 0.5);
ctx.lineTo(baseX - px * head * 0.5, baseY - py * head * 0.5);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawRect(ctx, x, y, w, h, color, width) {
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = width;
// Half-pixel offset for crisp 1px lines is not worth the branching at
// small width; the visible fuzz is negligible past width 2.
ctx.strokeRect(x, y, w, h);
ctx.restore();
}
function drawEllipse(ctx, x, y, w, h, color, width) {
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = width;
ctx.beginPath();
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
function drawPen(ctx, points, color, width) {
if (!points || points.length < 2) return;
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = width;
ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.stroke();
ctx.restore();
}
function drawTextLabel(ctx, x, y, text, color) {
if (!text) return;
ctx.save();
ctx.font = `600 18px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
ctx.textBaseline = "top";
const metrics = ctx.measureText(text);
const w = Math.ceil(metrics.width) + 8, h = 22;
// Backdrop for legibility over any background.
ctx.fillStyle = "rgba(0,0,0,.65)";
ctx.fillRect(x - 4, y - 2, w, h);
ctx.fillStyle = color;
ctx.fillText(text, x, y);
ctx.restore();
// -- 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";
}
// Mosaic pixelation: sample the region into a small offscreen, then draw
// back at full size with imageSmoothingEnabled off so each sample lands as
// a chunky square. Block size scales with stroke width for a "coarser /
// finer" knob on the same tool.
function applyMosaic(ctx, x, y, w, h, width) {
if (w <= 0 || h <= 0) return;
const block = Math.max(6, Math.min(40, width * 3));
const sw = Math.max(1, Math.round(w / block));
const sh = Math.max(1, Math.round(h / block));
const tmp = document.createElement("canvas");
tmp.width = sw; tmp.height = sh;
const tctx = tmp.getContext("2d");
tctx.imageSmoothingEnabled = false;
tctx.drawImage(ctx.canvas, x, y, w, h, 0, 0, sw, sh);
ctx.save();
ctx.imageSmoothingEnabled = false;
ctx.drawImage(tmp, 0, 0, sw, sh, x, y, w, h);
ctx.restore();
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 clearDraw() { dctx.clearRect(0, 0, draw.width, draw.height); }
function clearOverlay() { octx.clearRect(0, 0, overlay.width, overlay.height); }
function drawSelectionChrome(rect) {
clearOverlay();
if (!rect) return;
// Dim the surrounding area so the crop rect stands out.
octx.save();
octx.fillStyle = "rgba(0,0,0,.45)";
octx.fillRect(0, 0, overlay.width, overlay.height);
octx.clearRect(rect.x, rect.y, rect.w, rect.h);
octx.strokeStyle = "#d6ff3d";
octx.lineWidth = 1.5;
octx.setLineDash([6, 4]);
octx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1);
octx.restore();
}
// ---------------------------------------------------------------------------
// Pointer wiring
// ---------------------------------------------------------------------------
function pointerToCanvas(ev) {
const r = draw.getBoundingClientRect();
// r.width / draw.width gives us CSS px per canvas px, i.e. our current
// stageScale — computing it from the rect keeps us honest even if the
// fit-to-board math ever drifts.
const sx = draw.width / r.width;
const sy = draw.height / r.height;
return { x: (ev.clientX - r.left) * sx, y: (ev.clientY - r.top) * sy };
}
function normRect(a, b) {
function drawRect(a, b) {
strokeStyle();
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
const w = Math.abs(a.x - b.x), h = Math.abs(a.y - b.y);
return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y);
octx.strokeRect(x, y, w, h);
}
draw.addEventListener("pointerdown", (ev) => {
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();
}
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") {
beginText(ev);
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;
}
draw.setPointerCapture(ev.pointerId);
state.dragging = true;
state.start = pointerToCanvas(ev);
state.end = state.start;
if (state.tool === "pen") state.path = [state.start];
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);
});
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; return; }
commitOver();
state.drag = null;
});
draw.addEventListener("pointermove", (ev) => {
if (!state.dragging) return;
state.end = pointerToCanvas(ev);
if (state.tool === "pen") {
state.path.push(state.end);
// Live-render the whole path each move; simpler than incremental and
// fine at freehand cadence.
clearDraw();
drawPen(dctx, state.path, state.color, state.width);
return;
}
const r = normRect(state.start, state.end);
if (state.tool === "crop" || state.tool === "blur") {
drawSelectionChrome(r);
return;
}
clearDraw();
if (state.tool === "arrow") drawArrow(dctx, state.start.x, state.start.y, state.end.x, state.end.y, state.color, state.width);
if (state.tool === "rect") drawRect(dctx, r.x, r.y, r.w, r.h, state.color, state.width);
if (state.tool === "ellipse") drawEllipse(dctx, r.x, r.y, r.w, r.h, state.color, state.width);
// -- 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(); }
});
draw.addEventListener("pointerup", (ev) => {
if (!state.dragging) return;
state.dragging = false;
try { draw.releasePointerCapture(ev.pointerId); } catch {}
const r = normRect(state.start, state.end);
if (state.tool === "pen") {
if (state.path && state.path.length > 1) {
drawPen(cctx, state.path, state.color, state.width);
pushSnapshot();
el.addEventListener("blur", commitTextInput);
}
state.path = null;
clearDraw();
return;
}
if (state.tool === "arrow") {
if (Math.hypot(state.end.x - state.start.x, state.end.y - state.start.y) > 3) {
drawArrow(cctx, state.start.x, state.start.y, state.end.x, state.end.y, state.color, state.width);
pushSnapshot();
}
clearDraw();
return;
}
if (state.tool === "rect" || state.tool === "ellipse") {
if (r.w > 3 && r.h > 3) {
(state.tool === "rect" ? drawRect : drawEllipse)(cctx, r.x, r.y, r.w, r.h, state.color, state.width);
pushSnapshot();
}
clearDraw();
return;
}
if (state.tool === "blur") {
if (r.w > 3 && r.h > 3) {
applyMosaic(cctx, r.x, r.y, r.w, r.h, state.width);
pushSnapshot();
}
clearOverlay();
return;
}
if (state.tool === "crop") {
if (r.w > 3 && r.h > 3) {
state.cropRect = r;
applyCropBtn.hidden = false;
cancelCropBtn.hidden = false;
} else {
state.cropRect = null;
clearOverlay();
applyCropBtn.hidden = true;
cancelCropBtn.hidden = true;
}
}
});
// Escape unwinds progressively: in-flight text placement → in-flight
// crop rectangle → whole editor (drops the screenshot and closes the tab).
window.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") {
if (state.textInput) { cancelText(); ev.preventDefault(); return; }
if (state.tool === "crop" && state.cropRect) { state.cropRect = null; clearOverlay(); applyCropBtn.hidden = true; cancelCropBtn.hidden = true; return; }
discard(); ev.preventDefault(); return;
}
// Undo / redo shortcuts.
const meta = ev.ctrlKey || ev.metaKey;
if (meta && !ev.shiftKey && ev.key.toLowerCase() === "z") { doUndo(); ev.preventDefault(); return; }
if (meta && ev.shiftKey && ev.key.toLowerCase() === "z") { doRedo(); ev.preventDefault(); return; }
if (meta && ev.key.toLowerCase() === "y") { doRedo(); ev.preventDefault(); return; }
if (meta && ev.key.toLowerCase() === "s") { save(); ev.preventDefault(); return; }
if (meta && ev.key.toLowerCase() === "c" && !state.textInput) { copy(); ev.preventDefault(); return; }
});
// ---------------------------------------------------------------------------
// Text tool: click places an input; blur/Enter commits, Escape cancels.
// ---------------------------------------------------------------------------
function beginText(ev) {
if (state.textInput) commitText();
const p = pointerToCanvas(ev);
const inp = document.createElement("input");
inp.type = "text";
inp.className = "text-input";
inp.placeholder = "text";
// Place using viewport coords — body isn't positioned, so absolute
// left/top match clientX/Y as long as the board isn't scrolled.
inp.style.left = (ev.clientX + board.scrollLeft) + "px";
inp.style.top = (ev.clientY + board.scrollTop) + "px";
inp.style.color = state.color;
document.body.appendChild(inp);
inp.focus();
state.textInput = { x: p.x, y: p.y, el: inp, color: state.color };
inp.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); commitText(); }
else if (e.key === "Escape") { e.preventDefault(); cancelText(); }
});
inp.addEventListener("blur", () => setTimeout(commitText, 0));
}
function commitText() {
const t = state.textInput; if (!t) return;
const text = t.el.value.trim();
t.el.remove();
function commitTextInput() {
const ti = state.textInput;
if (!ti) return;
const value = ti.el.value.trim();
ti.el.remove();
state.textInput = null;
if (!text) return;
drawTextLabel(cctx, t.x, t.y, text, t.color);
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 cancelText() {
const t = state.textInput; if (!t) return;
t.el.remove();
function cancelTextInput() {
if (!state.textInput) return;
state.textInput.el.remove();
state.textInput = null;
}
// ---------------------------------------------------------------------------
// Undo / redo
// ---------------------------------------------------------------------------
// -- undo / redo -------------------------------------------------------
function doUndo() {
if (undo.length <= 1) return;
const cur = undo.pop();
redo.push(cur);
const prev = undo[undo.length - 1];
restoreSnapshot(prev);
clearDraw(); clearOverlay();
state.cropRect = null;
applyCropBtn.hidden = true;
cancelCropBtn.hidden = true;
updateUndoButtons();
restoreSnapshot(undo[undo.length - 1]);
updateUndoRedo();
}
function doRedo() {
if (!redo.length) return;
const snap = redo.pop();
if (!snap) return;
undo.push(snap);
restoreSnapshot(snap);
updateUndoButtons();
updateUndoRedo();
}
undoBtn.addEventListener("click", doUndo);
redoBtn.addEventListener("click", doRedo);
// ---------------------------------------------------------------------------
// Crop apply
// ---------------------------------------------------------------------------
applyCropBtn.addEventListener("click", () => {
const r = state.cropRect; if (!r) return;
// Clamp to canvas.
const x = Math.max(0, r.x), y = Math.max(0, r.y);
const w = Math.min(r.w, committed.width - x);
const h = Math.min(r.h, committed.height - y);
if (w <= 0 || h <= 0) { toast("Crop out of bounds", true); return; }
const tmp = document.createElement("canvas");
tmp.width = w; tmp.height = h;
tmp.getContext("2d").drawImage(committed, x, y, w, h, 0, 0, w, h);
sizeCanvases(w, h);
cctx.drawImage(tmp, 0, 0);
state.cropRect = null;
clearOverlay(); clearDraw();
applyCropBtn.hidden = true;
cancelCropBtn.hidden = true;
pushSnapshot();
fitBoard();
});
cancelCropBtn.addEventListener("click", () => {
state.cropRect = null;
clearOverlay();
applyCropBtn.hidden = true;
cancelCropBtn.hidden = true;
});
// ---------------------------------------------------------------------------
// Save & copy
// ---------------------------------------------------------------------------
// -- save / copy ------------------------------------------------------
function canvasBlob() {
return new Promise((resolve, reject) => {
committed.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob returned null")), "image/png");
});
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();
// Chromium's will-download listener catches this via the download attr;
// no separate capability needed.
const url = URL.createObjectURL(blob);
const a = $("download-link");
a.href = url;
a.download = baseName || "screenshot.png";
a.click();
// Blob URLs are cheap but leak; release once the browser has had a beat
// to start the download.
setTimeout(() => URL.revokeObjectURL(url), 4000);
toast(`Saved ${baseName} (${(blob.size / 1024).toFixed(1)} KB)`);
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.message, true);
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.message, true);
toast("Copy failed: " + (e && e.message || e), true);
}
}
$("save").addEventListener("click", save);
$("copy").addEventListener("click", copy);
$("discard").addEventListener("click", discard);
// Back button — navigate the sidebar view back to panel.html. Same
// webContents, so it's just a location swap; no IPC needed.
const backBtn = $("back");
if (backBtn) backBtn.addEventListener("click", () => { location.href = "panel.html"; });
// -- top-bar navigation ----------------------------------------------
$("back").addEventListener("click", () => { playDiscard(); location.href = "panel.html"; });
// Maximize / restore — asks the sidebar host to widen its view to the full
// window and back. Icon reflects state via silentmode.sidebar.onMaxChange.
const maxBtn = $("toggle-max");
if (maxBtn && window.silentmode?.sidebar) {
maxBtn.addEventListener("click", async () => {
try { await window.silentmode.sidebar.toggleMax(); }
catch (e) { console.warn("toggleMax failed:", e); }
// 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 paint = (isMax) => {
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>';
};
window.silentmode.sidebar.onMaxChange(paint);
window.silentmode.sidebar.isMax().then(paint).catch(() => {});
}
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(() => {});
}
// Drop the working screenshot and go back to the sidebar panel. Used by
// the Discard button and the top-level Escape shortcut.
async function discard() {
try { if (window.silentmode?.storage) await window.silentmode.storage.set("__pending", null); } catch {}
location.href = "panel.html";
}
// -- 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 (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 === "Escape") setTool("select");
});
init();

View file

@ -101,24 +101,10 @@ module.exports = {
};
});
// panel invokes "arm" right before it navigates itself to editor.html
// (in-sidebar navigation — SAME webContents, so the editor's storage-
// based load path Just Works). We rewrite __pending fresh here so
// whichever capture is currently previewed becomes the one the editor
// draws, even if a previous edit session already drained the entry.
api.onMessage("arm", (payload) => {
const wantedName = payload && payload.name ? String(payload.name) : "";
let recent = api.storage.get("recent", []);
if (!Array.isArray(recent)) recent = [];
recent = pruneRecent(recent);
api.storage.set("recent", recent);
const hit = wantedName ? recent.find((r) => r.id === wantedName) : recent[0];
if (!hit) throw new Error("no capture to edit — take one first");
const dataUrl = readAsDataUrl(hit.path);
api.storage.set("__pending", { name: hit.name, dataUrl, at: Date.now() });
api.log(`arm → editor.html?name=${hit.name}`);
return { ok: true, name: hit.name };
});
// (The old "arm" handler that pre-armed a __pending storage entry is
// gone: editor.html now pulls its bytes via getBytes on load, so there
// is nothing to hand off in advance. Idempotent — reopening the editor
// in the sidebar just re-fetches the bytes.)
// Read the ring for a "recent captures" strip in the sidebar. Bytes are
// reported; the actual images are pulled through "getBytes" on demand
@ -155,6 +141,6 @@ module.exports = {
return { ok: true };
});
api.log("registered screenshot v0.3.0 sidebar panel");
api.log("registered screenshot v0.5.0 sidebar panel");
},
};

View file

@ -59,15 +59,33 @@
}
.meta { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
.meta .host { color: var(--mut); }
.actions { display: flex; gap: 6px; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.actions .row { display: flex; gap: 6px; flex: 1 1 100%; }
button.act {
flex: 1; padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px;
flex: 1; padding: 9px 8px; border: 1px solid var(--line); border-radius: 8px;
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
line-height: 1;
}
button.act svg { width: 14px; height: 14px; display: block; }
button.act.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
button.act:hover:not(:disabled) { background: var(--btn-h); }
button.act.primary:hover:not(:disabled) { filter: brightness(1.05); }
button.act.primary:hover:not(:disabled) { filter: brightness(1.05); background: var(--acid); }
button.act:disabled { opacity: .45; cursor: default; }
.confirm {
display: none; gap: 6px; align-items: center;
padding: 8px 10px; border: 1px solid rgba(255,91,91,.40); border-radius: 8px;
background: rgba(255,91,91,.06); font-size: 12px;
}
.confirm.on { display: flex; }
.confirm .msg { flex: 1; color: var(--mut); }
.confirm button {
padding: 5px 10px; border: 1px solid var(--line); border-radius: 6px;
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
}
.confirm button.yes { background: #ff5b5b; color: #101418; border-color: transparent; font-weight: 600; }
.confirm button:hover { background: var(--btn-h); }
.confirm button.yes:hover { filter: brightness(1.05); background: #ff5b5b; }
.status { color: var(--mut); font-size: 12px; min-height: 16px; }
.status.err { color: var(--err); }
.status.ok { color: var(--acid); }
@ -91,6 +109,7 @@
<header>
<div class="t"><span class="em">📸</span> <span>Screenshot</span></div>
<div class="m" id="hdr-status"></div>
<button class="max" id="btn-sound" title="Toggle sound" aria-label="Toggle sound"></button>
<button class="max" id="btn-max" title="Expand the sidebar to full window width" aria-label="Expand sidebar">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/>
@ -121,8 +140,20 @@
</div>
<div class="actions">
<div class="row">
<button class="act" id="btn-copy" disabled title="Copy the raw capture to the clipboard">
<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>
<span>Copy</span>
</button>
<button class="act" id="btn-save" disabled title="Save the raw capture straight to Downloads">
<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>
</div>
<div class="row">
<button class="act" id="btn-discard" disabled>Discard</button>
<button class="act primary" id="btn-edit" disabled title="Edit the capture — crop, annotate, redact, save">Edit</button>
<button class="act primary" id="btn-edit" disabled title="Open the annotate editor — arrow, rect, ellipse, pen, text, undo, save, copy">Edit</button>
</div>
</div>
<div class="status" id="status"></div>
@ -132,7 +163,13 @@
<button class="clear" id="clear-recent" title="Delete every past capture">clear all</button>
</div>
<div class="strip" id="recent-strip"></div>
<div class="confirm" id="clear-confirm">
<span class="msg">Delete every past capture?</span>
<button id="clear-no">Cancel</button>
<button class="yes" id="clear-yes">Delete</button>
</div>
</div>
<a id="download-link" style="display:none"></a>
</main>
<script>
@ -144,7 +181,63 @@
const metaHost = $("meta-host");
const btnEdit = $("btn-edit");
const btnDiscard = $("btn-discard");
const btnCopy = $("btn-copy");
const btnSave = $("btn-save");
const btnMax = $("btn-max");
const btnSound = $("btn-sound");
const dl = $("download-link");
// ---- audio ----------------------------------------------------------
// Synthesized in-page so the tarball stays small — no wav shipped.
// Preference is stored under silentmode.storage so the editor sees the
// same setting when it loads (default on).
let soundOn = true;
let audioCtx = null;
function actx() {
if (!audioCtx) {
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch {}
}
// A user gesture is needed to resume in some Chromium setups; the
// toggle button click satisfies that.
if (audioCtx && audioCtx.state === "suspended") { audioCtx.resume().catch(() => {}); }
return audioCtx;
}
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);
}
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); }
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";
btnSound.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; // default on
} catch {}
paintSound();
})();
btnSound.addEventListener("click", async () => {
soundOn = !soundOn;
paintSound();
try { await window.silentmode.storage.set("soundOn", soundOn); } catch {}
if (soundOn) playShutter(); // audible confirmation
});
const hdrStatus = $("hdr-status");
const recentBox = $("recent");
const recentStrip = $("recent-strip");
@ -163,22 +256,16 @@
}
function renderPreview() {
if (!last || !last.dataUrl) {
previewImg.hidden = true; previewImg.src = "";
previewEmpty.hidden = false;
metaSize.textContent = "—";
metaHost.textContent = "";
btnEdit.disabled = true;
btnDiscard.disabled = true;
return;
}
previewImg.src = last.dataUrl;
previewImg.hidden = false;
previewEmpty.hidden = true;
metaSize.textContent = `${last.width}×${last.height} · ${fmt(last.bytes || 0)}`;
metaHost.textContent = last.host || "";
btnEdit.disabled = false;
btnDiscard.disabled = false;
const on = !!(last && last.dataUrl);
previewImg.hidden = !on;
previewImg.src = on ? last.dataUrl : "";
previewEmpty.hidden = on;
metaSize.textContent = on ? `${last.width}×${last.height} · ${fmt(last.bytes || 0)}` : "—";
metaHost.textContent = on ? (last.host || "") : "";
btnEdit.disabled = !on;
btnDiscard.disabled = !on;
btnCopy.disabled = !on;
btnSave.disabled = !on;
}
async function refreshRecent() {
@ -234,6 +321,7 @@
} else if (res && res.dataUrl) {
last = res;
renderPreview();
playShutter();
setStatus("Captured. Click Edit to annotate.", "ok");
refreshRecent();
} else {
@ -250,24 +338,11 @@
}
async function doEdit() {
if (!last || !last.dataUrl || busy) return;
busy = true;
btnEdit.disabled = true;
hdrStatus.textContent = "opening editor…";
try {
// Arm __pending with the currently-previewed capture, then navigate
// this sidebar view to editor.html — same webContents, same preload,
// so the editor keeps talking to the add-on through silentmode.*.
const armed = await window.silentmode.invoke("arm", { name: last.name });
const name = (armed && armed.name) || last.name;
location.href = "editor.html?name=" + encodeURIComponent(name);
} catch (e) {
console.warn("arm failed:", e);
setStatus("Edit failed: " + (e && e.message || e), "err");
btnEdit.disabled = !last;
hdrStatus.textContent = "";
busy = false;
}
if (!last || !last.name || busy) return;
// No IPC handshake — the editor pulls bytes on load via silentmode
// .invoke("getBytes", {name}) directly. That's idempotent, so a refresh
// or back-and-forth navigation always shows the image.
location.href = "editor.html?name=" + encodeURIComponent(last.name);
}
async function toggleMax() {
@ -275,15 +350,69 @@
catch (e) { console.warn("toggleMax failed:", e); }
}
// Convert a base64 data URL into a Blob without a fetch round-trip.
function dataUrlToBlob(dataUrl) {
const m = /^data:([^;,]+);base64,(.+)$/.exec(String(dataUrl || ""));
if (!m) throw new Error("expected base64 data URL");
const bin = atob(m[2]);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return new Blob([buf], { type: m[1] });
}
async function doCopy() {
if (!last || !last.dataUrl || busy) return;
try {
const blob = dataUrlToBlob(last.dataUrl);
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
playCopy();
setStatus("Copied to clipboard.", "ok");
} catch (e) {
console.warn("copy failed:", e);
setStatus("Copy failed: " + (e && e.message || e), "err");
}
}
async function doSave() {
if (!last || !last.dataUrl || busy) return;
try {
const blob = dataUrlToBlob(last.dataUrl);
const url = URL.createObjectURL(blob);
dl.href = url;
dl.download = last.name || "screenshot.png";
dl.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
setStatus(`Saved ${dl.download} (${(blob.size / 1024).toFixed(1)} KB)`, "ok");
} catch (e) {
console.warn("save failed:", e);
setStatus("Save failed: " + (e && e.message || e), "err");
}
}
for (const b of document.querySelectorAll("button.mode")) {
b.addEventListener("click", () => doCapture(b.dataset.mode));
}
btnEdit.addEventListener("click", doEdit);
btnDiscard.addEventListener("click", () => { last = null; renderPreview(); setStatus(""); });
btnDiscard.addEventListener("click", () => { playDiscard(); last = null; renderPreview(); setStatus(""); });
btnCopy.addEventListener("click", doCopy);
btnSave.addEventListener("click", doSave);
btnMax.addEventListener("click", toggleMax);
clearBtn.addEventListener("click", async () => {
if (!confirm("Delete every past capture?")) return;
try { await window.silentmode.invoke("clearRecent", {}); await refreshRecent(); }
// Inline confirmation for "clear all" — the native confirm() looks like an
// OS-modal from the last decade AND opens over the tab area (out of the
// sidebar's visual context). Toggle a styled strip inside the sidebar so
// the destructive action stays where the user is looking.
const confirmBox = $("clear-confirm");
clearBtn.addEventListener("click", () => {
confirmBox.classList.add("on");
clearBtn.disabled = true;
});
$("clear-no").addEventListener("click", () => {
confirmBox.classList.remove("on");
clearBtn.disabled = false;
});
$("clear-yes").addEventListener("click", async () => {
confirmBox.classList.remove("on");
clearBtn.disabled = false;
try { await window.silentmode.invoke("clearRecent", {}); await refreshRecent(); setStatus("Cleared past captures.", "ok"); }
catch (e) { setStatus("Clear failed: " + (e && e.message || e), "err"); }
});