'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'; // ── 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. */ onComponentStylesUpdate?: (nodeId: string, styles: Record) => void; /** Called when the iframe discovers routes in the running app. */ onRoutesDiscovered?: (routes: Array<{ path: string; label: string }>) => void; style?: React.CSSProperties; } // ── Component ───────────────────────────────────────────────────────────────── export function LiveArtboard({ id, src, width = 1280, height = 720, designTokens, selectedComponentId, onReady, onFiberTreeUpdate, onComponentSelected, onComponentStylesUpdate, onRoutesDiscovered, style, }: LiveArtboardProps) { const iframeRef = useRef(null); // Track whether the iframe has sent READY so we don't send messages too early. const isReadyRef = useRef(false); // 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; }, [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; // Push current design tokens into the iframe immediately. if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens }); // Restore any active selection that existed before the iframe loaded. if (selectedComponentId) { sendMessage('SELECT_COMPONENT', { nodeId: selectedComponentId }); } onReady?.(); break; case 'FIBER_TREE_UPDATE': onFiberTreeUpdate?.(msg.root); break; case 'COMPONENT_SELECTED': onComponentSelected?.(msg.nodeId); // Request computed styles so the Design tab can populate immediately. sendMessage('REQUEST_ELEMENT_STYLES', { nodeId: 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); break; case 'ROUTES_DISCOVERED': onRoutesDiscovered?.(msg.routes); break; } } window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected, onComponentStylesUpdate, onRoutesDiscovered]); // ── Push updated design tokens whenever they change ─────────────────────── useEffect(() => { if (!isReadyRef.current || !designTokens) return; sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens }); }, [designTokens, sendMessage]); // ── Style edit mailbox ───────────────────────────────────────────────────── // Watches the Zustand mailbox for PATCH_ELEMENT_STYLE events addressed to // this artboard and forwards them to the iframe immediately. const styleEditEvent = useCanvas((s) => s.styleEditEvent); const clearStyleEdit = useCanvas((s) => s.clearStyleEdit); const removeElementEvent = useCanvas((s) => s.removeElementEvent); const clearRemoveElement = useCanvas((s) => s.clearRemoveElement); useEffect(() => { if (styleEditEvent?.artboardId === id && isReadyRef.current) { sendMessage('PATCH_ELEMENT_STYLE', { nodeId: styleEditEvent.nodeId, property: styleEditEvent.property, value: styleEditEvent.value, }); clearStyleEdit(); } }, [id, styleEditEvent, sendMessage, clearStyleEdit]); 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 }); } else { sendMessage('DESELECT'); } }, [selectedComponentId, sendMessage]); return (