Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,112 @@ interface PageMetrics {
}

let originalScrollY = 0;
let captureOverlay: HTMLDivElement | null = null;

/**
* Build (once) the on-page capture notice. It is `position: fixed` so it stays
* put while the page scrolls, has the maximum z-index, and ignores pointer
* events. The orchestrator hides it (`display:none`) for each captureVisibleTab
* frame so it never lands in the screenshot, and re-shows it between frames.
*/
function ensureCaptureOverlay(): HTMLDivElement {
if (captureOverlay && document.documentElement.contains(captureOverlay)) {
return captureOverlay;
}

if (!document.getElementById("shotback-overlay-style")) {
const style = document.createElement("style");
style.id = "shotback-overlay-style";
style.textContent = "@keyframes shotback-spin{to{transform:rotate(360deg)}}";
document.documentElement.appendChild(style);
}

const overlay = document.createElement("div");
overlay.setAttribute("data-shotback-overlay", "");
overlay.style.cssText = [
"position:fixed",
"inset:0",
"z-index:2147483647",
"display:none",
"align-items:flex-start",
"justify-content:center",
"pointer-events:none",
"background:rgba(15,23,42,0.25)",
"font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif"
].join(";");

const pill = document.createElement("div");
pill.style.cssText = [
"margin-top:24px",
"display:flex",
"align-items:center",
"gap:12px",
"padding:12px 18px",
"border-radius:9999px",
"background:rgba(17,24,39,0.94)",
"color:#fff",
"box-shadow:0 10px 30px rgba(0,0,0,0.35)",
"font-size:14px",
"line-height:1.25",
"font-weight:600"
].join(";");

const spinner = document.createElement("div");
spinner.style.cssText = [
"flex:0 0 auto",
"width:16px",
"height:16px",
"border-radius:50%",
"border:2px solid rgba(255,255,255,0.35)",
"border-top-color:#fff",
"animation:shotback-spin 0.8s linear infinite"
].join(";");

const text = document.createElement("div");
const heading = document.createElement("div");
heading.textContent = "Capturing full page…";
const sub = document.createElement("div");
sub.textContent = "Please don’t switch tabs or scroll until it finishes";
sub.style.cssText = "font-weight:400;font-size:12px;opacity:0.8;margin-top:2px";
text.appendChild(heading);
text.appendChild(sub);

pill.appendChild(spinner);
pill.appendChild(text);
overlay.appendChild(pill);
document.documentElement.appendChild(overlay);
captureOverlay = overlay;
return overlay;
}

function setCaptureOverlayDisplay(visible: boolean): void {
if (captureOverlay) captureOverlay.style.display = visible ? "flex" : "none";
}

function removeCaptureOverlay(): void {
captureOverlay?.remove();
captureOverlay = null;
}

chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === "SB_CAPTURE_BEGIN") {
ensureCaptureOverlay().style.display = "flex";
window.requestAnimationFrame(() => sendResponse({ ok: true }));
return true;
}

if (message?.type === "SB_SET_OVERLAY") {
setCaptureOverlayDisplay(Boolean(message.visible));
window.requestAnimationFrame(() => sendResponse({ ok: true }));
return true;
}

if (message?.type === "SB_CAPTURE_END") {
removeCaptureOverlay();
sendResponse({ ok: true });
return true;
}

if (message?.type === "SB_GET_PAGE_METRICS") {
originalScrollY = window.scrollY;

Expand All @@ -27,6 +131,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === "SB_SCROLL_TO") {
const y = Number(message.y ?? 0);
window.scrollTo(0, y);
// Re-show the notice between capture frames (it was hidden for the shot).
setCaptureOverlayDisplay(true);

window.requestAnimationFrame(() => {
sendResponse({ ok: true, y: window.scrollY });
Expand All @@ -36,6 +142,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {

if (message?.type === "SB_RESTORE_SCROLL") {
window.scrollTo(0, originalScrollY);
removeCaptureOverlay();
sendResponse({ ok: true });
return true;
}
Expand Down
18 changes: 18 additions & 0 deletions src/lib/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ async function sendMessage<T>(tabId: number, message: unknown): Promise<T> {
return chrome.tabs.sendMessage(tabId, message) as Promise<T>;
}

/** Send a best-effort, cosmetic message (the capture notice). Never throws. */
async function notify(tabId: number, message: unknown): Promise<void> {
try {
await chrome.tabs.sendMessage(tabId, message);
} catch {
// The on-page notice is purely cosmetic; ignore if the receiver is absent.
}
}

async function wait(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
Expand Down Expand Up @@ -147,18 +156,27 @@ export async function captureFullPage(
const metrics = await sendToContentScript<PageMetrics>(tabId, { type: "SB_GET_PAGE_METRICS" });
const steps = buildScrollSteps(metrics.fullHeight, metrics.viewportHeight);

// Show an on-page notice (the user is looking at this tab, not the editor)
// and give them a moment to read it. Overlay messages are cosmetic, so a
// failure must never abort the capture.
await notify(tabId, { type: "SB_CAPTURE_BEGIN" });
await wait(450);

const segments: Array<{ y: number; dataUrl: string }> = [];
try {
for (let i = 0; i < steps.length; i += 1) {
const y = steps[i];
await sendMessage(tabId, { type: "SB_SCROLL_TO", y });
await wait(120);
// Hide the notice so it is not baked into this frame, then capture.
await notify(tabId, { type: "SB_SET_OVERLAY", visible: false });
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, { format: "png" });
segments.push({ y, dataUrl });
onProgress?.(i + 1, steps.length);
}
} finally {
await sendMessage(tabId, { type: "SB_RESTORE_SCROLL" }).catch(() => undefined);
await notify(tabId, { type: "SB_CAPTURE_END" });
}

const images = await Promise.all(segments.map((segment) => loadImage(segment.dataUrl)));
Expand Down
Loading