diff --git a/src/renderer/canvas/Canvas.tsx b/src/renderer/canvas/Canvas.tsx index 1bcaf827..0d503ff3 100644 --- a/src/renderer/canvas/Canvas.tsx +++ b/src/renderer/canvas/Canvas.tsx @@ -22,6 +22,7 @@ import { isWorktreePanelType, type WorktreePanelType } from '../../shared/panels import { openFileAsPanel } from '../lib/fs/fileRouting' import { setPendingReveal } from '../lib/editor/editorReveal' import { CanvasTopOverlayContext } from './CanvasTopOverlayContext' +import { syncBrowserSurfaces } from '../panels/browserSurfaceRegistry' // Module-level style injection — shared across all Canvas instances let canvasStyleInjected = false @@ -230,6 +231,10 @@ const Canvas: React.FC = ({ children, overlayChildren, onCreateAtPo el.style.transform = transform el.style.setProperty('--zoom', String(zoom)) } + // Browser and T3 guests live in a fixed host outside the transformed + // world. Realign them in this task so they paint with their canvas nodes; + // MutationObserver + rAF would leave them one frame behind while panning. + syncBrowserSurfaces() // Promote the world to its own GPU layer for the duration of the gesture so // pan/zoom stays smooth, then de-promote once it settles. While promoted, @@ -687,6 +692,7 @@ const Canvas: React.FC = ({ children, overlayChildren, onCreateAtPo {/* World div: transformed to implement pan/zoom */}
= ({ children, overlayChildren, onCreateAtPo >
= ({ return (
({ })) import BackgroundBrowserHost from './BackgroundBrowserHost' -import { BrowserPanelSurfaceSlot, PersistentBrowserHostContext } from './browserSurfaceRegistry' +import { BrowserPanelSurfaceSlot, PersistentBrowserHostContext, syncBrowserSurfaces } from './browserSurfaceRegistry' import { useAppStore } from '../stores/appStore' import { useUIStore } from '../stores/uiStore' @@ -200,6 +200,7 @@ describe('BackgroundBrowserHost', () => { act(() => { world.style.transform = 'scale(1.5) translate(20px, 10px)' + syncBrowserSurfaces() }) await vi.waitFor(() => { diff --git a/src/renderer/panels/browserSurfaceRegistry.test.tsx b/src/renderer/panels/browserSurfaceRegistry.test.tsx index b94afb2d..9150ed8c 100644 --- a/src/renderer/panels/browserSurfaceRegistry.test.tsx +++ b/src/renderer/panels/browserSurfaceRegistry.test.tsx @@ -1,7 +1,7 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { BrowserPanelSurfaceSlot, registerBrowserSurface } from './browserSurfaceRegistry' +import { BrowserPanelSurfaceSlot, registerBrowserSurface, syncBrowserSurfaces } from './browserSurfaceRegistry' ;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -109,6 +109,25 @@ describe('browser surface layout tracking', () => { expect(surface.style.width).toBe('300px') }) + it('can align a canvas-driven surface before the next animation frame', async () => { + await frame() + rect = new DOMRect(180, 70, 240, 160) + const transformed = host.querySelector('main')! + transformed.dataset.canvasWorld = '' + transformed.style.transform = 'translate(80px, 30px)' + const grid = host.querySelector('aside')! + grid.dataset.canvasGrid = '' + grid.style.backgroundPosition = '80px 30px' + + syncBrowserSurfaces() + await Promise.resolve() + + expect(surface.style.left).toBe('180px') + expect(surface.style.top).toBe('70px') + expect(surface.style.transform).toBe('scale(0.8, 0.8)') + expect(frames.size).toBe(0) + }) + it('follows sibling layout changes and ancestor child insertion', async () => { await frame() rect = new DOMRect(60, 70, 300, 200) diff --git a/src/renderer/panels/browserSurfaceRegistry.tsx b/src/renderer/panels/browserSurfaceRegistry.tsx index 2d769361..066fc7dc 100644 --- a/src/renderer/panels/browserSurfaceRegistry.tsx +++ b/src/renderer/panels/browserSurfaceRegistry.tsx @@ -291,33 +291,43 @@ function schedule(): void { if (frame !== null || surfaces.size === 0) return frame = requestAnimationFrame(() => { frame = null - const started = PERF_ENABLED ? performance.now() : 0 - if (rebuildTracking) watchSurfaces() - refreshAnimations() - // Background-workspace guests deliberately stay mounted so CLI/agent - // control keeps their exact webContents and page state. They have no live - // geometry slot, though, and are already parked by unregisterSlot. Exclude - // them from the per-frame layout pass without changing their lifecycle. - const activeIds = [...slots.entries()] - .filter(([id, slot]) => surfaces.has(id) && slot.isConnected) - .map(([id]) => id) - if (activeIds.length === 0) { - geometryAnimations.clear() - return - } - perfCount('browserGeometryFrame') - const geometry = frameGeometry() - const writes = activeIds.map((id) => measureSurface(id, geometry)) - for (const write of writes) write() - // Animated transforms produce no further mutations or resize notifications. - for (const [element, animations] of geometryAnimations) { - if (!animations.some((animation) => animation.playState === 'running')) geometryAnimations.delete(element) - } - if (PERF_ENABLED) perfCount('browserGeometryMicros', Math.round((performance.now() - started) * 1000)) + syncBrowserSurfaces() if (geometryAnimations.size) schedule() }) } +/** + * Align persistent webview surfaces immediately after an imperative canvas + * transform. Waiting for MutationObserver -> requestAnimationFrame puts the + * fixed guest host one paint behind the canvas node that owns its slot. + */ +export function syncBrowserSurfaces(): void { + if (surfaces.size === 0) return + const started = PERF_ENABLED ? performance.now() : 0 + if (rebuildTracking) watchSurfaces() + refreshAnimations() + // Background-workspace guests deliberately stay mounted so CLI/agent + // control keeps their exact webContents and page state. They have no live + // geometry slot, though, and are already parked by unregisterSlot. Exclude + // them from the layout pass without changing their lifecycle. + const activeIds = [...slots.entries()] + .filter(([id, slot]) => surfaces.has(id) && slot.isConnected) + .map(([id]) => id) + if (activeIds.length === 0) { + geometryAnimations.clear() + return + } + perfCount('browserGeometryFrame') + const geometry = frameGeometry() + const writes = activeIds.map((id) => measureSurface(id, geometry)) + for (const write of writes) write() + // Animated transforms produce no further mutations or resize notifications. + for (const [element, animations] of geometryAnimations) { + if (!animations.some((animation) => animation.playState === 'running')) geometryAnimations.delete(element) + } + if (PERF_ENABLED) perfCount('browserGeometryMicros', Math.round((performance.now() - started) * 1000)) +} + function watchSurfaces(): void { cleanupTracking?.() cleanupTracking = null @@ -332,8 +342,17 @@ function watchSurfaces(): void { } const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule) const mutation = typeof MutationObserver === 'undefined' ? null : new MutationObserver((records) => { - if (records.some((record) => record.type === 'childList')) rebuildTracking = true - for (const record of records) animatedElements.add(record.target as HTMLElement) + // Canvas owns these transforms and synchronously aligns the fixed guests. + // Observing their style/class writes would repeat that work next frame. + // Child changes still rebuild the tracked node set. + const pending = records.filter((record) => record.type === 'childList' || !( + (record.target as HTMLElement).hasAttribute('data-canvas-world') + || (record.target as HTMLElement).hasAttribute('data-canvas-top-overlay-world') + || (record.target as HTMLElement).hasAttribute('data-canvas-grid') + )) + if (pending.length === 0) return + if (pending.some((record) => record.type === 'childList')) rebuildTracking = true + for (const record of pending) animatedElements.add(record.target as HTMLElement) schedule() }) const ancestors = new Set()