fix(web): snapshot the srcDoc bridge frame in Mark mode so deck capture works (#3304)

The Mark tool (#3081/#3277) captured the preview via the *active* iframe. For
URL-load previews — decks especially — the active frame is the bridgeless URL
iframe, while the snapshot bridge lives only in the (mounted but hidden) srcDoc
transport frame. So Send on a deck timed out and showed 'Could not capture the
preview. Try again to avoid sending only ink.'

Snapshot the srcDoc-render-mode frame instead (capture mode already keeps it on
full content, so it carries the bridge), with a short retry while it finishes
swapping to full content. Falls back to the active frame for the non-URL-load
case where they are the same.

Red spec: PreviewDrawOverlay.test 'snapshots the srcDoc bridge iframe, not the
visible URL-load frame' fails on main (targets the URL frame), passes here.
This commit is contained in:
lefarcen 2026-05-29 19:50:37 +08:00 committed by GitHub
parent 9f09d1b649
commit 6f532ca35c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 49 additions and 2 deletions

View file

@ -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<HTMLIFrameElement>('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(

View file

@ -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<typeof import('../../src/runtime/exports')>();
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(
<PreviewDrawOverlay active captureViewport>
{/* URL-load frame is the visible/active one (e.g. a deck) but has no bridge */}
<iframe title="url" data-od-active="true" />
{/* srcDoc frame is mounted but hidden; it hosts the snapshot bridge */}
<iframe title="srcdoc" data-od-render-mode="srcdoc" data-od-active="false" />
</PreviewDrawOverlay>,
);
fireEvent.click(getByRole('button', { name: 'Send' }));
await waitFor(() => expect(snapshot).toHaveBeenCalled());
const usedIframe = snapshot.mock.calls[0]?.[0] as HTMLIFrameElement;
expect(usedIframe.getAttribute('data-od-render-mode')).toBe('srcdoc');
});
});