diff --git a/apps/desktop/scripts/picker-electron-capture.smoke.mjs b/apps/desktop/scripts/picker-electron-capture.smoke.mjs new file mode 100644 index 000000000000..7f7312cb993e --- /dev/null +++ b/apps/desktop/scripts/picker-electron-capture.smoke.mjs @@ -0,0 +1,278 @@ +// Isolated offscreen Electron guest. Never launches or modifies the installed app. +import * as NodeAssert from "node:assert/strict"; +import * as NodeHttp from "node:http"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const require = NodeModule.createRequire(import.meta.url); +const { app, BrowserWindow, nativeImage } = require("electron"); +const [ + bundlePath, + screenshotBundlePath, + userDataPath, + liveUrl, + evidenceDir, + liveTarget = ".document-title", +] = process.argv.slice(2); +NodeAssert.ok( + bundlePath && screenshotBundlePath && userDataPath && liveUrl, + "smoke arguments required", +); +app.setPath("userData", userDataPath); +app.on("window-all-closed", () => {}); + +async function waitForValue(guest, expression, label) { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (await guest.executeJavaScript(expression)) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`Timed out waiting for ${label}`); +} + +async function main() { + app.dock?.hide(); + const bundle = await NodeFSP.readFile(bundlePath, "utf8"); + const { captureAnnotationImage } = await import(NodeURL.pathToFileURL(screenshotBundlePath).href); + if (evidenceDir) await NodeFSP.mkdir(evidenceDir, { recursive: true }); + const guestCreated = new Promise((resolve) => { + app.on("web-contents-created", (_event, contents) => { + if (contents.getType() === "webview") resolve(contents); + }); + }); + // Logical visibility enables offscreen painting without creating a visible OS window. + const host = new BrowserWindow({ + show: true, + width: 700, + height: 500, + webPreferences: { backgroundThrottling: false, offscreen: true, webviewTag: true }, + }); + let fixtureServer; + try { + await host.loadURL( + `data:text/html,${encodeURIComponent(``)}`, + ); + const guest = await guestCreated; + const results = []; + + async function install() { + await guest.executeJavaScript(`(() => { + const original = Element.prototype.attachShadow; + Element.prototype.attachShadow = function(options) { + const root = original.call(this, options); + if (this.hasAttribute("data-t3code-annotation-ui")) globalThis.pickerRoot = root; + return root; + }; + })()`); + await guest.executeJavaScript(bundle); + } + + async function captureCase( + name, + frameSelector, + targetSelector, + scroll = "none", + synthetic = false, + ) { + const config = JSON.stringify({ frameSelector, targetSelector, scroll }); + await guest.executeJavaScript(`(() => { + const c = ${config}; + const frame = document.querySelector(c.frameSelector); + frame.contentWindow.scrollTo(0, 0); + const stage = frame.closest(".transcript-document-workspace__stage"); + if (stage) stage.scrollTop = 0; + globalThis.pickerMessages = []; + pickerEmit("preview:start-pick"); + const target = frame.contentDocument.querySelector(c.targetSelector); + const rect = target.getBoundingClientRect(); + const init = { bubbles:true, cancelable:true, button:0, clientX:rect.x + rect.width/2, clientY:rect.y + rect.height/2 }; + target.dispatchEvent(new frame.contentWindow.PointerEvent("pointerdown", init)); + target.dispatchEvent(new frame.contentWindow.PointerEvent("pointerup", init)); + target.dispatchEvent(new frame.contentWindow.MouseEvent("click", init)); + })()`); + const geometry = await guest.executeJavaScript(`(() => { + const c = ${config}; + const frame = document.querySelector(c.frameSelector); + const target = frame.contentDocument.querySelector(c.targetSelector); + const stage = frame.closest(".transcript-document-workspace__stage"); + if (c.scroll === "iframe") { + frame.contentDocument.body.style.minHeight = "1600px"; + frame.contentWindow.scrollTo(0, target.getBoundingClientRect().top + 5); + } else if (c.scroll === "panel") { + stage.scrollTop += frame.getBoundingClientRect().top + target.getBoundingClientRect().top - stage.getBoundingClientRect().top + target.getBoundingClientRect().height / 2; + } + return { + viewport: { width:innerWidth, height:innerHeight }, + target:target.getBoundingClientRect().toJSON(), frame:frame.getBoundingClientRect().toJSON(), + stage:stage?.getBoundingClientRect().toJSON(), frameScrollY:frame.contentWindow.scrollY, + stageScrollY:stage?.scrollTop ?? 0, + }; + })()`); + const picked = guest.executeJavaScript('pickerWaitFor("preview:element-picked")'); + await guest.executeJavaScript(`(() => { + const attach = [...pickerRoot.querySelectorAll("button")].find(x => x.textContent === "Attach"); + if (!attach || attach.disabled) throw new Error("Target was not selected"); + pickerRoot.querySelector("textarea").value = "Iframe capture regression"; + attach.click(); + })()`); + const { + args: [annotation, crop, submission], + } = await picked; + NodeAssert.equal(submission, "attach", `${name}: submission`); + NodeAssert.equal(annotation.comment, "Iframe capture regression"); + NodeAssert.equal(annotation.elements.length, 1); + const selected = annotation.elements[0]; + NodeAssert.equal(selected.element.pageUrl, "about:srcdoc"); + NodeAssert.equal(selected.element.framePath.length, 1); + NodeAssert.ok( + selected.element.selector && selected.element.htmlPreview && selected.element.styles, + ); + NodeAssert.ok(crop.x >= 0 && crop.y >= 0); + NodeAssert.ok(crop.x + crop.width <= geometry.viewport.width + 0.01); + NodeAssert.ok(crop.y + crop.height <= geometry.viewport.height + 0.01); + if (scroll !== "none") { + NodeAssert.ok( + selected.rect.height > 0 && selected.rect.height < geometry.target.height, + `${name}: partial clipping`, + ); + if (scroll === "iframe") NodeAssert.ok(geometry.frameScrollY > 0 && geometry.target.y < 0); + if (scroll === "panel") + NodeAssert.ok(geometry.stageScrollY > 0 && selected.rect.y >= geometry.stage.y); + } + const full = await guest.capturePage(); + const fullSize = full.getSize(); + let directResult; + try { + // Exactly the integer crop previously passed by Manager. + const direct = await guest.capturePage({ + x: Math.floor(crop.x), + y: Math.floor(crop.y), + width: Math.ceil(crop.width), + height: Math.ceil(crop.height), + }); + directResult = direct.getSize(); + } catch (cause) { + directResult = { error: cause instanceof Error ? cause.message : String(cause) }; + } + const screenshot = await captureAnnotationImage(guest, crop); + const image = nativeImage.createFromDataURL(screenshot.dataUrl); + NodeAssert.equal(image.isEmpty(), false); + NodeAssert.deepEqual(image.getSize(), { width: screenshot.width, height: screenshot.height }); + for (const key of ["x", "y", "width", "height"]) { + NodeAssert.ok( + Math.abs(screenshot.cropRect[key] - crop[key]) < 1e-9, + `${name}: CSS ${key} preserved`, + ); + } + NodeAssert.ok(screenshot.width < fullSize.width && screenshot.height < fullSize.height); + if (synthetic) { + NodeAssert.ok( + "error" in directResult, + "old native crop rejects the valid right-hand CSS crop", + ); + // A known cyan target verifies image alignment, independently of its dimensions. + const pixels = image.toBitmap(); + let cyan = 0; + for (let i = 0; i < pixels.length; i += 4) { + if (pixels[i] > 180 && pixels[i + 1] > 130 && pixels[i + 1] < 220 && pixels[i + 2] < 50) + cyan++; + } + NodeAssert.ok(cyan > 100, "native crop contains the cyan iframe target"); + } + const { dataUrl: _dataUrl, ...metadata } = screenshot; + const result = { + name, + geometry, + annotation, + crop, + fullSize, + screenshot: metadata, + directResult, + }; + results.push(result); + if (evidenceDir) { + await NodeFSP.writeFile(NodePath.join(evidenceDir, `${name}.png`), image.toPNG()); + await NodeFSP.writeFile( + NodePath.join(evidenceDir, `${name}.json`), + JSON.stringify(result, null, 2), + ); + if (synthetic) + await NodeFSP.writeFile( + NodePath.join(evidenceDir, "synthetic-fixture.png"), + full.toPNG(), + ); + } + await guest.executeJavaScript('pickerEmit("preview:annotation-captured")'); + NodeAssert.equal( + await guest.executeJavaScript( + 'document.querySelector("[data-t3code-annotation-ui]") === null', + ), + true, + ); + console.log( + `PASS: ${name}, native PNG ${screenshot.width}x${screenshot.height}, prior crop ${JSON.stringify(directResult)}`, + ); + } + + const child = `
Iframe capture
`; + const fixtureHtml = `

Same-origin iframe capture fixture

`; + fixtureServer = NodeHttp.createServer((_request, response) => { + response.writeHead(200, { "Content-Type": "text/html" }); + response.end(fixtureHtml); + }); + await new Promise((resolve) => fixtureServer.listen(0, "127.0.0.1", resolve)); + await guest.loadURL(`http://127.0.0.1:${fixtureServer.address().port}/`); + await waitForValue( + guest, + 'innerWidth === 1600 && document.querySelector("iframe")?.contentDocument?.querySelector(".target")', + "synthetic iframe", + ); + await install(); + await captureCase("synthetic-capture", "iframe", ".target", "none", true); + + await guest.loadURL(liveUrl); + await waitForValue( + guest, + `(() => { + const toggle = document.querySelector(".template-editor-switch input"); + if (!toggle) return false; + if (toggle.checked) toggle.click(); + return innerWidth === 1600 && Boolean(document.querySelector('iframe[title$="editable English Facsimile"]')?.contentDocument?.querySelector(${JSON.stringify(liveTarget)})); + })()`, + "populated Template Editor", + ); + await install(); + const frameSelector = 'iframe[title$="editable English Facsimile"]'; + await captureCase("facsimile-normal", frameSelector, liveTarget); + await captureCase("facsimile-frame-scroll", frameSelector, liveTarget, "iframe"); + await captureCase("facsimile-panel-scroll", frameSelector, liveTarget, "panel"); + if (evidenceDir) + await NodeFSP.writeFile( + NodePath.join(evidenceDir, "capture-results.json"), + JSON.stringify( + results.map(({ annotation: _annotation, ...result }) => result), + null, + 2, + ), + ); + } finally { + fixtureServer?.close(); + host.destroy(); + } +} + +// Awaiting readiness at ESM top level prevents Electron from finishing entry loading. +app + .whenReady() + .then(main) + .then( + () => app.quit(), + (cause) => { + console.error(cause); + app.exit(1); + }, + ); diff --git a/apps/desktop/scripts/picker-frames.smoke.mjs b/apps/desktop/scripts/picker-frames.smoke.mjs index 1fb6f445b4c8..c6bc61ecedc1 100644 --- a/apps/desktop/scripts/picker-frames.smoke.mjs +++ b/apps/desktop/scripts/picker-frames.smoke.mjs @@ -1,11 +1,14 @@ // Run explicitly with Chromium installed: node apps/desktop/scripts/picker-frames.smoke.mjs // Optional arguments: live editor URL, evidence directory. Never launches the installed app. import * as NodeAssert from "node:assert/strict"; +import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import { build } from "vite-plus"; import { chromium } from "playwright-core"; +import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; const entry = NodeURL.fileURLToPath(new URL("../src/preview/PickPreload.ts", import.meta.url)); const result = await build({ @@ -22,13 +25,29 @@ const result = await build({ if (id !== "\0picker-ipc") return; return `const listeners = new Map(); globalThis.pickerMessages = []; + globalThis.pickerWaiters = new Map(); globalThis.pickerEmit = (channel, ...args) => { for (const fn of [...(listeners.get(channel) ?? [])]) fn({}, ...args); }; + globalThis.pickerWaitFor = (channel) => { + const found = globalThis.pickerMessages.find((message) => message.channel === channel); + if (found) return Promise.resolve(found); + return new Promise((resolve) => { + globalThis.pickerWaiters.set(channel, [ + ...(globalThis.pickerWaiters.get(channel) ?? []), + resolve, + ]); + }); + }; export const ipcRenderer = { on(channel, fn) { listeners.set(channel, [...(listeners.get(channel) ?? []), fn]); return this; }, off(channel, fn) { listeners.set(channel, (listeners.get(channel) ?? []).filter(x => x !== fn)); return this; }, - send(channel, ...args) { globalThis.pickerMessages.push({channel, args}); } + send(channel, ...args) { + const message = { channel, args }; + globalThis.pickerMessages.push(message); + for (const resolve of globalThis.pickerWaiters.get(channel) ?? []) resolve(message); + globalThis.pickerWaiters.delete(channel); + } };`; }, }, @@ -38,6 +57,21 @@ const result = await build({ const bundle = (Array.isArray(result) ? result[0] : result).output.find( (x) => x.type === "chunk", ).code; +const screenshotEntry = NodeURL.fileURLToPath( + new URL("../src/preview/AnnotationScreenshot.ts", import.meta.url), +); +const screenshotResult = await build({ + configFile: false, + logLevel: "error", + build: { + write: false, + minify: false, + lib: { entry: screenshotEntry, formats: ["es"] }, + }, +}); +const screenshotBundle = ( + Array.isArray(screenshotResult) ? screenshotResult[0] : screenshotResult +).output.find((output) => output.type === "chunk").code; const browser = await chromium.launch({ headless: true }); const [liveUrl, evidenceDir, liveTarget = ".document-title"] = process.argv.slice(2); if (evidenceDir) await NodeFSP.mkdir(evidenceDir, { recursive: true }); @@ -270,21 +304,393 @@ try { await urls.evaluate(() => pickerEmit("preview:cancel-pick")); console.log("PASS: same-origin URL frames and opaque-origin exclusion"); + // SVG graphics bounds can extend beyond their viewport, especially with a + // viewBox. Verify the browser's clip and the captured rect in child documents. + for (const { nested, percent } of [ + { nested: false, percent: false }, + { nested: true, percent: false }, + { nested: false, percent: true }, + { nested: true, percent: true }, + ]) { + for (const fit of ["none", "xMidYMid meet", "xMaxYMin slice"]) { + for (const scale of [1, 1.5]) { + const svgPage = await browser.newPage({ viewport: { width: 800, height: 600 } }); + await svgPage.setContent( + '', + ); + await svgPage.locator("iframe").evaluate( + (frame, { nested, percent, fit, scale }) => { + const content = `SVG target`; + frame.srcdoc = `
+ ${nested ? `${content}` : content}
`; + }, + { nested, percent, fit, scale }, + ); + const selected = svgPage.frameLocator("iframe").locator("#svg-target"); + await selected.waitFor(); + const local = { + x: (50 + (nested ? 50 : 0)) * scale, + y: (50 + (nested ? 40 : 0)) * scale, + width: 200 * scale, + height: 100 * scale, + }; + for (const axis of ["x", "y"]) { + for (const end of [false, true]) { + for (const outside of [false, true]) { + const point = { x: local.x + local.width / 2, y: local.y + local.height / 2 }; + point[axis] = + local[axis] + + (end ? local[axis === "x" ? "width" : "height"] : 0) + + (outside ? 1 : -1) * (end ? 1 : -1); + NodeAssert.equal( + await selected.evaluate( + (element, point) => + element.ownerDocument.elementFromPoint(point.x, point.y) === element, + point, + ), + !outside, + `SVG ${nested}/${fit}/${scale}: ${axis} ${end ? "end" : "start"} clip`, + ); + } + } + } + await install(svgPage); + await svgPage.mouse.click(40 + local.x + local.width / 2, 40 + local.y + local.height / 2); + const svgRect = await attach( + svgPage, + /svg-target/, + `svg-${nested}-${percent}-${fit.replaceAll(" ", "-")}-${scale}`, + ); + NodeAssert.deepEqual(svgRect, { ...local, x: 40 + local.x, y: 40 + local.y }); + await svgPage.close(); + } + } + } + console.log( + "PASS: outer and nested SVG viewport clipping, viewBox fits, graphics groups and positive scale", + ); + + for (const scale of [1, 1.5]) { + for (const variant of ["relative", "absolute", "fixed"]) { + for (const percent of [false, true]) { + const foreignPage = await browser.newPage({ viewport: { width: 800, height: 600 } }); + await foreignPage.setContent( + '', + ); + await foreignPage.locator("iframe").evaluate( + (frame, { scale, percent, variant }) => { + frame.srcdoc = `
+ +
Foreign target
+
`; + }, + { scale, percent, variant }, + ); + const selected = foreignPage.frameLocator("iframe").locator("#foreign-target"); + await selected.waitFor(); + const local = { x: 90 * scale, y: 70 * scale, width: 200 * scale, height: 100 * scale }; + for (const axis of ["x", "y"]) { + for (const end of [false, true]) { + for (const outside of [false, true]) { + const point = { x: local.x + local.width / 2, y: local.y + local.height / 2 }; + point[axis] = + local[axis] + + (end ? local[axis === "x" ? "width" : "height"] : 0) + + (outside ? 1 : -1) * (end ? 1 : -1); + NodeAssert.equal( + await selected.evaluate( + (element, point) => + element.ownerDocument.elementFromPoint(point.x, point.y) === element, + point, + ), + !outside, + `foreignObject ${scale}/${percent}/${variant}: ${axis} ${end ? "end" : "start"} clip`, + ); + } + } + } + await install(foreignPage); + await foreignPage.mouse.click( + 40 + local.x + local.width / 2, + 40 + local.y + local.height / 2, + ); + const rect = await attach( + foreignPage, + /Foreign target/, + `foreign-object-${scale}-${percent}-${variant}`, + ); + NodeAssert.deepEqual(rect, { ...local, x: 40 + local.x, y: 40 + local.y }); + await foreignPage.close(); + } + } + } + console.log( + "PASS: foreignObject HTML clipping, viewBox, positive scaling and CSS percentage geometry", + ); + + // Positioned boxes (and their children) escape overflow before their containing + // block. Verify Chromium actually paints/hit-tests the pixels that we capture. + for (const scenario of [ + { name: "viewport-fixed", position: "fixed", blockStyle: "", width: 100 }, + { + name: "transformed-fixed", + position: "fixed", + blockStyle: "transform:translateX(0)", + width: 50, + }, + { name: "outside-absolute", position: "absolute", blockStyle: "position:relative", width: 50 }, + ]) { + for (const descendant of [false, true]) { + const positionedPage = await browser.newPage({ viewport: { width: 800, height: 600 } }); + await positionedPage.setContent( + '', + ); + await positionedPage.locator("iframe").evaluate( + (frame, { scenario, descendant }) => { + frame.srcdoc = `
+
${descendant ? 'Positioned child' : "Positioned target"}
+
`; + }, + { scenario, descendant }, + ); + const selected = positionedPage + .frameLocator("iframe") + .locator(descendant ? "#target" : "#positioned"); + await selected.waitFor(); + NodeAssert.equal( + await selected.evaluate( + (element) => element.ownerDocument.elementFromPoint(175, 125) === element, + ), + true, + `${scenario.name}: pixels outside intermediate scroller remain visible`, + ); + NodeAssert.equal( + await selected.evaluate( + (element) => element.ownerDocument.elementFromPoint(225, 125) === element, + ), + scenario.width === 100, + `${scenario.name}: real containing block determines visible right edge`, + ); + await install(positionedPage); + await positionedPage.mouse.click(80 + 175, 80 + 125); + const positionedRect = await attach( + positionedPage, + /Positioned/, + `${scenario.name}${descendant ? "-descendant" : ""}`, + ); + NodeAssert.deepEqual(positionedRect, { x: 230, y: 180, width: scenario.width, height: 50 }); + await positionedPage.close(); + } + } + console.log( + "PASS: viewport-fixed, transformed-fixed and absolute overflow escape, including ordinary descendants", + ); + + // HTML body overflow can belong to the viewport even when its box is short. + // Root overflow and containment disable body propagation and retain its clip. + for (const overflow of ["hidden", "auto", "clip"]) { + for (const scenario of [ + { name: "body-propagated", rootStyle: "", bodyStyle: "", height: 50 }, + { name: "boxless-ancestor", rootStyle: "", bodyStyle: "", height: 50 }, + ...["inline", "inline-block", "inline-flex", "inline-grid"].map((display) => ({ + name: `${display}-ancestor`, + rootStyle: "", + bodyStyle: "", + display, + height: display === "inline" ? 50 : 20, + })), + { name: "root-overflow", rootStyle: "overflow:hidden", bodyStyle: "", height: 20 }, + ...["style", "size", "inline-size", "layout", "paint", "content", "strict"].flatMap( + (contain) => [ + { + name: `root-contain-${contain}`, + rootStyle: `height:350px;contain:${contain}`, + bodyStyle: "", + height: 20, + }, + { + name: `body-contain-${contain}`, + rootStyle: "", + bodyStyle: `contain:${contain}`, + height: 20, + }, + ], + ), + { + name: "root-propagated", + rootStyle: `height:60px;overflow:${overflow}`, + bodyStyle: "overflow:visible", + height: 50, + }, + ]) { + const overflowPage = await browser.newPage({ viewport: { width: 800, height: 600 } }); + await overflowPage.setContent( + '', + ); + await overflowPage.locator("iframe").evaluate( + (frame, { overflow, scenario }) => { + frame.srcdoc = `
Overflow target
`; + }, + { overflow, scenario }, + ); + const selected = overflowPage.frameLocator("iframe").locator("#target"); + await selected.waitFor(); + NodeAssert.equal( + await selected.evaluate( + (element) => element.ownerDocument.elementFromPoint(150, 110) === element, + ), + true, + `${scenario.name}-${overflow}: target starts within the visible area`, + ); + NodeAssert.equal( + await selected.evaluate( + (element) => element.ownerDocument.elementFromPoint(150, 140) === element, + ), + scenario.height === 50, + `${scenario.name}-${overflow}: browser hit testing confirms the clip boundary`, + ); + await install(overflowPage); + await overflowPage.mouse.click(80 + 150, 80 + 110); + const overflowRect = await attach( + overflowPage, + /Overflow target/, + `${scenario.name}-${overflow}`, + ); + NodeAssert.deepEqual(overflowRect, { x: 180, y: 180, width: 100, height: scenario.height }); + await overflowPage.close(); + } + } + console.log("PASS: viewport overflow propagation and root/body containment clipping"); + + // Compare captured bounds with Chromium hit testing, including reference boxes, + // positive scaling and cases where Chromium ignores overflow-clip-margin. + for (const scenario of [ + { overflow: "clip", margin: "20px", start: 85, end: 245 }, + { overflow: "clip", margin: "padding-box 20px", start: 85, end: 245 }, + { overflow: "clip", margin: "content-box 20px", start: 95, end: 235 }, + { overflow: "clip", margin: "border-box 20px", start: 80, end: 250 }, + { overflow: "hidden", margin: "20px", start: 105, end: 225 }, + { overflow: "clip visible", margin: "20px", start: 105, end: 225, yStart: 75, yEnd: 275 }, + { overflow: "visible clip", margin: "20px", start: 75, end: 275, yStart: 105, yEnd: 225 }, + ]) { + for (const scale of [1, 1.5]) { + const clipPage = await browser.newPage({ viewport: { width: 800, height: 700 } }); + await clipPage.setContent( + '', + ); + await clipPage.locator("iframe").evaluate( + (frame, { scenario, scale }) => { + frame.srcdoc = `
Clip margin target
`; + }, + { scenario, scale }, + ); + const selected = clipPage.frameLocator("iframe").locator("#target"); + await selected.waitFor(); + // The target extends beyond every reference box on all four sides. + const expected = { + x: Math.max(75, scenario.start) * scale, + y: Math.max(75, scenario.yStart ?? scenario.start) * scale, + width: (scenario.end - Math.max(75, scenario.start)) * scale, + height: + ((scenario.yEnd ?? scenario.end) - Math.max(75, scenario.yStart ?? scenario.start)) * + scale, + }; + for (const axis of ["x", "y"]) { + for (const edge of ["start", "end"]) { + for (const outside of [false, true]) { + const boundary = + expected[axis] + (edge === "end" ? expected[axis === "x" ? "width" : "height"] : 0); + const offset = (outside ? 1 : -1) * (edge === "end" ? 1 : -1); + const point = + axis === "x" + ? { x: boundary + offset, y: 150 * scale } + : { x: 150 * scale, y: boundary + offset }; + NodeAssert.equal( + await selected.evaluate( + (element, point) => + element.ownerDocument.elementFromPoint(point.x, point.y) === element, + point, + ), + !outside, + `${scenario.overflow}/${scenario.margin}/${scale}: ${axis} ${edge} clip edge`, + ); + } + } + } + await install(clipPage); + await clipPage.mouse.click(150 * scale, 150 * scale); + const clipRect = await attach( + clipPage, + /Clip margin target/, + `clip-margin-${scenario.overflow.replaceAll(" ", "-")}-${scenario.margin.replaceAll(" ", "-")}-${scale}`, + ); + NodeAssert.deepEqual(clipRect, expected); + await clipPage.close(); + } + } + console.log( + "PASS: overflow clip margins, reference boxes, positive scale and ignored-margin controls", + ); + if (liveUrl) { - const live = await browser.newPage({ viewport: { width: 1600, height: 1100 } }); - await live.goto(liveUrl); - await live.locator(".template-editor-workspace").waitFor(); - await live.locator(".template-editor-switch input").uncheck(); - const facsimile = live.frameLocator('iframe[title$="editable English Facsimile"]'); - const element = facsimile.locator(liveTarget); - await element.waitFor(); - await element.scrollIntoViewIfNeeded(); - if (evidenceDir) - await live.screenshot({ path: NodePath.join(evidenceDir, "facsimile-before.png") }); - await install(live); - await element.click(); - await attach(live, /data-binding-id/, "facsimile-after"); - console.log("PASS: populated live Facsimile selection, screenshot crop and annotation payload"); + const scratch = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "picker-electron-")); + const bundlePath = NodePath.join(scratch, "picker-bundle.js"); + const screenshotBundlePath = NodePath.join(scratch, "annotation-screenshot.mjs"); + const userDataPath = NodePath.join(scratch, "user-data"); + await NodeFSP.writeFile(bundlePath, bundle); + await NodeFSP.writeFile(screenshotBundlePath, screenshotBundle); + await NodeFSP.mkdir(userDataPath); + try { + const childPath = NodeURL.fileURLToPath( + new URL("./picker-electron-capture.smoke.mjs", import.meta.url), + ); + const environment = { ...process.env }; + delete environment.ELECTRON_RUN_AS_NODE; + const child = NodeChildProcess.spawnSync( + ensureElectronRuntime(), + [ + childPath, + bundlePath, + screenshotBundlePath, + userDataPath, + liveUrl, + evidenceDir ?? "", + liveTarget, + ], + { encoding: "utf8", env: environment, timeout: 30_000 }, + ); + if (child.stdout) process.stdout.write(child.stdout); + if (child.stderr) process.stderr.write(child.stderr); + NodeAssert.equal(child.error, undefined, child.error?.message); + NodeAssert.equal( + child.status, + 0, + `Electron capture exited ${child.status ?? "without status"}`, + ); + } finally { + await NodeFSP.rm(scratch, { force: true, recursive: true }); + } } } finally { await browser.close(); diff --git a/apps/desktop/src/preview/AnnotationScreenshot.test.ts b/apps/desktop/src/preview/AnnotationScreenshot.test.ts new file mode 100644 index 000000000000..6602d8dac999 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationScreenshot.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import type { NativeImage } from "electron"; + +import { captureAnnotationImage } from "./AnnotationScreenshot.ts"; + +function guest(width = 1280, height = 800) { + const crop = vi.fn((rect: { width: number; height: number }) => ({ + getSize: () => ({ width: rect.width, height: rect.height }), + isEmpty: () => false, + toDataURL: () => "data:image/png;base64,crop", + })); + const source = { + getSize: () => ({ width, height }), + isEmpty: () => false, + crop, + } as unknown as NativeImage; + return { + crop, + source, + wc: { + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 800 })), + capturePage: vi.fn(async (...args: unknown[]) => { + // Reproduce the native guest failure: the full surface works but + // Chromium rejects a direct crop in the scaled host. + if (args.length) throw new Error("UnknownVizError"); + return source; + }), + }, + }; +} + +describe("annotation screenshot", () => { + it("crops the complete native image using translated iframe coordinates", async () => { + const { wc, crop } = guest(); + const rect = { x: 810, y: 250, width: 350, height: 60 }; + expect(await captureAnnotationImage(wc, rect)).toEqual({ + dataUrl: "data:image/png;base64,crop", + width: 350, + height: 60, + cropRect: rect, + }); + expect(wc.capturePage).toHaveBeenCalledWith(); + expect(crop).toHaveBeenCalledWith(rect); + }); + + it("maps CSS coordinates to the captured surface and rounds edges outward", async () => { + const { wc, crop } = guest(640, 400); + const rect = { x: 811, y: 253, width: 352, height: 64 }; + const screenshot = await captureAnnotationImage(wc, rect); + expect(crop).toHaveBeenCalledWith({ x: 405, y: 126, width: 177, height: 33 }); + expect(screenshot.cropRect).toEqual(rect); + }); + + it("clips right and bottom edges after scrolling without shifting the crop", async () => { + const { wc, crop } = guest(2560, 1600); + const screenshot = await captureAnnotationImage(wc, { + x: 1200, + y: 760, + width: 200, + height: 100, + }); + expect(crop).toHaveBeenCalledWith({ x: 2400, y: 1520, width: 160, height: 80 }); + expect(screenshot.cropRect).toEqual({ x: 1200, y: 760, width: 80, height: 40 }); + }); + + it("clips negative edges and supports a full viewport annotation", async () => { + const { wc, crop } = guest(); + await captureAnnotationImage(wc, { x: -20, y: -10, width: 100, height: 70 }); + expect(crop).toHaveBeenLastCalledWith({ x: 0, y: 0, width: 80, height: 60 }); + const screenshot = await captureAnnotationImage(wc, null); + expect(screenshot.cropRect).toEqual({ x: 0, y: 0, width: 1280, height: 800 }); + }); + + it("rejects empty or offscreen captures so the manager can keep the annotation", async () => { + const { wc, source } = guest(); + await expect( + captureAnnotationImage(wc, { + x: 1300, + y: 0, + width: 20, + height: 20, + }), + ).rejects.toThrow("outside the viewport"); + vi.spyOn(source, "isEmpty").mockReturnValue(true); + await expect(captureAnnotationImage(wc, null)).rejects.toThrow("no capturable image"); + }); +}); diff --git a/apps/desktop/src/preview/AnnotationScreenshot.ts b/apps/desktop/src/preview/AnnotationScreenshot.ts new file mode 100644 index 000000000000..1056b845ebb2 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationScreenshot.ts @@ -0,0 +1,50 @@ +import type { PreviewAnnotationRect, PreviewAnnotationScreenshot } from "@t3tools/contracts"; +import type { WebContents } from "electron"; + +/** Capture the guest surface first: Electron can reject cropped guest captures + * while the complete surface is still capturable. */ +export async function captureAnnotationImage( + wc: Pick, + cropRect: PreviewAnnotationRect | null, +): Promise { + const viewport: { width: number; height: number } = await wc.executeJavaScript( + "({ width: window.innerWidth, height: window.innerHeight })", + ); + const source = await wc.capturePage(); + const sourceSize = source.getSize(); + if ( + source.isEmpty() || + !Number.isFinite(viewport.width) || + !Number.isFinite(viewport.height) || + viewport.width <= 0 || + viewport.height <= 0 + ) { + throw new Error("The annotation viewport has no capturable image"); + } + const requested = cropRect ?? { x: 0, y: 0, ...viewport }; + const left = Math.max(0, requested.x); + const top = Math.max(0, requested.y); + const right = Math.min(viewport.width, requested.x + requested.width); + const bottom = Math.min(viewport.height, requested.y + requested.height); + if (right <= left || bottom <= top) { + throw new Error("The annotation crop is outside the viewport"); + } + // The picker uses top-document CSS pixels. The image uses the guest surface's + // pixels, which can differ with page zoom and a scaled preview in the host. + const scaleX = sourceSize.width / viewport.width; + const scaleY = sourceSize.height / viewport.height; + const x = Math.floor(left * scaleX); + const y = Math.floor(top * scaleY); + const image = source.crop({ + x, + y, + width: Math.min(sourceSize.width, Math.ceil(right * scaleX)) - x, + height: Math.min(sourceSize.height, Math.ceil(bottom * scaleY)) - y, + }); + if (image.isEmpty()) throw new Error("The annotation crop is empty"); + return { + dataUrl: image.toDataURL(), + ...image.getSize(), + cropRect: { x: left, y: top, width: right - left, height: bottom - top }, + }; +} diff --git a/apps/desktop/src/preview/FramePicking.test.ts b/apps/desktop/src/preview/FramePicking.test.ts index 6e1a18d4c0a4..6c365cb6c3f5 100644 --- a/apps/desktop/src/preview/FramePicking.test.ts +++ b/apps/desktop/src/preview/FramePicking.test.ts @@ -123,6 +123,99 @@ describe("frame picking coordinates", () => { expect(topViewportRect(element, top)).toEqual(new Rect(154, 114, 40, 10)); }); + it("clips iframe selections to target and frame ancestor scrollports per axis", () => { + const { top, frame, child, element } = fixture(); + const targetScroller = { + ownerDocument: child, + parentElement: null, + offsetWidth: 50, + offsetHeight: 50, + clientWidth: 30, + clientHeight: 25, + clientLeft: 5, + clientTop: 5, + getBoundingClientRect: () => new Rect(0, 0, 100, 100), + } as unknown as Element; + Object.defineProperty(element, "parentElement", { value: targetScroller }); + element.getBoundingClientRect = () => new DOMRect(0, 20, 40, 40); + + const stage = { + ownerDocument: top, + parentElement: null, + offsetWidth: 300, + offsetHeight: 200, + clientWidth: 280, + clientHeight: 50, + clientLeft: 4, + clientTop: 3, + getBoundingClientRect: () => new Rect(90, 80, 300, 200), + } as unknown as Element; + Object.defineProperty(frame, "parentElement", { value: stage }); + + const childStyle = child.defaultView!.getComputedStyle; + child.defaultView!.getComputedStyle = (candidate) => + candidate === targetScroller + ? ({ overflowX: "hidden", overflowY: "visible" } as CSSStyleDeclaration) + : childStyle(candidate); + const topStyle = top.defaultView!.getComputedStyle; + top.defaultView!.getComputedStyle = (candidate) => + candidate === stage + ? ({ overflowX: "visible", overflowY: "auto" } as CSSStyleDeclaration) + : topStyle(candidate); + + // The target scroller's 2x transformed client box clips x to [10, 70]. + // The stage clips y to its bordered client scrollport [83, 133]. + expect(topViewportRect(element, top)).toEqual(new Rect(132, 102, 60, 31)); + + element.getBoundingClientRect = () => new DOMRect(-20, 20, 5, 10); + expect(topViewportRect(element, top)).toEqual(new Rect(132, 102, 0, 20)); + }); + + it.each([ + { position: "fixed", block: {}, clippedWidth: 60 }, + { position: "fixed", block: { transform: "matrix(1, 0, 0, 1, 0, 0)" }, clippedWidth: 40 }, + { position: "fixed", block: { translate: "0px" }, clippedWidth: 40 }, + { position: "fixed", block: { contain: "layout" }, clippedWidth: 40 }, + { position: "fixed", block: { willChange: "opacity, transform " }, clippedWidth: 40 }, + { position: "fixed", block: { willChange: "custom-transform" }, clippedWidth: 60 }, + { position: "absolute", block: { position: "relative" }, clippedWidth: 40 }, + ])("clips $position at its containing block: $block", ({ position, block, clippedWidth }) => { + for (const descendant of [false, true]) { + const { top, child, element } = fixture(); + const containingBlock = { + ownerDocument: child, + parentElement: null, + offsetWidth: 30, + offsetHeight: 100, + clientWidth: 30, + clientHeight: 100, + clientLeft: 0, + clientTop: 0, + getBoundingClientRect: () => new Rect(0, 0, 30, 100), + } as unknown as Element; + const scroller = { + ...containingBlock, + parentElement: containingBlock, + getBoundingClientRect: () => new Rect(70, 70, 30, 100), + } as unknown as Element; + const positioned = descendant ? ({ parentElement: scroller } as unknown as Element) : element; + Object.defineProperty(element, "parentElement", { + value: descendant ? positioned : scroller, + }); + child.defaultView!.getComputedStyle = (candidate) => + ({ + ...(candidate === containingBlock ? block : {}), + ...(candidate === scroller || candidate === containingBlock + ? { overflowX: "hidden", overflowY: "hidden" } + : {}), + ...(candidate === positioned ? { position } : {}), + }) as unknown as CSSStyleDeclaration; + // The intermediate scroller excludes the whole target, but has no + // containing block. The real block clips x; viewport-fixed boxes escape it. + expect(topViewportRect(element, top)).toEqual(new Rect(132, 102, clippedWidth, 80)); + } + }); + it("drops detached, navigated, and now-inaccessible frame documents", () => { const { top, frame, child, element } = fixture(); frame.isConnected = false; diff --git a/apps/desktop/src/preview/FramePicking.ts b/apps/desktop/src/preview/FramePicking.ts index 599a545bd4a4..97b2ea5ce82e 100644 --- a/apps/desktop/src/preview/FramePicking.ts +++ b/apps/desktop/src/preview/FramePicking.ts @@ -93,6 +93,234 @@ function intersect(rect: DOMRect, clip: { x: number; y: number; width: number; h ); } +const clipsOverflow = (value: string): boolean => + value === "auto" || value === "clip" || value === "hidden" || value === "scroll"; + +/** Propagated overflow clips at the viewport, not the root/body border box. */ +function propagatesOverflowToViewport(element: Element, style: CSSStyleDeclaration): boolean { + const owner = element.ownerDocument; + const root = owner.documentElement; + if (element === root) return style.display !== "none"; + if ( + element !== owner.body || + element.parentElement !== root || + root?.localName !== "html" || + root.namespaceURI !== "http://www.w3.org/1999/xhtml" || + style.display === "none" || + (style.contain && style.contain !== "none") + ) + return false; + const rootStyle = owner.defaultView?.getComputedStyle(root); + return ( + !!rootStyle && + rootStyle.display !== "none" && + rootStyle.overflowX === "visible" && + rootStyle.overflowY === "visible" && + (!rootStyle.contain || rootStyle.contain === "none") + ); +} + +function establishesPositioningBlock(style: CSSStyleDeclaration, position: string): boolean { + if (style.display === "contents" || style.display === "none") return false; + return ( + (position === "absolute" && !!style.position && style.position !== "static") || + [ + style.transform, + style.translate, + style.rotate, + style.scale, + style.perspective, + style.filter, + style.backdropFilter, + ].some((value) => !!value && value !== "none") || + /(?:^|\s)(layout|paint|strict|content)(?:\s|$)/.test(style.contain) || + (style.willChange ?? "") + .split(",") + .some((value) => + [ + "transform", + "translate", + "rotate", + "scale", + "perspective", + "filter", + "backdrop-filter", + "contain", + ].includes(value.trim()), + ) || + style.contentVisibility === "auto" + ); +} + +function svgViewportLength( + svg: SVGSVGElement | SVGForeignObjectElement, + axis: "width" | "height" | "x" | "y", +): number { + const value = svg.ownerDocument.defaultView!.getComputedStyle(svg).getPropertyValue(axis); + const length = Number.parseFloat(value); + if (!value.endsWith("%")) return length; + const parent = svg.viewportElement; + if (!parent || parent.localName !== "svg") return Number.NaN; + const viewport = parent as SVGSVGElement; + const dimension = axis === "x" ? "width" : axis === "y" ? "height" : axis; + const reference = viewport.viewBox.animVal[dimension] || svgViewportLength(viewport, dimension); + return (length * reference) / 100; +} + +/** Remove the viewBox fit from the screen transform to recover the actual SVG viewport. */ +function svgViewportRect( + svg: SVGSVGElement | SVGForeignObjectElement, + style: CSSStyleDeclaration, +): DOMRect | null { + const matrix = svg.getScreenCTM(); + if ( + !matrix || + ![matrix.a, matrix.d, matrix.e, matrix.f].every(Number.isFinite) || + matrix.b !== 0 || + matrix.c !== 0 || + !(matrix.a > 0) || + !(matrix.d > 0) + ) + return null; + let width = svgViewportLength(svg, "width"); + let height = svgViewportLength(svg, "height"); + if (style.boxSizing === "border-box") { + width -= [ + style.paddingLeft, + style.paddingRight, + style.borderLeftWidth, + style.borderRightWidth, + ].reduce((sum, value) => sum + (Number.parseFloat(value) || 0), 0); + height -= [ + style.paddingTop, + style.paddingBottom, + style.borderTopWidth, + style.borderBottomWidth, + ].reduce((sum, value) => sum + (Number.parseFloat(value) || 0), 0); + } + if (!Number.isFinite(width) || !Number.isFinite(height) || !(width > 0) || !(height > 0)) + return null; + if (!("viewBox" in svg)) { + const x = svgViewportLength(svg, "x"); + const y = svgViewportLength(svg, "y"); + if (!Number.isFinite(x) || !Number.isFinite(y)) return null; + return new DOMRect( + matrix.e + x * matrix.a, + matrix.f + y * matrix.d, + width * matrix.a, + height * matrix.d, + ); + } + const box = svg.viewBox.animVal; + let scaleX = 1; + let scaleY = 1; + let translateX = 0; + let translateY = 0; + if (box.width > 0 && box.height > 0) { + scaleX = width / box.width; + scaleY = height / box.height; + const { align, meetOrSlice } = svg.preserveAspectRatio.animVal; + if (align !== 1) { + scaleX = scaleY = meetOrSlice === 2 ? Math.max(scaleX, scaleY) : Math.min(scaleX, scaleY); + // SVG alignment constants run xMin/xMid/xMax within yMin/yMid/yMax. + translateX = (((align - 2) % 3) * (width - box.width * scaleX)) / 2; + translateY = (Math.floor((align - 2) / 3) * (height - box.height * scaleY)) / 2; + } + translateX -= box.x * scaleX; + translateY -= box.y * scaleY; + } + return new DOMRect( + matrix.e - (translateX * matrix.a) / scaleX, + matrix.f - (translateY * matrix.d) / scaleY, + (width * matrix.a) / scaleX, + (height * matrix.d) / scaleY, + ); +} + +/** Overflow clips the containing-block chain, skipping ancestors escaped by positioned boxes. */ +function clipThroughOverflowAncestors(rect: DOMRect, element: Element): DOMRect | null { + const view = element.ownerDocument.defaultView; + if (!view) return null; + let position = element.parentElement ? view.getComputedStyle(element).position : "static"; + for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) { + const style = view.getComputedStyle(ancestor); + // Boxless ancestors do not clip or change their descendants' positioning context. + if (style.display === "contents") continue; + // This applies to positioned ancestors too: their ordinary descendants escape + // intermediate scrollports with them (CSS2 overflow / CSS Position 3 section 2.1). + if ( + (position === "fixed" || position === "absolute") && + !establishesPositioningBlock(style, position) && + // A foreignObject establishes the containing block for its HTML content. + !( + ancestor.namespaceURI === "http://www.w3.org/2000/svg" && + ancestor.localName === "foreignObject" + ) + ) + continue; + position = style.position; + // Non-replaced HTML inline boxes do not establish overflow clips. Their + // positioning still matters for absolutely positioned descendants. + if (style.display === "inline" && ancestor.namespaceURI === "http://www.w3.org/1999/xhtml") + continue; + const clipX = clipsOverflow(style.overflowX); + const clipY = clipsOverflow(style.overflowY); + if (!clipX && !clipY) continue; + if (propagatesOverflowToViewport(ancestor, style)) continue; + if (!hasSupportedTransform(style)) return null; + if (ancestor.namespaceURI === "http://www.w3.org/2000/svg") { + // Graphics groups do not establish overflow clips; svg and foreignObject do. + if (ancestor.localName !== "svg" && ancestor.localName !== "foreignObject") continue; + const viewport = svgViewportRect( + ancestor as unknown as SVGSVGElement | SVGForeignObjectElement, + style, + ); + if (!viewport) return null; + rect = intersect(rect, { + x: clipX ? viewport.x : rect.x, + y: clipY ? viewport.y : rect.y, + width: clipX ? viewport.width : rect.width, + height: clipY ? viewport.height : rect.height, + }); + continue; + } + const bounds = ancestor.getBoundingClientRect(); + const scaleX = ancestor.offsetWidth ? bounds.width / ancestor.offsetWidth : 0; + const scaleY = ancestor.offsetHeight ? bounds.height / ancestor.offsetHeight : 0; + if ((clipX && !(scaleX > 0)) || (clipY && !(scaleY > 0))) return null; + let clipLeft = ancestor.clientLeft; + let clipTop = ancestor.clientTop; + let clipRight = clipLeft + ancestor.clientWidth; + let clipBottom = clipTop + ancestor.clientHeight; + // Chromium applies the clip margin only when both axes use overflow: clip. + // Resolve its reference box before scaling the margin into viewport pixels. + if (style.overflowX === "clip" && style.overflowY === "clip") { + const margin = (style.overflowClipMargin ?? "0px").split(/\s+/); + const distance = Number.parseFloat(margin.find((part) => part.endsWith("px")) ?? "0"); + if (margin.includes("border-box")) { + clipLeft = clipTop = 0; + clipRight = ancestor.offsetWidth; + clipBottom = ancestor.offsetHeight; + } else if (margin.includes("content-box")) { + clipLeft += Number.parseFloat(style.paddingLeft) || 0; + clipTop += Number.parseFloat(style.paddingTop) || 0; + clipRight -= Number.parseFloat(style.paddingRight) || 0; + clipBottom -= Number.parseFloat(style.paddingBottom) || 0; + } + clipLeft -= distance; + clipTop -= distance; + clipRight += distance; + clipBottom += distance; + } + const left = clipX ? Math.max(rect.left, bounds.left + clipLeft * scaleX) : rect.left; + const top = clipY ? Math.max(rect.top, bounds.top + clipTop * scaleY) : rect.top; + const right = clipX ? Math.min(rect.right, bounds.left + clipRight * scaleX) : rect.right; + const bottom = clipY ? Math.min(rect.bottom, bounds.top + clipBottom * scaleY) : rect.bottom; + rect = new DOMRect(left, top, Math.max(0, right - left), Math.max(0, bottom - top)); + } + return rect; +} + /** Converts a child viewport rect through each frame's content box, clipping at every viewport. */ export function topViewportRect( element: Element, @@ -101,10 +329,15 @@ export function topViewportRect( if (!element.isConnected) return null; let owner = element.ownerDocument; let rect = element.getBoundingClientRect(); + let carrier = element; + const framed = owner !== topDocument; while (owner !== topDocument) { const view = owner.defaultView; const frame = owningFrame(owner); if (!view || !frame) return null; + const clipped = clipThroughOverflowAncestors(rect, carrier); + if (!clipped) return null; + rect = clipped; rect = intersect(rect, { x: 0, y: 0, width: view.innerWidth, height: view.innerHeight }); const geometry = frameGeometry(frame); if (!geometry) return null; @@ -118,6 +351,12 @@ export function topViewportRect( geometry, ); owner = frame.ownerDocument; + carrier = frame; + } + if (framed) { + const clipped = clipThroughOverflowAncestors(rect, carrier); + if (!clipped) return null; + rect = clipped; } const view = topDocument.defaultView; return view diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 79c7fd1725e1..bc04baa63413 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -3415,6 +3415,101 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("delivers the iframe annotation with its native image crop", () => + withManager((manager) => + Effect.gen(function* () { + let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; + const crop = vi.fn(() => ({ + isEmpty: () => false, + getSize: () => ({ width: 200, height: 60 }), + toDataURL: () => "data:image/png;base64,crop", + })); + const capturePage = vi.fn(async (...args: unknown[]) => { + if (args.length) throw new Error("UnknownVizError"); + return { + isEmpty: () => false, + getSize: () => ({ width: 1280, height: 720 }), + toJPEG: () => Buffer.from("image"), + crop, + }; + }); + const wc = Object.assign(makeTestPreviewWebContents(capturePage), { + once: vi.fn(), + isFocused: () => true, + }); + wc.ipc.removeListener = vi.fn(); + vi.mocked(wc.ipc.on).mockImplementation((channel, listener) => { + if (channel === "preview:element-picked") { + onPicked = (event, ...args) => listener(event as Electron.IpcMainEvent, ...args); + } + return wc.ipc; + }); + fromId.mockReturnValue(wc); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + const rect = { x: 900, y: 300, width: 200, height: 60 }; + const payload = { + id: "annotation_1", + pageUrl: "https://example.com/editor", + pageTitle: "Editor", + comment: "Tighten this spacing", + elements: [ + { + id: "element_1", + rect, + element: { + pageUrl: "about:srcdoc", + pageTitle: "Preview", + tagName: "h1", + selector: "h1", + htmlPreview: "

Preview

", + componentName: null, + source: null, + stack: [], + styles: "color: black", + pickedAt: "2026-06-11T00:00:00.000Z", + framePath: [{ pageUrl: "https://example.com/editor", selector: "iframe" }], + }, + }, + ], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + }; + onPicked?.({}, payload, rect, "attach"); + const result = yield* Fiber.join(pick); + expect(result).toEqual({ + annotation: { + ...payload, + screenshot: { + dataUrl: "data:image/png;base64,crop", + width: 200, + height: 60, + cropRect: rect, + }, + }, + submission: "attach", + }); + expect(capturePage).toHaveBeenCalledWith(); + expect(crop).toHaveBeenCalledWith(rect); + expect(webviewSend).toHaveBeenCalledWith("preview:annotation-captured"); + capturePage.mockRejectedValueOnce(new Error("Compositor unavailable")); + const failedPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + onPicked?.({}, payload, rect, "send"); + expect(yield* Fiber.join(failedPick)).toEqual({ + annotation: payload, + submission: "send", + screenshotFailed: true, + }); + }), + ), + ); + effectIt.effect("settles the pick when the annotation screenshot never arrives", () => withManager((manager) => Effect.gen(function* () { @@ -3435,6 +3530,7 @@ describe("PreviewManager", () => { once: vi.fn(), off: vi.fn(), // A wedged compositor leaves `capturePage` pending forever. + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 800 })), capturePage: vi.fn(() => new Promise(() => {})), ipc: { on: vi.fn((channel: string, listener: typeof onPicked) => { @@ -3512,6 +3608,7 @@ describe("PreviewManager", () => { on: vi.fn(), once: vi.fn(), off: vi.fn(), + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 800 })), capturePage: vi.fn(() => new Promise(() => {})), ipc: { on: vi.fn((channel: string, listener: typeof onPicked) => { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 900ba5fe983c..d50d9c832d33 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -54,6 +54,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; +import { captureAnnotationImage } from "./AnnotationScreenshot.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, @@ -306,12 +307,8 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { ) { return null; } - return { - x: Math.max(0, Math.floor(x)), - y: Math.max(0, Math.floor(y)), - width: Math.max(1, Math.ceil(width)), - height: Math.max(1, Math.ceil(height)), - }; + // Keep CSS-pixel precision until the crop is mapped into the captured image. + return { x, y, width, height }; }; /** `capturePage` never settles when the guest's compositor is wedged. */ @@ -331,17 +328,7 @@ const captureAnnotationScreenshot = ( // The unused abort signal is what makes this interruptible, and therefore // what lets the timeout below fire. Drop the parameter and a stalled // capture strands the pick session again. - try: (_signal) => - wc.capturePage( - cropRect - ? { - x: cropRect.x, - y: cropRect.y, - width: cropRect.width, - height: cropRect.height, - } - : undefined, - ), + try: (_signal) => captureAnnotationImage(wc, cropRect), catch: (cause) => new PreviewOperationError({ operation: "captureAnnotationScreenshot", @@ -350,15 +337,6 @@ const captureAnnotationScreenshot = ( cause, }), }).pipe( - Effect.map((image): PreviewAnnotationPayload["screenshot"] => { - const size = image.getSize(); - return { - dataUrl: image.toDataURL(), - width: size.width, - height: size.height, - cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, - }; - }), Effect.timeoutOption(ANNOTATION_SCREENSHOT_TIMEOUT), Effect.flatMap((screenshot) => Option.isSome(screenshot) diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts index be723040152a..4f3a897102e1 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -145,21 +145,27 @@ describe("highlightSourceFile", () => { const highlighter = await import("./shikiReviewHighlighter"); const source = "const answer: number = 42;"; - const highlighted = await highlighter.highlightSourceFile({ - path: "example.ts", - contents: source, - theme: "dark", - }); - - expect( - highlighted - .flat() - .map((token) => token.content) - .join(""), - ).toBe(source); - expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); - expect( - await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), - ).toEqual(highlighted); + // Compare both entry points independently of Shiki's wall-clock tokenization budget. + const clock = vi.spyOn(Date, "now").mockReturnValue(Date.now()); + try { + const highlighted = await highlighter.highlightSourceFile({ + path: "example.ts", + contents: source, + theme: "dark", + }); + + expect( + highlighted + .flat() + .map((token) => token.content) + .join(""), + ).toBe(source); + expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); + expect( + await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), + ).toEqual(highlighted); + } finally { + clock.mockRestore(); + } }); }); diff --git a/docs/user/browser-annotations.md b/docs/user/browser-annotations.md index 77fb484240c2..3ad755d1bc7e 100644 --- a/docs/user/browser-annotations.md +++ b/docs/user/browser-annotations.md @@ -8,7 +8,8 @@ selected content and a screenshot of the marked area. Elements inside same-origin iframes are selectable, including embedded `srcdoc` previews and nested frames. Highlights follow scrolling within the frame and the surrounding page. Screenshot crops use the element's visible position in the -browser. If a frame navigates or is removed, its old selections are discarded; +browser, including responsive previews scaled to fit the panel. Crops are clipped +to the visible browser area. If a frame navigates or is removed, its old selections are discarded; select the replacement content to annotate it. The packaged element context includes the embedded document's URL and the path through its containing frames, so selectors can be resolved inside the correct