theseus/bundled-addons/screenshot/panel.html
Local Dev 8eda0433d7 feat(theseus/screenshot): 0.6.3 — per-tile delete, no Select button, text tool halo, right-anchor panel controls
Four issues from the user's report on 0.6.2:

- Recent captures had a global "clear all" but no way to drop a single
  screenshot. Each tile now grows a small × button (visible on hover;
  drops in behind the thumbnail preview so it never obstructs the
  content). Clicking the × invokes clearRecent({name}) and removes both
  the ring entry and the scratch PNG on disk. Bubble-guarded so the ×
  click doesn't also trigger the tile's "load into preview" handler.

- Select tool button removed — clicking it did nothing visible, so users
  read it as broken. The internal "select" mode still exists as the
  no-tool state; you get back to it now by clicking the same drawing
  tool a second time (toggle-off) or hitting Escape. The active-drawing-
  tool button flips its border when armed.

- Text tool made unmistakable: input paints with a 2 px acid border, a
  glowing acid halo, dark background, and the visible ink colour on the
  text itself. Focus attempt is three-layered (sync, rAF, timer) to
  outrun any Chromium build that drops the mid-pointer-event focus. Non-
  Enter/Escape keys get stopPropagation so a stray document listener
  can't steal the focus mid-typing.

- Panel header's sound / max / close cluster kept nudging inward when
  the status text was empty. The parent's `justify-content: space-
  between` distributed the row unevenly. Force-anchor the cluster with
  `#btn-sound { margin-left: auto }` so the three window-control icons
  hug the right edge regardless of what fills the middle.

Bundled but not shipped separately — parent session signs and pushes.
2026-09-09 22:01:06 +02:00

513 lines
22 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;
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; }
/* Force the sound / max / close cluster to hug the right edge no matter
what else fills the header. justify-content on the parent kept nudging
them mid-row when the status text was empty. */
header #btn-sound { margin-left: auto; }
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; }
header .max.close:hover { border-color: rgba(255,91,91,.55); color: var(--err); }
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 .tile .tdel {
position: absolute; top: 2px; right: 2px;
width: 18px; height: 18px; line-height: 16px; text-align: center;
background: rgba(11,14,20,.85); color: #fff; border-radius: 50%;
font-size: 14px; font-weight: 700; cursor: pointer;
opacity: 0; transition: opacity 120ms, background 120ms, color 120ms;
user-select: none;
}
.recent .tile:hover .tdel { opacity: 1; }
.recent .tile .tdel:hover { background: #ff5b5b; color: #101418; }
.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>
<button class="max close" id="btn-close" title="Close the sidebar" aria-label="Close sidebar">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<path d="M4 4l8 8M12 4l-8 8"/>
</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);
}
// Filtered noise burst — the metallic "click" of the shutter and the
// paper-feed texture of the print sound. Band-pass around `bp` with
// sharpness `q` picks a resonance so we don't just play white noise.
function noise(dur, vol, bp, q, at) {
if (!soundOn) return;
const c = actx(); if (!c) return;
const t0 = c.currentTime + (at || 0);
const n = Math.max(1, Math.floor(c.sampleRate * dur));
const buf = c.createBuffer(1, n, c.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
const src = c.createBufferSource(); src.buffer = buf;
const flt = c.createBiquadFilter(); flt.type = "bandpass";
flt.frequency.setValueAtTime(bp || 2000, t0);
flt.Q.setValueAtTime(q || 4, t0);
const g = c.createGain();
g.gain.setValueAtTime(vol || 0.2, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
src.connect(flt); flt.connect(g); g.connect(c.destination);
src.start(t0);
src.stop(t0 + dur + 0.02);
}
// Photoshoot shutter — mirror-slap up (metallic ping + soft thud), then
// mirror-slap down. Reads as a real camera click rather than a beep.
function playShutter() {
noise(0.03, 0.28, 4200, 6, 0);
tone(180, 0.04, "square", 0.10, 0.005);
noise(0.04, 0.32, 3600, 5, 0.08);
tone(140, 0.05, "square", 0.12, 0.09);
}
// Copy — two-chirp "printer feed", modeled on Firefox Easy Screenshot's
// copy feedback: fast staccato ascending pair, no residual hum.
function playCopy() {
noise(0.02, 0.14, 3200, 8, 0);
tone(1400, 0.05, "sine", 0.14, 0);
noise(0.02, 0.14, 4200, 8, 0.06);
tone(2000, 0.06, "sine", 0.14, 0.06);
}
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) => {
const nameAttr = r.name.replace(/"/g, "&quot;");
return `<div class="tile" data-name="${nameAttr}" title="${nameAttr} · ${fmt(r.bytes || 0)}">`
+ `<span class="tdel" data-del="${nameAttr}" title="Delete this capture">×</span>`
+ `</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) {
// Keep the delete button; drop it back on top of the image.
const del = tile.querySelector(".tdel");
tile.innerHTML = `<img src="${b.dataUrl}" alt="">`;
if (del) tile.appendChild(del);
}
} catch {}
tile.addEventListener("click", async (ev) => {
// The per-tile X sits inside the tile; don't count its click as
// a tile click.
if (ev.target && ev.target.classList.contains("tdel")) return;
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");
}
});
}
// Per-tile delete — no confirmation strip (single-item destroy is
// small enough to be safe with just the X; the sweeping "clear all"
// still gates on the inline red confirmation).
for (const del of recentStrip.querySelectorAll(".tdel")) {
del.addEventListener("click", async (ev) => {
ev.stopPropagation();
const nm = del.dataset.del;
try {
await window.silentmode.invoke("clearRecent", { name: nm });
if (last && last.name === nm) { last = null; renderPreview(); }
await refreshRecent();
} catch (e) {
setStatus("Delete failed: " + (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);
document.getElementById("btn-close").addEventListener("click", async () => {
// Explicit close from the sidebar itself — same IPC the toolbar dock
// icon toggles. No need to un-maximize first; setSidebar(false) on the
// main side flips visibility and the maximize state is remembered so
// the next open lands where it left off.
try { await window.silentmode.sidebar.close(); }
catch (e) { setStatus("Close failed: " + (e && e.message || e), "err"); }
});
// 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>