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", "id": "screenshot",
"name": "Screenshot", "name": "Screenshot",
"version": "0.4.0", "version": "0.5.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.", "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", "author": "Silent Mode",
"icon": "📸", "icon": "📸",
"main": "index.js", "main": "index.js",

View file

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

@ -1,124 +1,89 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Screenshot editor</title> <title>Screenshot editor</title>
<link rel="stylesheet" href="editor.css"> <link rel="stylesheet" href="editor.css">
</head> </head>
<body> <body>
<div class="toolbar" id="toolbar"> <div class="topbar">
<button class="tool" id="back" title="Back to sidebar panel"> <button class="btn" 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> <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>
<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> <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>
<span class="name" id="name">screenshot</span> <button class="btn" id="toggle-sound" title="Toggle sound"></button>
<span class="sep"></span> <span class="name" id="name">screenshot</span>
<span class="spacer"></span>
<!-- Tool selector. Each button carries data-tool; buttons for the <button class="btn wide" id="copy" title="Copy PNG to clipboard (Ctrl+C)">
drawing shapes and crop go here. --> <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>
<button class="tool active" data-tool="select" title="Select / no tool"> <span>Copy</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2l10 5-4 1-1 4z"/></svg> </button>
</button> <button class="btn wide primary" id="save" title="Save PNG (Ctrl+S)">
<button class="tool" data-tool="crop" title="Crop"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 1v11h11M1 4h11v11"/></svg> <span>Save</span>
</button> </button>
<button class="tool" data-tool="arrow" title="Arrow"> </div>
<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> <div class="toolbar">
<button class="tool" data-tool="rect" title="Rectangle"> <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"><rect x="2.5" y="3.5" width="11" height="9"/></svg> <svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 2l10 5-4 1-1 4z"/></svg>
</button> </button>
<button class="tool" data-tool="ellipse" title="Ellipse"> <button class="tool" data-tool="arrow" title="Arrow (A)">
<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> <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>
<button class="tool" data-tool="pen" title="Pen (freehand)"> <button class="tool" data-tool="rect" title="Rectangle (R)">
<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> <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>
<button class="tool" data-tool="text" title="Text label"> <button class="tool" data-tool="ellipse" title="Ellipse (O)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3h10M8 3v10M6 13h4"/></svg> <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>
<button class="tool" data-tool="blur" title="Blur / mosaic redaction"> <button class="tool" data-tool="pen" title="Pen (P)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> <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>
<rect x="2.5" y="2.5" width="11" height="11"/> </button>
<path d="M5 6h1M8 6h1M11 6h1M5 9h1M8 9h1M11 9h1M5 12h1M8 12h1M11 12h1"/> <button class="tool" data-tool="text" title="Text (T)">
</svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 3h10M8 3v10M6 13h4"/></svg>
</button> </button>
<span class="sep"></span> <span class="sep"></span>
<!-- Palette --> <button class="swatch active" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button>
<button class="swatch active" data-color="var(--acid)" style="background:var(--acid)" title="Acid"></button> <button class="swatch" data-color="#d6ff3d" style="background:#d6ff3d" title="Acid"></button>
<button class="swatch" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button> <button class="swatch" data-color="#ffcc00" style="background:#ffcc00" title="Yellow"></button>
<button class="swatch" data-color="#ff9500" style="background:#ff9500" title="Orange"></button> <button class="swatch" data-color="#0aa8ff" style="background:#0aa8ff" title="Blue"></button>
<button class="swatch" data-color="#ffcc00" style="background:#ffcc00" title="Yellow"></button> <button class="swatch" data-color="#ffffff" style="background:#ffffff" title="White"></button>
<button class="swatch" data-color="#0a84ff" style="background:#0a84ff" title="Blue"></button> <button class="swatch" data-color="#000000" style="background:#000000" title="Black"></button>
<button class="swatch" data-color="#bf5af2" style="background:#bf5af2" title="Purple"></button>
<button class="swatch" data-color="#ffffff" style="background:#ffffff" title="White"></button> <span class="sep"></span>
<button class="swatch" data-color="#000000" style="background:#000000" title="Black"></button>
<button class="width" data-width="3" title="Thin"><span class="dot" style="width:4px;height:4px"></span></button>
<span class="sep"></span> <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>
<!-- Stroke width -->
<button class="width" data-width="2" title="Thin"> <span class="sep"></span>
<span class="dot" style="width:4px;height:4px"></span>
</button> <button class="btn" id="undo" title="Undo (Ctrl+Z)" disabled>
<button class="width active" data-width="4" title="Medium"> <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>
<span class="dot" style="width:7px;height:7px"></span> </button>
</button> <button class="btn" id="redo" title="Redo (Ctrl+Shift+Z)" disabled>
<button class="width" data-width="8" title="Thick"> <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>
<span class="dot" style="width:10px;height:10px"></span> </button>
</button> </div>
<span class="sep"></span> <div class="board" id="board">
<div class="empty" id="empty">
<button class="tool" id="undo" title="Undo (Ctrl+Z)" disabled> <div>Loading capture…</div>
<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> </div>
</button> <div class="stage" id="stage" hidden>
<button class="tool" id="redo" title="Redo (Ctrl+Shift+Z)" disabled> <canvas id="base"></canvas>
<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> <canvas id="over"></canvas>
</button> </div>
</div>
<span class="spacer"></span>
<div class="toast" id="toast"></div>
<button class="tool wide" id="apply-crop" title="Apply the current crop rectangle" hidden> <a id="download-link" style="display:none"></a>
<span>Apply crop</span>
</button> <script src="editor.js"></script>
<button class="tool wide" id="cancel-crop" title="Cancel crop" hidden> </body>
<span>Cancel</span> </html>
</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>
</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>
</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>
</body>
</html>

View file

@ -1,218 +1,218 @@
// Screenshot editor. Three stacked canvases: // Screenshot editor v2. Lives inside the sidebar view; no separate tab.
// #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)
// //
// Undo/redo is snapshot-based for correctness over cleverness: each committed // Load path (idempotent — this is the key change from v1's __pending drain):
// edit pushes an ImageData onto an undo stack. Redo stack is cleared as soon // editor.html?name=<scratchfile.png>
// as a new edit lands. Memory footprint is width * height * 4 * (stack depth); // → silentmode.invoke("getBytes", {name})
// for a 1920x1080 image at depth 20 that's ~170 MB, so we cap the stack. // → addon reads scratch/<name>.png from disk and returns
// { name, dataUrl:"data:image/png;base64,…", bytes, at, mode }
const UNDO_MAX = 25; // → 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 $ = (id) => document.getElementById(id);
const committed = $("committed"); const UNDO_MAX = 25;
const draw = $("draw");
const overlay = $("overlay");
const stage = $("stage");
const board = $("board");
const nameEl = $("name");
const undoBtn = $("undo");
const redoBtn = $("redo");
const applyCropBtn = $("apply-crop");
const cancelCropBtn = $("cancel-crop");
const hintEl = $("hint");
const toastEl = $("toast");
const cctx = committed.getContext("2d"); // ---- audio -----------------------------------------------------------
const dctx = draw.getContext("2d"); // Kept in sync with the sidebar panel: preference stored under
const octx = overlay.getContext("2d"); // 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 toastEl = $("toast");
const dl = $("download-link");
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 params = new URLSearchParams(location.search);
const srcUrl = params.get("src") || ""; const NAME = params.get("name") || "";
const baseName = params.get("name") || "screenshot.png";
const initialTool = params.get("tool") || "";
nameEl.textContent = baseName;
document.title = baseName + " — editor";
let state = { let state = {
tool: "select", tool: "select",
color: "#d6ff3d", color: "#ff3b30",
width: 4, width: 5,
dragging: false, drag: null, // {x0,y0,x,y}
start: null, // {x,y} in canvas coords (not CSS pixels) pen: null, // [{x,y}, …]
end: null, textInput: null, // {x, y, el}
path: null, // pen points
cropRect: null, // {x,y,w,h} in canvas coords
textInput: null, // {x,y, el}
}; };
let undo = []; // ImageData let undo = [];
let redo = []; let redo = [];
// --------------------------------------------------------------------------- // -- utilities --------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------
function toast(msg, err) { function toast(msg, err) {
toastEl.textContent = msg; toastEl.textContent = msg;
toastEl.classList.toggle("err", !!err); toastEl.classList.toggle("err", !!err);
toastEl.classList.add("on"); toastEl.classList.add("on");
clearTimeout(toast._t); clearTimeout(toast._t);
toast._t = setTimeout(() => toastEl.classList.remove("on"), 1600); toast._t = setTimeout(() => toastEl.classList.remove("on"), 1800);
} }
function showHint(msg) { function updateUndoRedo() {
hintEl.textContent = msg || ""; undoBtn.disabled = undo.length <= 1; // baseline snapshot always at index 0
hintEl.classList.toggle("on", !!msg);
}
function updateUndoButtons() {
undoBtn.disabled = undo.length <= 1; // one entry = the base image
redoBtn.disabled = redo.length === 0; redoBtn.disabled = redo.length === 0;
} }
function pushSnapshot() { function pushSnapshot() {
try { try {
const snap = cctx.getImageData(0, 0, committed.width, committed.height); const snap = bctx.getImageData(0, 0, base.width, base.height);
undo.push(snap); undo.push(snap);
if (undo.length > UNDO_MAX) undo.splice(0, undo.length - UNDO_MAX); if (undo.length > UNDO_MAX) undo.shift();
redo.length = 0; redo = [];
updateUndoButtons(); updateUndoRedo();
} catch (e) { console.warn("snapshot failed:", e); } } catch (e) {
console.warn("snapshot failed:", e);
}
} }
function restoreSnapshot(snap) { function restoreSnapshot(snap) {
if (!snap) return; if (!snap) return;
// Resize canvases to match the snapshot (crop is destructive to size). if (base.width !== snap.width || base.height !== snap.height) {
if (committed.width !== snap.width || committed.height !== snap.height) {
sizeCanvases(snap.width, snap.height); sizeCanvases(snap.width, snap.height);
} }
cctx.putImageData(snap, 0, 0); bctx.putImageData(snap, 0, 0);
} }
function sizeCanvases(w, h) { function sizeCanvases(w, h) {
for (const c of [committed, draw, overlay]) { for (const c of [base, over]) {
c.width = w; c.width = w;
c.height = h; 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.width = w + "px";
c.style.height = h + "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) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.crossOrigin = "anonymous"; img.onload = () => resolve(img);
img.onload = () => resolve(img); img.onerror = () => reject(new Error("image decode failed"));
img.onerror = (e) => reject(new Error("failed to load image")); // NB: no crossOrigin — data: URLs are same-origin, and setting it
img.src = url; // 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() { async function init() {
// Preferred source: a pending capture handed to us through addon storage document.title = NAME ? NAME + " — editor" : "Screenshot editor";
// (the toolbar-menu capture path). A file:// src won't load — Chromium nameEl.textContent = NAME || "screenshot";
// treats the editor at file:///…/addons/screenshot/editor.html as a if (!NAME) {
// different origin from the scratch PNG at file:///…/addons-data/…, empty.classList.add("err");
// so <img> quietly errors. Storage-based delivery sidesteps the origin empty.innerHTML = "<div>Missing <code>?name</code> — open a capture from the Screenshot sidebar.</div>";
// entirely. return;
let dataUrl = null; }
let toolFromPending = ""; let res;
let pendingName = baseName; try { res = await loadFromAddon(NAME); }
try { catch (e) {
if (window.silentmode?.storage) { console.warn("editor load failed:", e);
const pending = await window.silentmode.storage.get("__pending", null); empty.classList.add("err");
if (pending && pending.dataUrl) { empty.innerHTML = `<div>Couldn't load capture: ${(e && e.message || e)}</div>`;
dataUrl = pending.dataUrl; return;
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);
}
}
} 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 img; let img;
try { img = await loadImage(dataUrl); } try { img = await loadImage(res.dataUrl); }
catch (e) { toast("Couldn't load capture: " + e.message, true); return; } 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); sizeCanvases(img.naturalWidth, img.naturalHeight);
cctx.drawImage(img, 0, 0); bctx.drawImage(img, 0, 0);
undo = []; undo = [];
redo = []; redo = [];
pushSnapshot(); // baseline so the very first edit is undoable pushSnapshot();
updateUndoButtons(); empty.hidden = true;
stage.hidden = false;
fitBoard(); 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 // -- tool selection --------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
function setTool(name) { function setTool(name) {
state.tool = name; state.tool = name;
stage.dataset.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); b.classList.toggle("active", b.dataset.tool === name);
} }
// Crop has a two-step commit; show its buttons when relevant. cancelTextInput();
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] || "");
} }
for (const b of document.querySelectorAll(".tool[data-tool]")) { for (const b of document.querySelectorAll(".tool")) {
b.addEventListener("click", () => setTool(b.dataset.tool)); b.addEventListener("click", () => setTool(b.dataset.tool));
} }
for (const b of document.querySelectorAll(".swatch")) { for (const b of document.querySelectorAll(".swatch")) {
b.addEventListener("click", () => { b.addEventListener("click", () => {
state.color = b.dataset.color; state.color = b.dataset.color;
@ -221,401 +221,264 @@ for (const b of document.querySelectorAll(".swatch")) {
} }
for (const b of document.querySelectorAll(".width")) { for (const b of document.querySelectorAll(".width")) {
b.addEventListener("click", () => { 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); for (const x of document.querySelectorAll(".width")) x.classList.toggle("active", x === b);
}); });
} }
// --------------------------------------------------------------------------- // -- drawing ---------------------------------------------------------------
// Drawing primitives on ANY 2D context — used for both the live preview function clearOver() { octx.clearRect(0, 0, over.width, over.height); }
// and the committed bake. Coordinates are in natural canvas px.
// --------------------------------------------------------------------------- function strokeStyle() {
function drawArrow(ctx, x1, y1, x2, y2, color, width) { octx.strokeStyle = state.color;
ctx.save(); octx.fillStyle = state.color;
ctx.strokeStyle = color; ctx.fillStyle = color; octx.lineWidth = state.width;
ctx.lineWidth = width; ctx.lineCap = "round"; ctx.lineJoin = "round"; octx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); octx.lineJoin = "round";
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();
} }
// Mosaic pixelation: sample the region into a small offscreen, then draw function drawArrow(a, b) {
// back at full size with imageSmoothingEnabled off so each sample lands as strokeStyle();
// a chunky square. Block size scales with stroke width for a "coarser / octx.beginPath();
// finer" knob on the same tool. octx.moveTo(a.x, a.y); octx.lineTo(b.x, b.y); octx.stroke();
function applyMosaic(ctx, x, y, w, h, width) { // arrowhead
if (w <= 0 || h <= 0) return; const dx = b.x - a.x, dy = b.y - a.y;
const block = Math.max(6, Math.min(40, width * 3)); const len = Math.hypot(dx, dy);
const sw = Math.max(1, Math.round(w / block)); if (len < 1) return;
const sh = Math.max(1, Math.round(h / block)); const head = Math.max(10, state.width * 3);
const tmp = document.createElement("canvas"); const ang = Math.atan2(dy, dx);
tmp.width = sw; tmp.height = sh; const spread = Math.PI / 6;
const tctx = tmp.getContext("2d"); octx.beginPath();
tctx.imageSmoothingEnabled = false; octx.moveTo(b.x, b.y);
tctx.drawImage(ctx.canvas, x, y, w, h, 0, 0, sw, sh); octx.lineTo(b.x - head * Math.cos(ang - spread), b.y - head * Math.sin(ang - spread));
ctx.save(); octx.moveTo(b.x, b.y);
ctx.imageSmoothingEnabled = false; octx.lineTo(b.x - head * Math.cos(ang + spread), b.y - head * Math.sin(ang + spread));
ctx.drawImage(tmp, 0, 0, sw, sh, x, y, w, h); octx.stroke();
ctx.restore();
} }
function clearDraw() { dctx.clearRect(0, 0, draw.width, draw.height); } function drawRect(a, b) {
function clearOverlay() { octx.clearRect(0, 0, overlay.width, overlay.height); } strokeStyle();
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) {
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y); 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); const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y);
return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) }; octx.strokeRect(x, y, w, h);
} }
draw.addEventListener("pointerdown", (ev) => { function drawEllipse(a, b) {
if (state.tool === "select") return; strokeStyle();
if (state.tool === "text") { const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2;
beginText(ev); const rx = Math.abs(b.x - a.x) / 2, ry = Math.abs(b.y - a.y) / 2;
return; octx.beginPath();
} octx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
draw.setPointerCapture(ev.pointerId); octx.stroke();
state.dragging = true;
state.start = pointerToCanvas(ev);
state.end = state.start;
if (state.tool === "pen") state.path = [state.start];
});
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);
});
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();
}
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; function drawPen(points) {
const text = t.el.value.trim(); if (!points || points.length < 2) return;
t.el.remove(); strokeStyle();
state.textInput = null; octx.beginPath();
if (!text) return; octx.moveTo(points[0].x, points[0].y);
drawTextLabel(cctx, t.x, t.y, text, t.color); 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(); pushSnapshot();
} }
function cancelText() {
const t = state.textInput; if (!t) return; // -- input --------------------------------------------------------------
t.el.remove(); 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;
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;
});
// -- 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; state.textInput = null;
} }
// --------------------------------------------------------------------------- // -- undo / redo -------------------------------------------------------
// Undo / redo
// ---------------------------------------------------------------------------
function doUndo() { function doUndo() {
if (undo.length <= 1) return; if (undo.length <= 1) return;
const cur = undo.pop(); const cur = undo.pop();
redo.push(cur); redo.push(cur);
const prev = undo[undo.length - 1]; restoreSnapshot(undo[undo.length - 1]);
restoreSnapshot(prev); updateUndoRedo();
clearDraw(); clearOverlay();
state.cropRect = null;
applyCropBtn.hidden = true;
cancelCropBtn.hidden = true;
updateUndoButtons();
} }
function doRedo() { function doRedo() {
if (!redo.length) return;
const snap = redo.pop(); const snap = redo.pop();
if (!snap) return;
undo.push(snap); undo.push(snap);
restoreSnapshot(snap); restoreSnapshot(snap);
updateUndoButtons(); updateUndoRedo();
} }
undoBtn.addEventListener("click", doUndo); undoBtn.addEventListener("click", doUndo);
redoBtn.addEventListener("click", doRedo); redoBtn.addEventListener("click", doRedo);
// --------------------------------------------------------------------------- // -- save / copy ------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
function canvasBlob() { function canvasBlob() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) =>
committed.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob returned null")), "image/png"); base.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob returned null")), "image/png"));
});
} }
async function save() { async function save() {
try { try {
const blob = await canvasBlob(); 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 url = URL.createObjectURL(blob);
const a = $("download-link"); dl.href = url;
a.href = url; dl.download = NAME || "screenshot.png";
a.download = baseName || "screenshot.png"; dl.click();
a.click(); setTimeout(() => URL.revokeObjectURL(url), 5000);
// Blob URLs are cheap but leak; release once the browser has had a beat playSave();
// to start the download. toast(`Saved ${dl.download} (${(blob.size / 1024).toFixed(1)} KB)`);
setTimeout(() => URL.revokeObjectURL(url), 4000);
toast(`Saved ${baseName} (${(blob.size / 1024).toFixed(1)} KB)`);
} catch (e) { } catch (e) {
toast("Save failed: " + e.message, true); toast("Save failed: " + (e && e.message || e), true);
} }
} }
async function copy() { async function copy() {
try { try {
const blob = await canvasBlob(); const blob = await canvasBlob();
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
playCopy();
toast("Copied to clipboard"); toast("Copied to clipboard");
} catch (e) { } catch (e) {
toast("Copy failed: " + e.message, true); toast("Copy failed: " + (e && e.message || e), true);
} }
} }
$("save").addEventListener("click", save); $("save").addEventListener("click", save);
$("copy").addEventListener("click", copy); $("copy").addEventListener("click", copy);
$("discard").addEventListener("click", discard);
// Back button — navigate the sidebar view back to panel.html. Same // -- top-bar navigation ----------------------------------------------
// webContents, so it's just a location swap; no IPC needed. $("back").addEventListener("click", () => { playDiscard(); location.href = "panel.html"; });
const backBtn = $("back");
if (backBtn) backBtn.addEventListener("click", () => { 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();
});
// 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"); const maxBtn = $("toggle-max");
if (maxBtn && window.silentmode?.sidebar) { 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 () => { maxBtn.addEventListener("click", async () => {
try { await window.silentmode.sidebar.toggleMax(); } try { await window.silentmode.sidebar.toggleMax(); }
catch (e) { console.warn("toggleMax failed:", e); } catch (e) { console.warn("toggleMax failed:", e); }
}); });
const paint = (isMax) => { window.silentmode.sidebar.onMaxChange(paintMaxIcon);
maxBtn.title = isMax ? "Restore sidebar width" : "Expand the sidebar to full window"; window.silentmode.sidebar.isMax().then(paintMaxIcon).catch(() => {});
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(() => {});
} }
// Drop the working screenshot and go back to the sidebar panel. Used by // -- keyboard shortcuts ---------------------------------------------
// the Discard button and the top-level Escape shortcut. window.addEventListener("keydown", (ev) => {
async function discard() { if (state.textInput) return;
try { if (window.silentmode?.storage) await window.silentmode.storage.set("__pending", null); } catch {} const meta = ev.ctrlKey || ev.metaKey;
location.href = "panel.html"; 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(); init();

View file

@ -101,24 +101,10 @@ module.exports = {
}; };
}); });
// panel invokes "arm" right before it navigates itself to editor.html // (The old "arm" handler that pre-armed a __pending storage entry is
// (in-sidebar navigation — SAME webContents, so the editor's storage- // gone: editor.html now pulls its bytes via getBytes on load, so there
// based load path Just Works). We rewrite __pending fresh here so // is nothing to hand off in advance. Idempotent — reopening the editor
// whichever capture is currently previewed becomes the one the editor // in the sidebar just re-fetches the bytes.)
// 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 };
});
// Read the ring for a "recent captures" strip in the sidebar. Bytes are // Read the ring for a "recent captures" strip in the sidebar. Bytes are
// reported; the actual images are pulled through "getBytes" on demand // reported; the actual images are pulled through "getBytes" on demand
@ -155,6 +141,6 @@ module.exports = {
return { ok: true }; 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 { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
.meta .host { color: var(--mut); } .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 { 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; 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.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
button.act:hover:not(:disabled) { background: var(--btn-h); } 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; } 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 { color: var(--mut); font-size: 12px; min-height: 16px; }
.status.err { color: var(--err); } .status.err { color: var(--err); }
.status.ok { color: var(--acid); } .status.ok { color: var(--acid); }
@ -91,6 +109,7 @@
<header> <header>
<div class="t"><span class="em">📸</span> <span>Screenshot</span></div> <div class="t"><span class="em">📸</span> <span>Screenshot</span></div>
<div class="m" id="hdr-status"></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"> <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"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/> <path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/>
@ -121,8 +140,20 @@
</div> </div>
<div class="actions"> <div class="actions">
<button class="act" id="btn-discard" disabled>Discard</button> <div class="row">
<button class="act primary" id="btn-edit" disabled title="Edit the capture — crop, annotate, redact, save">Edit</button> <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="Open the annotate editor — arrow, rect, ellipse, pen, text, undo, save, copy">Edit</button>
</div>
</div> </div>
<div class="status" id="status"></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> <button class="clear" id="clear-recent" title="Delete every past capture">clear all</button>
</div> </div>
<div class="strip" id="recent-strip"></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> </div>
<a id="download-link" style="display:none"></a>
</main> </main>
<script> <script>
@ -144,7 +181,63 @@
const metaHost = $("meta-host"); const metaHost = $("meta-host");
const btnEdit = $("btn-edit"); const btnEdit = $("btn-edit");
const btnDiscard = $("btn-discard"); const btnDiscard = $("btn-discard");
const btnCopy = $("btn-copy");
const btnSave = $("btn-save");
const btnMax = $("btn-max"); 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 hdrStatus = $("hdr-status");
const recentBox = $("recent"); const recentBox = $("recent");
const recentStrip = $("recent-strip"); const recentStrip = $("recent-strip");
@ -163,22 +256,16 @@
} }
function renderPreview() { function renderPreview() {
if (!last || !last.dataUrl) { const on = !!(last && last.dataUrl);
previewImg.hidden = true; previewImg.src = ""; previewImg.hidden = !on;
previewEmpty.hidden = false; previewImg.src = on ? last.dataUrl : "";
metaSize.textContent = "—"; previewEmpty.hidden = on;
metaHost.textContent = ""; metaSize.textContent = on ? `${last.width}×${last.height} · ${fmt(last.bytes || 0)}` : "—";
btnEdit.disabled = true; metaHost.textContent = on ? (last.host || "") : "";
btnDiscard.disabled = true; btnEdit.disabled = !on;
return; btnDiscard.disabled = !on;
} btnCopy.disabled = !on;
previewImg.src = last.dataUrl; btnSave.disabled = !on;
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;
} }
async function refreshRecent() { async function refreshRecent() {
@ -234,6 +321,7 @@
} else if (res && res.dataUrl) { } else if (res && res.dataUrl) {
last = res; last = res;
renderPreview(); renderPreview();
playShutter();
setStatus("Captured. Click Edit to annotate.", "ok"); setStatus("Captured. Click Edit to annotate.", "ok");
refreshRecent(); refreshRecent();
} else { } else {
@ -250,24 +338,11 @@
} }
async function doEdit() { async function doEdit() {
if (!last || !last.dataUrl || busy) return; if (!last || !last.name || busy) return;
busy = true; // No IPC handshake — the editor pulls bytes on load via silentmode
btnEdit.disabled = true; // .invoke("getBytes", {name}) directly. That's idempotent, so a refresh
hdrStatus.textContent = "opening editor…"; // or back-and-forth navigation always shows the image.
try { location.href = "editor.html?name=" + encodeURIComponent(last.name);
// 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;
}
} }
async function toggleMax() { async function toggleMax() {
@ -275,15 +350,69 @@
catch (e) { console.warn("toggleMax failed:", e); } 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")) { for (const b of document.querySelectorAll("button.mode")) {
b.addEventListener("click", () => doCapture(b.dataset.mode)); b.addEventListener("click", () => doCapture(b.dataset.mode));
} }
btnEdit.addEventListener("click", doEdit); 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); btnMax.addEventListener("click", toggleMax);
clearBtn.addEventListener("click", async () => {
if (!confirm("Delete every past capture?")) return; // Inline confirmation for "clear all" — the native confirm() looks like an
try { await window.silentmode.invoke("clearRecent", {}); await refreshRecent(); } // 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"); } catch (e) { setStatus("Clear failed: " + (e && e.message || e), "err"); }
}); });