User report: the sidebar preview lands correctly, but the moment the editor
opens in its own tab the picture is blank. Rather than chase that class of
handoff race again, put the editor in the same webContents as the panel:
the sidebar view navigates panel.html ↔ editor.html in place. Same
document object, same silentmode.storage surface, no cross-tab __pending
transfer at all.
- panel.html "Edit" button now calls silentmode.invoke("arm", …) — the
add-on rewrites __pending with the currently-previewed capture's bytes,
and the panel does location.href = "editor.html?name=…". Sidebar view
loads the editor with the same preload; editor.js's storage-based load
path pulls the pending entry out and paints.
- editor.html gains a "Back" arrow (returns to panel.html) and a
maximize / restore icon.
- discard() now navigates to panel.html instead of closeTab() — there is
no tab to close.
- Manifest drops the "open-tab" capability entirely (no more full-tab
editor); keeps sidebar-panel + capture-tab.
Framework: new silentmode.sidebar.{maximize, restore, toggleMax, isMax,
onMaxChange}. main.js honours them via new sidebar-maximize / -restore /
-toggle-max / -is-max IPCs, remembering the pre-maximize width so a
restore drops back exactly. The sidebar drag-grip auto-exits maximize
mode on any user drag, so pulling the edge always lands on the pre-max
value plus/minus the delta. sidebar-preload exposes the surface;
chrome.html renderer is untouched — this is a per-panel affordance.
Editor tools (crop / arrow / rect / ellipse / pen / text / mosaic /
undo / redo / copy / save) unchanged. Save still goes through Chromium's
<a download> path, so the file lands in Downloads and appears in the
download chip like any other save.
Bundled but not shipped — leaving version bump + deploy to parent session.
293 lines
12 KiB
HTML
293 lines
12 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); 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; }
|
||
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; }
|
||
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; 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 .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-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>
|
||
</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-edit" disabled title="Edit the capture — crop, annotate, redact, save">Edit</button>
|
||
</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>
|
||
</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 btnMax = $("btn-max");
|
||
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 = "";
|
||
btnEdit.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 || "";
|
||
btnEdit.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)}"></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) 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 };
|
||
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. 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.dataUrl || busy) return;
|
||
busy = true;
|
||
btnEdit.disabled = true;
|
||
hdrStatus.textContent = "opening editor…";
|
||
try {
|
||
// Arm __pending with the currently-previewed capture, then navigate
|
||
// this sidebar view to editor.html — same webContents, same preload,
|
||
// so the editor keeps talking to the add-on through silentmode.*.
|
||
const armed = await window.silentmode.invoke("arm", { name: last.name });
|
||
const name = (armed && armed.name) || last.name;
|
||
location.href = "editor.html?name=" + encodeURIComponent(name);
|
||
} catch (e) {
|
||
console.warn("arm failed:", e);
|
||
setStatus("Edit failed: " + (e && e.message || e), "err");
|
||
btnEdit.disabled = !last;
|
||
hdrStatus.textContent = "";
|
||
busy = false;
|
||
}
|
||
}
|
||
|
||
async function toggleMax() {
|
||
try { await window.silentmode.sidebar.toggleMax(); }
|
||
catch (e) { console.warn("toggleMax failed:", e); }
|
||
}
|
||
|
||
for (const b of document.querySelectorAll("button.mode")) {
|
||
b.addEventListener("click", () => doCapture(b.dataset.mode));
|
||
}
|
||
btnEdit.addEventListener("click", doEdit);
|
||
btnDiscard.addEventListener("click", () => { last = null; renderPreview(); setStatus(""); });
|
||
btnMax.addEventListener("click", toggleMax);
|
||
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>
|