From 1ff18f9530e6c7b788baa204a31ba18146e38bd5 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Wed, 5 Aug 2026 21:12:55 +0800 Subject: [PATCH] fix(extension): settle lazy pages before capture and keep chrome out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full-page captures walk the viewport through the page first (bounded 7s march) so loading=lazy images, IntersectionObserver reveals and content-visibility sections exist before the extractor reads the DOM, then capture from the top — the one scroll position where fixed/sticky chrome rests at its page coordinates — and restore the user's scroll afterwards. - beginCapture tears down a still-armed element-picker overlay: a full-page capture does not go through the pick flow, and the armed highlight box and hint banner imported as page content. The overlay also marks itself for the extractor to skip (belt and braces). - The .op download blob is application/octet-stream: Chrome's download pipeline second-guessed the unknown .op suffix against the JSON MIME type and renamed the file to .json. --- packages/op-chrome-extension/capture.js | 84 +++++++++++++++++++++++++ packages/op-chrome-extension/picker.js | 5 ++ packages/op-chrome-extension/popup.js | 21 ++++++- 3 files changed, 107 insertions(+), 3 deletions(-) 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')) {