diff --git a/bundled-addons/screenshot/addon.json b/bundled-addons/screenshot/addon.json
index f0089d2..78fd890 100644
--- a/bundled-addons/screenshot/addon.json
+++ b/bundled-addons/screenshot/addon.json
@@ -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",
diff --git a/bundled-addons/screenshot/editor.css b/bundled-addons/screenshot/editor.css
index f58449a..6299e3c 100644
--- a/bundled-addons/screenshot/editor.css
+++ b/bundled-addons/screenshot/editor.css
@@ -1,90 +1,112 @@
-/* Screenshot editor β full-tab page. Matches the Theseus dark aesthetic
- from the sidebar. Light-mode swap is symmetric. */
-: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;
- --danger:#ff5b5b; --board:#0a0d13; }
-@media (prefers-color-scheme: light) {
- :root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10);
- --ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid: #0AC18E;
- --board:#dde3ee; }
-}
-* { box-sizing: border-box; }
-html, body { margin: 0; height: 100%; }
-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);
- 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); }
-
-.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); }
-.width .dot { border-radius: 50%; background: currentColor; color: var(--ink); }
-.width.active .dot { color: var(--acid); }
-
-.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;
-}
-
-/* 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); }
+/* 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;
+ --danger:#ff5b5b; --board:#0a0d13; }
+@media (prefers-color-scheme: light) {
+ :root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10);
+ --ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid:#0AC18E;
+ --board:#dde3ee; }
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; height: 100%; }
+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; }
+
+.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;
+ 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; }
+
+.sep { width: 1px; height: 20px; background: var(--line); margin: 0 3px; }
+
+.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);
+}
+.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, -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); }
+
diff --git a/bundled-addons/screenshot/editor.html b/bundled-addons/screenshot/editor.html
index bfa8732..5e4f97f 100644
--- a/bundled-addons/screenshot/editor.html
+++ b/bundled-addons/screenshot/editor.html
@@ -1,124 +1,89 @@
-
-
-
-
-Screenshot editor
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+Screenshot editor
+
+
+
+
+
+
+
+
screenshot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundled-addons/screenshot/editor.js b/bundled-addons/screenshot/editor.js
index 021337f..44e9c56 100644
--- a/bundled-addons/screenshot/editor.js
+++ b/bundled-addons/screenshot/editor.js
@@ -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=
+// β silentmode.invoke("getBytes", {name})
+// β addon reads scratch/.png from disk and returns
+// { name, dataUrl:"data:image/png;base64,β¦", bytes, at, mode }
+// β we set an
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 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 UNDO_MAX = 25;
-const cctx = committed.getContext("2d");
-const dctx = draw.getContext("2d");
-const octx = overlay.getContext("2d");
+// ---- 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 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 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
- textInput: null, // {x,y, el}
+ 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]) {
- c.width = w;
+ 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.onload = () => resolve(img);
+ img.onerror = () => reject(new Error("image decode failed"));
+ // NB: no crossOrigin β data: URLs are same-origin, and setting it
+ // to anonymous would require CORS headers that a data URL can't carry,
+ // which is precisely what tripped up v1.
+ img.src = dataUrl;
});
}
async function init() {
- // 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
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);
- }
- }
- } 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;
+ document.title = NAME ? NAME + " β editor" : "Screenshot editor";
+ nameEl.textContent = NAME || "screenshot";
+ if (!NAME) {
+ empty.classList.add("err");
+ empty.innerHTML = "Missing ?name β open a capture from the Screenshot sidebar.
";
+ return;
+ }
+ let res;
+ try { res = await loadFromAddon(NAME); }
+ catch (e) {
+ console.warn("editor load failed:", e);
+ empty.classList.add("err");
+ empty.innerHTML = `Couldn't load capture: ${(e && e.message || e)}
`;
+ 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 = `Couldn't decode capture bytes (${res.bytes || 0} B)
`;
+ 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) => {
- if (state.tool === "select") return;
- if (state.tool === "text") {
- beginText(ev);
- return;
- }
- draw.setPointerCapture(ev.pointerId);
- 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 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 commitText() {
- const t = state.textInput; if (!t) return;
- const text = t.el.value.trim();
- t.el.remove();
- state.textInput = null;
- if (!text) return;
- drawTextLabel(cctx, t.x, t.y, text, t.color);
+
+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();
}
-function cancelText() {
- const t = state.textInput; if (!t) return;
- t.el.remove();
+
+// -- input --------------------------------------------------------------
+over.style.pointerEvents = "none"; // draw layer never blocks input; base receives
+base.addEventListener("pointerdown", (ev) => {
+ if (state.tool === "select") return;
+ if (state.tool === "text") { openTextInput(pointToCanvas(ev)); return; }
+ base.setPointerCapture(ev.pointerId);
+ const p = pointToCanvas(ev);
+ if (state.tool === "pen") { state.pen = [p]; }
+ else { state.drag = { x0: p.x, y0: p.y, x: p.x, y: p.y }; }
+});
+base.addEventListener("pointermove", (ev) => {
+ const p = pointToCanvas(ev);
+ if (state.tool === "pen" && state.pen) {
+ state.pen.push(p);
+ clearOver();
+ drawPen(state.pen);
+ return;
+ }
+ if (!state.drag) return;
+ state.drag.x = p.x; state.drag.y = p.y;
+ 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;
}
-// ---------------------------------------------------------------------------
-// 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"; });
+
+// 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
+ ? ''
+ : '';
+}
+(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");
-if (maxBtn && window.silentmode?.sidebar) {
+async function paintMaxIcon(isMax) {
+ maxBtn.title = isMax ? "Restore sidebar width" : "Expand the sidebar to full window";
+ maxBtn.innerHTML = isMax
+ ? ''
+ : '';
+}
+if (window.silentmode?.sidebar) {
maxBtn.addEventListener("click", async () => {
try { await window.silentmode.sidebar.toggleMax(); }
catch (e) { console.warn("toggleMax failed:", e); }
});
- const paint = (isMax) => {
- maxBtn.title = isMax ? "Restore sidebar width" : "Expand the sidebar to full window";
- maxBtn.innerHTML = isMax
- ? ''
- : '';
- };
- window.silentmode.sidebar.onMaxChange(paint);
- window.silentmode.sidebar.isMax().then(paint).catch(() => {});
+ 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();
diff --git a/bundled-addons/screenshot/index.js b/bundled-addons/screenshot/index.js
index 70255ad..c165824 100644
--- a/bundled-addons/screenshot/index.js
+++ b/bundled-addons/screenshot/index.js
@@ -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");
},
};
diff --git a/bundled-addons/screenshot/panel.html b/bundled-addons/screenshot/panel.html
index 7f2275c..86c9cc6 100644
--- a/bundled-addons/screenshot/panel.html
+++ b/bundled-addons/screenshot/panel.html
@@ -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 @@
πΈ Screenshot
+