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.
513 lines
22 KiB
HTML
513 lines
22 KiB
HTML
<!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, """);
|
||
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>
|