Skip to content

Commit 680709b

Browse files
committed
Merge wt/pin-follow-scroll: keep comment pins glued across iframe scroll
- iframe becomes source of truth for rect via new WATCH_SELECTORS / ELEMENT_RECTS protocol (rAF-coalesced) - store.liveRects drives PinOverlay + CommentBubble positioning - onLoad callback closes the first-mount race on WATCH_SELECTORS - ELEMENT_RECTS entries capped at 256 to block spoof-flooding from LLM HTML # Conflicts: # apps/desktop/src/renderer/src/components/PreviewPane.tsx
2 parents 03598be + f698ea2 commit 680709b

9 files changed

Lines changed: 556 additions & 74 deletions

File tree

apps/desktop/src/renderer/src/components/PreviewPane.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ describe('handlePreviewMessage trust boundary', () => {
7070
return {
7171
onElementSelected: vi.fn(),
7272
onIframeError: vi.fn(),
73+
onElementRects: vi.fn(),
7374
};
7475
}
7576

@@ -134,6 +135,58 @@ describe('handlePreviewMessage trust boundary', () => {
134135
expect(errorOutcome).toEqual({ status: 'handled', type: 'IFRAME_ERROR' });
135136
expect(handlers.onIframeError).toHaveBeenCalledOnce();
136137
});
138+
139+
it('accepts well-formed ELEMENT_RECTS payloads and forwards entries', () => {
140+
const handlers = makeHandlers();
141+
const outcome = handlePreviewMessage(
142+
{
143+
__codesign: true,
144+
type: 'ELEMENT_RECTS',
145+
entries: [
146+
{ selector: '#a', rect: { top: 10, left: 20, width: 30, height: 40 } },
147+
{ selector: '[data-codesign-id="x"]', rect: { top: 1, left: 2, width: 3, height: 4 } },
148+
],
149+
},
150+
handlers,
151+
);
152+
expect(outcome).toEqual({ status: 'handled', type: 'ELEMENT_RECTS' });
153+
expect(handlers.onElementRects).toHaveBeenCalledOnce();
154+
const payload = handlers.onElementRects.mock.calls[0]?.[0] as {
155+
entries: Array<{ selector: string }>;
156+
};
157+
expect(payload.entries).toHaveLength(2);
158+
expect(payload.entries[0]?.selector).toBe('#a');
159+
});
160+
161+
it('rejects ELEMENT_RECTS with a malformed rect entry', () => {
162+
const handlers = makeHandlers();
163+
const outcome = handlePreviewMessage(
164+
{
165+
__codesign: true,
166+
type: 'ELEMENT_RECTS',
167+
entries: [{ selector: '#bad', rect: { top: 'NaN' } }],
168+
},
169+
handlers,
170+
);
171+
expect(outcome.status).toBe('rejected');
172+
expect(handlers.onElementRects).not.toHaveBeenCalled();
173+
});
174+
175+
it('rejects ELEMENT_RECTS whose entries array exceeds the hard cap', () => {
176+
// An LLM-controlled iframe script could try to flood liveRects. Validator
177+
// should drop the message before it reaches the handler.
178+
const handlers = makeHandlers();
179+
const entries = Array.from({ length: 257 }, (_, i) => ({
180+
selector: `#a${i}`,
181+
rect: { top: 0, left: 0, width: 1, height: 1 },
182+
}));
183+
const outcome = handlePreviewMessage(
184+
{ __codesign: true, type: 'ELEMENT_RECTS', entries },
185+
handlers,
186+
);
187+
expect(outcome.status).toBe('rejected');
188+
expect(handlers.onElementRects).not.toHaveBeenCalled();
189+
});
137190
});
138191

139192
describe('postModeToPreviewWindow', () => {

apps/desktop/src/renderer/src/components/PreviewPane.tsx

Lines changed: 127 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { useT } from '@open-codesign/i18n';
22
import {
3+
type ElementRectsMessage,
34
type IframeErrorMessage,
45
type OverlayMessage,
56
buildSrcdoc,
7+
isElementRectsMessage,
68
isIframeErrorMessage,
79
isOverlayMessage,
810
} from '@open-codesign/runtime';
9-
import { useCallback, useEffect, useMemo, useRef } from 'react';
11+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1012
import { EmptyState } from '../preview/EmptyState';
1113
import { ErrorState } from '../preview/ErrorState';
1214
import { useCodesignStore } from '../store';
@@ -85,11 +87,12 @@ export function stablePreviewSourceKey(source: string): string {
8587
);
8688
}
8789

88-
export type AllowedPreviewMessageType = 'ELEMENT_SELECTED' | 'IFRAME_ERROR';
90+
export type AllowedPreviewMessageType = 'ELEMENT_SELECTED' | 'IFRAME_ERROR' | 'ELEMENT_RECTS';
8991

9092
export interface PreviewMessageHandlers {
9193
onElementSelected: (msg: OverlayMessage) => void;
9294
onIframeError: (msg: IframeErrorMessage) => void;
95+
onElementRects: (msg: ElementRectsMessage) => void;
9396
}
9497

9598
export type PreviewMessageOutcome =
@@ -121,6 +124,12 @@ export function handlePreviewMessage(
121124
return { status: 'handled', type: 'IFRAME_ERROR' };
122125
}
123126
return { status: 'rejected', reason: 'shape', type: envelope.type };
127+
case 'ELEMENT_RECTS':
128+
if (isElementRectsMessage(data)) {
129+
handlers.onElementRects(data);
130+
return { status: 'handled', type: 'ELEMENT_RECTS' };
131+
}
132+
return { status: 'rejected', reason: 'shape', type: envelope.type };
124133
default:
125134
return { status: 'rejected', reason: 'unknown-type', type: envelope.type };
126135
}
@@ -141,6 +150,7 @@ interface PreviewSlotProps {
141150
interactionMode: string;
142151
registerIframe: (designId: string, el: HTMLIFrameElement | null) => void;
143152
onIframeError: (message: string) => void;
153+
onIframeLoaded: (designId: string) => void;
144154
}
145155

146156
// One iframe per pool entry. Hidden (display:none) when not active, but kept
@@ -160,6 +170,7 @@ function PreviewSlot({
160170
interactionMode,
161171
registerIframe,
162172
onIframeError,
173+
onIframeLoaded,
163174
}: PreviewSlotProps) {
164175
const srcDocStableKey = useMemo(() => stablePreviewSourceKey(html), [html]);
165176

@@ -190,6 +201,10 @@ function PreviewSlot({
190201
if (!active) return;
191202
const target = e.currentTarget as HTMLIFrameElement;
192203
postModeToPreviewWindow(target.contentWindow, interactionMode, onIframeError);
204+
// The parent's WATCH_SELECTORS post can race past a freshly-mounted
205+
// iframe before its message listener installs. Ping the parent so it
206+
// re-broadcasts after load has confirmed the overlay is live.
207+
onIframeLoaded(designId);
193208
}}
194209
className={
195210
isMobile
@@ -282,12 +297,19 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
282297
const openCommentBubble = useCodesignStore((s) => s.openCommentBubble);
283298
const closeCommentBubble = useCodesignStore((s) => s.closeCommentBubble);
284299
const addComment = useCodesignStore((s) => s.addComment);
300+
const applyLiveRects = useCodesignStore((s) => s.applyLiveRects);
301+
const clearLiveRects = useCodesignStore((s) => s.clearLiveRects);
302+
const liveRects = useCodesignStore((s) => s.liveRects);
285303

286304
// Active iframe ref consumed by TweakPanel (postMessage target) and by the
287305
// window.message guard. We re-point this whenever the active design changes
288306
// or the active iframe element re-mounts.
289307
const iframeRef = useRef<HTMLIFrameElement | null>(null);
290308
const iframesByDesign = useRef<Map<string, HTMLIFrameElement>>(new Map());
309+
// Bumped every time the active iframe fires onLoad — used to re-trigger
310+
// the WATCH_SELECTORS effect so we don't race past overlay installation
311+
// on first mount.
312+
const [iframeLoadTick, setIframeLoadTick] = useState(0);
291313

292314
const registerIframe = useCallback((designId: string, el: HTMLIFrameElement | null) => {
293315
if (el) {
@@ -297,6 +319,13 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
297319
}
298320
}, []);
299321

322+
const handleIframeLoaded = useCallback(
323+
(designId: string) => {
324+
if (designId === currentDesignId) setIframeLoadTick((t) => t + 1);
325+
},
326+
[currentDesignId],
327+
);
328+
300329
// When the active design changes, retarget iframeRef and re-broadcast the
301330
// current interaction mode. Background iframes keep their last mode — fine,
302331
// they're inert until reactivated.
@@ -310,7 +339,35 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
310339
if (el) {
311340
postModeToPreviewWindow(el.contentWindow, interactionMode, pushIframeError);
312341
}
313-
}, [currentDesignId, interactionMode, pushIframeError]);
342+
// New iframe / new design → liveRects from the old one are stale.
343+
clearLiveRects();
344+
}, [currentDesignId, interactionMode, pushIframeError, clearLiveRects]);
345+
346+
// Tell the sandbox which selectors to track. The sandbox re-measures each
347+
// on scroll/resize and broadcasts ELEMENT_RECTS; we merge into liveRects.
348+
// Selectors: all comments on the current snapshot + the active bubble's
349+
// selector (usually the freshly-pinned one, included for the moment
350+
// between click and save).
351+
// biome-ignore lint/correctness/useExhaustiveDependencies: currentDesignId and iframeLoadTick are deliberate triggers — iframeRef.current is a ref so biome can't see it swap when the active design changes, and we must wait for the iframe's onLoad before the overlay's message listener exists (otherwise the post is dropped).
352+
useEffect(() => {
353+
const win = iframeRef.current?.contentWindow;
354+
if (!win) return;
355+
const selectors = new Set<string>();
356+
if (currentSnapshotId) {
357+
for (const c of comments) {
358+
if (c.snapshotId === currentSnapshotId) selectors.add(c.selector);
359+
}
360+
}
361+
if (commentBubble) selectors.add(commentBubble.selector);
362+
try {
363+
win.postMessage(
364+
{ __codesign: true, type: 'WATCH_SELECTORS', selectors: Array.from(selectors) },
365+
'*',
366+
);
367+
} catch {
368+
/* sandbox gone — retry happens next render */
369+
}
370+
}, [comments, currentSnapshotId, commentBubble, currentDesignId, iframeLoadTick]);
314371

315372
useEffect(() => {
316373
function onMessage(event: MessageEvent): void {
@@ -340,6 +397,9 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
340397
},
341398
onIframeError: (msg) =>
342399
pushIframeError(formatIframeError(msg.kind, msg.message, msg.source, msg.lineno)),
400+
onElementRects: (msg) => {
401+
applyLiveRects(msg.entries);
402+
},
343403
});
344404

345405
if (outcome.status === 'rejected' && outcome.reason === 'unknown-type') {
@@ -349,7 +409,7 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
349409

350410
window.addEventListener('message', onMessage);
351411
return () => window.removeEventListener('message', onMessage);
352-
}, [pushIframeError, selectCanvasElement, openCommentBubble, previewZoom]);
412+
}, [pushIframeError, selectCanvasElement, openCommentBubble, previewZoom, applyLiveRects]);
353413

354414
// Pool entries: active design first (using the freshest in-memory
355415
// previewHtml), then any other recently-visited designs that still have a
@@ -385,21 +445,18 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
385445
<PinOverlay
386446
comments={snapshotComments}
387447
zoom={previewZoom}
388-
onPinClick={(c) =>
448+
liveRects={liveRects}
449+
onPinClick={(c) => {
450+
const live = liveRects[c.selector] ?? c.rect;
389451
openCommentBubble({
390452
selector: c.selector,
391453
tag: c.tag,
392454
outerHTML: c.outerHTML,
393-
rect: {
394-
top: c.rect.top * (previewZoom / 100),
395-
left: c.rect.left * (previewZoom / 100),
396-
width: c.rect.width * (previewZoom / 100),
397-
height: c.rect.height * (previewZoom / 100),
398-
},
455+
rect: scaleRectForZoom(live, previewZoom),
399456
existingCommentId: c.id,
400457
initialText: c.text,
401-
})
402-
}
458+
});
459+
}}
403460
/>
404461
);
405462

@@ -455,6 +512,7 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
455512
interactionMode={interactionMode}
456513
registerIframe={registerIframe}
457514
onIframeError={pushIframeError}
515+
onIframeLoaded={handleIframeLoaded}
458516
/>
459517
))}
460518
{!activeHasHtml ? (
@@ -487,54 +545,62 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
487545
{body}
488546
{previewHtml ? <TweakPanel iframeRef={iframeRef} /> : null}
489547
</div>
490-
{commentBubble && interactionMode === 'comment' ? (
491-
<CommentBubble
492-
key={commentBubble.selector}
493-
selector={commentBubble.selector}
494-
tag={commentBubble.tag}
495-
outerHTML={commentBubble.outerHTML}
496-
rect={commentBubble.rect}
497-
{...(commentBubble.initialText !== undefined
498-
? { initialText: commentBubble.initialText }
499-
: {})}
500-
onClose={() => {
501-
const win = iframeRef.current?.contentWindow;
502-
if (win) {
503-
try {
504-
win.postMessage({ __codesign: true, type: 'CLEAR_PIN' }, '*');
505-
} catch {
506-
/* noop */
507-
}
508-
}
509-
closeCommentBubble();
510-
}}
511-
onSendToClaude={async (text: string) => {
512-
await addComment({
513-
kind: 'edit',
514-
selector: commentBubble.selector,
515-
tag: commentBubble.tag,
516-
outerHTML: commentBubble.outerHTML,
517-
rect: commentBubble.rect,
518-
text,
519-
scope: 'element',
520-
...(commentBubble.parentOuterHTML
521-
? { parentOuterHTML: commentBubble.parentOuterHTML }
522-
: {}),
523-
});
524-
const win = iframeRef.current?.contentWindow;
525-
if (win) {
526-
try {
527-
win.postMessage({ __codesign: true, type: 'CLEAR_PIN' }, '*');
528-
} catch {
529-
/* noop */
530-
}
531-
}
532-
closeCommentBubble();
533-
// Stage only — user clicks the "Apply" button on the chip bar
534-
// to send all accumulated edits in one go.
535-
}}
536-
/>
537-
) : null}
548+
{commentBubble && interactionMode === 'comment'
549+
? (() => {
550+
const liveForBubble = liveRects[commentBubble.selector];
551+
const scaled = liveForBubble
552+
? scaleRectForZoom(liveForBubble, previewZoom)
553+
: commentBubble.rect;
554+
return (
555+
<CommentBubble
556+
key={commentBubble.selector}
557+
selector={commentBubble.selector}
558+
tag={commentBubble.tag}
559+
outerHTML={commentBubble.outerHTML}
560+
rect={scaled}
561+
{...(commentBubble.initialText !== undefined
562+
? { initialText: commentBubble.initialText }
563+
: {})}
564+
onClose={() => {
565+
const win = iframeRef.current?.contentWindow;
566+
if (win) {
567+
try {
568+
win.postMessage({ __codesign: true, type: 'CLEAR_PIN' }, '*');
569+
} catch {
570+
/* noop */
571+
}
572+
}
573+
closeCommentBubble();
574+
}}
575+
onSendToClaude={async (text: string) => {
576+
await addComment({
577+
kind: 'edit',
578+
selector: commentBubble.selector,
579+
tag: commentBubble.tag,
580+
outerHTML: commentBubble.outerHTML,
581+
rect: commentBubble.rect,
582+
text,
583+
scope: 'element',
584+
...(commentBubble.parentOuterHTML
585+
? { parentOuterHTML: commentBubble.parentOuterHTML }
586+
: {}),
587+
});
588+
const win = iframeRef.current?.contentWindow;
589+
if (win) {
590+
try {
591+
win.postMessage({ __codesign: true, type: 'CLEAR_PIN' }, '*');
592+
} catch {
593+
/* noop */
594+
}
595+
}
596+
closeCommentBubble();
597+
// Stage only — user clicks the "Apply" button on the chip bar
598+
// to send all accumulated edits in one go.
599+
}}
600+
/>
601+
);
602+
})()
603+
: null}
538604
</div>
539605
</div>
540606
);

0 commit comments

Comments
 (0)