Files
wursor/packages/app/src/components/canvas/LiveArtboard.tsx
T
SinachPatandClaude Sonnet 4.6 82dddb1801 feat: Figma-parity design editing — visual panel, live resize, route grid
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>
2026-05-01 01:05:16 +01:00

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,
}}
/>
);
}