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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- - - - - - -
-
- -
-
- - - - - - + + + + +Screenshot editor + + + +
+ + + + screenshot + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
Loading capture…
+
+ +
+ +
+ + + + + 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
+ - +
+ + +
+
+ + +
@@ -132,7 +163,13 @@
+
+ Delete every past capture? + + +
+