diff --git a/src/content.ts b/src/content.ts index cb089d7..0f1107e 100644 --- a/src/content.ts +++ b/src/content.ts @@ -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; @@ -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 }); @@ -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; } diff --git a/src/lib/capture.ts b/src/lib/capture.ts index 529bd99..292f369 100644 --- a/src/lib/capture.ts +++ b/src/lib/capture.ts @@ -29,6 +29,15 @@ async function sendMessage(tabId: number, message: unknown): Promise { return chrome.tabs.sendMessage(tabId, message) as Promise; } +/** Send a best-effort, cosmetic message (the capture notice). Never throws. */ +async function notify(tabId: number, message: unknown): Promise { + 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 { await new Promise((resolve) => setTimeout(resolve, ms)); } @@ -147,18 +156,27 @@ export async function captureFullPage( const metrics = await sendToContentScript(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)));