theseus/bundled-addons/screenshot/panel.html
Local Dev 5642959eca feat(theseus/screenshot): bundled screenshot add-on (visible / full page / region)
New capture-tab capability on the addon-host, and the screenshot add-on
uses it to expose three modes in a sidebar launcher panel:

- Visible viewport: Electron's WebContents.capturePage() on the active tab
- Full scrollable page: temp-resize the tab view to document.scrollHeight,
  capturePage, restore
- Region: preload overlays a translucent selection div, tracks mousedown /
  move / up, sends the rect back; main takes the visible capture and
  crops via nativeImage.crop({x,y,width,height})

Saves land in the user's Downloads folder via session.downloadURL — same
pipeline as any file download, so the download chip picks them up.
Filename: theseus-screenshot-<host>-<ISO date>.png. JPEG option for
smaller files.

A follow-up task (task_b9608dc6) reworks this to open captures in a
full-tab editor with crop / draw / annotate / undo / copy-to-clipboard
instead of the current bare launcher.
2026-09-07 00:18:48 +02:00

230 lines
8.8 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; }
@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; }
}
* { 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; }
main { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; }
.row { display: flex; gap: 6px; }
.row.stack { flex-direction: column; }
button.mode {
flex: 1; min-width: 0;
padding: 10px 8px; 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: 12px; }
.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;
}
.prev-wrap img { max-width: 100%; max-height: 320px; border-radius: 4px; display: block; }
.format { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--mut); }
.format label { display: flex; gap: 4px; align-items: center; cursor: pointer; }
.format input[type="radio"] { accent-color: var(--acid); }
.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; }
.meta { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
.status { color: var(--mut); font-size: 12px; min-height: 16px; }
.status.err { color: #ff9081; }
.status.ok { color: var(--acid); }
</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="row">
<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 — fixed headers may repeat">
<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 id="meta-host"></span>
</div>
<div class="format">
<span>Format:</span>
<label><input type="radio" name="fmt" value="png" checked> PNG</label>
<label><input type="radio" name="fmt" value="jpeg"> JPEG</label>
</div>
<div class="actions">
<button class="act" id="btn-clear" disabled>Discard</button>
<button class="act primary" id="btn-save" disabled>Save to Downloads</button>
</div>
<div class="status" id="status"></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 btnSave = $("btn-save");
const btnClear = $("btn-clear");
const hdrStatus = $("hdr-status");
let last = null; // { dataUrl, width, height, host, format }
let busy = false;
const bytesFromDataUrl = (u) => {
const i = u.indexOf(",");
if (i < 0) return 0;
// base64 length → decoded byte count.
const b64 = u.slice(i + 1);
return Math.floor(b64.length * 3 / 4) - (b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0);
};
const fmt = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + " MB" : n >= 1e3 ? (n / 1e3).toFixed(0) + " KB" : n + " B";
function currentFormat() {
const el = document.querySelector('input[name="fmt"]:checked');
return el && el.value === "jpeg" ? "jpeg" : "png";
}
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 = "";
btnSave.disabled = true;
btnClear.disabled = true;
return;
}
previewImg.src = last.dataUrl;
previewImg.hidden = false;
previewEmpty.hidden = true;
metaSize.textContent = `${last.width}×${last.height} · ${fmt(bytesFromDataUrl(last.dataUrl))}`;
metaHost.textContent = last.host || "";
btnSave.disabled = false;
btnClear.disabled = false;
}
async function doCapture(mode) {
if (busy) return;
busy = true;
hdrStatus.textContent = "capturing…";
setStatus("");
// For region mode the user has to interact with the page; encourage them.
if (mode === "region") setStatus("Drag on the page. Esc to cancel.");
// For full-page the layout flicker is visible; explain.
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, format: currentFormat() });
if (res && res.cancelled) {
setStatus("Region capture cancelled.");
} else if (res && res.dataUrl) {
last = res;
renderPreview();
setStatus("Captured. Review, then save.", "ok");
} 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 doSave() {
if (!last || !last.dataUrl || busy) return;
busy = true;
btnSave.disabled = true;
hdrStatus.textContent = "saving…";
try {
const res = await window.silentmode.invoke("save", {
dataUrl: last.dataUrl,
host: last.host,
format: last.format,
});
setStatus("Saved: " + (res && res.savePath ? res.savePath : "Downloads"), "ok");
} catch (e) {
console.warn("save failed:", e);
setStatus("Save failed: " + String(e && e.message || e), "err");
} finally {
busy = false;
btnSave.disabled = !last;
hdrStatus.textContent = "";
}
}
for (const b of document.querySelectorAll("button.mode")) {
b.addEventListener("click", () => doCapture(b.dataset.mode));
}
btnSave.addEventListener("click", doSave);
btnClear.addEventListener("click", () => { last = null; renderPreview(); setStatus(""); });
// Changing format after a capture: reset preview so the meta size reflects
// the format that will actually be saved next.
for (const r of document.querySelectorAll('input[name="fmt"]')) {
r.addEventListener("change", () => {
if (last) setStatus("Format changed. Re-capture to see the new file size.");
});
}
</script>
</body>
</html>