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.
230 lines
8.8 KiB
HTML
230 lines
8.8 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; }
|
||
@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>
|