fix(web): allow Comment/Inspect picker to select iframe-backed components (#2254)

This commit is contained in:
Neha Prasad 2026-05-19 16:56:11 +05:30 committed by GitHub
parent 7fc4362ba8
commit 716f06cb73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 84 additions and 4 deletions

View file

@ -1261,6 +1261,11 @@ function meaningfulDomFallbackTarget(el) {
html[data-od-comment-mode] body * { cursor: crosshair !important; }
html[data-od-inspect-mode] body * { cursor: crosshair !important; }
html[data-od-comment-mode][data-od-comment-mode-kind="pod"] body * { cursor: cell !important; }
/* Nested iframes (e.g. shared device frames) consume clicks in their own browsing context.
While picker modes are on, disable pointer events on outer-document iframes so the
hit target resolves to an annotated ancestor (card, shell) in this document. */
html[data-od-comment-mode] body iframe,
html[data-od-inspect-mode] body iframe { pointer-events: none !important; }
</style>`;
return injectBeforeBodyEnd(injectBeforeHeadEnd(doc, style), script);
}

View file

@ -38,6 +38,13 @@ function extractBridgeScript(srcdoc: string): string {
return match[1];
}
function extractSelectionBridgeStyle(srcdoc: string): string {
const match = srcdoc.match(
/<style data-od-selection-bridge-style>[\s\S]*?<\/style>/,
);
return match?.[0] ?? '';
}
function markVisible(win: { document: Document }, selector: string): void {
const el = win.document.querySelector(selector);
if (!el) throw new Error(`selector not found: ${selector}`);
@ -67,14 +74,18 @@ function setupBridgeDom(
commentBridge: mode === 'comment',
});
const script = extractBridgeScript(srcdoc);
const bridgeStyle = extractSelectionBridgeStyle(srcdoc);
// Build a fresh JSDOM that mirrors the iframe runtime: `window.parent`
// is what the bridge calls into, and we spy on its postMessage to
// capture the messages it would emit to the host.
const dom = new JSDOM(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
runScripts: 'outside-only',
pretendToBeVisual: true,
});
const dom = new JSDOM(
`<!doctype html><html><head>${bridgeStyle}</head><body>${bodyHtml}</body></html>`,
{
runScripts: 'outside-only',
pretendToBeVisual: true,
},
);
const win = dom.window;
const parentPostMessage = vi.fn();
// jsdom defaults `window.parent` to `window` itself for top-level
@ -285,4 +296,64 @@ describe('selection bridge — empty annotation surface (#890)', () => {
]),
);
});
it('applies pointer-events none to body iframes while Comment picker is active', async () => {
const { win } = setupBridgeDom(
'<article data-od-id="card"><div>Tablet edition</div><iframe id="f" src="about:blank"></iframe></article>',
'comment',
['#f'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
const iframe = win.document.getElementById('f');
expect(iframe).not.toBeNull();
expect(win.getComputedStyle(iframe!).pointerEvents).toBe('none');
});
it('applies pointer-events none to body iframes while Inspect picker is active', async () => {
const { win } = setupBridgeDom(
'<article data-od-id="card"><iframe id="f" src="about:blank"></iframe></article>',
'inspect',
['#f'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
expect(win.getComputedStyle(win.document.getElementById('f')!).pointerEvents).toBe('none');
});
it('restores iframe pointer events after Comment mode is disabled', async () => {
const { win } = setupBridgeDom(
'<article data-od-id="card"><iframe id="f" src="about:blank"></iframe></article>',
'comment',
['#f'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
const iframe = win.document.getElementById('f')!;
expect(win.getComputedStyle(iframe).pointerEvents).toBe('none');
win.dispatchEvent(
new win.MessageEvent('message', {
data: { type: 'od:comment-mode', enabled: false, mode: 'picker' },
}),
);
expect(win.getComputedStyle(iframe).pointerEvents).toBe('auto');
});
it('posts od:comment-target for the annotated card when the device-frame iframe is clicked', async () => {
const { win, parentPostMessage } = setupBridgeDom(
'<article data-od-id="tablet-card" class="frame-card"><div class="meta">Tablet edition</div><iframe id="f" class="tablet-frame" title="Tablet edition" src="about:blank"></iframe></article>',
'comment',
['#f', '.frame-card'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
parentPostMessage.mockClear();
win.document.getElementById('f')!.dispatchEvent(
new win.MouseEvent('click', { bubbles: true, cancelable: true }),
);
const clickMessages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:comment-target');
expect(clickMessages).toHaveLength(1);
expect(clickMessages[0].elementId).toBe('tablet-card');
});
});

View file

@ -76,6 +76,9 @@ describe('buildSrcdoc', () => {
expect(srcdoc).toContain("type: 'od:preview-scroll'");
expect(srcdoc).toContain("type: 'od:preview-scroll-request'");
expect(srcdoc).toContain('data-od-selection-bridge-style');
expect(srcdoc).toContain('html[data-od-comment-mode] body iframe');
expect(srcdoc).toContain('html[data-od-inspect-mode] body iframe');
expect(srcdoc).toContain('pointer-events: none !important');
});
it('emits free-pin fallback coordinates in viewport space', () => {
@ -230,6 +233,7 @@ describe('buildSrcdoc', () => {
expect(srcdoc).not.toContain('data-od-selection-bridge');
expect(srcdoc).not.toContain("type: 'od:comment-target'");
expect(srcdoc).not.toContain("type: 'od:inspect-overrides'");
expect(srcdoc).not.toContain('html[data-od-comment-mode] body iframe');
});
// Regression for nexu-io/open-design#892: imported designs (e.g. Claude