11import { useT } from '@open-codesign/i18n' ;
22import {
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' ;
1012import { EmptyState } from '../preview/EmptyState' ;
1113import { ErrorState } from '../preview/ErrorState' ;
1214import { 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
9092export interface PreviewMessageHandlers {
9193 onElementSelected : ( msg : OverlayMessage ) => void ;
9294 onIframeError : ( msg : IframeErrorMessage ) => void ;
95+ onElementRects : ( msg : ElementRectsMessage ) => void ;
9396}
9497
9598export 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