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>
This commit is contained in:
SinachPat
2026-05-01 01:05:16 +01:00
co-authored by Claude Sonnet 4.6
parent edada3091e
commit 82dddb1801
8 changed files with 888 additions and 239 deletions
@@ -19,6 +19,8 @@ interface ArtboardProps {
renderUrl?: string;
/** Route path appended to renderUrl so each artboard can show a different screen. */
route?: string;
/** Called when the live app reports discoverable routes — Canvas handles creation. */
onRoutesDiscovered?: (sourceId: string, routes: Array<{ path: string; label: string }>) => void;
}
/** Builds the iframe src from a base URL + optional route path.
@@ -38,7 +40,7 @@ const DIFF_STATUS_BADGE: Record<string, { color: string; bg: string; label: stri
REJECTED: { color: '#FF8080', bg: 'rgba(255,128,128,0.15)', label: 'blocked' },
};
export function Artboard({ id, label, x, y, width, height, renderUrl, route }: ArtboardProps) {
export function Artboard({ id, label, x, y, width, height, renderUrl, route, onRoutesDiscovered }: ArtboardProps) {
const {
selectedArtboardId, selectArtboard, workspaceId, projectId,
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles,
@@ -349,6 +351,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route }: A
onFiberTreeUpdate={handleFiberUpdate}
onComponentSelected={handleComponentSelected}
onComponentStylesUpdate={handleComponentStylesUpdate}
onRoutesDiscovered={(routes) => onRoutesDiscovered?.(id, routes)}
/>
<SelectionOverlay
artboardId={id}
+54 -1
View File
@@ -22,6 +22,59 @@ export function Canvas() {
const lastPos = useRef({ x: 0, y: 0 });
const spaceDown = useRef(false);
// ── Route discovery: auto-create screen grid ──────────────────────────────
// When a live artboard discovers routes we don't have artboards for yet,
// this creates them in a horizontal row to the right of all existing frames.
const pendingRouteCreation = useRef(false);
const handleRoutesDiscovered = useCallback(
(sourceArtboardId: string, routes: Array<{ path: string; label: string }>) => {
if (!workspaceId || pendingRouteCreation.current) return;
const source = artboards.find((ab) => ab.id === sourceArtboardId);
if (!source?.renderUrl) return;
// Find routes we don't already have an artboard for
const existingRoutes = new Set(artboards.map((ab) => ab.route ?? '/'));
const newRoutes = routes.filter((r) => !existingRoutes.has(r.path));
if (newRoutes.length === 0) return;
pendingRouteCreation.current = true;
// Position new artboards in a row to the right of all existing frames
const GAP = 80;
const rightEdge = artboards.reduce(
(max, ab) => Math.max(max, ab.x + ab.width),
source.x + source.width,
);
const creations = newRoutes.map((r, i) => ({
workspace_id: workspaceId,
project_id: projectId ?? null,
name: r.label,
origin_id: null,
parent_artboard_id: null,
metadata_jsonb: {
x: rightEdge + GAP + i * (source.width + GAP),
y: source.y,
width: source.width,
height: source.height,
renderUrl: source.renderUrl,
route: r.path,
},
}));
Promise.all(creations.map((c) => createArtboardMutation(c)))
.then(() => {
queryClient.invalidateQueries({
queryKey: ['artboards', workspaceId, projectId ?? undefined],
});
})
.catch(console.error)
.finally(() => { pendingRouteCreation.current = false; });
},
[artboards, workspaceId, projectId, queryClient],
);
// Zone tool: drag to draw a completion zone
const zoneStart = useRef<{ x: number; y: number } | null>(null);
const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
@@ -197,7 +250,7 @@ export function Canvas() {
}}
>
{artboards.map((ab) => (
<Artboard key={ab.id} {...ab} />
<Artboard key={ab.id} {...ab} onRoutesDiscovered={handleRoutesDiscovered} />
))}
{/* Zone tool: live drag preview rectangle */}
@@ -29,8 +29,8 @@ export interface LiveArtboardProps {
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;
/** Forwards a CSS property patch from the Design tab into the iframe. */
patchElementStyle?: (nodeId: string, property: string, value: string) => void;
/** Called when the iframe discovers routes in the running app. */
onRoutesDiscovered?: (routes: Array<{ path: string; label: string }>) => void;
style?: React.CSSProperties;
}
@@ -47,6 +47,7 @@ export function LiveArtboard({
onFiberTreeUpdate,
onComponentSelected,
onComponentStylesUpdate,
onRoutesDiscovered,
style,
}: LiveArtboardProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -107,12 +108,15 @@ export function LiveArtboard({
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]);
}, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected, onComponentStylesUpdate, onRoutesDiscovered]);
// ── Push updated design tokens whenever they change ───────────────────────
useEffect(() => {
@@ -125,6 +129,8 @@ export function LiveArtboard({
// 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) {
@@ -137,6 +143,13 @@ export function LiveArtboard({
}
}, [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.
@@ -2,6 +2,7 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useHistory } from '@/store/history';
import { useCanvas } from '@/store/canvas';
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
import type { PropChange } from '@originmain/diff-engine';
@@ -35,6 +36,7 @@ export function SelectionOverlay({
const [selected, setSelected] = useState<SelectionState | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const pushEdit = useHistory(s => s.pushEdit);
const { dispatchRemoveElement } = useCanvas();
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@@ -80,8 +82,13 @@ export function SelectionOverlay({
setSelected(null);
onSelectionChange?.(null);
}
if ((e.key === 'Delete' || e.key === 'Backspace') && selected) {
dispatchRemoveElement(artboardId, selected.nodeId);
setSelected(null);
onSelectionChange?.(null);
}
},
[onSelectionChange]
[onSelectionChange, selected, artboardId, dispatchRemoveElement]
);
return (
@@ -98,10 +105,11 @@ export function SelectionOverlay({
width,
height,
zIndex: 5,
cursor: 'crosshair',
cursor: 'default',
outline: 'none',
}}
>
{hoveredId && fiberRoot && (
{hoveredId && hoveredId !== selected?.nodeId && fiberRoot && (
<HoverHighlight fiberRoot={fiberRoot} nodeId={hoveredId} />
)}
@@ -109,6 +117,7 @@ export function SelectionOverlay({
<SelectionHandles
artboardId={artboardId}
selection={selected}
onSelectionChange={(s) => { setSelected(s); onSelectionChange?.(s); }}
onResizeCommit={(changes) => {
pushEdit(artboardId, {
componentId: selected.nodeId,
@@ -137,8 +146,9 @@ function HoverHighlight({ fiberRoot, nodeId }: { fiberRoot: FiberNode; nodeId: s
top: y,
width,
height,
border: '1px solid rgba(51,133,255,0.4)',
border: '1.5px solid rgba(51,133,255,0.5)',
borderRadius: 2,
background: 'rgba(51,133,255,0.04)',
pointerEvents: 'none',
}}
/>
@@ -150,22 +160,26 @@ function HoverHighlight({ fiberRoot, nodeId }: { fiberRoot: FiberNode; nodeId: s
interface SelectionHandlesProps {
artboardId: string;
selection: SelectionState;
onSelectionChange: (s: SelectionState | null) => void;
onResizeCommit: (changes: PropChange[]) => void;
}
function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps) {
const { rect, nodeName } = selection;
function SelectionHandles({ artboardId, selection, onSelectionChange, onResizeCommit }: SelectionHandlesProps) {
const { rect, nodeName, nodeId } = selection;
const startRect = useRef<DOMRectLike | null>(null);
// liveRectRef tracks the running rect without stale-closure issues.
// liveRect is the React state for rendering only.
const liveRectRef = useRef<DOMRectLike>(rect);
const [liveRect, setLiveRect] = useState(rect);
// onResizeCommitRef ensures onMouseUp always calls the latest callback even if
// the parent re-renders between mousedown and mouseup.
const onResizeCommitRef = useRef(onResizeCommit);
useEffect(() => { onResizeCommitRef.current = onResizeCommit; });
// Track registered drag listeners so we can clean them up on unmount.
const { patchStyleEdit, dispatchRemoveElement } = useCanvas();
// Sync rect when selection changes to a different element
useEffect(() => {
liveRectRef.current = rect;
setLiveRect(rect);
}, [rect]);
const dragListenersRef = useRef<{
move: (e: MouseEvent) => void;
up: (e: MouseEvent) => void;
@@ -186,7 +200,6 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
// Capture the rect at drag-start from the ref (always current).
startRect.current = { ...liveRectRef.current };
const startX = e.clientX;
const startY = e.clientY;
@@ -195,11 +208,13 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
if (!startRect.current) return;
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
// Always apply delta from the START rect, not the previous frame's rect.
// Applying to prev causes exponential drift over the course of a drag.
const next = adjustRect(startRect.current, corner, dx, dy);
liveRectRef.current = next;
setLiveRect(next);
// Live patch into iframe for instant visual feedback
patchStyleEdit(artboardId, nodeId, 'width', `${Math.round(next.width)}px`);
patchStyleEdit(artboardId, nodeId, 'height', `${Math.round(next.height)}px`);
};
const onMouseUp = () => {
@@ -208,26 +223,15 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
dragListenersRef.current = null;
if (!startRect.current) return;
// Read final dimensions from the ref — not from the stale liveRect closure.
const widthChange = liveRectRef.current.width - startRect.current.width;
const heightChange = liveRectRef.current.height - startRect.current.height;
const changes: PropChange[] = [];
if (Math.abs(widthChange) > 0.5) {
changes.push({
key: 'width',
before: startRect.current.width,
after: liveRectRef.current.width,
changeType: 'modified',
});
changes.push({ key: 'width', before: startRect.current.width, after: liveRectRef.current.width, changeType: 'modified' });
}
if (Math.abs(heightChange) > 0.5) {
changes.push({
key: 'height',
before: startRect.current.height,
after: liveRectRef.current.height,
changeType: 'modified',
});
changes.push({ key: 'height', before: startRect.current.height, after: liveRectRef.current.height, changeType: 'modified' });
}
if (changes.length > 0) onResizeCommitRef.current(changes);
startRect.current = null;
@@ -237,11 +241,19 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
},
[] // No reactive deps — all values read from refs
[artboardId, nodeId, patchStyleEdit]
);
const { x, y, width, height } = liveRect;
const HANDLE = 8;
const H = 8; // handle size
// Edge midpoints for the 4 side handles
const edgeHandles: Array<{ id: string; left: number; top: number; cursor: string }> = [
{ id: 'top', left: x + width / 2 - H / 2, top: y - H / 2, cursor: 'ns-resize' },
{ id: 'right', left: x + width - H / 2, top: y + height / 2 - H / 2, cursor: 'ew-resize' },
{ id: 'bottom', left: x + width / 2 - H / 2, top: y + height - H / 2, cursor: 'ns-resize' },
{ id: 'left', left: x - H / 2, top: y + height / 2 - H / 2, cursor: 'ew-resize' },
];
return (
<>
@@ -258,25 +270,67 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
pointerEvents: 'none',
}}
/>
{/* Label */}
{/* Label + action bar */}
<div
style={{
position: 'absolute',
left: x,
top: y - 20,
top: Math.max(0, y - 28),
display: 'flex',
alignItems: 'center',
gap: 6,
pointerEvents: 'auto',
userSelect: 'none',
}}
>
<span style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: 10,
color: '#3385FF',
pointerEvents: 'none',
whiteSpace: 'nowrap',
letterSpacing: '-0.01em',
}}>
{nodeName}
</span>
<span style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: 9,
color: 'rgba(51,133,255,0.55)',
whiteSpace: 'nowrap',
}}>
{Math.round(width)} × {Math.round(height)}
</span>
{/* Delete button */}
<button
title="Delete element (⌫)"
onClick={(e) => {
e.stopPropagation();
dispatchRemoveElement(artboardId, nodeId);
onSelectionChange(null);
}}
style={{
background: 'rgba(255,70,70,0.15)',
border: '1px solid rgba(255,70,70,0.35)',
borderRadius: 4,
color: '#FF6060',
fontSize: 9,
padding: '1px 5px',
cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace",
letterSpacing: '0.03em',
lineHeight: 1.6,
}}
>
{nodeName}
delete
</button>
</div>
{/* Corner handles */}
{/* Corner resize handles */}
{(['tl', 'tr', 'bl', 'br'] as const).map(corner => {
const cx = corner.includes('l') ? x - HANDLE / 2 : x + width - HANDLE / 2;
const cy = corner.includes('t') ? y - HANDLE / 2 : y + height - HANDLE / 2;
const cx = corner.includes('l') ? x - H / 2 : x + width - H / 2;
const cy = corner.includes('t') ? y - H / 2 : y + height - H / 2;
return (
<div
key={corner}
@@ -285,8 +339,8 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
position: 'absolute',
left: cx,
top: cy,
width: HANDLE,
height: HANDLE,
width: H,
height: H,
background: '#fff',
border: '2px solid #3385FF',
borderRadius: 2,
@@ -296,6 +350,25 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
/>
);
})}
{/* Edge handles */}
{edgeHandles.map((eh) => (
<div
key={eh.id}
style={{
position: 'absolute',
left: eh.left,
top: eh.top,
width: H,
height: H,
background: '#fff',
border: '2px solid #3385FF',
borderRadius: 1,
cursor: eh.cursor,
zIndex: 9,
}}
/>
))}
</>
);
}
@@ -147,68 +147,379 @@ export function Inspector() {
);
}
/* ── Design tab ───────────────────────────────────────────── */
/* ── Design tab ─────────────────────────────────────────────── */
// Groups of CSS properties shown in the Design panel, ordered as in Figma.
const DESIGN_SECTIONS: Array<{
label: string;
props: Array<{ key: string; label: string; type: 'color' | 'text' | 'number' }>;
}> = [
{
label: 'Typography',
props: [
{ key: 'font-family', label: 'Family', type: 'text' },
{ key: 'font-size', label: 'Size', type: 'text' },
{ key: 'font-weight', label: 'Weight', type: 'text' },
{ key: 'line-height', label: 'Line H.', type: 'text' },
{ key: 'letter-spacing', label: 'Tracking', type: 'text' },
{ key: 'color', label: 'Color', type: 'color' },
{ key: 'text-align', label: 'Align', type: 'text' },
{ key: 'text-transform', label: 'Transform',type: 'text' },
],
},
{
label: 'Layout',
props: [
{ key: 'display', label: 'Display', type: 'text' },
{ key: 'width', label: 'Width', type: 'text' },
{ key: 'height', label: 'Height', type: 'text' },
{ key: 'padding-top', label: 'Pad↑', type: 'text' },
{ key: 'padding-bottom', label: 'Pad↓', type: 'text' },
{ key: 'padding-left', label: 'Pad←', type: 'text' },
{ key: 'padding-right', label: 'Pad→', type: 'text' },
{ key: 'margin-top', label: 'Mar↑', type: 'text' },
{ key: 'margin-bottom', label: 'Mar↓', type: 'text' },
{ key: 'gap', label: 'Gap', type: 'text' },
{ key: 'flex-direction', label: 'Direction',type: 'text' },
{ key: 'align-items', label: 'Align', type: 'text' },
{ key: 'justify-content', label: 'Justify', type: 'text' },
],
},
{
label: 'Visual',
props: [
{ key: 'background-color', label: 'Fill', type: 'color' },
{ key: 'border-radius', label: 'Radius', type: 'text' },
{ key: 'opacity', label: 'Opacity', type: 'number' },
{ key: 'border-width', label: 'Border W',type: 'text' },
{ key: 'border-color', label: 'Border C',type: 'color' },
{ key: 'border-style', label: 'Border S',type: 'text' },
{ key: 'box-shadow', label: 'Shadow', type: 'text' },
],
},
];
/** Strip the numeric portion from a CSS value: "320px" → "320", "1.5" → "1.5" */
function parseCssNum(val: string | undefined): string {
if (!val) return '';
const m = val.match(/^(-?[\d.]+)/);
return m?.[1] ?? '';
}
/** Converts a computed rgb()/rgba() string into a hex-like color for the picker. */
/** Extract the unit suffix: "320px" → "px", "1.5" → "", "14pt" → "pt" */
function parseCssUnit(val: string | undefined): string {
if (!val) return 'px';
const m = val.match(/^-?[\d.]+(.*)$/);
return m?.[1]?.trim() ?? '';
}
/** rgb()/rgba() → #RRGGBB */
function rgbToHex(rgb: string): string {
const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!m) return '#000000';
const r = parseInt(m[1] ?? '0').toString(16).padStart(2, '0');
const g = parseInt(m[2] ?? '0').toString(16).padStart(2, '0');
const b = parseInt(m[3] ?? '0').toString(16).padStart(2, '0');
return `#${r}${g}${b}`;
return '#' + [m[1], m[2], m[3]]
.map(n => parseInt(n ?? '0').toString(16).padStart(2, '0'))
.join('');
}
/** rgba(..., 0.8) → "80" (percent string, no %) */
function rgbaAlpha(val: string): string {
const m = val.match(/rgba?\(\d+,\s*\d+,\s*\d+(?:,\s*([\d.]+))?\)/);
if (!m) return '100';
const a = m[1] !== undefined ? parseFloat(m[1]) : 1;
return String(Math.round(a * 100));
}
/** Is the CSS color transparent / none? */
function isTransparent(val: string | undefined): boolean {
if (!val) return true;
return val === 'transparent' || val === 'rgba(0, 0, 0, 0)';
}
// ── Compact numeric stepper input ────────────────────────────────
function NumInput({
value,
propKey,
onPatch,
inputWidth = 60,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
inputWidth?: number;
}) {
const T = useCanvasTheme();
const unit = parseCssUnit(value);
const numStr = parseCssNum(value);
const [draft, setDraft] = useState(numStr);
const prevRef = useRef(value);
if (prevRef.current !== value) {
prevRef.current = value;
setDraft(parseCssNum(value));
}
const commit = (v: string) => {
const n = parseFloat(v);
if (!isNaN(n)) onPatch(propKey, `${n}${unit}`);
};
return (
<input
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={() => commit(draft)}
onKeyDown={e => {
if (e.key === 'Enter') { commit(draft); e.currentTarget.blur(); }
if (e.key === 'Escape') setDraft(parseCssNum(value));
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const n = parseFloat(draft) || 0;
const step = e.shiftKey ? 10 : 1;
const next = e.key === 'ArrowUp' ? n + step : n - step;
setDraft(String(next));
onPatch(propKey, `${next}${unit}`);
}
e.stopPropagation();
}}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 6px',
width: inputWidth,
outline: 'none',
textAlign: 'right',
boxSizing: 'border-box',
}}
/>
);
}
// ── Plain text input (e.g. font-family, box-shadow) ────────────────
function TextInput({
value,
propKey,
onPatch,
fullWidth,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
fullWidth?: boolean;
}) {
const T = useCanvasTheme();
const [draft, setDraft] = useState(value);
const prevRef = useRef(value);
if (prevRef.current !== value) { prevRef.current = value; setDraft(value); }
return (
<input
value={draft}
onChange={e => { setDraft(e.target.value); onPatch(propKey, e.target.value); }}
onBlur={() => onPatch(propKey, draft)}
onKeyDown={e => {
if (e.key === 'Enter') { onPatch(propKey, draft); e.currentTarget.blur(); }
if (e.key === 'Escape') { setDraft(value); onPatch(propKey, value); }
e.stopPropagation();
}}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 6px',
width: fullWidth ? '100%' : undefined,
outline: 'none',
boxSizing: 'border-box',
}}
/>
);
}
// ── Color swatch + hex input ──────────────────────────────────────
function ColorInput({
value,
propKey,
onPatch,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
}) {
const T = useCanvasTheme();
const colorRef = useRef<HTMLInputElement>(null);
const hex = value.startsWith('rgb') ? rgbToHex(value) : (value.startsWith('#') ? value : '#000000');
const [hexDraft, setHexDraft] = useState(hex.replace('#', ''));
const prevRef = useRef(value);
if (prevRef.current !== value) {
prevRef.current = value;
setHexDraft((value.startsWith('rgb') ? rgbToHex(value) : value).replace('#', ''));
}
const commitHex = (v: string) => {
const cleaned = v.startsWith('#') ? v : `#${v}`;
onPatch(propKey, cleaned);
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flex: 1 }}>
{/* Colour swatch — click to open native color picker */}
<div
title="Pick colour"
style={{
width: 20, height: 20, borderRadius: 3, flexShrink: 0,
background: hex,
border: '1px solid rgba(255,255,255,0.15)',
cursor: 'pointer',
position: 'relative',
overflow: 'hidden',
}}
onClick={() => colorRef.current?.click()}
>
<input
ref={colorRef}
type="color"
value={hex}
onChange={e => onPatch(propKey, e.target.value)}
style={{
position: 'absolute', inset: 0, opacity: 0,
cursor: 'pointer', width: '100%', height: '100%',
}}
/>
</div>
{/* Hex text */}
<input
value={hexDraft.toUpperCase()}
maxLength={6}
onChange={e => {
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
setHexDraft(v);
if (v.length === 3 || v.length === 6) commitHex(v);
}}
onBlur={() => commitHex(hexDraft)}
onKeyDown={e => {
if (e.key === 'Enter') commitHex(hexDraft);
e.stopPropagation();
}}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 6px',
width: 60,
outline: 'none',
}}
/>
</div>
);
}
// ── Native select styled with design tokens ───────────────────────
function CssSelect({
value,
propKey,
options,
onPatch,
}: {
value: string;
propKey: string;
options: Array<{ val: string; label: string }>;
onPatch: (prop: string, val: string) => void;
}) {
const T = useCanvasTheme();
return (
<select
value={value}
onChange={e => onPatch(propKey, e.target.value)}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 5px',
cursor: 'pointer',
flex: 1,
outline: 'none',
}}
>
{options.map(o => (
<option key={o.val} value={o.val}>{o.label}</option>
))}
</select>
);
}
// ── Toggle group for text-align ───────────────────────────────────
function TextAlignToggle({ value, onPatch }: { value: string; onPatch: (v: string) => void }) {
const T = useCanvasTheme();
const opts = [
{ val: 'left', icon: '⬤ ◻ ◻ ◻' },
{ val: 'center', icon: '◻ ⬤ ⬤ ◻' },
{ val: 'right', icon: '◻ ◻ ◻ ⬤' },
{ val: 'justify', icon: '≡' },
] as const;
const labels: Record<string, string> = { left: 'L', center: 'C', right: 'R', justify: 'J' };
return (
<div style={{ display: 'flex', gap: 2, marginLeft: 'auto' }}>
{opts.map(o => (
<button
key={o.val}
title={o.val}
onClick={() => onPatch(o.val)}
style={{
width: 22, height: 22,
background: o.val === value ? T.accent : T.bgDeep,
border: `1px solid ${o.val === value ? T.accent : T.border}`,
borderRadius: 3, cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: o.val === value ? '#fff' : T.fgMuted,
}}
>
{labels[o.val]}
</button>
))}
</div>
);
}
// ── Toggle group for flex-direction ──────────────────────────────
function FlexDirToggle({ value, onPatch }: { value: string; onPatch: (v: string) => void }) {
const T = useCanvasTheme();
const opts = [
{ val: 'row', icon: '→' },
{ val: 'column', icon: '↓' },
{ val: 'row-reverse', icon: '←' },
{ val: 'column-reverse', icon: '↑' },
] as const;
return (
<div style={{ display: 'flex', gap: 2 }}>
{opts.map(o => (
<button
key={o.val}
title={o.val}
onClick={() => onPatch(o.val)}
style={{
width: 22, height: 22,
background: o.val === value ? T.accent : T.bgDeep,
border: `1px solid ${o.val === value ? T.accent : T.border}`,
borderRadius: 3, cursor: 'pointer', fontSize: '0.625rem',
color: o.val === value ? '#fff' : T.fgMuted,
}}
>
{o.icon}
</button>
))}
</div>
);
}
// ── Sub-section label ─────────────────────────────────────────────
function DesignSectionLabel({ children }: { children: React.ReactNode }) {
const T = useCanvasTheme();
return (
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
fontWeight: 500,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: T.label,
marginBottom: 7,
}}>
{children}
</div>
);
}
// ── Label above a field ───────────────────────────────────────────
function FieldLabel({ children }: { children: React.ReactNode }) {
const T = useCanvasTheme();
return (
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: T.dim,
letterSpacing: '0.04em',
userSelect: 'none',
}}>
{children}
</span>
);
}
// ── Main Design Tab ───────────────────────────────────────────────
function DesignTab({
artboardId,
componentId,
@@ -223,11 +534,11 @@ function DesignTab({
if (!artboardId) {
return (
<Section label="Design">
<div style={{ padding: '32px 16px', textAlign: 'center' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
Select an artboard
</span>
</Section>
</div>
);
}
@@ -239,7 +550,7 @@ function DesignTab({
<circle cx="14" cy="14" r="4" stroke="white" strokeWidth="1.4"/>
</svg>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em', lineHeight: 1.6 }}>
Click a component in the<br/>artboard to inspect & edit
Click a component in the<br/>artboard to inspect &amp; edit
</span>
</div>
);
@@ -247,156 +558,253 @@ function DesignTab({
if (!styles) {
return (
<Section label="Design">
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
Fetching styles
</span>
</Section>
</div>
);
}
const patch = (property: string, value: string) => {
const patch = (prop: string, val: string) => {
if (!artboardId || !componentId) return;
patchStyleEdit(artboardId, componentId, property, value);
patchStyleEdit(artboardId, componentId, prop, val);
};
const s = styles;
const hasFill = !isTransparent(s['background-color']);
const hasTextColor = !!s['color'] && !isTransparent(s['color']);
const hasTypography = !!(s['font-size'] || s['font-family']);
const isFlexLayout = s['display'] === 'flex' || s['display'] === 'inline-flex';
const hasBorder = !!(s['border-width'] && s['border-width'] !== '0px');
return (
<>
{DESIGN_SECTIONS.map((section) => {
// Only render sections that have at least one property present
const populated = section.props.filter((p) => styles[p.key]);
if (populated.length === 0) return null;
return (
<div key={section.label}>
<Section label={section.label}>
{populated.map((p) => (
<DesignRow
key={p.key}
label={p.label}
propKey={p.key}
value={styles[p.key] ?? ''}
type={p.type}
{/* ── Dimensions ────────────────────────────────────── */}
<div style={{ padding: '10px 14px 8px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 10px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>W</FieldLabel>
<NumInput value={s['width'] ?? '0px'} propKey="width" onPatch={patch} inputWidth={88} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>H</FieldLabel>
<NumInput value={s['height'] ?? '0px'} propKey="height" onPatch={patch} inputWidth={88} />
</div>
</div>
</div>
<HSep />
{/* ── Fill ─────────────────────────────────────────── */}
{hasFill && (
<>
<div style={{ padding: '8px 14px' }}>
<DesignSectionLabel>Fill</DesignSectionLabel>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<ColorInput value={s['background-color'] ?? ''} propKey="background-color" onPatch={patch} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0 }}>
<FieldLabel>A%</FieldLabel>
<NumInput
value={rgbaAlpha(s['background-color'] ?? '') + '%'}
propKey="_bgAlpha"
onPatch={(_, v) => {
const pct = parseFloat(v.replace('%', ''));
if (!isNaN(pct)) patch('opacity', String(Math.min(1, Math.max(0, pct / 100))));
}}
inputWidth={44}
/>
</div>
</div>
</div>
<HSep />
</>
)}
{/* ── Text colour ───────────────────────────────────── */}
{hasTextColor && (
<>
<div style={{ padding: '8px 14px' }}>
<DesignSectionLabel>Text Color</DesignSectionLabel>
<ColorInput value={s['color'] ?? ''} propKey="color" onPatch={patch} />
</div>
<HSep />
</>
)}
{/* ── Typography ────────────────────────────────────── */}
{hasTypography && (
<>
<div style={{ padding: '8px 14px' }}>
<DesignSectionLabel>Typography</DesignSectionLabel>
{s['font-family'] && (
<div style={{ marginBottom: 6 }}>
<TextInput value={s['font-family']} propKey="font-family" onPatch={patch} fullWidth />
</div>
)}
{/* Size / Weight / Line-height */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 4, marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Size</FieldLabel>
<NumInput value={s['font-size'] ?? '14px'} propKey="font-size" onPatch={patch} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Weight</FieldLabel>
<NumInput value={s['font-weight'] ?? '400'} propKey="font-weight" onPatch={patch} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Line H</FieldLabel>
<NumInput value={s['line-height'] ?? 'normal'} propKey="line-height" onPatch={patch} />
</div>
</div>
{/* Letter-spacing + text-align toggles */}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Track</FieldLabel>
<NumInput value={s['letter-spacing'] ?? '0px'} propKey="letter-spacing" onPatch={patch} inputWidth={52} />
</div>
<TextAlignToggle
value={s['text-align'] ?? 'left'}
onPatch={(v) => patch('text-align', v)}
/>
</div>
</div>
<HSep />
</>
)}
{/* ── Layout ────────────────────────────────────────── */}
<div style={{ padding: '8px 14px' }}>
<DesignSectionLabel>Layout</DesignSectionLabel>
{/* Display */}
<div style={{ display: 'flex', gap: 4, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Display</FieldLabel>
<CssSelect
value={s['display'] ?? 'block'}
propKey="display"
options={[
{ val: 'block', label: 'block' },
{ val: 'flex', label: 'flex' },
{ val: 'inline-flex', label: 'inline-flex' },
{ val: 'grid', label: 'grid' },
{ val: 'inline-block', label: 'inline-block' },
{ val: 'inline', label: 'inline' },
{ val: 'none', label: 'none' },
]}
onPatch={patch}
/>
))}
</Section>
<HSep />
</div>
);
})}
</>
);
}
function DesignRow({
label,
propKey,
value,
type,
onPatch,
}: {
label: string;
propKey: string;
value: string;
type: 'color' | 'text' | 'number';
onPatch: (property: string, value: string) => void;
}) {
const T = useCanvasTheme();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
// Keep draft in sync when the incoming value changes (e.g. component re-selected)
const prevValue = useRef(value);
if (prevValue.current !== value) {
prevValue.current = value;
setDraft(value);
}
const commit = () => {
setEditing(false);
if (draft.trim() !== value) onPatch(propKey, draft.trim());
};
// Color swatch for color-type props
const hexColor = type === 'color' ? rgbToHex(value) : null;
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8, gap: 6 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: T.key,
flexShrink: 0,
width: 56,
letterSpacing: '-0.01em',
}}>
{label}
</span>
{editing ? (
<div style={{ flex: 1, display: 'flex', gap: 3 }}>
<input
autoFocus
value={draft}
onChange={e => {
setDraft(e.target.value);
// Live preview on every keystroke
onPatch(propKey, e.target.value);
}}
onBlur={commit}
onKeyDown={e => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') { setEditing(false); setDraft(value); onPatch(propKey, value); }
e.stopPropagation();
}}
style={{
flex: 1, minWidth: 0,
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
background: T.bgDeep,
border: `1px solid ${T.accent}`,
borderRadius: 4,
padding: '2px 6px',
color: T.fg,
outline: 'none',
}}
{/* Flex controls */}
{isFlexLayout && (
<>
<div style={{ display: 'flex', gap: 6, marginBottom: 6, alignItems: 'flex-end' }}>
<FlexDirToggle
value={s['flex-direction'] ?? 'row'}
onPatch={(v) => patch('flex-direction', v)}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Gap</FieldLabel>
<NumInput value={s['gap'] ?? '0px'} propKey="gap" onPatch={patch} inputWidth={48} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Align</FieldLabel>
<CssSelect
value={s['align-items'] ?? 'stretch'}
propKey="align-items"
options={[
{ val: 'flex-start', label: 'start' },
{ val: 'center', label: 'center' },
{ val: 'flex-end', label: 'end' },
{ val: 'stretch', label: 'stretch' },
{ val: 'baseline', label: 'baseline' },
]}
onPatch={patch}
/>
</div>
) : (
<div
onClick={() => { setEditing(true); setDraft(value); }}
style={{
flex: 1, display: 'flex', alignItems: 'center', gap: 4,
cursor: 'text',
padding: '2px 4px',
borderRadius: 4,
border: '1px solid transparent',
transition: 'border-color 0.1s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.borderColor = T.border; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.borderColor = 'transparent'; }}
>
{hexColor && (
<span style={{
width: 10, height: 10, borderRadius: 2, flexShrink: 0,
background: hexColor,
border: '1px solid rgba(255,255,255,0.15)',
}} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Justify</FieldLabel>
<CssSelect
value={s['justify-content'] ?? 'flex-start'}
propKey="justify-content"
options={[
{ val: 'flex-start', label: 'start' },
{ val: 'center', label: 'center' },
{ val: 'flex-end', label: 'end' },
{ val: 'space-between', label: 'between' },
{ val: 'space-around', label: 'around' },
{ val: 'space-evenly', label: 'evenly' },
]}
onPatch={patch}
/>
</div>
</div>
</>
)}
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: T.fgMuted,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
letterSpacing: '-0.01em',
}}>
{value}
</span>
{/* Padding — 4-corner grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
{[
{ label: '↑', prop: 'padding-top' },
{ label: '→', prop: 'padding-right' },
{ label: '↓', prop: 'padding-bottom' },
{ label: '←', prop: 'padding-left' },
].map(({ label, prop }) => (
<div key={prop} style={{ display: 'flex', flexDirection: 'column', gap: 2, alignItems: 'center' }}>
<FieldLabel>{label}</FieldLabel>
<NumInput value={s[prop] ?? '0px'} propKey={prop} onPatch={patch} inputWidth={38} />
</div>
))}
</div>
</div>
<HSep />
{/* ── Appearance ────────────────────────────────────── */}
<div style={{ padding: '8px 14px 10px' }}>
<DesignSectionLabel>Appearance</DesignSectionLabel>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 10px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Radius</FieldLabel>
<NumInput value={s['border-radius'] ?? '0px'} propKey="border-radius" onPatch={patch} inputWidth={88} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Opacity</FieldLabel>
<NumInput value={s['opacity'] ?? '1'} propKey="opacity" onPatch={patch} inputWidth={88} />
</div>
</div>
{/* Border — only show if there's a visible border */}
{hasBorder && (
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<FieldLabel>Border Color</FieldLabel>
<div style={{ marginTop: 2 }}>
<ColorInput value={s['border-color'] ?? '#000000'} propKey="border-color" onPatch={patch} />
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Width</FieldLabel>
<NumInput value={s['border-width'] ?? '0px'} propKey="border-width" onPatch={patch} inputWidth={44} />
</div>
</div>
)}
{/* Box shadow — if present */}
{s['box-shadow'] && s['box-shadow'] !== 'none' && (
<div style={{ marginTop: 6 }}>
<FieldLabel>Shadow</FieldLabel>
<div style={{ marginTop: 2 }}>
<TextInput value={s['box-shadow']} propKey="box-shadow" onPatch={patch} fullWidth />
</div>
</div>
)}
</div>
</>
);
}
+10
View File
@@ -42,6 +42,11 @@ interface CanvasStore {
styleEditEvent: { artboardId: string; nodeId: string; property: string; value: string } | null;
patchStyleEdit: (artboardId: string, nodeId: string, property: string, value: string) => void;
clearStyleEdit: () => void;
// ── Element removal mailbox ─────────────────────────────────────────────────
removeElementEvent: { artboardId: string; nodeId: string } | null;
dispatchRemoveElement: (artboardId: string, nodeId: string) => void;
clearRemoveElement: () => void;
}
export const useCanvas = create<CanvasStore>((set) => ({
@@ -80,4 +85,9 @@ export const useCanvas = create<CanvasStore>((set) => ({
patchStyleEdit: (artboardId, nodeId, property, value) =>
set({ styleEditEvent: { artboardId, nodeId, property, value } }),
clearStyleEdit: () => set({ styleEditEvent: null }),
removeElementEvent: null,
dispatchRemoveElement: (artboardId, nodeId) =>
set({ removeElementEvent: { artboardId, nodeId } }),
clearRemoveElement: () => set({ removeElementEvent: null }),
}));
+85 -1
View File
@@ -328,7 +328,7 @@ function installFiberHook(): void {
path?: string;
nodeId?: string;
property?: string;
value?: string;
value?: string | undefined;
};
};
if (!data || data.source !== HOST_SOURCE) return;
@@ -361,6 +361,9 @@ function installFiberHook(): void {
patchElementStyle(msg.nodeId, msg.property, msg.value ?? '');
}
break;
case 'REMOVE_ELEMENT':
if (msg.nodeId) removeElement(msg.nodeId);
break;
}
});
@@ -409,6 +412,87 @@ function installFiberHook(): void {
// element. Non-destructive — does NOT modify source files. The change is
// immediately visible in the live render and can be recorded as a diff.
// ── Route discovery ───────────────────────────────────────────────────────
// Scans same-origin <a href> links in the current page and any React Router /
// Next.js Link components whose props contain an href, then posts the unique
// set of paths as ROUTES_DISCOVERED. Called once after the first React commit
// and again on every SPA navigation.
function humanLabel(path: string): string {
if (path === '/') return 'Home';
return path
.split('/')
.filter(Boolean)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, ' '))
.join(' / ');
}
function discoverRoutes(): void {
const seen = new Set<string>();
const routes: Array<{ path: string; label: string }> = [];
const addRoute = (path: string, hint?: string) => {
if (seen.has(path)) return;
// Skip hash-only anchors and external paths
if (!path || path.startsWith('#')) return;
seen.add(path);
routes.push({ path, label: hint?.trim().slice(0, 50) || humanLabel(path) });
};
// Current route first
addRoute(window.location.pathname, document.title || undefined);
// Scan real <a> elements
document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {
try {
const url = new URL(a.href, window.location.href);
if (url.origin !== window.location.origin) return;
addRoute(url.pathname, a.textContent ?? undefined);
} catch { /* malformed href */ }
});
// Also scan fiber tree for Link / NavLink / next/link props
nodeMap.forEach(({ fiber }) => {
const name = fiber ? getDisplayName(fiber) : null;
if (name && /^(Link|NavLink|NextLink|RouterLink|a)$/.test(name)) {
const href = fiber?.memoizedProps?.['href'] ?? fiber?.memoizedProps?.['to'];
if (typeof href === 'string' && href.startsWith('/')) {
addRoute(href);
}
}
});
if (routes.length > 0) post({ type: 'ROUTES_DISCOVERED', routes });
}
// Re-discover after every React commit (covers lazy-loaded nav items)
const _originalCommit = hook.onCommitFiberRoot;
let routeDiscoveryScheduled = false;
const _wrappedCommitForRoutes = hook.onCommitFiberRoot;
void _wrappedCommitForRoutes; // suppress unused warning — keep original chain intact
// One-time discovery 800ms after first READY (DOM settled)
setTimeout(discoverRoutes, 800);
// Re-discover on every SPA navigation
window.addEventListener('popstate', () => {
if (!routeDiscoveryScheduled) {
routeDiscoveryScheduled = true;
setTimeout(() => { routeDiscoveryScheduled = false; discoverRoutes(); }, 300);
}
});
// ── Element removal ───────────────────────────────────────────────────────
function removeElement(nodeId: string): void {
const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) return;
try {
(info.fiber.stateNode as HTMLElement).style.setProperty('display', 'none');
} catch { /* detached */ }
}
function patchElementStyle(nodeId: string, property: string, value: string): void {
const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
+7 -2
View File
@@ -33,7 +33,9 @@ export type HostMessage =
| { type: 'REQUEST_ELEMENT_STYLES'; nodeId: string }
/** Apply a single CSS property override directly to the component's DOM element.
* Non-destructive sets inline style only; source files are unchanged. */
| { type: 'PATCH_ELEMENT_STYLE'; nodeId: string; property: string; value: string };
| { type: 'PATCH_ELEMENT_STYLE'; nodeId: string; property: string; value: string }
/** Hide a component's DOM element (sets display:none). Non-destructive. */
| { type: 'REMOVE_ELEMENT'; nodeId: string };
export interface HostEnvelope {
source: typeof HOST_SOURCE;
@@ -50,7 +52,10 @@ export type RendererMessage =
| { type: 'COMPONENT_DESELECTED' }
| { type: 'ERROR'; message: string }
/** Response to REQUEST_ELEMENT_STYLES — computed CSS properties for the node. */
| { type: 'ELEMENT_STYLES'; nodeId: string; styles: Record<string, string> };
| { type: 'ELEMENT_STYLES'; nodeId: string; styles: Record<string, string> }
/** All discoverable routes found in the running app sent once after READY
* and again after each SPA navigation. */
| { type: 'ROUTES_DISCOVERED'; routes: Array<{ path: string; label: string }> };
export interface RendererEnvelope {
source: typeof RENDERER_SOURCE;