diff --git a/apps/codex-plus-manager/src/image-overlay.test.ts b/apps/codex-plus-manager/src/image-overlay.test.ts new file mode 100644 index 000000000..68ded2a59 --- /dev/null +++ b/apps/codex-plus-manager/src/image-overlay.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { it } from "node:test"; + +const source = await readFile(new URL("../../../assets/inject/renderer-inject.js", import.meta.url), "utf8"); + +function functionSource(name: string) { + const match = source.match(new RegExp(`^ function ${name}\\([^]*?^ \\}`, "m")); + assert.ok(match, name); + return match[0]; +} + +it('uses native scroll timelines and releases owned animations', () => { + const foreground = functionSource('installCodexPlusImageOverlayForeground'); + for (const fragment of ['new ScrollTimeline', 'typeof ScrollTimeline', + 'clearScrollLayers(record)', 'xAnimation?.cancel()', 'yAnimation?.cancel()']) { + assert.ok(foreground.includes(fragment), fragment); + } + assert.ok(!foreground.includes('preventDefault(')); +}); + +it("keeps the complete upstream tint layer and its configured opacity", () => { + const install = functionSource("installCodexPlusImageOverlay"); + assert.match(install, /width: "100vw"/); + assert.match(install, /height: "100vh"/); + assert.match(install, /opacity: String\(opacity\)/); + assert.match(install, /zIndex: "2147483646"/); + assert.match(install, /pointerEvents: "none"/); + assert.doesNotMatch(install, /clipPath|clip-path|maskImage|mask-image/); +}); + +it("does not rewrite native palettes, foreground paint, or the ordinary app shell", () => { + const foreground = functionSource("installCodexPlusImageOverlayForeground"); + assert.doesNotMatch(foreground, /styleSheets|cssRules/); + assert.doesNotMatch(foreground, /--composer-[\w-]+\s*:/); + assert.doesNotMatch(foreground, /document\.querySelectorAll\([^)]*body/); + assert.match(foreground, /img, video, canvas/); + assert.match(foreground, /data-browser-sidebar-webview/); + assert.match(foreground, /image-preview-dismiss-area/); +}); + +it("uses bounded media geometry updates and restores every owned resource", () => { + const foreground = functionSource("installCodexPlusImageOverlayForeground"); + assert.match(foreground, /getBoundingClientRect/); + assert.match(foreground, /requestAnimationFrame/); + assert.match(foreground, /addEventListener\("scroll"/); + assert.match(foreground, /new ResizeObserver/); + assert.match(foreground, /record\.plane\.remove\(\)/); + assert.match(foreground, /resizeObserver\.disconnect\(\)/); + assert.match(foreground, /target\.removeEventListener/); + assert.doesNotMatch(foreground, /setInterval|setTimeout/); +}); + +it("retains native media parents and uses the original video for playback controls", () => { + const foreground = functionSource("installCodexPlusImageOverlayForeground"); + assert.doesNotMatch(foreground, /anchor-name|positionAnchor|anchor-size|anchor\(/); + assert.match(foreground, /document\.documentElement\.appendChild\(plane\)/); + assert.match(foreground, /pointer-events: none/); + assert.match(foreground, /drawImage\(source/); + assert.match(foreground, /native\.host\.showPopover\(\)/); + assert.doesNotMatch(foreground, /appendChild\(source\)|append\(source\)/); + assert.doesNotMatch(foreground, /copy\.src\s*=\s*record\.source\.currentSrc/); +}); diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 72bee2f9b..17853662a 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -573,6 +573,8 @@ window.__codexThreadScrollRestoreRevision = (window.__codexThreadScrollRestoreRevision || 0) + 1; function installCodexPlusImageOverlay() { + window.__codexPlusImageOverlayCleanup?.(); + window.__codexPlusImageOverlayCleanup = null; const config = window.__CODEX_PLUS_IMAGE_OVERLAY__ || {}; const canQueryById = typeof document?.getElementById === "function"; const existing = canQueryById ? document.getElementById(codexPlusImageOverlayId) : null; @@ -604,6 +606,7 @@ if (existing && existing !== overlay) existing.remove(); overlay.id = codexPlusImageOverlayId; overlay.setAttribute("aria-hidden", "true"); + overlay.setAttribute("data-codex-plus-ext", "image-overlay"); Object.assign(overlay.style, { position: "fixed", inset: "0", @@ -619,6 +622,7 @@ userSelect: "none", }); if (!overlay.parentElement) root.appendChild(overlay); + installCodexPlusImageOverlayForeground(); sendCodexPlusDiagnostic("image_overlay_installed", { opacity, fitMode, @@ -626,13 +630,654 @@ }); } + function installCodexPlusImageOverlayForeground() { + // Keep upstream's single tint layer intact. Only actual media receives a + // foreground plane; geometry is refreshed on bounded layout events. + const mediaSelector = "img, video, canvas"; + const raisedSelector = '[role="dialog"]:has([data-testid="image-preview-dismiss-area"]), [data-browser-sidebar-webview]'; + const blockerSelector = '[role="menu"], [role="listbox"], [role="tooltip"], [role="dialog"]'; + const records = new Map(); + const nativeHosts = new Map(); + const raised = new Map(); + const owned = node => node?.closest?.('[data-codex-plus-ext="image-overlay"]'); + const blockers = new Set(); + const dirty = new Set(); + const visibleMedia = new Set(); + const watched = new Map(); + const layoutRoots = new Map(); + let frame = 0; + let reobserveQueued = false; + let disposed = false; + let frameStyles = new Map(); + let frameRects = new Map(); + const readStyle = element => { + if (!frameStyles.has(element)) frameStyles.set(element, getComputedStyle(element)); + return frameStyles.get(element); + }; + const readRect = element => { + if (!frameRects.has(element)) frameRects.set(element, element.getBoundingClientRect()); + return frameRects.get(element); + }; + const resetGeometry = () => { frameStyles = new Map(); frameRects = new Map(); }; + const style = document.createElement("style"); + style.setAttribute("data-codex-plus-ext", "image-overlay"); + style.textContent = ` + .codex-plus-media-plane { + position: fixed !important; inset: 0 !important; margin: 0 !important; + padding: 0 !important; border: 0 !important; background: transparent !important; + pointer-events: none !important; user-select: none !important; + z-index: 2147483647 !important; display: block !important; + width: 100vw; height: 100vh; + } + .codex-plus-media-copy { + position: fixed !important; inset: auto; + left: 0; top: 0; width: 0; height: 0; + margin: 0 !important; padding: 0 !important; border: 0 !important; + max-width: none !important; max-height: none !important; + pointer-events: none !important; + } + .codex-plus-media-scrollport, .codex-plus-media-scroll-offset { + position: absolute !important; margin: 0 !important; padding: 0 !important; + border: 0 !important; background: transparent !important; + pointer-events: none !important; display: block !important; + } + .codex-plus-media-scroll-offset { inset: 0; } + .codex-plus-media-scrollport .codex-plus-media-copy { position: absolute !important; } + .codex-plus-media-plane[hidden] { display: none !important; } + [data-codex-plus-native-media]::backdrop { display: none !important; } + `; + document.documentElement.appendChild(style); + const preserveStyle = (element, name, value) => { + const previous = element.style.getPropertyValue(name); + const priority = element.style.getPropertyPriority(name); + element.style.setProperty(name, value, "important"); + const applied = element.style.getPropertyValue(name); + return () => { + if (element.style.getPropertyValue(name) !== applied || + element.style.getPropertyPriority(name) !== "important") return; + if (previous) element.style.setProperty(name, previous, priority); + else element.style.removeProperty(name); + }; + }; + const stopStream = record => { + if (record.player) { + if (record.canvasFrame != null) record.player.cancelVideoFrameCallback(record.canvasFrame); + record.player.pause(); + record.player.srcObject = null; + } + if (record.ownsStream) record.stream?.getTracks().forEach(track => track.stop()); + record.stream = null; + record.ownsStream = false; + }; + const display = record => { + if (disposed || records.get(record.source) !== record || !record.plane.isConnected) return; + const visible = record.visible && record.inViewport && !record.blocked; + if (record.native) { + record.plane.hidden = true; + if (document.fullscreenElement?.contains(record.source)) return; + if (visible && !record.native.host.matches(':popover-open')) record.native.host.showPopover(); + else if (!visible && record.native.host.matches(':popover-open')) record.native.host.hidePopover(); + return; + } + if (record.source.tagName === 'VIDEO' && record.source.controls) { + // Unknown compound layouts keep their usable native player. Never + // conceal its controls with a frame copy or reparent React-owned DOM. + record.plane.hidden = true; + return; + } + if (record.nextVideoFrame) { + if (visible && record.videoFrame == null) record.videoFrame = record.source.requestVideoFrameCallback(record.nextVideoFrame); + else if (!visible && record.videoFrame != null) { + record.source.cancelVideoFrameCallback(record.videoFrame); + record.videoFrame = null; + } + } + if (!visible) { + record.plane.hidden = true; + if (record.stream) stopStream(record); + return; + } + if (record.source.tagName === "IMG" && (!record.copy.complete || !record.copy.naturalWidth)) { + record.plane.hidden = true; + return; + } + if (record.source.tagName === "CANVAS" && !record.stream) { + try { + const { source, copy } = record; + if (copy.width !== source.width) copy.width = source.width; + if (copy.height !== source.height) copy.height = source.height; + copy.getContext("2d").drawImage(source, 0, 0); + record.stream = record.source.captureStream?.(15); + record.ownsStream = true; + if (record.stream) { + const player = record.player || (record.player = document.createElement("video")); + player.muted = true; + player.srcObject = record.stream; + const paint = () => { + if (disposed || !record.stream || !records.has(source)) return; + if (player.readyState >= 2) { + if (copy.width !== source.width) copy.width = source.width; + if (copy.height !== source.height) copy.height = source.height; + copy.getContext("2d").drawImage(player, 0, 0, copy.width, copy.height); + } + record.canvasFrame = player.requestVideoFrameCallback(paint); + }; + record.canvasFrame = player.requestVideoFrameCallback(paint); + player.play().catch(() => {}); + } + } catch (error) { + sendCodexPlusDiagnostic("image_overlay_video_error", { message: String(error?.message || error) }); + stopStream(record); + record.plane.hidden = true; + return; + } + } + record.plane.hidden = false; + }; + const visibleRect = element => { + if (!element.isConnected || element.hidden || element.getAttribute("data-state") === "closed") return null; + if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return null; + const rect = readRect(element); + return rect.width > 0 && rect.height > 0 ? rect : null; + }; + const intersects = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; + const drawVideo = record => { + const { source, copy } = record; + if (source.readyState < 2 || !record.visible || !record.inViewport || record.blocked) return; + const width = source.videoWidth; + const height = source.videoHeight; + if (!width || !height) return; + if (copy.width !== width) copy.width = width; + if (copy.height !== height) copy.height = height; + copy.getContext("2d").drawImage(source, 0, 0, width, height); + }; + const nativeVideo = record => { + const source = record.source; + const host = source.parentElement; + if (!source.controls || !host || host.id === 'root' || host === document.body || + host.childElementCount !== 1 || host.textContent.trim() || host.hasAttribute('popover') || !host.showPopover) return; + const placeholder = document.createElement(host.tagName); + for (const name of ['class', 'style']) { + if (host.hasAttribute(name)) placeholder.setAttribute(name, host.getAttribute(name)); + } + const spacer = document.createElement('video'); + for (const name of ['class', 'style', 'width', 'height']) { + if (source.hasAttribute(name)) spacer.setAttribute(name, source.getAttribute(name)); + } + const css = getComputedStyle(source); + spacer.style.width = css.width; + spacer.style.height = css.height; + spacer.style.maxWidth = '100%'; + placeholder.append(spacer); + placeholder.setAttribute('data-codex-plus-ext', 'image-overlay'); + placeholder.setAttribute('aria-hidden', 'true'); + placeholder.style.setProperty('visibility', 'hidden', 'important'); + placeholder.style.setProperty('pointer-events', 'none', 'important'); + host.before(placeholder); + record.native = { host, placeholder, spacer, saved: new Map(), applied: new Map() }; + nativeHosts.set(host, record); + host.setAttribute('data-codex-plus-native-media', ''); + host.setAttribute('popover', 'manual'); + resizeObserver.observe(placeholder); + }; + const setNativeStyle = (record, name, value) => { + const { native } = record; + const host = native.host; + if (!native.saved.has(name)) native.saved.set(name, [host.style.getPropertyValue(name), host.style.getPropertyPriority(name)]); + host.style.setProperty(name, value, 'important'); + native.applied.set(name, host.style.getPropertyValue(name)); + native.style = host.getAttribute('style'); + }; + const clearScrollLayers = record => { + for (const layer of record.scrollLayers || []) { + layer.xAnimation?.cancel(); + layer.yAnimation?.cancel(); + } + if (record.scrollLayers?.length) { + record.plane.appendChild(record.copy); + record.scrollLayers[0].port.remove(); + } + record.scrollLayers = []; + }; + const positionScrollCopy = (record, rect) => { + if (record.native || typeof ScrollTimeline !== 'function') return false; + if (/^(fixed|sticky)$/.test(readStyle(record.source).position)) { + clearScrollLayers(record); + return false; + } + const parents = []; + for (let node = record.source.parentElement; node; node = node.parentElement) { + const css = readStyle(node); + // Sticky motion is constrained by layout, not a linear scroll offset. + if (css.position === 'sticky') { clearScrollLayers(record); return false; } + if (/^(auto|scroll|hidden|clip)$/.test(css.overflowX) || + /^(auto|scroll|hidden|clip)$/.test(css.overflowY) || node === document.scrollingElement) parents.unshift(node); + if (css.position === 'fixed') break; + } + if (!parents.length) { clearScrollLayers(record); return false; } + if (parents.length !== record.scrollLayers?.length || + parents.some((node, index) => record.scrollLayers[index].source !== node)) { + clearScrollLayers(record); + let container = record.plane; + for (const source of parents) { + const port = document.createElement('div'); + port.className = 'codex-plus-media-scrollport'; + const x = document.createElement('div'); + const y = document.createElement('div'); + x.className = y.className = 'codex-plus-media-scroll-offset'; + port.appendChild(x); x.appendChild(y); container.appendChild(port); + record.scrollLayers.push({ source, port, x, y }); + container = y; + } + container.appendChild(record.copy); + } + let originX = 0, originY = 0; + for (const layer of record.scrollLayers) { + const { source, port } = layer; + const css = readStyle(source); + const root = source === document.scrollingElement; + const zoom = source.currentCSSZoom || 1; + const box = root ? { left: 0, top: 0 } : readRect(source); + const left = box.left + (root ? 0 : source.clientLeft * zoom); + const top = box.top + (root ? 0 : source.clientTop * zoom); + const width = root ? innerWidth : source.clientWidth * zoom; + const height = root ? innerHeight : source.clientHeight * zoom; + const clipsX = root || /^(auto|scroll|hidden|clip)$/.test(css.overflowX); + const clipsY = root || /^(auto|scroll|hidden|clip)$/.test(css.overflowY); + Object.assign(port.style, { left: `${left - originX}px`, top: `${top - originY}px`, + width: `${width}px`, height: `${height}px`, + clipPath: `inset(${clipsY ? 0 : -100000}px ${clipsX ? 0 : -100000}px)` }); + for (const axis of ['x', 'y']) { + const horizontal = axis === 'x'; + const extent = Math.max(0, horizontal ? source.scrollWidth - source.clientWidth : source.scrollHeight - source.clientHeight); + const reverse = horizontal ? css.direction === 'rtl' : css.display.includes('flex') && css.flexDirection === 'column-reverse'; + const distance = (reverse ? 1 : -1) * extent * zoom; + const key = axis + 'Animation'; + const enabled = extent > 0 && css[horizontal ? 'overflowX' : 'overflowY'] !== 'clip'; + if (layer[axis + 'Distance'] === distance && layer[axis + 'Enabled'] === enabled) continue; + layer[key]?.cancel(); + layer[key] = null; + layer[axis + 'Distance'] = distance; + layer[axis + 'Enabled'] = enabled; + // The compositor samples the source scroll position even when the + // main thread is busy. Do not chase wheel events with fixed left/top. + if (enabled) { + layer[key] = layer[axis].animate([ + { transform: 'translate' + axis.toUpperCase() + '(0px)' }, + { transform: 'translate' + axis.toUpperCase() + '(' + distance + 'px)' }, + ], { duration: 1, fill: 'both', timeline: new ScrollTimeline({ source, axis }) }); + } + } + originX = left - source.scrollLeft * zoom; + originY = top - source.scrollTop * zoom; + } + record.copy.style.left = `${rect.left - originX}px`; + record.copy.style.top = `${rect.top - originY}px`; + return true; + }; + const refresh = source => { + if (disposed) return; + if (source) dirty.add(source); + else visibleMedia.forEach(element => dirty.add(element)); + if (frame || !dirty.size) return; + frame = requestAnimationFrame(() => { + frame = 0; + resetGeometry(); + const pending = [...dirty]; + dirty.clear(); + pending.forEach(update); + }); + }; + const update = source => { + const record = records.get(source); + if (!record) return; + if (record.native && document.fullscreenElement?.contains(source)) return; + const geometry = record.native?.placeholder || source; + const css = readStyle(geometry); + const copy = record.copy; + copy.style.objectFit = css.objectFit; + copy.style.objectPosition = css.objectPosition; + copy.style.borderRadius = css.borderRadius; + copy.style.backgroundColor = css.backgroundColor === "rgba(0, 0, 0, 0)" + ? "var(--color-surface, var(--color-token-bg-primary, Canvas))" : css.backgroundColor; + if (source.tagName === "IMG") { + const src = source.currentSrc || source.src; + if (copy.src !== src) copy.src = src; + } + const rect = readRect(geometry); + if (rect.bottom <= 0 || rect.right <= 0 || rect.left >= innerWidth || rect.top >= innerHeight) { + record.inViewport = false; + display(record); + return; + } + record.visible = Math.max(rect.width, rect.height) >= 48 && (record.native + ? geometry.isConnected && !geometry.closest('[hidden]') && rect.width > 0 && rect.height > 0 + : !!visibleRect(source)); + if (record.native) { + for (let node = source; node; node = node.parentElement) { + const native = readStyle(node); + if (native.visibility !== 'visible' || (native.display === 'none' && node !== record.native.host) || Number(native.opacity) === 0 || + node.hidden || node.inert || node.getAttribute('aria-hidden') === 'true') record.visible = false; + } + } + if (source.tagName === "VIDEO" && !record.native && source.readyState < 2) record.visible = false; + record.blocked = [...blockers].some(element => { + if (element.contains(source)) return false; + const box = visibleRect(element); + return box && intersects(rect, box); + }); + const clip = { left: Math.max(0, rect.left), top: Math.max(0, rect.top), + right: Math.min(innerWidth, rect.right), bottom: Math.min(innerHeight, rect.bottom) }; + for (let parent = geometry.parentElement; parent && parent !== document.documentElement; parent = parent.parentElement) { + const native = readStyle(parent); + const clipsX = /^(auto|scroll|hidden|clip)$/.test(native.overflowX); + const clipsY = /^(auto|scroll|hidden|clip)$/.test(native.overflowY); + if (!clipsX && !clipsY) continue; + const box = readRect(parent); + if (clipsX) { clip.left = Math.max(clip.left, box.left); clip.right = Math.min(clip.right, box.right); } + if (clipsY) { clip.top = Math.max(clip.top, box.top); clip.bottom = Math.min(clip.bottom, box.bottom); } + } + const thread = source.closest('.thread-scroll-container'); + if (thread) { + const footer = document.querySelector('[data-thread-scroll-footer]'); + const box = footer && visibleRect(footer); + if (box && intersects(rect, box)) clip.bottom = Math.min(clip.bottom, box.top); + } + record.inViewport = rect.width > 0 && rect.height > 0 && + clip.right > clip.left && clip.bottom > clip.top; + record.plane.style.clipPath = `inset(${Math.max(0, clip.top)}px ${Math.max(0, innerWidth - clip.right)}px ${Math.max(0, innerHeight - clip.bottom)}px ${Math.max(0, clip.left)}px)`; + Object.assign(copy.style, { + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + }); + if (positionScrollCopy(record, rect)) { + // Only the media scrollports clip. The complete decorative overlay + // stays untouched; its copy must not retain a stale source-rect clip. + const footer = thread && document.querySelector('[data-thread-scroll-footer]'); + const footerBox = footer && visibleRect(footer); + record.plane.style.clipPath = `inset(0px 0px ${footerBox ? Math.max(0, innerHeight - footerBox.top) : 0}px 0px)`; + } + if (record.native) { + const zoom = geometry.currentCSSZoom || 1; + for (const [name, value] of Object.entries({position:'fixed', margin:'0', inset:'auto', + left:`${rect.left / zoom}px`, top:`${rect.top / zoom}px`, width:`${rect.width / zoom}px`, height:`${rect.height / zoom}px`, + 'max-width':'none', 'max-height':'none', 'box-sizing':'border-box', padding:css.padding, border:css.border, 'background-color':css.backgroundColor, + 'clip-path':`inset(${Math.max(0, clip.top - rect.top) / zoom}px ${Math.max(0, rect.right - clip.right) / zoom}px ${Math.max(0, rect.bottom - clip.bottom) / zoom}px ${Math.max(0, clip.left - rect.left) / zoom}px)`})) setNativeStyle(record, name, value); + } else if (source.tagName === "VIDEO") drawVideo(record); + display(record); + }; + const watch = element => { + const path = []; + const layoutRoot = element.closest('.thread-scroll-container, [role="tabpanel"], [data-testid="image-preview-dismiss-area"]'); + if (layoutRoot) layoutRoots.set(layoutRoot, (layoutRoots.get(layoutRoot) || 0) + 1); + for (let node = element; node; node = node.parentElement) { + path.push(node); + watched.set(node, (watched.get(node) || 0) + 1); + if (watched.get(node) !== 1 && node !== layoutRoot) continue; + attributes.observe(node, { attributes: true, subtree: layoutRoots.has(node), + attributeFilter: ["src", "srcset", "sizes", "class", "style", "hidden", "data-state", "aria-hidden"] }); + resizeObserver.observe(node); + } + return { path, layoutRoot }; + }; + const unwatch = ({ path, layoutRoot }) => { + if (layoutRoot) { + const count = layoutRoots.get(layoutRoot) - 1; + if (count) layoutRoots.set(layoutRoot, count); + else layoutRoots.delete(layoutRoot); + } + for (const node of path) { + const count = watched.get(node) - 1; + if (count) watched.set(node, count); + else { watched.delete(node); resizeObserver.unobserve(node); } + } + if (disposed || reobserveQueued) return; + reobserveQueued = true; + queueMicrotask(() => { + reobserveQueued = false; + if (disposed) return; + attributes.disconnect(); + watched.forEach((_, node) => attributes.observe(node, { attributes: true, subtree: layoutRoots.has(node), + attributeFilter: ["src", "srcset", "sizes", "class", "style", "hidden", "data-state", "aria-hidden"] })); + }); + }; + const add = source => { + if (records.has(source) || owned(source) || !source.isConnected || + [...raised.keys()].some(element => element.contains(source)) || source.getAttribute("aria-hidden") === "true") return; + const css = getComputedStyle(source); + if (Math.max(parseFloat(css.width) || 0, parseFloat(css.height) || 0, source.naturalWidth || 0, source.naturalHeight || 0) < 48) return; + const plane = document.createElement("div"); + plane.className = "codex-plus-media-plane"; + plane.setAttribute("data-codex-plus-ext", "image-overlay"); + plane.setAttribute("aria-hidden", "true"); + plane.hidden = true; + const copy = document.createElement(source.tagName === "IMG" ? "img" : "canvas"); + copy.className = "codex-plus-media-copy"; + copy.setAttribute("aria-hidden", "true"); + copy.draggable = false; + const record = { + source, plane, copy, stream: null, ownsStream: false, visible: true, + inViewport: false, listeners: [], videoFrame: null, + }; + copy.addEventListener("load", () => display(record)); + plane.appendChild(copy); + document.documentElement.appendChild(plane); + records.set(source, record); + if (source.tagName === 'VIDEO') nativeVideo(record); + visibleMedia.add(source); + intersectionObserver.observe(record.native?.placeholder || source); + for (const event of ["load", "loadeddata", "loadedmetadata", "canplay", "play", "playing", "pause", "seeked", "emptied"]) { + const listener = () => refresh(source); + source.addEventListener(event, listener); + record.listeners.push([event, listener]); + } + record.watched = watch(source); + if (source.tagName === "VIDEO" && !source.controls && !record.native && source.requestVideoFrameCallback) { + const nextFrame = () => { + if (disposed || !records.has(source)) return; + record.videoFrame = null; + if (record.visible && record.inViewport && !record.blocked) { + drawVideo(record); + record.videoFrame = source.requestVideoFrameCallback(nextFrame); + } + }; + record.nextVideoFrame = nextFrame; + record.videoFrame = source.requestVideoFrameCallback(nextFrame); + } + resetGeometry(); + update(source); + }; + const remove = source => { + const record = records.get(source); + if (!record) return; + records.delete(source); + visibleMedia.delete(source); + intersectionObserver.unobserve(record.native?.placeholder || source); + dirty.delete(source); + for (const [event, listener, target = source] of record.listeners) target.removeEventListener(event, listener); + unwatch(record.watched); + if (record.videoFrame !== null) source.cancelVideoFrameCallback(record.videoFrame); + stopStream(record); + clearScrollLayers(record); + if (record.native) { + const host = record.native.host; + if (host.matches(':popover-open')) host.hidePopover(); + host.removeAttribute('popover'); + host.removeAttribute('data-codex-plus-native-media'); + for (const [name, [value, priority]] of record.native.saved) { + if (host.style.getPropertyValue(name) !== record.native.applied.get(name)) continue; + if (value) host.style.setProperty(name, value, priority); + else host.style.removeProperty(name); + } + nativeHosts.delete(host); + resizeObserver.unobserve(record.native.placeholder); + record.native.placeholder.remove(); + } + record.plane.remove(); + }; + const raise = element => { + if (raised.has(element) || owned(element)) return; + let target = element; + for (let parent = element.parentElement; parent && parent !== document.body; parent = parent.parentElement) { + const css = getComputedStyle(parent); + if (element.matches('[data-browser-sidebar-webview]')) { + if (parent.id === "root" || parent === document.documentElement) return; + if (css.position !== "static") target = parent; + continue; + } + const stacking = css.transform !== "none" || css.isolation === "isolate" || + css.zIndex !== "auto" || Number(css.opacity) < 1 || /paint|layout/.test(css.contain) || + css.filter !== "none" || css.perspective !== "none" || /^(fixed|sticky)$/.test(css.position); + if (!stacking) continue; + // A native portal wrapper can be lifted with its child; an app surface + // containing unrelated UI cannot. Its media keeps the copy fallback. + if (parent.id === "root" || parent.childElementCount !== 1) return; + target = parent; + } + if (getComputedStyle(target).position === "static") return; + if ([...raised.keys()].some(node => node.contains(element))) return; + raised.set(element, preserveStyle(target, "z-index", "2147483647")); + element.querySelectorAll(mediaSelector).forEach(remove); + }; + const visit = (node, callback, selector) => { + if (node.nodeType !== 1 || owned(node)) return; + if (node.matches(selector)) callback(node); + if (node.childElementCount) node.querySelectorAll(selector).forEach(callback); + }; + const resizeObserver = new ResizeObserver(() => refresh()); + const intersectionObserver = new IntersectionObserver(entries => { + for (const entry of entries) { + const target = records.has(entry.target) ? entry.target : [...records.values()].find(record => record.native?.placeholder === entry.target)?.source; + if (!target) continue; + if (entry.isIntersecting) visibleMedia.add(target); + else visibleMedia.delete(target); + refresh(target); + } + }); + const attributes = new MutationObserver(mutations => { + for (const mutation of mutations) { + if (owned(mutation.target)) continue; + const record = nativeHosts.get(mutation.target); + if (record?.native && mutation.attributeName === 'style' && record.native.style === mutation.target.getAttribute('style')) continue; + if (record?.native) { + if (mutation.attributeName === 'class') record.native.placeholder.className = record.native.host.className; + if (mutation.attributeName === 'style') { + for (const [name, expected] of record.native.applied) { + const value = record.native.host.style.getPropertyValue(name); + if (value === expected) continue; + const priority = record.native.host.style.getPropertyPriority(name); + record.native.saved.set(name, [value, priority]); + if (value) record.native.placeholder.style.setProperty(name, value, priority); + else record.native.placeholder.style.removeProperty(name); + } + } + } + invalidateLayout(mutation.target); + } + }); + const blockerPaths = new Map(); + const addBlocker = element => { + if (blockers.has(element)) return; + blockers.add(element); + blockerPaths.set(element, watch(element)); + }; + const removeBlocker = element => { + if (element.isConnected || !blockers.delete(element)) return; + unwatch(blockerPaths.get(element)); + blockerPaths.delete(element); + }; + const layoutChanged = () => refresh(); + const scrollChanged = event => { + const target = event.target === document ? document.scrollingElement : event.target; + resetGeometry(); + const hasVisibleBlocker = [...blockers].some(element => !!visibleRect(element)); + for (const source of visibleMedia) { + if (!target?.contains(source)) continue; + const record = records.get(source); + if (record?.scrollLayers?.length && !hasVisibleBlocker) continue; + refresh(source); + } + }; + const invalidateLayout = target => { + for (const source of visibleMedia) { + const record = records.get(source); + const root = record?.watched.layoutRoot || source.parentElement; + if (root && (root.contains(target) || target.contains?.(root) || blockers.has(target))) refresh(source); + } + }; + window.addEventListener("resize", layoutChanged, { passive: true }); + document.addEventListener("scroll", scrollChanged, { passive: true, capture: true }); + document.addEventListener("transitionend", layoutChanged, true); + document.addEventListener("fullscreenchange", layoutChanged, true); + const mediaLoaded = event => { if (event.target.matches?.(mediaSelector)) { add(event.target); refresh(event.target); } }; + document.addEventListener("load", mediaLoaded, true); + resizeObserver.observe(document.body); + const changes = new MutationObserver(mutations => { + for (const mutation of mutations) { + if (owned(mutation.target)) continue; + invalidateLayout(mutation.target); + for (const node of mutation.removedNodes) { + if (node.nodeType !== 1 || owned(node)) continue; + visit(node, source => { + remove(source); + }, mediaSelector); + visit(node, element => { + if (element.isConnected) return; + raised.get(element)?.(); + raised.delete(element); + }, raisedSelector); + visit(node, removeBlocker, blockerSelector); + } + for (const node of mutation.addedNodes) { + if (node.nodeType !== 1 || owned(node)) continue; + visit(node, raise, raisedSelector); + visit(node, add, mediaSelector); + visit(node, addBlocker, blockerSelector); + } + } + }); + document.querySelectorAll(raisedSelector).forEach(raise); + document.querySelectorAll(blockerSelector).forEach(addBlocker); + document.querySelectorAll(mediaSelector).forEach(add); + changes.observe(document.body, { childList: true, subtree: true, characterData: true }); + window.__codexPlusImageOverlayCleanup = () => { + disposed = true; + changes.disconnect(); + window.removeEventListener("resize", layoutChanged); + document.removeEventListener("scroll", scrollChanged, true); + document.removeEventListener("transitionend", layoutChanged, true); + document.removeEventListener("fullscreenchange", layoutChanged, true); + document.removeEventListener("load", mediaLoaded, true); + cancelAnimationFrame(frame); + [...records.keys()].forEach(remove); + attributes.disconnect(); + resizeObserver.disconnect(); + intersectionObserver.disconnect(); + watched.clear(); + layoutRoots.clear(); + blockerPaths.clear(); + raised.forEach(restore => restore()); + raised.clear(); + style.remove(); + }; + } + function scheduleCodexPlusImageOverlay() { + window.__codexPlusImageOverlayReadyCleanup?.(); + window.__codexPlusImageOverlayReadyCleanup = null; if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", installCodexPlusImageOverlay, { once: true }); + const onReady = () => { + window.__codexPlusImageOverlayReadyCleanup = null; + installCodexPlusImageOverlay(); + }; + document.addEventListener("DOMContentLoaded", onReady, { once: true }); + window.__codexPlusImageOverlayReadyCleanup = () => + document.removeEventListener("DOMContentLoaded", onReady); return; } installCodexPlusImageOverlay(); - setTimeout(installCodexPlusImageOverlay, 250); } scheduleCodexPlusImageOverlay(); @@ -667,7 +1312,6 @@ conversationViewFooter: "[data-thread-scroll-footer]", }; const headerContextButtonClass = "border-token-border user-select-none no-drag cursor-interaction flex items-center gap-1 border whitespace-nowrap focus:outline-none disabled:cursor-not-allowed disabled:opacity-40 rounded-lg border-token-border text-token-button-tertiary-foreground bg-token-bg-fog enabled:hover:bg-token-list-hover-background data-[state=open]:bg-token-list-hover-background border h-token-button-composer px-2 py-0 text-base leading-[18px]"; - /** * 拓展注册中心。 * diff --git a/assets/inject/renderer-inject/00-prelude.js b/assets/inject/renderer-inject/00-prelude.js index 217abc0c4..aa8bc5910 100644 --- a/assets/inject/renderer-inject/00-prelude.js +++ b/assets/inject/renderer-inject/00-prelude.js @@ -573,6 +573,8 @@ window.__codexThreadScrollRestoreRevision = (window.__codexThreadScrollRestoreRevision || 0) + 1; function installCodexPlusImageOverlay() { + window.__codexPlusImageOverlayCleanup?.(); + window.__codexPlusImageOverlayCleanup = null; const config = window.__CODEX_PLUS_IMAGE_OVERLAY__ || {}; const canQueryById = typeof document?.getElementById === "function"; const existing = canQueryById ? document.getElementById(codexPlusImageOverlayId) : null; @@ -604,6 +606,7 @@ if (existing && existing !== overlay) existing.remove(); overlay.id = codexPlusImageOverlayId; overlay.setAttribute("aria-hidden", "true"); + overlay.setAttribute("data-codex-plus-ext", "image-overlay"); Object.assign(overlay.style, { position: "fixed", inset: "0", @@ -619,6 +622,7 @@ userSelect: "none", }); if (!overlay.parentElement) root.appendChild(overlay); + installCodexPlusImageOverlayForeground(); sendCodexPlusDiagnostic("image_overlay_installed", { opacity, fitMode, @@ -626,13 +630,654 @@ }); } + function installCodexPlusImageOverlayForeground() { + // Keep upstream's single tint layer intact. Only actual media receives a + // foreground plane; geometry is refreshed on bounded layout events. + const mediaSelector = "img, video, canvas"; + const raisedSelector = '[role="dialog"]:has([data-testid="image-preview-dismiss-area"]), [data-browser-sidebar-webview]'; + const blockerSelector = '[role="menu"], [role="listbox"], [role="tooltip"], [role="dialog"]'; + const records = new Map(); + const nativeHosts = new Map(); + const raised = new Map(); + const owned = node => node?.closest?.('[data-codex-plus-ext="image-overlay"]'); + const blockers = new Set(); + const dirty = new Set(); + const visibleMedia = new Set(); + const watched = new Map(); + const layoutRoots = new Map(); + let frame = 0; + let reobserveQueued = false; + let disposed = false; + let frameStyles = new Map(); + let frameRects = new Map(); + const readStyle = element => { + if (!frameStyles.has(element)) frameStyles.set(element, getComputedStyle(element)); + return frameStyles.get(element); + }; + const readRect = element => { + if (!frameRects.has(element)) frameRects.set(element, element.getBoundingClientRect()); + return frameRects.get(element); + }; + const resetGeometry = () => { frameStyles = new Map(); frameRects = new Map(); }; + const style = document.createElement("style"); + style.setAttribute("data-codex-plus-ext", "image-overlay"); + style.textContent = ` + .codex-plus-media-plane { + position: fixed !important; inset: 0 !important; margin: 0 !important; + padding: 0 !important; border: 0 !important; background: transparent !important; + pointer-events: none !important; user-select: none !important; + z-index: 2147483647 !important; display: block !important; + width: 100vw; height: 100vh; + } + .codex-plus-media-copy { + position: fixed !important; inset: auto; + left: 0; top: 0; width: 0; height: 0; + margin: 0 !important; padding: 0 !important; border: 0 !important; + max-width: none !important; max-height: none !important; + pointer-events: none !important; + } + .codex-plus-media-scrollport, .codex-plus-media-scroll-offset { + position: absolute !important; margin: 0 !important; padding: 0 !important; + border: 0 !important; background: transparent !important; + pointer-events: none !important; display: block !important; + } + .codex-plus-media-scroll-offset { inset: 0; } + .codex-plus-media-scrollport .codex-plus-media-copy { position: absolute !important; } + .codex-plus-media-plane[hidden] { display: none !important; } + [data-codex-plus-native-media]::backdrop { display: none !important; } + `; + document.documentElement.appendChild(style); + const preserveStyle = (element, name, value) => { + const previous = element.style.getPropertyValue(name); + const priority = element.style.getPropertyPriority(name); + element.style.setProperty(name, value, "important"); + const applied = element.style.getPropertyValue(name); + return () => { + if (element.style.getPropertyValue(name) !== applied || + element.style.getPropertyPriority(name) !== "important") return; + if (previous) element.style.setProperty(name, previous, priority); + else element.style.removeProperty(name); + }; + }; + const stopStream = record => { + if (record.player) { + if (record.canvasFrame != null) record.player.cancelVideoFrameCallback(record.canvasFrame); + record.player.pause(); + record.player.srcObject = null; + } + if (record.ownsStream) record.stream?.getTracks().forEach(track => track.stop()); + record.stream = null; + record.ownsStream = false; + }; + const display = record => { + if (disposed || records.get(record.source) !== record || !record.plane.isConnected) return; + const visible = record.visible && record.inViewport && !record.blocked; + if (record.native) { + record.plane.hidden = true; + if (document.fullscreenElement?.contains(record.source)) return; + if (visible && !record.native.host.matches(':popover-open')) record.native.host.showPopover(); + else if (!visible && record.native.host.matches(':popover-open')) record.native.host.hidePopover(); + return; + } + if (record.source.tagName === 'VIDEO' && record.source.controls) { + // Unknown compound layouts keep their usable native player. Never + // conceal its controls with a frame copy or reparent React-owned DOM. + record.plane.hidden = true; + return; + } + if (record.nextVideoFrame) { + if (visible && record.videoFrame == null) record.videoFrame = record.source.requestVideoFrameCallback(record.nextVideoFrame); + else if (!visible && record.videoFrame != null) { + record.source.cancelVideoFrameCallback(record.videoFrame); + record.videoFrame = null; + } + } + if (!visible) { + record.plane.hidden = true; + if (record.stream) stopStream(record); + return; + } + if (record.source.tagName === "IMG" && (!record.copy.complete || !record.copy.naturalWidth)) { + record.plane.hidden = true; + return; + } + if (record.source.tagName === "CANVAS" && !record.stream) { + try { + const { source, copy } = record; + if (copy.width !== source.width) copy.width = source.width; + if (copy.height !== source.height) copy.height = source.height; + copy.getContext("2d").drawImage(source, 0, 0); + record.stream = record.source.captureStream?.(15); + record.ownsStream = true; + if (record.stream) { + const player = record.player || (record.player = document.createElement("video")); + player.muted = true; + player.srcObject = record.stream; + const paint = () => { + if (disposed || !record.stream || !records.has(source)) return; + if (player.readyState >= 2) { + if (copy.width !== source.width) copy.width = source.width; + if (copy.height !== source.height) copy.height = source.height; + copy.getContext("2d").drawImage(player, 0, 0, copy.width, copy.height); + } + record.canvasFrame = player.requestVideoFrameCallback(paint); + }; + record.canvasFrame = player.requestVideoFrameCallback(paint); + player.play().catch(() => {}); + } + } catch (error) { + sendCodexPlusDiagnostic("image_overlay_video_error", { message: String(error?.message || error) }); + stopStream(record); + record.plane.hidden = true; + return; + } + } + record.plane.hidden = false; + }; + const visibleRect = element => { + if (!element.isConnected || element.hidden || element.getAttribute("data-state") === "closed") return null; + if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return null; + const rect = readRect(element); + return rect.width > 0 && rect.height > 0 ? rect : null; + }; + const intersects = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; + const drawVideo = record => { + const { source, copy } = record; + if (source.readyState < 2 || !record.visible || !record.inViewport || record.blocked) return; + const width = source.videoWidth; + const height = source.videoHeight; + if (!width || !height) return; + if (copy.width !== width) copy.width = width; + if (copy.height !== height) copy.height = height; + copy.getContext("2d").drawImage(source, 0, 0, width, height); + }; + const nativeVideo = record => { + const source = record.source; + const host = source.parentElement; + if (!source.controls || !host || host.id === 'root' || host === document.body || + host.childElementCount !== 1 || host.textContent.trim() || host.hasAttribute('popover') || !host.showPopover) return; + const placeholder = document.createElement(host.tagName); + for (const name of ['class', 'style']) { + if (host.hasAttribute(name)) placeholder.setAttribute(name, host.getAttribute(name)); + } + const spacer = document.createElement('video'); + for (const name of ['class', 'style', 'width', 'height']) { + if (source.hasAttribute(name)) spacer.setAttribute(name, source.getAttribute(name)); + } + const css = getComputedStyle(source); + spacer.style.width = css.width; + spacer.style.height = css.height; + spacer.style.maxWidth = '100%'; + placeholder.append(spacer); + placeholder.setAttribute('data-codex-plus-ext', 'image-overlay'); + placeholder.setAttribute('aria-hidden', 'true'); + placeholder.style.setProperty('visibility', 'hidden', 'important'); + placeholder.style.setProperty('pointer-events', 'none', 'important'); + host.before(placeholder); + record.native = { host, placeholder, spacer, saved: new Map(), applied: new Map() }; + nativeHosts.set(host, record); + host.setAttribute('data-codex-plus-native-media', ''); + host.setAttribute('popover', 'manual'); + resizeObserver.observe(placeholder); + }; + const setNativeStyle = (record, name, value) => { + const { native } = record; + const host = native.host; + if (!native.saved.has(name)) native.saved.set(name, [host.style.getPropertyValue(name), host.style.getPropertyPriority(name)]); + host.style.setProperty(name, value, 'important'); + native.applied.set(name, host.style.getPropertyValue(name)); + native.style = host.getAttribute('style'); + }; + const clearScrollLayers = record => { + for (const layer of record.scrollLayers || []) { + layer.xAnimation?.cancel(); + layer.yAnimation?.cancel(); + } + if (record.scrollLayers?.length) { + record.plane.appendChild(record.copy); + record.scrollLayers[0].port.remove(); + } + record.scrollLayers = []; + }; + const positionScrollCopy = (record, rect) => { + if (record.native || typeof ScrollTimeline !== 'function') return false; + if (/^(fixed|sticky)$/.test(readStyle(record.source).position)) { + clearScrollLayers(record); + return false; + } + const parents = []; + for (let node = record.source.parentElement; node; node = node.parentElement) { + const css = readStyle(node); + // Sticky motion is constrained by layout, not a linear scroll offset. + if (css.position === 'sticky') { clearScrollLayers(record); return false; } + if (/^(auto|scroll|hidden|clip)$/.test(css.overflowX) || + /^(auto|scroll|hidden|clip)$/.test(css.overflowY) || node === document.scrollingElement) parents.unshift(node); + if (css.position === 'fixed') break; + } + if (!parents.length) { clearScrollLayers(record); return false; } + if (parents.length !== record.scrollLayers?.length || + parents.some((node, index) => record.scrollLayers[index].source !== node)) { + clearScrollLayers(record); + let container = record.plane; + for (const source of parents) { + const port = document.createElement('div'); + port.className = 'codex-plus-media-scrollport'; + const x = document.createElement('div'); + const y = document.createElement('div'); + x.className = y.className = 'codex-plus-media-scroll-offset'; + port.appendChild(x); x.appendChild(y); container.appendChild(port); + record.scrollLayers.push({ source, port, x, y }); + container = y; + } + container.appendChild(record.copy); + } + let originX = 0, originY = 0; + for (const layer of record.scrollLayers) { + const { source, port } = layer; + const css = readStyle(source); + const root = source === document.scrollingElement; + const zoom = source.currentCSSZoom || 1; + const box = root ? { left: 0, top: 0 } : readRect(source); + const left = box.left + (root ? 0 : source.clientLeft * zoom); + const top = box.top + (root ? 0 : source.clientTop * zoom); + const width = root ? innerWidth : source.clientWidth * zoom; + const height = root ? innerHeight : source.clientHeight * zoom; + const clipsX = root || /^(auto|scroll|hidden|clip)$/.test(css.overflowX); + const clipsY = root || /^(auto|scroll|hidden|clip)$/.test(css.overflowY); + Object.assign(port.style, { left: `${left - originX}px`, top: `${top - originY}px`, + width: `${width}px`, height: `${height}px`, + clipPath: `inset(${clipsY ? 0 : -100000}px ${clipsX ? 0 : -100000}px)` }); + for (const axis of ['x', 'y']) { + const horizontal = axis === 'x'; + const extent = Math.max(0, horizontal ? source.scrollWidth - source.clientWidth : source.scrollHeight - source.clientHeight); + const reverse = horizontal ? css.direction === 'rtl' : css.display.includes('flex') && css.flexDirection === 'column-reverse'; + const distance = (reverse ? 1 : -1) * extent * zoom; + const key = axis + 'Animation'; + const enabled = extent > 0 && css[horizontal ? 'overflowX' : 'overflowY'] !== 'clip'; + if (layer[axis + 'Distance'] === distance && layer[axis + 'Enabled'] === enabled) continue; + layer[key]?.cancel(); + layer[key] = null; + layer[axis + 'Distance'] = distance; + layer[axis + 'Enabled'] = enabled; + // The compositor samples the source scroll position even when the + // main thread is busy. Do not chase wheel events with fixed left/top. + if (enabled) { + layer[key] = layer[axis].animate([ + { transform: 'translate' + axis.toUpperCase() + '(0px)' }, + { transform: 'translate' + axis.toUpperCase() + '(' + distance + 'px)' }, + ], { duration: 1, fill: 'both', timeline: new ScrollTimeline({ source, axis }) }); + } + } + originX = left - source.scrollLeft * zoom; + originY = top - source.scrollTop * zoom; + } + record.copy.style.left = `${rect.left - originX}px`; + record.copy.style.top = `${rect.top - originY}px`; + return true; + }; + const refresh = source => { + if (disposed) return; + if (source) dirty.add(source); + else visibleMedia.forEach(element => dirty.add(element)); + if (frame || !dirty.size) return; + frame = requestAnimationFrame(() => { + frame = 0; + resetGeometry(); + const pending = [...dirty]; + dirty.clear(); + pending.forEach(update); + }); + }; + const update = source => { + const record = records.get(source); + if (!record) return; + if (record.native && document.fullscreenElement?.contains(source)) return; + const geometry = record.native?.placeholder || source; + const css = readStyle(geometry); + const copy = record.copy; + copy.style.objectFit = css.objectFit; + copy.style.objectPosition = css.objectPosition; + copy.style.borderRadius = css.borderRadius; + copy.style.backgroundColor = css.backgroundColor === "rgba(0, 0, 0, 0)" + ? "var(--color-surface, var(--color-token-bg-primary, Canvas))" : css.backgroundColor; + if (source.tagName === "IMG") { + const src = source.currentSrc || source.src; + if (copy.src !== src) copy.src = src; + } + const rect = readRect(geometry); + if (rect.bottom <= 0 || rect.right <= 0 || rect.left >= innerWidth || rect.top >= innerHeight) { + record.inViewport = false; + display(record); + return; + } + record.visible = Math.max(rect.width, rect.height) >= 48 && (record.native + ? geometry.isConnected && !geometry.closest('[hidden]') && rect.width > 0 && rect.height > 0 + : !!visibleRect(source)); + if (record.native) { + for (let node = source; node; node = node.parentElement) { + const native = readStyle(node); + if (native.visibility !== 'visible' || (native.display === 'none' && node !== record.native.host) || Number(native.opacity) === 0 || + node.hidden || node.inert || node.getAttribute('aria-hidden') === 'true') record.visible = false; + } + } + if (source.tagName === "VIDEO" && !record.native && source.readyState < 2) record.visible = false; + record.blocked = [...blockers].some(element => { + if (element.contains(source)) return false; + const box = visibleRect(element); + return box && intersects(rect, box); + }); + const clip = { left: Math.max(0, rect.left), top: Math.max(0, rect.top), + right: Math.min(innerWidth, rect.right), bottom: Math.min(innerHeight, rect.bottom) }; + for (let parent = geometry.parentElement; parent && parent !== document.documentElement; parent = parent.parentElement) { + const native = readStyle(parent); + const clipsX = /^(auto|scroll|hidden|clip)$/.test(native.overflowX); + const clipsY = /^(auto|scroll|hidden|clip)$/.test(native.overflowY); + if (!clipsX && !clipsY) continue; + const box = readRect(parent); + if (clipsX) { clip.left = Math.max(clip.left, box.left); clip.right = Math.min(clip.right, box.right); } + if (clipsY) { clip.top = Math.max(clip.top, box.top); clip.bottom = Math.min(clip.bottom, box.bottom); } + } + const thread = source.closest('.thread-scroll-container'); + if (thread) { + const footer = document.querySelector('[data-thread-scroll-footer]'); + const box = footer && visibleRect(footer); + if (box && intersects(rect, box)) clip.bottom = Math.min(clip.bottom, box.top); + } + record.inViewport = rect.width > 0 && rect.height > 0 && + clip.right > clip.left && clip.bottom > clip.top; + record.plane.style.clipPath = `inset(${Math.max(0, clip.top)}px ${Math.max(0, innerWidth - clip.right)}px ${Math.max(0, innerHeight - clip.bottom)}px ${Math.max(0, clip.left)}px)`; + Object.assign(copy.style, { + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + }); + if (positionScrollCopy(record, rect)) { + // Only the media scrollports clip. The complete decorative overlay + // stays untouched; its copy must not retain a stale source-rect clip. + const footer = thread && document.querySelector('[data-thread-scroll-footer]'); + const footerBox = footer && visibleRect(footer); + record.plane.style.clipPath = `inset(0px 0px ${footerBox ? Math.max(0, innerHeight - footerBox.top) : 0}px 0px)`; + } + if (record.native) { + const zoom = geometry.currentCSSZoom || 1; + for (const [name, value] of Object.entries({position:'fixed', margin:'0', inset:'auto', + left:`${rect.left / zoom}px`, top:`${rect.top / zoom}px`, width:`${rect.width / zoom}px`, height:`${rect.height / zoom}px`, + 'max-width':'none', 'max-height':'none', 'box-sizing':'border-box', padding:css.padding, border:css.border, 'background-color':css.backgroundColor, + 'clip-path':`inset(${Math.max(0, clip.top - rect.top) / zoom}px ${Math.max(0, rect.right - clip.right) / zoom}px ${Math.max(0, rect.bottom - clip.bottom) / zoom}px ${Math.max(0, clip.left - rect.left) / zoom}px)`})) setNativeStyle(record, name, value); + } else if (source.tagName === "VIDEO") drawVideo(record); + display(record); + }; + const watch = element => { + const path = []; + const layoutRoot = element.closest('.thread-scroll-container, [role="tabpanel"], [data-testid="image-preview-dismiss-area"]'); + if (layoutRoot) layoutRoots.set(layoutRoot, (layoutRoots.get(layoutRoot) || 0) + 1); + for (let node = element; node; node = node.parentElement) { + path.push(node); + watched.set(node, (watched.get(node) || 0) + 1); + if (watched.get(node) !== 1 && node !== layoutRoot) continue; + attributes.observe(node, { attributes: true, subtree: layoutRoots.has(node), + attributeFilter: ["src", "srcset", "sizes", "class", "style", "hidden", "data-state", "aria-hidden"] }); + resizeObserver.observe(node); + } + return { path, layoutRoot }; + }; + const unwatch = ({ path, layoutRoot }) => { + if (layoutRoot) { + const count = layoutRoots.get(layoutRoot) - 1; + if (count) layoutRoots.set(layoutRoot, count); + else layoutRoots.delete(layoutRoot); + } + for (const node of path) { + const count = watched.get(node) - 1; + if (count) watched.set(node, count); + else { watched.delete(node); resizeObserver.unobserve(node); } + } + if (disposed || reobserveQueued) return; + reobserveQueued = true; + queueMicrotask(() => { + reobserveQueued = false; + if (disposed) return; + attributes.disconnect(); + watched.forEach((_, node) => attributes.observe(node, { attributes: true, subtree: layoutRoots.has(node), + attributeFilter: ["src", "srcset", "sizes", "class", "style", "hidden", "data-state", "aria-hidden"] })); + }); + }; + const add = source => { + if (records.has(source) || owned(source) || !source.isConnected || + [...raised.keys()].some(element => element.contains(source)) || source.getAttribute("aria-hidden") === "true") return; + const css = getComputedStyle(source); + if (Math.max(parseFloat(css.width) || 0, parseFloat(css.height) || 0, source.naturalWidth || 0, source.naturalHeight || 0) < 48) return; + const plane = document.createElement("div"); + plane.className = "codex-plus-media-plane"; + plane.setAttribute("data-codex-plus-ext", "image-overlay"); + plane.setAttribute("aria-hidden", "true"); + plane.hidden = true; + const copy = document.createElement(source.tagName === "IMG" ? "img" : "canvas"); + copy.className = "codex-plus-media-copy"; + copy.setAttribute("aria-hidden", "true"); + copy.draggable = false; + const record = { + source, plane, copy, stream: null, ownsStream: false, visible: true, + inViewport: false, listeners: [], videoFrame: null, + }; + copy.addEventListener("load", () => display(record)); + plane.appendChild(copy); + document.documentElement.appendChild(plane); + records.set(source, record); + if (source.tagName === 'VIDEO') nativeVideo(record); + visibleMedia.add(source); + intersectionObserver.observe(record.native?.placeholder || source); + for (const event of ["load", "loadeddata", "loadedmetadata", "canplay", "play", "playing", "pause", "seeked", "emptied"]) { + const listener = () => refresh(source); + source.addEventListener(event, listener); + record.listeners.push([event, listener]); + } + record.watched = watch(source); + if (source.tagName === "VIDEO" && !source.controls && !record.native && source.requestVideoFrameCallback) { + const nextFrame = () => { + if (disposed || !records.has(source)) return; + record.videoFrame = null; + if (record.visible && record.inViewport && !record.blocked) { + drawVideo(record); + record.videoFrame = source.requestVideoFrameCallback(nextFrame); + } + }; + record.nextVideoFrame = nextFrame; + record.videoFrame = source.requestVideoFrameCallback(nextFrame); + } + resetGeometry(); + update(source); + }; + const remove = source => { + const record = records.get(source); + if (!record) return; + records.delete(source); + visibleMedia.delete(source); + intersectionObserver.unobserve(record.native?.placeholder || source); + dirty.delete(source); + for (const [event, listener, target = source] of record.listeners) target.removeEventListener(event, listener); + unwatch(record.watched); + if (record.videoFrame !== null) source.cancelVideoFrameCallback(record.videoFrame); + stopStream(record); + clearScrollLayers(record); + if (record.native) { + const host = record.native.host; + if (host.matches(':popover-open')) host.hidePopover(); + host.removeAttribute('popover'); + host.removeAttribute('data-codex-plus-native-media'); + for (const [name, [value, priority]] of record.native.saved) { + if (host.style.getPropertyValue(name) !== record.native.applied.get(name)) continue; + if (value) host.style.setProperty(name, value, priority); + else host.style.removeProperty(name); + } + nativeHosts.delete(host); + resizeObserver.unobserve(record.native.placeholder); + record.native.placeholder.remove(); + } + record.plane.remove(); + }; + const raise = element => { + if (raised.has(element) || owned(element)) return; + let target = element; + for (let parent = element.parentElement; parent && parent !== document.body; parent = parent.parentElement) { + const css = getComputedStyle(parent); + if (element.matches('[data-browser-sidebar-webview]')) { + if (parent.id === "root" || parent === document.documentElement) return; + if (css.position !== "static") target = parent; + continue; + } + const stacking = css.transform !== "none" || css.isolation === "isolate" || + css.zIndex !== "auto" || Number(css.opacity) < 1 || /paint|layout/.test(css.contain) || + css.filter !== "none" || css.perspective !== "none" || /^(fixed|sticky)$/.test(css.position); + if (!stacking) continue; + // A native portal wrapper can be lifted with its child; an app surface + // containing unrelated UI cannot. Its media keeps the copy fallback. + if (parent.id === "root" || parent.childElementCount !== 1) return; + target = parent; + } + if (getComputedStyle(target).position === "static") return; + if ([...raised.keys()].some(node => node.contains(element))) return; + raised.set(element, preserveStyle(target, "z-index", "2147483647")); + element.querySelectorAll(mediaSelector).forEach(remove); + }; + const visit = (node, callback, selector) => { + if (node.nodeType !== 1 || owned(node)) return; + if (node.matches(selector)) callback(node); + if (node.childElementCount) node.querySelectorAll(selector).forEach(callback); + }; + const resizeObserver = new ResizeObserver(() => refresh()); + const intersectionObserver = new IntersectionObserver(entries => { + for (const entry of entries) { + const target = records.has(entry.target) ? entry.target : [...records.values()].find(record => record.native?.placeholder === entry.target)?.source; + if (!target) continue; + if (entry.isIntersecting) visibleMedia.add(target); + else visibleMedia.delete(target); + refresh(target); + } + }); + const attributes = new MutationObserver(mutations => { + for (const mutation of mutations) { + if (owned(mutation.target)) continue; + const record = nativeHosts.get(mutation.target); + if (record?.native && mutation.attributeName === 'style' && record.native.style === mutation.target.getAttribute('style')) continue; + if (record?.native) { + if (mutation.attributeName === 'class') record.native.placeholder.className = record.native.host.className; + if (mutation.attributeName === 'style') { + for (const [name, expected] of record.native.applied) { + const value = record.native.host.style.getPropertyValue(name); + if (value === expected) continue; + const priority = record.native.host.style.getPropertyPriority(name); + record.native.saved.set(name, [value, priority]); + if (value) record.native.placeholder.style.setProperty(name, value, priority); + else record.native.placeholder.style.removeProperty(name); + } + } + } + invalidateLayout(mutation.target); + } + }); + const blockerPaths = new Map(); + const addBlocker = element => { + if (blockers.has(element)) return; + blockers.add(element); + blockerPaths.set(element, watch(element)); + }; + const removeBlocker = element => { + if (element.isConnected || !blockers.delete(element)) return; + unwatch(blockerPaths.get(element)); + blockerPaths.delete(element); + }; + const layoutChanged = () => refresh(); + const scrollChanged = event => { + const target = event.target === document ? document.scrollingElement : event.target; + resetGeometry(); + const hasVisibleBlocker = [...blockers].some(element => !!visibleRect(element)); + for (const source of visibleMedia) { + if (!target?.contains(source)) continue; + const record = records.get(source); + if (record?.scrollLayers?.length && !hasVisibleBlocker) continue; + refresh(source); + } + }; + const invalidateLayout = target => { + for (const source of visibleMedia) { + const record = records.get(source); + const root = record?.watched.layoutRoot || source.parentElement; + if (root && (root.contains(target) || target.contains?.(root) || blockers.has(target))) refresh(source); + } + }; + window.addEventListener("resize", layoutChanged, { passive: true }); + document.addEventListener("scroll", scrollChanged, { passive: true, capture: true }); + document.addEventListener("transitionend", layoutChanged, true); + document.addEventListener("fullscreenchange", layoutChanged, true); + const mediaLoaded = event => { if (event.target.matches?.(mediaSelector)) { add(event.target); refresh(event.target); } }; + document.addEventListener("load", mediaLoaded, true); + resizeObserver.observe(document.body); + const changes = new MutationObserver(mutations => { + for (const mutation of mutations) { + if (owned(mutation.target)) continue; + invalidateLayout(mutation.target); + for (const node of mutation.removedNodes) { + if (node.nodeType !== 1 || owned(node)) continue; + visit(node, source => { + remove(source); + }, mediaSelector); + visit(node, element => { + if (element.isConnected) return; + raised.get(element)?.(); + raised.delete(element); + }, raisedSelector); + visit(node, removeBlocker, blockerSelector); + } + for (const node of mutation.addedNodes) { + if (node.nodeType !== 1 || owned(node)) continue; + visit(node, raise, raisedSelector); + visit(node, add, mediaSelector); + visit(node, addBlocker, blockerSelector); + } + } + }); + document.querySelectorAll(raisedSelector).forEach(raise); + document.querySelectorAll(blockerSelector).forEach(addBlocker); + document.querySelectorAll(mediaSelector).forEach(add); + changes.observe(document.body, { childList: true, subtree: true, characterData: true }); + window.__codexPlusImageOverlayCleanup = () => { + disposed = true; + changes.disconnect(); + window.removeEventListener("resize", layoutChanged); + document.removeEventListener("scroll", scrollChanged, true); + document.removeEventListener("transitionend", layoutChanged, true); + document.removeEventListener("fullscreenchange", layoutChanged, true); + document.removeEventListener("load", mediaLoaded, true); + cancelAnimationFrame(frame); + [...records.keys()].forEach(remove); + attributes.disconnect(); + resizeObserver.disconnect(); + intersectionObserver.disconnect(); + watched.clear(); + layoutRoots.clear(); + blockerPaths.clear(); + raised.forEach(restore => restore()); + raised.clear(); + style.remove(); + }; + } + function scheduleCodexPlusImageOverlay() { + window.__codexPlusImageOverlayReadyCleanup?.(); + window.__codexPlusImageOverlayReadyCleanup = null; if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", installCodexPlusImageOverlay, { once: true }); + const onReady = () => { + window.__codexPlusImageOverlayReadyCleanup = null; + installCodexPlusImageOverlay(); + }; + document.addEventListener("DOMContentLoaded", onReady, { once: true }); + window.__codexPlusImageOverlayReadyCleanup = () => + document.removeEventListener("DOMContentLoaded", onReady); return; } installCodexPlusImageOverlay(); - setTimeout(installCodexPlusImageOverlay, 250); } scheduleCodexPlusImageOverlay(); @@ -667,4 +1312,3 @@ conversationViewFooter: "[data-thread-scroll-footer]", }; const headerContextButtonClass = "border-token-border user-select-none no-drag cursor-interaction flex items-center gap-1 border whitespace-nowrap focus:outline-none disabled:cursor-not-allowed disabled:opacity-40 rounded-lg border-token-border text-token-button-tertiary-foreground bg-token-bg-fog enabled:hover:bg-token-list-hover-background data-[state=open]:bg-token-list-hover-background border h-token-button-composer px-2 py-0 text-base leading-[18px]"; - diff --git a/scripts/test-image-overlay.cjs b/scripts/test-image-overlay.cjs new file mode 100644 index 000000000..e15c534e7 --- /dev/null +++ b/scripts/test-image-overlay.cjs @@ -0,0 +1,290 @@ +// Optional browser regression: supply installed Playwright/sharp/pngjs via NODE_PATH. +const fs = require('node:fs'); +const path = require('node:path'); +const cp = require('node:child_process'); +const assert = require('node:assert/strict'); +const { chromium } = require('playwright'); +const sharp = require('sharp'); +const { PNG } = require('pngjs'); +const root = path.resolve(__dirname, '..'); +const output = process.env.CODEX_OVERLAY_TEST_OUTPUT; +const ref = process.env.CODEX_OVERLAY_TEST_REF; +const nativeDirectory = process.env.CODEX_OVERLAY_NATIVE_CSS_DIR; +const nativeCSS = nativeDirectory ? fs.readdirSync(nativeDirectory).filter(name => /^app-(initial|primary|shared)-.*\.css$/.test(name)) + .map(name => fs.readFileSync(path.join(nativeDirectory,name),'utf8')).join('\n') : ''; +const source = ref ? cp.execFileSync('git', ['show', `${ref}:assets/inject/renderer-inject.js`], {cwd:root,encoding:'utf8',maxBuffer:8e6}) + : fs.readFileSync(path.join(root, 'assets/inject/renderer-inject.js'), 'utf8'); +const extract = name => { + const match = source.match(new RegExp(`^ function ${name}\\([^]*?^ \\}`, 'm')); + assert.ok(match, name); return match[0]; +}; +const install = `const codexPlusImageOverlayId='codex-plus-image-overlay'; + const sendCodexPlusDiagnostic=()=>{}; + ${extract('installCodexPlusImageOverlayForeground')} + ${extract('installCodexPlusImageOverlay')} + window.installOverlay=installCodexPlusImageOverlay; installOverlay();`; +async function bitmap(r,g,b,format='png',alpha=1) { + return `data:image/${format};base64,` + (await sharp({create:{width:80,height:80,channels:4,background:{r,g,b,alpha}}})[format]().toBuffer()).toString('base64'); +} +async function main() { + const red=await bitmap(220,40,50), green=await bitmap(30,180,60,'gif'), wall=await bitmap(40,80,200),clear=await bitmap(0,0,0,'png',0); + const browser=await chromium.launch({channel:'msedge',headless:true}); + const results=[]; + try { + for(const width of (process.env.CODEX_OVERLAY_QUICK ? [1088] : [480,1088,1727])) for(const zoom of (process.env.CODEX_OVERLAY_QUICK ? [1] : [.8,1,1.25])) { + const page=await browser.newPage({viewport:{width,height:900},deviceScaleFactor:1}); + const errors=[];page.on('pageerror',e=>errors.push(e.message)); + let maxWheelDrift=0,maxNestedDrift=0; + await page.route('**/*',route=>route.request().url().startsWith('data:')?route.continue():route.abort()); + await page.setContent(`
Header
+
+ +
+
+ +
+
+ `); + await page.evaluate(({wall})=>{ + window.__CODEX_PLUS_IMAGE_OVERLAY__={enabled:true,dataUrl:wall,opacity:.2,fitMode:'stretch'}; + window.feed=document.createElement('canvas');feed.width=80;feed.height=80; + window.paint=(color)=>{feed.getContext('2d').fillStyle=color;feed.getContext('2d').fillRect(0,0,80,80)}; + paint('rgb(220,40,50)');window.stream=feed.captureStream(15);document.querySelector('#video').srcObject=stream; + document.querySelector('#canvas').getContext('2d').fillStyle='rgb(220,40,50)';document.querySelector('#canvas').getContext('2d').fillRect(0,0,80,80); + window.paintTimer=setInterval(()=>{feed.getContext('2d').fillRect(0,0,80,80)},70); + window.controlledSource=document.querySelector('#controlled');controlledSource.srcObject=stream; + return Promise.all([document.querySelector('#video').play(),controlledSource.play()]); + },{wall}); + const geometryBefore=await page.locator('#controlled').boundingBox(); + const neighborBefore=await page.locator('#clip').boundingBox(); + await page.locator('textarea').focus(); + await page.evaluate(()=>{ + window.rafCalls=0;window.deferredOverlayFrames=[];const raf=window.requestAnimationFrame; + window.requestAnimationFrame=cb=>{window.rafCalls++;return raf(time=>{ + if(window.deferOverlayFrames)window.deferredOverlayFrames.push(cb);else cb(time); + })}; + window.resumeOverlayFrames=()=>{window.deferOverlayFrames=false;for(const cb of window.deferredOverlayFrames.splice(0))raf(cb)}; + }); + await page.evaluate(install); + assert.equal(await page.locator('textarea').evaluate(e=>e===document.activeElement),true,'promotion does not steal focus'); + const screenshot=async()=>PNG.sync.read(await page.screenshot()); + const pixel=(image,x,y)=>Array.from(image.data.subarray((Math.floor(y)*image.width+Math.floor(x))*4,(Math.floor(y)*image.width+Math.floor(x))*4+3)); + const at=async(selector,fx=.5,fy=.5)=>page.locator(selector).evaluate((e,{fx,fy})=>{const r=e.getBoundingClientRect();return [r.x+r.width*fx,r.y+r.height*fy]},{fx,fy}); + const near=(actual,expected)=>actual.every((v,i)=>Math.abs(v-expected[i])<=3); + const expectColor=async(selector,expected,label)=>{ + let actual; + for(let i=0;i<25;i++){const [x,y]=await at(selector);actual=pixel(await screenshot(),x,y);if(near(actual,expected))return;await page.waitForTimeout(40)} + assert.fail(`${width}/${zoom} ${label}: expected ${expected}, got ${actual}`); + }; + await expectColor('#photo',[220,40,50],'hidden menus must not suppress image'); + await expectColor('#gif',[30,180,60],'GIF'); + await expectColor('#clear',[24,24,24],'transparent image backplane'); + await expectColor('#video',[220,40,50],'video first frame'); + await expectColor('#canvas',[220,40,50],'canvas first frame'); + // Wheel scrolling is compositor-driven. Waiting for layout to settle hid + // the old fixed-copy lag, so deliberately defer only injected RAF work. + await page.evaluate(()=>{window.deferOverlayFrames=true}); + const wheelBox=await page.locator('#scroller').boundingBox(); + await page.mouse.move(wheelBox.x+wheelBox.width-20,wheelBox.y+wheelBox.height/2); + for(const delta of [14,14,-10,-18]) { + await page.mouse.wheel(0,delta); + await page.waitForTimeout(90); + const drift=await page.evaluate(()=>['photo','gif','clear','video','canvas'].map((id,index)=>{ + const source=document.getElementById(id).getBoundingClientRect(); + const copy=document.querySelectorAll('.codex-plus-media-copy')[index].getBoundingClientRect(); + return {id,dx:Math.abs(source.x-copy.x),dy:Math.abs(source.y-copy.y)}; + })); + assert.ok(drift.every(x=>x.dx<1 && x.dy<1),'wheel motion must not wait for injected layout: '+JSON.stringify(drift)); + maxWheelDrift=Math.max(maxWheelDrift,...drift.flatMap(x=>[x.dx,x.dy])); + } + await page.evaluate(()=>{document.querySelector('#scroller').scrollTop=0;window.resumeOverlayFrames()}); + await page.waitForTimeout(80); + assert.equal(await page.locator('#controlled').evaluate(e=>e===window.controlledSource && e.controls && e.srcObject===window.stream),true,'original video and controls retained'); + const controlGeometry=await page.locator('#controlled').boundingBox(); + const placeholderGeometry=await page.locator('#row > [data-codex-plus-ext="image-overlay"]').boundingBox(); + for(const key of ['x','y','width','height'])assert.ok(Math.abs(controlGeometry[key]-placeholderGeometry[key])<1,`native controls geometry ${key}: ${controlGeometry[key]} vs ${placeholderGeometry[key]}`); + for(const key of ['x','y','width','height'])assert.ok(Math.abs(controlGeometry[key]-geometryBefore[key])<1,`pre-injection geometry ${key}`); + assert.deepEqual(await page.locator('#clip').boundingBox(),neighborBefore,'adjacent content layout preserved'); + await page.locator('#controlled').hover(); + const controlShot=await screenshot(); + let controlPixels=0; + for(let y=controlGeometry.y+controlGeometry.height*.6;y100,'native playback controls are painted, not covered by a video-frame copy'); + await page.locator('#controlled').evaluate(e=>e.requestFullscreen()); + assert.equal(await page.evaluate(()=>document.fullscreenElement===controlledSource),true,'fullscreen uses the original video'); + await page.evaluate(()=>document.exitFullscreen()); + await page.waitForTimeout(80); + await page.evaluate(()=>document.querySelector('#row').style.opacity='0'); + await page.waitForTimeout(80); + assert.equal(await page.locator('#control-host').evaluate(e=>e.matches(':popover-open')),false,'hidden ancestor must hide promoted video'); + await page.evaluate(()=>document.querySelector('#row').style.opacity='1'); + await page.waitForTimeout(80); + assert.equal(await page.locator('#control-host').evaluate(e=>e.matches(':popover-open')),true,'visible ancestor restores promoted video'); + if(output&&width===1088&&zoom===1) await page.screenshot({path:path.join(output,'foreground-fixture.png')}); + const ordinaryPoints=[[10,200],[width-8,850],[70*zoom,430*zoom],[75*zoom,535*zoom]]; + const ordinaryBefore=await screenshot(); + const originalGeometry=await page.locator('#codex-plus-image-overlay').boundingBox(); + await page.evaluate(()=>{document.querySelector('#menu').hidden=false}); + const [mx,my]=await at('#menu'); + assert.ok(near(pixel(await screenshot(),mx,my),[119,119,119]),'open menu must paint over media'); + await page.evaluate(()=>{document.querySelector('#menu').hidden=true}); + await expectColor('#photo',[220,40,50],'hidden toggle restores image'); + await page.evaluate(()=>{document.querySelector('#spacer').style.height='35px'}); + await expectColor('#photo',[220,40,50],'position-only ancestor layout'); + await page.evaluate(()=>{document.querySelector('#scroller').scrollTop=60;document.querySelector('#side').scrollTop=45}); + await page.waitForTimeout(80); + const box=await page.locator('#scroller').boundingBox(); + const scrollShot=await screenshot(); + assert.ok(!near(pixel(scrollShot,box.x+25*zoom,box.y-4),[220,40,50]),'image must not escape scroll top'); + const clip=await page.locator('#clip').boundingBox(); + assert.ok(!near(pixel(scrollShot,clip.x+clip.width+5,clip.y+25*zoom),[220,40,50]),'image must not escape horizontal clip'); + await page.evaluate(()=>{document.querySelector('#scroller').scrollTop=0;document.querySelector('#spacer').style.height='0px'}); + await expectColor('#photo',[220,40,50],'scroll back'); + await page.evaluate(({red})=>{ + const nested=document.createElement('div');nested.id='nested'; + nested.style.cssText='width:160px;height:140px;overflow:auto;border:3px solid white;position:relative'; + const content=document.createElement('div');content.id='nested-content'; + content.style.cssText='width:500px;height:500px;padding:32px'; + const img=new Image();img.id='nested-photo';img.src=red;img.style.cssText='width:80px;height:80px'; + content.append(img);nested.append(content);document.querySelector('#scroller').prepend(nested); + },{red}); + await expectColor('#nested-photo',[220,40,50],'nested initial'); + for(const rtl of [false,true]) { + await page.evaluate(rtl=>{document.querySelector('#nested').style.direction=rtl?'rtl':'ltr'},rtl); + await page.waitForTimeout(80); + await page.evaluate(()=>{window.deferOverlayFrames=true}); + for(const amount of [12,24,8,0]) { + await page.evaluate(({amount,rtl})=>{ + document.querySelector('#nested').scrollTop=amount; + document.querySelector('#nested').scrollLeft=rtl?-amount:amount; + document.querySelector('#scroller').scrollTop=amount/2; + },{amount,rtl}); + await page.waitForTimeout(60); + const delta=await page.evaluate(()=>{ + const source=document.querySelector('#nested-photo').getBoundingClientRect(); + const copy=[...document.querySelectorAll('.codex-plus-media-copy')].at(-1).getBoundingClientRect(); + return [Math.abs(source.x-copy.x),Math.abs(source.y-copy.y)]; + }); + assert.ok(delta.every(d=>d<1),'nested scroll alignment rtl='+rtl+': '+delta); + maxNestedDrift=Math.max(maxNestedDrift,...delta); + } + await page.evaluate(()=>window.resumeOverlayFrames()); + } + await page.evaluate(()=>{document.querySelector('#nested-content').style.height='730px';document.querySelector('#nested').style.direction='ltr'}); + await page.waitForTimeout(80); + await page.evaluate(()=>{document.querySelector('#nested').style.overflow='clip'}); + await page.waitForTimeout(80); + await page.evaluate(()=>{document.querySelector('#nested').style.overflow='auto'}); + await page.waitForTimeout(80); + await page.evaluate(()=>{window.deferOverlayFrames=true;document.querySelector('#nested').scrollTop=26}); + await page.waitForTimeout(80); + await expectColor('#nested-photo',[220,40,50],'changed scroll extent'); + await page.evaluate(()=>{window.resumeOverlayFrames();document.querySelector('#nested').remove();document.querySelector('#scroller').scrollTop=0}); + await page.waitForTimeout(80); + await page.evaluate(()=>{ + const photo=document.querySelector('#photo'),scale=photo.currentCSSZoom||1; + photo.style.cssText='position:fixed;left:'+(innerWidth-100)/scale+'px;top:'+220/scale+'px'; + }); + await expectColor('#photo',[220,40,50],'fixed media fallback'); + assert.equal(await page.locator('.codex-plus-media-copy').first().evaluate(e=>e.closest('.codex-plus-media-scrollport')),null,'fixed media must not follow ancestor scroll'); + await page.evaluate(()=>{document.querySelector('#scroller').scrollTop=40}); + await page.waitForTimeout(80); + await expectColor('#photo',[220,40,50],'fixed media after scroll'); + await page.evaluate(()=>{document.querySelector('#photo').removeAttribute('style');document.querySelector('#scroller').scrollTop=0}); + await page.waitForTimeout(80); + await page.evaluate(()=>paint('rgb(30,180,60)')); + await expectColor('#video',[30,180,60],'video frame update'); + await page.evaluate(()=>document.querySelector('#video').pause()); + await page.evaluate(()=>paint('rgb(220,40,50)')); + await page.waitForTimeout(140); + await expectColor('#video',[30,180,60],'paused video retains frame'); + await page.evaluate(()=>{document.querySelector('#video').srcObject=null;document.querySelector('#video').removeAttribute('src');document.querySelector('#video').load()}); + await page.waitForTimeout(80); + const emptyVideo=await at('#video'); + assert.ok(!near(pixel(await screenshot(),...emptyVideo),[30,180,60]),'empty video clears stale frame'); + await page.waitForTimeout(150); + const idleStart=await page.evaluate(()=>rafCalls); + await page.waitForTimeout(250); + assert.ok((await page.evaluate(()=>rafCalls))-idleStart<=2,'empty video must not keep scheduling layout frames'); + await page.evaluate(({red})=>{ + const modal=document.createElement('div');modal.id='root-modal';modal.setAttribute('role','dialog'); + modal.innerHTML='
'; + document.querySelector('#root').append(modal); + },{red}); + await expectColor('#root-modal img',[220,40,50],'preview beneath root is not excluded'); + await page.evaluate(()=>document.querySelector('#root-modal').remove()); + await expectColor('#photo',[220,40,50],'modal close'); + await page.evaluate(({red})=>{ + const modal=document.createElement('div');modal.id='root-modal';modal.setAttribute('role','dialog'); + modal.style.cssText='inset:auto;left:180px;top:220px;width:140px;height:140px'; + modal.innerHTML='
'; + document.querySelector('#shell').append(modal); + },{red}); + await expectColor('#root-modal img',[220,40,50],'isolated inline preview fallback'); + assert.equal(await page.locator('#shell').evaluate(e=>getComputedStyle(e).zIndex),'auto','ordinary shell must never be raised'); + await page.evaluate(()=>document.querySelector('#root-modal').remove()); + await page.evaluate(({red})=>{const img=new Image();img.id='dynamic';img.src=red;document.querySelector('#row').append(img)},{red}); + await expectColor('#dynamic',[220,40,50],'dynamic image'); + await page.evaluate(()=>document.querySelector('#dynamic').remove()); + if(width===1088&&zoom===1) { + const perf=await page.evaluate(async({red})=>{ + let reads=0;const original=Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect=function(){reads++;return original.call(this)}; + const wait=()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); + const bank=document.createElement('div');bank.style.cssText='position:absolute;top:3000px'; + for(let i=0;i<200;i++){const image=new Image();image.src=red;image.style.cssText='width:80px;height:80px';bank.append(image)} + document.body.append(bank);await wait();await wait(); + reads=0; + const text=document.querySelector('nav').firstChild; + for(let i=0;i<30;i++){text.data=String(i);await wait()} + const unrelatedReads=reads;reads=0; + const related=document.createTextNode('');document.querySelector('#tail').append(related); + for(let i=0;i<30;i++){related.data=String(i);await wait()} + const relatedReads=reads; + const start=performance.now();bank.remove();await wait();const cleanupMs=performance.now()-start; + related.remove();Element.prototype.getBoundingClientRect=original; + return {unrelatedReads,relatedReads,cleanupMs}; + },{red}); + assert.ok(perf.unrelatedReads<100,`unrelated streaming must not refresh media: ${JSON.stringify(perf)}`); + assert.ok(perf.relatedReads<2000,`shared geometry work bounded: ${JSON.stringify(perf)}`); + if(output)fs.writeFileSync(path.join(output,'performance.json'),JSON.stringify(perf,null,2)); + } + assert.deepEqual(await page.locator('#codex-plus-image-overlay').boundingBox(),originalGeometry,'complete overlay geometry'); + const finalShot=await screenshot(); + for(const [x,y] of ordinaryPoints)assert.deepEqual(pixel(finalShot,x,y),pixel(ordinaryBefore,x,y),'ordinary injected paint'); + // Ordinary surfaces must match the exact original upstream tint, not just the new implementation before/after. + await page.evaluate(()=>window.__codexPlusImageOverlayCleanup()); + const baseline=await screenshot(); + for(const [x,y] of ordinaryPoints)assert.deepEqual(pixel(finalShot,x,y),pixel(baseline,x,y),'ordinary upstream overlay equivalence'); + assert.equal(await page.locator('.codex-plus-media-plane').count(),0,'cleanup planes'); + assert.equal(await page.evaluate(()=>document.getAnimations().filter(a=>a.timeline instanceof ScrollTimeline).length),0,'cleanup scroll animations'); + assert.equal(await page.locator('#controlled').getAttribute('popover'),null,'native video restored'); + assert.equal(await page.locator('#row > [data-codex-plus-ext="image-overlay"]').count(),0,'placeholder removed'); + assert.deepEqual(await page.locator('#controlled').boundingBox(),geometryBefore,'native geometry restored on cleanup'); + assert.equal(await page.evaluate(()=>stream.getTracks().every(t=>t.readyState==='live')),true,'source stream preserved'); + for(let i=0;i<3;i++)await page.evaluate(()=>{installOverlay();window.__codexPlusImageOverlayCleanup()}); + assert.equal(await page.locator('.codex-plus-media-plane').count(),0,'repeated cleanup'); + await page.evaluate(()=>{clearInterval(paintTimer);stream.getTracks().forEach(t=>t.stop())}); + assert.deepEqual(errors,[],'page errors'); + results.push({width,zoom,passed:true,maxWheelDrift,maxNestedDrift}); + await page.close(); + console.log(`PASS ${width} zoom=${zoom}`); + } + if(output)fs.writeFileSync(path.join(output,'browser-verification.json'),JSON.stringify(results,null,2)); + }finally{await browser.close()} +} +main().catch(e=>{console.error(e);process.exitCode=1});