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

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

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

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

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

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

Bundled but not shipped — parent session handles the OTA sign + push.
2026-09-09 00:56:41 +02:00

422 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Screenshot</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
--btn:#1c2432; --btn-h:#242e40; --err:#ff9081; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
--btn:#f0f3fa; --btn-h:#e3e8f2; --acid:#088A66; --err:#c81f0f; }
}
* { 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; }
header { display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-bottom: 1px solid var(--line);
background: var(--panel); gap: 8px; }
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; min-width: 0; }
header .t .em { font-size: 15px; }
header .m { color: var(--dim); font-size: 11.5px; min-height: 15px; flex: 1; text-align: right; }
header .max {
border: 1px solid var(--line); border-radius: 6px; background: var(--btn);
color: var(--ink); cursor: pointer; padding: 4px 6px; font: inherit; line-height: 0;
}
header .max:hover { background: var(--btn-h); }
header .max svg { width: 14px; height: 14px; display: block; }
main { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; }
.modes { display: flex; gap: 6px; }
button.mode {
flex: 1; min-width: 0;
padding: 10px 6px; border: 1px solid var(--line); border-radius: 8px;
background: var(--btn); color: var(--ink); cursor: pointer;
font: inherit; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 4px;
transition: background 120ms;
}
button.mode:hover:not(:disabled) { background: var(--btn-h); }
button.mode:disabled { opacity: .5; cursor: default; }
button.mode .em { font-size: 18px; line-height: 1; }
button.mode .lbl { font-size: 11.5px; }
.prev-wrap {
border: 1px dashed var(--line); border-radius: 8px;
background:
repeating-conic-gradient(rgba(255,255,255,.02) 0% 25%, transparent 0% 50%)
50%/16px 16px;
padding: 6px; min-height: 120px;
display: flex; align-items: center; justify-content: center;
color: var(--dim); font-size: 12px; text-align: center;
}
.prev-wrap img {
max-width: 100%; max-height: 320px; border-radius: 4px; display: block;
background: #fff;
}
.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; flex-wrap: wrap; }
.actions .row { display: flex; gap: 6px; flex: 1 1 100%; }
button.act {
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); 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); }
.recent { display: none; }
.recent.on { display: block; }
.recent .lbl { color: var(--dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: .5px; margin: 4px 0 6px; display: flex; justify-content: space-between; }
.recent .strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
.recent .tile {
flex: 0 0 auto; width: 78px; height: 52px; border-radius: 4px;
border: 1px solid var(--line); background: var(--btn);
display: flex; align-items: center; justify-content: center;
cursor: pointer; overflow: hidden; position: relative;
}
.recent .tile img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
.recent .tile:hover { border-color: var(--acid); }
.recent .clear { background: none; border: 0; color: var(--dim); font: inherit; cursor: pointer; text-transform: none; letter-spacing: 0; }
.recent .clear:hover { color: var(--err); }
</style>
</head>
<body>
<header>
<div class="t"><span class="em">📸</span> <span>Screenshot</span></div>
<div class="m" id="hdr-status"></div>
<button class="max" id="btn-sound" title="Toggle sound" aria-label="Toggle sound"></button>
<button class="max" id="btn-max" title="Expand the sidebar to full window width" aria-label="Expand sidebar">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/>
</svg>
</button>
</header>
<main>
<div class="modes">
<button class="mode" data-mode="visible" title="Capture the visible part of the current tab">
<span class="em">🖼️</span><span class="lbl">Visible</span>
</button>
<button class="mode" data-mode="full" title="Capture the entire scrollable page">
<span class="em">📄</span><span class="lbl">Full page</span>
</button>
<button class="mode" data-mode="region" title="Drag a rectangle on the page to select what to capture">
<span class="em">✂️</span><span class="lbl">Region</span>
</button>
</div>
<div class="prev-wrap" id="prev-wrap">
<span id="prev-empty">No capture yet. Pick a mode above.</span>
<img id="prev-img" alt="" hidden>
</div>
<div class="meta">
<span id="meta-size"></span>
<span class="host" id="meta-host"></span>
</div>
<div class="actions">
<div class="row">
<button class="act" id="btn-copy" disabled title="Copy the raw capture to the clipboard">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="4" width="9" height="10"/><path d="M3 12V3h9"/></svg>
<span>Copy</span>
</button>
<button class="act" id="btn-save" disabled title="Save the raw capture straight to Downloads">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg>
<span>Save</span>
</button>
</div>
<div class="row">
<button class="act" id="btn-discard" disabled>Discard</button>
<button class="act primary" id="btn-edit" disabled title="Open the annotate editor — arrow, rect, ellipse, pen, text, undo, save, copy">Edit</button>
</div>
</div>
<div class="status" id="status"></div>
<div class="recent" id="recent">
<div class="lbl"><span>Recent</span>
<button class="clear" id="clear-recent" title="Delete every past capture">clear all</button>
</div>
<div class="strip" id="recent-strip"></div>
<div class="confirm" id="clear-confirm">
<span class="msg">Delete every past capture?</span>
<button id="clear-no">Cancel</button>
<button class="yes" id="clear-yes">Delete</button>
</div>
</div>
<a id="download-link" style="display:none"></a>
</main>
<script>
const $ = (id) => document.getElementById(id);
const previewImg = $("prev-img");
const previewEmpty = $("prev-empty");
const status = $("status");
const metaSize = $("meta-size");
const metaHost = $("meta-host");
const btnEdit = $("btn-edit");
const btnDiscard = $("btn-discard");
const btnCopy = $("btn-copy");
const btnSave = $("btn-save");
const btnMax = $("btn-max");
const btnSound = $("btn-sound");
const dl = $("download-link");
// ---- audio ----------------------------------------------------------
// Synthesized in-page so the tarball stays small — no wav shipped.
// Preference is stored under silentmode.storage so the editor sees the
// same setting when it loads (default on).
let soundOn = true;
let audioCtx = null;
function actx() {
if (!audioCtx) {
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch {}
}
// A user gesture is needed to resume in some Chromium setups; the
// toggle button click satisfies that.
if (audioCtx && audioCtx.state === "suspended") { audioCtx.resume().catch(() => {}); }
return audioCtx;
}
function tone(freq, dur, type, vol, at) {
if (!soundOn) return;
const c = actx(); if (!c) return;
const t0 = c.currentTime + (at || 0);
const osc = c.createOscillator();
const g = c.createGain();
osc.type = type || "sine";
osc.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(vol || 0.15, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g); g.connect(c.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}
function playShutter() { tone(1500, 0.05, "square", 0.20, 0); tone(600, 0.06, "square", 0.14, 0.03); }
function playCopy() { tone(900, 0.06, "sine", 0.15, 0); tone(1400, 0.08, "sine", 0.15, 0.05); }
function playDiscard() { tone(500, 0.06, "sine", 0.18, 0); tone(280, 0.10, "sine", 0.14, 0.05); }
function paintSound() {
btnSound.title = soundOn ? "Sounds on — click to mute" : "Sounds off — click to enable";
btnSound.innerHTML = soundOn
? '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h3l3.5 3V3L5.5 6z"/><path d="M11 5.5c1 .8 1 4.2 0 5"/><path d="M12.5 4c2 1.5 2 6.5 0 8"/></svg>'
: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h3l3.5 3V3L5.5 6z"/><path d="M11 5l4 6M15 5l-4 6"/></svg>';
}
(async () => {
try {
const v = await window.silentmode.storage.get("soundOn", true);
soundOn = v !== false; // default on
} catch {}
paintSound();
})();
btnSound.addEventListener("click", async () => {
soundOn = !soundOn;
paintSound();
try { await window.silentmode.storage.set("soundOn", soundOn); } catch {}
if (soundOn) playShutter(); // audible confirmation
});
const hdrStatus = $("hdr-status");
const recentBox = $("recent");
const recentStrip = $("recent-strip");
const clearBtn = $("clear-recent");
let last = null; // { name, dataUrl, width, height, host, bytes, mode }
let busy = false;
const fmt = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + " MB"
: n >= 1e3 ? (n / 1e3).toFixed(0) + " KB"
: n + " B";
function setStatus(text, cls = "") {
status.textContent = text || "";
status.className = "status" + (cls ? " " + cls : "");
}
function renderPreview() {
const on = !!(last && last.dataUrl);
previewImg.hidden = !on;
previewImg.src = on ? last.dataUrl : "";
previewEmpty.hidden = on;
metaSize.textContent = on ? `${last.width}×${last.height} · ${fmt(last.bytes || 0)}` : "—";
metaHost.textContent = on ? (last.host || "") : "";
btnEdit.disabled = !on;
btnDiscard.disabled = !on;
btnCopy.disabled = !on;
btnSave.disabled = !on;
}
async function refreshRecent() {
try {
const list = await window.silentmode.invoke("listRecent", {});
if (!Array.isArray(list) || list.length === 0) {
recentBox.classList.remove("on");
recentStrip.innerHTML = "";
return;
}
recentBox.classList.add("on");
recentStrip.innerHTML = list.map((r) =>
`<div class="tile" data-name="${r.name.replace(/"/g,"&quot;")}" title="${r.name.replace(/"/g,"&quot;")} · ${fmt(r.bytes || 0)}"></div>`
).join("");
for (const tile of recentStrip.querySelectorAll(".tile")) {
const name = tile.dataset.name;
try {
const b = await window.silentmode.invoke("getBytes", { name });
if (b && b.dataUrl) tile.innerHTML = `<img src="${b.dataUrl}" alt="">`;
} catch {}
tile.addEventListener("click", async () => {
try {
const b = await window.silentmode.invoke("getBytes", { name });
if (!b) return;
last = { name: b.name, dataUrl: b.dataUrl, width: 0, height: 0, host: "", bytes: b.bytes };
const im = new Image();
im.onload = () => { last.width = im.naturalWidth; last.height = im.naturalHeight; renderPreview(); };
im.src = b.dataUrl;
renderPreview();
setStatus(`Loaded ${b.name}.`, "ok");
} catch (e) {
setStatus("Couldn't load: " + (e && e.message || e), "err");
}
});
}
} catch (e) {
console.warn("refreshRecent failed:", e);
}
}
async function doCapture(mode) {
if (busy) return;
busy = true;
hdrStatus.textContent = "capturing…";
setStatus("");
if (mode === "region") setStatus("Drag on the page. Esc to cancel.");
else if (mode === "full") setStatus("Rendering full page — this can take a moment.");
for (const b of document.querySelectorAll("button.mode")) b.disabled = true;
try {
const res = await window.silentmode.invoke("capture", { mode });
if (res && res.cancelled) {
setStatus("Region capture cancelled.");
} else if (res && res.dataUrl) {
last = res;
renderPreview();
playShutter();
setStatus("Captured. Click Edit to annotate.", "ok");
refreshRecent();
} else {
setStatus("Nothing captured.", "err");
}
} catch (e) {
console.warn("capture failed:", e);
setStatus(String(e && e.message || e), "err");
} finally {
busy = false;
hdrStatus.textContent = "";
for (const b of document.querySelectorAll("button.mode")) b.disabled = false;
}
}
async function doEdit() {
if (!last || !last.name || busy) return;
// No IPC handshake — the editor pulls bytes on load via silentmode
// .invoke("getBytes", {name}) directly. That's idempotent, so a refresh
// or back-and-forth navigation always shows the image.
location.href = "editor.html?name=" + encodeURIComponent(last.name);
}
async function toggleMax() {
try { await window.silentmode.sidebar.toggleMax(); }
catch (e) { console.warn("toggleMax failed:", e); }
}
// Convert a base64 data URL into a Blob without a fetch round-trip.
function dataUrlToBlob(dataUrl) {
const m = /^data:([^;,]+);base64,(.+)$/.exec(String(dataUrl || ""));
if (!m) throw new Error("expected base64 data URL");
const bin = atob(m[2]);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return new Blob([buf], { type: m[1] });
}
async function doCopy() {
if (!last || !last.dataUrl || busy) return;
try {
const blob = dataUrlToBlob(last.dataUrl);
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
playCopy();
setStatus("Copied to clipboard.", "ok");
} catch (e) {
console.warn("copy failed:", e);
setStatus("Copy failed: " + (e && e.message || e), "err");
}
}
async function doSave() {
if (!last || !last.dataUrl || busy) return;
try {
const blob = dataUrlToBlob(last.dataUrl);
const url = URL.createObjectURL(blob);
dl.href = url;
dl.download = last.name || "screenshot.png";
dl.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
setStatus(`Saved ${dl.download} (${(blob.size / 1024).toFixed(1)} KB)`, "ok");
} catch (e) {
console.warn("save failed:", e);
setStatus("Save failed: " + (e && e.message || e), "err");
}
}
for (const b of document.querySelectorAll("button.mode")) {
b.addEventListener("click", () => doCapture(b.dataset.mode));
}
btnEdit.addEventListener("click", doEdit);
btnDiscard.addEventListener("click", () => { playDiscard(); last = null; renderPreview(); setStatus(""); });
btnCopy.addEventListener("click", doCopy);
btnSave.addEventListener("click", doSave);
btnMax.addEventListener("click", toggleMax);
// Inline confirmation for "clear all" — the native confirm() looks like an
// OS-modal from the last decade AND opens over the tab area (out of the
// sidebar's visual context). Toggle a styled strip inside the sidebar so
// the destructive action stays where the user is looking.
const confirmBox = $("clear-confirm");
clearBtn.addEventListener("click", () => {
confirmBox.classList.add("on");
clearBtn.disabled = true;
});
$("clear-no").addEventListener("click", () => {
confirmBox.classList.remove("on");
clearBtn.disabled = false;
});
$("clear-yes").addEventListener("click", async () => {
confirmBox.classList.remove("on");
clearBtn.disabled = false;
try { await window.silentmode.invoke("clearRecent", {}); await refreshRecent(); setStatus("Cleared past captures.", "ok"); }
catch (e) { setStatus("Clear failed: " + (e && e.message || e), "err"); }
});
refreshRecent();
</script>
</body>
</html>