'use client'; import { useEffect, useRef, useCallback } from 'react'; import { createHostEnvelope, isRendererEnvelope, } from '@originmain/renderer'; import type { FiberNode, RendererMessage } from '@originmain/renderer'; import { useCanvas } from '@/store/canvas'; import { artboardIframeMap } from '@/lib/artboard-iframe-map'; // ── Types ───────────────────────────────────────────────────────────────────── export interface LiveArtboardProps { id: string; /** URL of the connected application route to render. * For live dev: the CLI proxy URL (e.g., http://localhost:4170) * For previews: the Vercel/Netlify preview URL (with @originmain/live SDK) */ src: string; width?: number; height?: number; /** DLF design tokens to inject into the iframe as CSS custom properties. */ designTokens?: Record; /** The currently selected component node ID (from the canvas store). * When set, sends SELECT_COMPONENT to the iframe so the fiber hook renders * a blue highlight ring over that component's DOM element. */ selectedComponentId?: string | null; onReady?: () => void; onFiberTreeUpdate?: (root: FiberNode) => void; onComponentSelected?: (nodeId: string) => void; /** Called when the iframe responds with computed CSS properties for a selected element. * Also carries structural flags from the ELEMENT_STYLES message. */ onComponentStylesUpdate?: (nodeId: string, styles: Record, hasDirectText: boolean, hasParagraphChildren: boolean) => void; /** Called when the iframe discovers routes in the running app. */ onRoutesDiscovered?: (routes: Array<{ path: string; label: string }>) => void; /** Called when READY fires but no React commits arrive within 4 s — signals a * static HTML page where the fiber hook can't find a React runtime. */ onStaticPageDetected?: () => void; /** Phase 0: Called when the renderer responds to CAPTURE_THUMBNAIL with a JPEG data URL (or null on failure). */ onThumbnailReady?: (dataUrl: string | null) => void; /** Phase 4: Called when the renderer responds to CAPTURE_SNAPSHOT with a PNG data URL (or null on failure). */ onSnapshotReady?: (dataUrl: string | null, nodeId: string) => void; style?: React.CSSProperties; } // ── Component ───────────────────────────────────────────────────────────────── export function LiveArtboard({ id, src, width = 1280, height = 720, designTokens, selectedComponentId, onReady, onFiberTreeUpdate, onComponentSelected, onComponentStylesUpdate, onRoutesDiscovered, onStaticPageDetected, onThumbnailReady, onSnapshotReady, style, }: LiveArtboardProps) { const { setArtboardRootFontSize } = useCanvas(); const iframeRef = useRef(null); // Track whether the iframe has sent READY so we don't send messages too early. const isReadyRef = useRef(false); // Whether a React commit (FIBER_TREE_UPDATE) has been received for this src. const hasReactRef = useRef(false); // Timer that fires if READY arrives but no React commits follow — static page. const staticTimerRef = useRef | null>(null); // After READY fires the nodeMap inside the iframe is still empty — we can't // request styles until after the first FIBER_TREE_UPDATE (which populates it). // This ref holds the nodeId to re-request styles for after that first commit. const pendingStylesFetchRef = useRef(null); // Reset ready state whenever src changes. Without this, isReadyRef stays // true from the previous page, causing design-token / selection effects to // fire against a half-loaded iframe between navigation and the new READY. useEffect(() => { isReadyRef.current = false; hasReactRef.current = false; pendingStylesFetchRef.current = null; if (staticTimerRef.current) clearTimeout(staticTimerRef.current); staticTimerRef.current = null; }, [src]); // ── Send a typed message to the iframe ─────────────────────────────────── const sendMessage = useCallback( (type: Parameters[1]['type'], payload?: Record) => { const iframe = iframeRef.current; if (!iframe?.contentWindow) return; const envelope = createHostEnvelope( id, { type, ...(payload ?? {}) } as Parameters[1], ); iframe.contentWindow.postMessage(envelope, '*'); }, [id], ); // ── Handle messages from the renderer iframe ────────────────────────────── useEffect(() => { function handleMessage(event: MessageEvent) { if (!isRendererEnvelope(event.data)) return; if (event.data.artboardId !== id) return; const msg: RendererMessage = event.data.message; switch (msg.type) { case 'READY': isReadyRef.current = true; // Store the root font size for rem→px normalisation in the token resolver. if (typeof msg.rootFontSizePx === 'number') { setArtboardRootFontSize(id, msg.rootFontSizePx); } // Push current design tokens into the iframe immediately. if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens }); // Restore the highlight ring for any active selection. // We cannot send REQUEST_ELEMENT_STYLES here — nodeMap is empty until // the first React commit fires FIBER_TREE_UPDATE. Defer it via ref. if (selectedComponentId) { sendMessage('SELECT_COMPONENT', { nodeId: selectedComponentId }); pendingStylesFetchRef.current = selectedComponentId; } onReady?.(); // Start a 4-second timer: if React never commits, this is a static page. if (staticTimerRef.current) clearTimeout(staticTimerRef.current); staticTimerRef.current = setTimeout(() => { if (!hasReactRef.current) onStaticPageDetected?.(); }, 4000); break; case 'FIBER_TREE_UPDATE': // Mark that this iframe contains a live React app. hasReactRef.current = true; if (staticTimerRef.current) { clearTimeout(staticTimerRef.current); staticTimerRef.current = null; } onFiberTreeUpdate?.(msg.root); // If READY deferred a style fetch (nodeMap was empty at that point), // the first commit has now populated nodeMap — request styles now. if (pendingStylesFetchRef.current) { sendMessage('REQUEST_ELEMENT_STYLES', { nodeId: pendingStylesFetchRef.current }); pendingStylesFetchRef.current = null; } break; case 'COMPONENT_SELECTED': onComponentSelected?.(msg.nodeId); break; case 'COMPONENT_DESELECTED': // Renderer clicked empty space — clear the host-side selection. onComponentSelected?.(''); break; case 'ELEMENT_STYLES': onComponentStylesUpdate?.(msg.nodeId, msg.styles, msg.hasDirectText, msg.hasParagraphChildren); break; case 'ROUTES_DISCOVERED': onRoutesDiscovered?.(msg.routes); break; case 'THUMBNAIL_READY': // Phase 0: store base64 JPEG for the far-state placeholder in Artboard.tsx. onThumbnailReady?.(msg.dataUrl); break; case 'SNAPSHOT_READY': // Phase 4: PNG of the selected element for the Code Preview diff in Inspector. onSnapshotReady?.(msg.dataUrl, msg.nodeId); break; } } window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected, onComponentStylesUpdate, onRoutesDiscovered, onThumbnailReady, onSnapshotReady]); // ── Push updated design tokens whenever they change ─────────────────────── useEffect(() => { if (!isReadyRef.current || !designTokens) return; sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens }); }, [designTokens, sendMessage]); // ── Style edit queue ─────────────────────────────────────────────────────── // Drains all PATCH_ELEMENT_STYLE events addressed to this artboard and // forwards them to the iframe. Uses a queue (not a single slot) so that // simultaneous width + height patches from resize are both delivered. const styleEditQueue = useCanvas((s) => s.styleEditQueue); const clearStyleEdits = useCanvas((s) => s.clearStyleEdits); const childrenStyleEditQueue = useCanvas((s) => s.childrenStyleEditQueue); const clearChildrenStyleEdits = useCanvas((s) => s.clearChildrenStyleEdits); const removeElementEvent = useCanvas((s) => s.removeElementEvent); const clearRemoveElement = useCanvas((s) => s.clearRemoveElement); useEffect(() => { const mine = styleEditQueue.filter((e) => e.artboardId === id); if (mine.length === 0 || !isReadyRef.current) return; for (const e of mine) { sendMessage('PATCH_ELEMENT_STYLE', { nodeId: e.nodeId, property: e.property, value: e.value }); } clearStyleEdits(id); }, [id, styleEditQueue, sendMessage, clearStyleEdits]); // ── Children style edit queue (PATCH_CHILDREN_STYLE — paragraph spacing etc.) ── useEffect(() => { const mine = childrenStyleEditQueue.filter((e) => e.artboardId === id); if (mine.length === 0 || !isReadyRef.current) return; for (const e of mine) { sendMessage('PATCH_CHILDREN_STYLE', { parentNodeId: e.parentNodeId, selector: e.selector, property: e.property, value: e.value }); } clearChildrenStyleEdits(id); }, [id, childrenStyleEditQueue, sendMessage, clearChildrenStyleEdits]); useEffect(() => { if (removeElementEvent?.artboardId === id && isReadyRef.current) { sendMessage('REMOVE_ELEMENT', { nodeId: removeElementEvent.nodeId }); clearRemoveElement(); } }, [id, removeElementEvent, sendMessage, clearRemoveElement]); // ── Sync selection changes into the iframe ──────────────────────────────── // Sends SELECT_COMPONENT on every selectedComponentId change so the blue // highlight ring stays in sync with the canvas selection store. useEffect(() => { if (!isReadyRef.current) return; if (selectedComponentId) { sendMessage('SELECT_COMPONENT', { nodeId: selectedComponentId }); sendMessage('REQUEST_ELEMENT_STYLES', { nodeId: selectedComponentId }); } else { sendMessage('DESELECT'); } }, [selectedComponentId, sendMessage]); // ── Register / deregister in the artboardIframeMap singleton ──────────────── // This allows canvas-level dispatch (e.g. CompletionZone, CodeTab send-to-agent) // to reach the correct iframe without going through React state or Zustand. useEffect(() => { const el = iframeRef.current; if (!el) return; artboardIframeMap.set(id, el); return () => { artboardIframeMap.delete(id); }; }, [id]); return (