278 lines
11 KiB
HTML
278 lines
11 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; justify-content: space-between;
|
|||
|
|
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
|||
|
|
background: var(--panel); }
|
|||
|
|
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; }
|
|||
|
|
header .t .em { font-size: 15px; }
|
|||
|
|
header .m { color: var(--dim); font-size: 11.5px; min-height: 15px; }
|
|||
|
|
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; }
|
|||
|
|
button.act {
|
|||
|
|
flex: 1; padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px;
|
|||
|
|
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
|
|||
|
|
}
|
|||
|
|
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:disabled { opacity: .45; cursor: default; }
|
|||
|
|
.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; }
|
|||
|
|
.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; float: right; }
|
|||
|
|
.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>
|
|||
|
|
</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">
|
|||
|
|
<button class="act" id="btn-discard" disabled>Discard</button>
|
|||
|
|
<button class="act primary" id="btn-open-tab" disabled title="Open the capture in a full editor tab (crop, annotate, redact, save)">Open in editor tab</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="status" id="status"></div>
|
|||
|
|
|
|||
|
|
<div class="recent" id="recent">
|
|||
|
|
<div class="lbl">Recent
|
|||
|
|
<button class="clear" id="clear-recent" title="Delete every past capture">clear all</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="strip" id="recent-strip"></div>
|
|||
|
|
</div>
|
|||
|
|
</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 btnOpenTab = $("btn-open-tab");
|
|||
|
|
const btnDiscard = $("btn-discard");
|
|||
|
|
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() {
|
|||
|
|
if (!last || !last.dataUrl) {
|
|||
|
|
previewImg.hidden = true; previewImg.src = "";
|
|||
|
|
previewEmpty.hidden = false;
|
|||
|
|
metaSize.textContent = "—";
|
|||
|
|
metaHost.textContent = "";
|
|||
|
|
btnOpenTab.disabled = true;
|
|||
|
|
btnDiscard.disabled = true;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
previewImg.src = last.dataUrl;
|
|||
|
|
previewImg.hidden = false;
|
|||
|
|
previewEmpty.hidden = true;
|
|||
|
|
metaSize.textContent = `${last.width}×${last.height} · ${fmt(last.bytes || 0)}`;
|
|||
|
|
metaHost.textContent = last.host || "";
|
|||
|
|
btnOpenTab.disabled = false;
|
|||
|
|
btnDiscard.disabled = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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,""")}" title="${r.name.replace(/"/g,""")} · ${fmt(r.bytes || 0)}">
|
|||
|
|
<span class="dim">${r.mode ? r.mode.charAt(0).toUpperCase() + r.mode.slice(1) : ""}</span>
|
|||
|
|
</div>`).join("");
|
|||
|
|
for (const tile of recentStrip.querySelectorAll(".tile")) {
|
|||
|
|
// Lazy-load thumbnail on hover / first paint.
|
|||
|
|
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 };
|
|||
|
|
// Fill in dims from the loaded image.
|
|||
|
|
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();
|
|||
|
|
setStatus("Captured. Preview above; open in editor 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 doOpenTab() {
|
|||
|
|
if (!last || !last.dataUrl || busy) return;
|
|||
|
|
busy = true;
|
|||
|
|
btnOpenTab.disabled = true;
|
|||
|
|
hdrStatus.textContent = "opening…";
|
|||
|
|
try {
|
|||
|
|
await window.silentmode.invoke("openInTab", { name: last.name });
|
|||
|
|
setStatus("Opened in editor tab.", "ok");
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn("openInTab failed:", e);
|
|||
|
|
setStatus("Open failed: " + (e && e.message || e), "err");
|
|||
|
|
} finally {
|
|||
|
|
busy = false;
|
|||
|
|
btnOpenTab.disabled = !last;
|
|||
|
|
hdrStatus.textContent = "";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const b of document.querySelectorAll("button.mode")) {
|
|||
|
|
b.addEventListener("click", () => doCapture(b.dataset.mode));
|
|||
|
|
}
|
|||
|
|
btnOpenTab.addEventListener("click", doOpenTab);
|
|||
|
|
btnDiscard.addEventListener("click", () => { last = null; renderPreview(); setStatus(""); });
|
|||
|
|
clearBtn.addEventListener("click", async () => {
|
|||
|
|
if (!confirm("Delete every past capture?")) return;
|
|||
|
|
try { await window.silentmode.invoke("clearRecent", {}); await refreshRecent(); }
|
|||
|
|
catch (e) { setStatus("Clear failed: " + (e && e.message || e), "err"); }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
refreshRecent();
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|