diff --git a/apps/web/src/components/PreviewDrawOverlay.tsx b/apps/web/src/components/PreviewDrawOverlay.tsx index 46498b820..eb1d28a14 100644 --- a/apps/web/src/components/PreviewDrawOverlay.tsx +++ b/apps/web/src/components/PreviewDrawOverlay.tsx @@ -172,6 +172,18 @@ export function PreviewDrawOverlay({ ) ?? null; } + // The snapshot bridge only lives in the srcDoc transport iframe. For URL-load + // previews (e.g. decks) that iframe is mounted but hidden (data-od-active is on + // the bridgeless URL iframe), so snapshotting the *active* frame times out and + // capture fails. Prefer the srcDoc-render-mode frame; capture mode keeps it on + // full content, so it carries the bridge. + function snapshotHostIframe(): HTMLIFrameElement | null { + return ( + wrapRef.current?.querySelector('iframe[data-od-render-mode="srcdoc"]') ?? + activePreviewIframe() + ); + } + function onPointerDown(e: PointerEvent) { if (!active || sending) return; (e.target as Element).setPointerCapture?.(e.pointerId); @@ -335,9 +347,16 @@ export function PreviewDrawOverlay({ } async function requestSnapshot(): Promise<{ dataUrl: string; w: number; h: number } | null> { - const iframe = activePreviewIframe(); + const iframe = snapshotHostIframe(); if (!iframe) return null; - return requestPreviewSnapshot(iframe); + // Capture mode may still be swapping the srcDoc frame to full content when + // the user submits, so retry with growing timeouts before giving up. + const timeouts = [1500, 3000, 6000]; + for (const timeout of timeouts) { + const snapshot = await requestPreviewSnapshot(iframe, timeout); + if (snapshot) return snapshot; + } + return null; } function drawCaptureTarget( diff --git a/apps/web/tests/components/PreviewDrawOverlay.test.tsx b/apps/web/tests/components/PreviewDrawOverlay.test.tsx index d1b974503..839abd762 100644 --- a/apps/web/tests/components/PreviewDrawOverlay.test.tsx +++ b/apps/web/tests/components/PreviewDrawOverlay.test.tsx @@ -4,9 +4,19 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { PreviewDrawOverlay } from '../../src/components/PreviewDrawOverlay'; +import { requestPreviewSnapshot } from '../../src/runtime/exports'; + +vi.mock('../../src/runtime/exports', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + requestPreviewSnapshot: vi.fn(async () => ({ dataUrl: 'data:image/png;base64,AAAA', w: 10, h: 10 })), + }; +}); afterEach(() => { cleanup(); + vi.mocked(requestPreviewSnapshot).mockClear(); }); describe('PreviewDrawOverlay', () => { @@ -160,4 +170,22 @@ describe('PreviewDrawOverlay', () => { expect(onActiveChange).toHaveBeenCalledWith(false); }); + + it('snapshots the srcDoc bridge iframe, not the visible URL-load frame', async () => { + const snapshot = vi.mocked(requestPreviewSnapshot); + const { getByRole } = render( + + {/* URL-load frame is the visible/active one (e.g. a deck) but has no bridge */} +