From 82dddb1801d43b192ce0c52319c8d648b28aeabf Mon Sep 17 00:00:00 2001 From: SinachPat Date: Fri, 1 May 2026 01:05:16 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Figma-parity=20design=20editing=20?= =?UTF-8?q?=E2=80=94=20visual=20panel,=20live=20resize,=20route=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + 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 --- .../app/src/components/canvas/Artboard.tsx | 5 +- packages/app/src/components/canvas/Canvas.tsx | 55 +- .../src/components/canvas/LiveArtboard.tsx | 19 +- .../components/canvas/SelectionOverlay.tsx | 161 +++- .../src/components/inspector/Inspector.tsx | 782 +++++++++++++----- packages/app/src/store/canvas.ts | 10 + packages/live-sdk/src/hook.ts | 86 +- packages/renderer/src/protocol.ts | 9 +- 8 files changed, 888 insertions(+), 239 deletions(-) diff --git a/packages/app/src/components/canvas/Artboard.tsx b/packages/app/src/components/canvas/Artboard.tsx index 1689093..757a9b4 100644 --- a/packages/app/src/components/canvas/Artboard.tsx +++ b/packages/app/src/components/canvas/Artboard.tsx @@ -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 onRoutesDiscovered?.(id, routes)} /> ) => { + 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) => ( - + ))} {/* Zone tool: live drag preview rectangle */} diff --git a/packages/app/src/components/canvas/LiveArtboard.tsx b/packages/app/src/components/canvas/LiveArtboard.tsx index f943e31..d39f057 100644 --- a/packages/app/src/components/canvas/LiveArtboard.tsx +++ b/packages/app/src/components/canvas/LiveArtboard.tsx @@ -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) => 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(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. diff --git a/packages/app/src/components/canvas/SelectionOverlay.tsx b/packages/app/src/components/canvas/SelectionOverlay.tsx index 31146d8..41f1de1 100644 --- a/packages/app/src/components/canvas/SelectionOverlay.tsx +++ b/packages/app/src/components/canvas/SelectionOverlay.tsx @@ -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(null); const [hoveredId, setHoveredId] = useState(null); const pushEdit = useHistory(s => s.pushEdit); + const { dispatchRemoveElement } = useCanvas(); const handleClick = useCallback( (e: React.MouseEvent) => { @@ -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 && ( )} @@ -109,6 +117,7 @@ export function SelectionOverlay({ { 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(null); - // liveRectRef tracks the running rect without stale-closure issues. - // liveRect is the React state for rendering only. const liveRectRef = useRef(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 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 */}
+ - {nodeName} + letterSpacing: '-0.01em', + }}> + {nodeName} + + + {Math.round(width)} × {Math.round(height)} + + + {/* Delete button */} +
- {/* 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 (
); })} + + {/* Edge handles */} + {edgeHandles.map((eh) => ( +
+ ))} ); } @@ -329,9 +402,9 @@ function adjustRect( dy: number ): DOMRectLike { let { x, y, width, height } = rect; - if (corner.includes('l')) { x += dx; width = Math.max(8, width - dx); } - else { width = Math.max(8, width + dx); } + if (corner.includes('l')) { x += dx; width = Math.max(8, width - dx); } + else { width = Math.max(8, width + dx); } if (corner.includes('t')) { y += dy; height = Math.max(8, height - dy); } - else { height = Math.max(8, height + dy); } + else { height = Math.max(8, height + dy); } return { x, y, width, height }; } diff --git a/packages/app/src/components/inspector/Inspector.tsx b/packages/app/src/components/inspector/Inspector.tsx index 4f4c5aa..834553a 100644 --- a/packages/app/src/components/inspector/Inspector.tsx +++ b/packages/app/src/components/inspector/Inspector.tsx @@ -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 ( + 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 ( + { 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(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 ( +
+ {/* Colour swatch — click to open native color picker */} +
colorRef.current?.click()} + > + onPatch(propKey, e.target.value)} + style={{ + position: 'absolute', inset: 0, opacity: 0, + cursor: 'pointer', width: '100%', height: '100%', + }} + /> +
+ {/* Hex text */} + { + 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', + }} + /> +
+ ); +} + +// ── 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 ( + + ); +} + +// ── 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 = { left: 'L', center: 'C', right: 'R', justify: 'J' }; + return ( +
+ {opts.map(o => ( + + ))} +
+ ); +} + +// ── 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 ( +
+ {opts.map(o => ( + + ))} +
+ ); +} + +// ── Sub-section label ───────────────────────────────────────────── + +function DesignSectionLabel({ children }: { children: React.ReactNode }) { + const T = useCanvasTheme(); + return ( +
+ {children} +
+ ); +} + +// ── Label above a field ─────────────────────────────────────────── + +function FieldLabel({ children }: { children: React.ReactNode }) { + const T = useCanvasTheme(); + return ( + + {children} + + ); +} + +// ── Main Design Tab ─────────────────────────────────────────────── + function DesignTab({ artboardId, componentId, @@ -223,11 +534,11 @@ function DesignTab({ if (!artboardId) { return ( -
+
Select an artboard -
+
); } @@ -239,7 +550,7 @@ function DesignTab({ - Click a component in the
artboard to inspect & edit + Click a component in the
artboard to inspect & edit
); @@ -247,156 +558,253 @@ function DesignTab({ if (!styles) { return ( -
+
Fetching styles… -
+ ); } - 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 ( -
-
- {populated.map((p) => ( - - ))} -
- + {/* ── Dimensions ────────────────────────────────────── */} +
+
+
+ W +
- ); - })} - - ); -} +
+ H + +
+
+
+ -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); + {/* ── Fill ─────────────────────────────────────────── */} + {hasFill && ( + <> +
+ Fill +
+ +
+ A% + { + const pct = parseFloat(v.replace('%', '')); + if (!isNaN(pct)) patch('opacity', String(Math.min(1, Math.max(0, pct / 100)))); + }} + inputWidth={44} + /> +
+
+
+ + + )} - // 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); - } + {/* ── Text colour ───────────────────────────────────── */} + {hasTextColor && ( + <> +
+ Text Color + +
+ + + )} - const commit = () => { - setEditing(false); - if (draft.trim() !== value) onPatch(propKey, draft.trim()); - }; + {/* ── Typography ────────────────────────────────────── */} + {hasTypography && ( + <> +
+ Typography - // Color swatch for color-type props - const hexColor = type === 'color' ? rgbToHex(value) : null; + {s['font-family'] && ( +
+ +
+ )} - return ( -
- - {label} - + {/* Size / Weight / Line-height */} +
+
+ Size + +
+
+ Weight + +
+
+ Line H + +
+
- {editing ? ( -
- { - 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', - }} + {/* Letter-spacing + text-align toggles */} +
+
+ Track + +
+ patch('text-align', v)} + /> +
+
+ + + )} + + {/* ── Layout ────────────────────────────────────────── */} +
+ Layout + + {/* Display */} +
+ Display +
- ) : ( -
{ 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 && ( - - )} - - {value} - + + {/* Flex controls */} + {isFlexLayout && ( + <> +
+ patch('flex-direction', v)} + /> +
+ Gap + +
+
+
+
+ Align + +
+
+ Justify + +
+
+ + )} + + {/* Padding — 4-corner grid */} +
+ {[ + { label: '↑', prop: 'padding-top' }, + { label: '→', prop: 'padding-right' }, + { label: '↓', prop: 'padding-bottom' }, + { label: '←', prop: 'padding-left' }, + ].map(({ label, prop }) => ( +
+ {label} + +
+ ))}
- )} -
+
+ + + {/* ── Appearance ────────────────────────────────────── */} +
+ Appearance +
+
+ Radius + +
+
+ Opacity + +
+
+ + {/* Border — only show if there's a visible border */} + {hasBorder && ( +
+
+ Border Color +
+ +
+
+
+ Width + +
+
+ )} + + {/* Box shadow — if present */} + {s['box-shadow'] && s['box-shadow'] !== 'none' && ( +
+ Shadow +
+ +
+
+ )} +
+ ); } diff --git a/packages/app/src/store/canvas.ts b/packages/app/src/store/canvas.ts index 8710509..a69e68c 100644 --- a/packages/app/src/store/canvas.ts +++ b/packages/app/src/store/canvas.ts @@ -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((set) => ({ @@ -80,4 +85,9 @@ export const useCanvas = create((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 }), })); diff --git a/packages/live-sdk/src/hook.ts b/packages/live-sdk/src/hook.ts index f483807..88c3356 100644 --- a/packages/live-sdk/src/hook.ts +++ b/packages/live-sdk/src/hook.ts @@ -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
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(); + 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 elements + document.querySelectorAll('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' diff --git a/packages/renderer/src/protocol.ts b/packages/renderer/src/protocol.ts index 543bab4..cac7e44 100644 --- a/packages/renderer/src/protocol.ts +++ b/packages/renderer/src/protocol.ts @@ -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 }; + | { type: 'ELEMENT_STYLES'; nodeId: string; styles: Record } + /** 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;