266 lines
12 KiB
TypeScript
266 lines
12 KiB
TypeScript
'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<string, string>;
|
|
/** 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<string, string>, 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<HTMLIFrameElement>(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<ReturnType<typeof setTimeout> | 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<string | null>(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<typeof createHostEnvelope>[1]['type'], payload?: Record<string, unknown>) => {
|
|
const iframe = iframeRef.current;
|
|
if (!iframe?.contentWindow) return;
|
|
const envelope = createHostEnvelope(
|
|
id,
|
|
{ type, ...(payload ?? {}) } as Parameters<typeof createHostEnvelope>[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 (
|
|
<iframe
|
|
ref={iframeRef}
|
|
// The name attribute carries the artboard ID to the fiber hook.
|
|
// The hook reads window.name to tag postMessage envelopes and to
|
|
// guard against activating outside Originmain iframes.
|
|
// Format: "om:<artboardId>"
|
|
name={`om:${id}`}
|
|
src={src}
|
|
title={`artboard-${id}`}
|
|
// Security: allow-scripts required for React; allow-same-origin required
|
|
// for postMessage origin validation. Do NOT add allow-top-navigation or
|
|
// allow-popups unless explicitly needed — principle of least privilege.
|
|
sandbox="allow-scripts allow-same-origin allow-forms"
|
|
style={{
|
|
width,
|
|
height,
|
|
border: 'none',
|
|
display: 'block',
|
|
...style,
|
|
}}
|
|
/>
|
|
);
|
|
}
|