diff --git a/packages/op-chrome-extension/capture.js b/packages/op-chrome-extension/capture.js index a8ae13c10..ae31021ad 100644 --- a/packages/op-chrome-extension/capture.js +++ b/packages/op-chrome-extension/capture.js @@ -37,6 +37,8 @@ import { getCore } from './core-registry.js'; const EXTRACTOR_FILE = 'vendor/snapshot-extractor.js'; /** Sentinel object URL, so the harness only swallows the extractor's own anchor. */ const CAPTURE_URL = 'blob:openpencil-capture'; +/** Hard ceiling on the lazy-content scroll march before a full-page capture. */ +const SETTLE_BUDGET_MS = 7000; /* ------------------------------------------------------------------------- * Functions below run inside the tab (ISOLATED world). They must be @@ -52,6 +54,13 @@ function beginCapture(captureUrl, keepPickedRoot) { // clear whatever a previous element pick left behind, or it would silently // capture that old subtree again; a pick capture must keep it. if (!keepPickedRoot) delete globalThis.openpencilSnapshotOptions; + // An element-pick overlay can still be armed when a capture starts (the + // user reopened the popup instead of picking). It is page DOM in this + // isolated world, so tear it down or it gets captured as content. After a + // committed pick the teardown already ran and this is a no-op; the + // abandoned `awaitPick` injection settles through its own timeout. + const pick = globalThis.openpencilPick; + if (pick && typeof pick.teardown === 'function') pick.teardown(); if (!state.originals) { state.originals = { createObjectURL: URL.createObjectURL, @@ -116,6 +125,64 @@ function readChunk(offset, length) { return state.text.slice(offset, offset + length); } +/** + * Walk the viewport through the whole page so lazy content actually exists + * before the extractor reads the DOM, then park the page at the top. + * + * Three problems, one scroll journey: + * - `loading="lazy"` images and IntersectionObserver reveals only load when + * the viewport approaches them — a capture taken from the top of a long + * page otherwise ships placeholders for everything below the fold. + * - `content-visibility: auto` sections have no laid-out geometry until + * they have been near the viewport once. + * - `position: fixed` / `sticky` chrome sits wherever the CURRENT scroll + * put it; capturing from the very top is the one scroll position where + * viewport coordinates and resting page coordinates agree. + * + * The march is bounded (deadline + step cap) so an infinite feed cannot + * hang the capture: whatever loaded within the budget is what gets + * captured. Returns the original scroll offset for `restoreScroll`. + */ +async function settlePageForCapture(maxMs) { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const scroller = document.scrollingElement || document.documentElement; + const original = { x: window.scrollX, y: window.scrollY }; + const deadline = Date.now() + maxMs; + const step = Math.max(200, window.innerHeight * 0.85); + let y = 0; + let steps = 0; + // Re-read scrollHeight every pass: it grows as lazy sections materialize. + while (y < scroller.scrollHeight && Date.now() < deadline && steps < 120) { + window.scrollTo(0, y); + y += step; + steps += 1; + // oxlint-disable-next-line no-await-in-loop -- the pause IS the point: + // lazy loaders watch the viewport, not a synchronous scroll sweep. + await sleep(90); + } + // Give the images the march started a bounded chance to finish. + while (Date.now() < deadline) { + const pending = Array.prototype.filter.call( + document.images, + (image) => !image.complete, + ); + if (pending.length === 0) break; + // oxlint-disable-next-line no-await-in-loop + await sleep(150); + } + window.scrollTo(0, 0); + await sleep(180); + return original; +} + +/** Put the page back where the user left it. */ +function restoreScroll(position) { + if (position && typeof position.y === 'number') { + window.scrollTo(position.x || 0, position.y); + } + return true; +} + /** * Teardown, safe to run at any point after `beginCapture`: restore the * patched globals and drop the parked snapshot. Restoring is idempotent — @@ -169,6 +236,18 @@ async function runInTab(tabId, injection) { export async function capturePage(tabId, onProgress, options) { const core = getCore(); const pickedRoot = Boolean(options && options.pickedRoot); + // Full-page captures walk the viewport through the page first so lazy + // content loads and fixed chrome rests at the top (see + // `settlePageForCapture`). A picked element was chosen on screen, already + // loaded — scrolling out from under the user's pick would be worse than + // capturing it as seen. + let scrollBack = null; + if (!pickedRoot) { + scrollBack = await runInTab(tabId, { + func: settlePageForCapture, + args: [SETTLE_BUDGET_MS], + }).catch(() => null); + } await runInTab(tabId, { func: beginCapture, args: [CAPTURE_URL, pickedRoot] }); try { await runInTab(tabId, { files: [EXTRACTOR_FILE] }); @@ -231,5 +310,10 @@ export async function capturePage(tabId, onProgress, options) { // leave the tab with its own `URL.createObjectURL` / anchor `click` / // clipboard back in place and the parked snapshot freed. await runInTab(tabId, { func: endCapture }).catch(() => undefined); + if (scrollBack) { + await runInTab(tabId, { func: restoreScroll, args: [scrollBack] }).catch( + () => undefined, + ); + } } } diff --git a/packages/op-chrome-extension/picker.js b/packages/op-chrome-extension/picker.js index 8bc9ba4e8..01291477d 100644 --- a/packages/op-chrome-extension/picker.js +++ b/packages/op-chrome-extension/picker.js @@ -49,6 +49,11 @@ function startPick(stateKey, labels) { const doc = document; const host = doc.createElement('div'); + // The extractor skips elements carrying this marker: the overlay is + // capture chrome, never page content. Belt to `beginCapture`'s braces — a + // full-page capture can start while the picker is still armed (popup + // reopened without picking), and the overlay must not import as content. + host.setAttribute('data-openpencil-ui', ''); // A max z-index and `position: fixed` put the overlay above page content // without touching any page element. `pointer-events: none` keeps // `elementFromPoint` and the page's own hover states honest — the overlay diff --git a/packages/op-chrome-extension/popup.js b/packages/op-chrome-extension/popup.js index f013cf35c..d7e05837d 100644 --- a/packages/op-chrome-extension/popup.js +++ b/packages/op-chrome-extension/popup.js @@ -310,15 +310,30 @@ async function onDownload() { setBusy(true); try { const { text, meta } = await ensureCapture(); + // Convert the raw snapshot into a ready-to-open `.op` document (the same + // conversion `op import:snapshot` runs), so the download opens directly by + // double-click with no CLI step. + const converted = JSON.parse(getCore().snapshotToOpDocument(text, String(meta.title || ''))); + if (!converted.ok) { + const error = new Error(String(converted.error || 'empty capture')); + error.code = 'empty'; + throw error; + } // The page title is attacker-controlled, so the name is sanitised by the // core (`filename.rs`) — no title can produce a `..` component or a // dot-file. - const filename = getCore().snapshotFilename(String(meta.title || '')); - const objectUrl = URL.createObjectURL(new Blob([text], { type: 'application/json' })); + const filename = getCore().opFilename(String(meta.title || '')); + // `application/octet-stream`, NOT json: Chrome's download pipeline + // second-guesses an unknown suffix against the blob's MIME type and + // rewrote `.op` to `<title>.json`. A generic byte stream keeps + // the `.op` name the core built. + const objectUrl = URL.createObjectURL(new Blob([converted.op], { type: 'application/octet-stream' })); const id = await chrome.downloads.download({ url: objectUrl, filename, saveAs: false }); revokeWhenDownloadSettles(id, objectUrl); await rememberAction('download'); - setStatus(msg('statusDownloaded', [filename]), 'ok', { detail: captureDetail(meta) }); + setStatus(msg('statusDownloaded', [filename]), 'ok', { + detail: captureDetail(meta, converted.warnings), + }); } catch (error) { if (isCoreTrap(error)) invalidateCore(); if (error && (error.code === 'offline' || error.code === 'import')) {