Inspector: - Design tab rebuilt with semantic Figma-style layout: W/H stepper inputs at top, Fill with colour-picker swatch, Typography row (size/weight/ line-height), text-align toggle group, Layout section with flex controls + padding 4-corner grid, Appearance (radius/opacity/border/shadow) - All fields fire patchStyleEdit on every keystroke / arrow-key step - Sections conditionally render: Fill hidden when bg is transparent, etc. - NumInput: strips/restores CSS unit, Shift+↑↓ for ×10 step - ColorInput: native colour picker behind swatch + hex text input SelectionOverlay: - Resize handles call patchStyleEdit on every mousemove → live iframe preview - 8 handles: 4 corners (nwse/nesw) + 4 edge midpoints (ns/ew) - Delete button in label bar + Delete/Backspace keyboard shortcut - Dimension tooltip: ComponentName W × H Canvas / Artboard: - ROUTES_DISCOVERED handler: auto-creates artboards in a row for every undiscovered route, pendingRouteCreation ref prevents duplicates - buildSrc() helper resolves route-aware iframe src live-sdk / hook: - discoverRoutes(): scans <a href> + fiber tree for Link/NavLink components - removeElement(), patchElementStyle(), respondWithStyles() handlers - Route re-discovery on popstate protocol: REQUEST_ELEMENT_STYLES, PATCH_ELEMENT_STYLE, REMOVE_ELEMENT host messages; ELEMENT_STYLES, ROUTES_DISCOVERED renderer messages canvas store: styleEditEvent + removeElementEvent mailboxes (Zustand v5 compatible — useEffect selectors, no 2-arg subscribe) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
189 lines
7.7 KiB
TypeScript
189 lines
7.7 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';
|
|
|
|
// ── 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. */
|
|
onComponentStylesUpdate?: (nodeId: string, styles: Record<string, string>) => 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<HTMLIFrameElement>(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<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;
|
|
// 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 (
|
|
<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,
|
|
}}
|
|
/>
|
|
);
|
|
}
|