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:
co-authored by
Claude Sonnet 4.6
parent
edada3091e
commit
82dddb1801
@@ -19,6 +19,8 @@ interface ArtboardProps {
|
|||||||
renderUrl?: string;
|
renderUrl?: string;
|
||||||
/** Route path appended to renderUrl so each artboard can show a different screen. */
|
/** Route path appended to renderUrl so each artboard can show a different screen. */
|
||||||
route?: string;
|
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.
|
/** 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' },
|
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 {
|
const {
|
||||||
selectedArtboardId, selectArtboard, workspaceId, projectId,
|
selectedArtboardId, selectArtboard, workspaceId, projectId,
|
||||||
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles,
|
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles,
|
||||||
@@ -349,6 +351,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route }: A
|
|||||||
onFiberTreeUpdate={handleFiberUpdate}
|
onFiberTreeUpdate={handleFiberUpdate}
|
||||||
onComponentSelected={handleComponentSelected}
|
onComponentSelected={handleComponentSelected}
|
||||||
onComponentStylesUpdate={handleComponentStylesUpdate}
|
onComponentStylesUpdate={handleComponentStylesUpdate}
|
||||||
|
onRoutesDiscovered={(routes) => onRoutesDiscovered?.(id, routes)}
|
||||||
/>
|
/>
|
||||||
<SelectionOverlay
|
<SelectionOverlay
|
||||||
artboardId={id}
|
artboardId={id}
|
||||||
|
|||||||
@@ -22,6 +22,59 @@ export function Canvas() {
|
|||||||
const lastPos = useRef({ x: 0, y: 0 });
|
const lastPos = useRef({ x: 0, y: 0 });
|
||||||
const spaceDown = useRef(false);
|
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
|
// Zone tool: drag to draw a completion zone
|
||||||
const zoneStart = useRef<{ x: number; y: number } | null>(null);
|
const zoneStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: 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) => (
|
{artboards.map((ab) => (
|
||||||
<Artboard key={ab.id} {...ab} />
|
<Artboard key={ab.id} {...ab} onRoutesDiscovered={handleRoutesDiscovered} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Zone tool: live drag preview rectangle */}
|
{/* Zone tool: live drag preview rectangle */}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export interface LiveArtboardProps {
|
|||||||
onComponentSelected?: (nodeId: string) => void;
|
onComponentSelected?: (nodeId: string) => void;
|
||||||
/** Called when the iframe responds with computed CSS properties for a selected element. */
|
/** Called when the iframe responds with computed CSS properties for a selected element. */
|
||||||
onComponentStylesUpdate?: (nodeId: string, styles: Record<string, string>) => void;
|
onComponentStylesUpdate?: (nodeId: string, styles: Record<string, string>) => void;
|
||||||
/** Forwards a CSS property patch from the Design tab into the iframe. */
|
/** Called when the iframe discovers routes in the running app. */
|
||||||
patchElementStyle?: (nodeId: string, property: string, value: string) => void;
|
onRoutesDiscovered?: (routes: Array<{ path: string; label: string }>) => void;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ export function LiveArtboard({
|
|||||||
onFiberTreeUpdate,
|
onFiberTreeUpdate,
|
||||||
onComponentSelected,
|
onComponentSelected,
|
||||||
onComponentStylesUpdate,
|
onComponentStylesUpdate,
|
||||||
|
onRoutesDiscovered,
|
||||||
style,
|
style,
|
||||||
}: LiveArtboardProps) {
|
}: LiveArtboardProps) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
@@ -107,12 +108,15 @@ export function LiveArtboard({
|
|||||||
case 'ELEMENT_STYLES':
|
case 'ELEMENT_STYLES':
|
||||||
onComponentStylesUpdate?.(msg.nodeId, msg.styles);
|
onComponentStylesUpdate?.(msg.nodeId, msg.styles);
|
||||||
break;
|
break;
|
||||||
|
case 'ROUTES_DISCOVERED':
|
||||||
|
onRoutesDiscovered?.(msg.routes);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('message', handleMessage);
|
window.addEventListener('message', handleMessage);
|
||||||
return () => window.removeEventListener('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 ───────────────────────
|
// ── Push updated design tokens whenever they change ───────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,6 +129,8 @@ export function LiveArtboard({
|
|||||||
// this artboard and forwards them to the iframe immediately.
|
// this artboard and forwards them to the iframe immediately.
|
||||||
const styleEditEvent = useCanvas((s) => s.styleEditEvent);
|
const styleEditEvent = useCanvas((s) => s.styleEditEvent);
|
||||||
const clearStyleEdit = useCanvas((s) => s.clearStyleEdit);
|
const clearStyleEdit = useCanvas((s) => s.clearStyleEdit);
|
||||||
|
const removeElementEvent = useCanvas((s) => s.removeElementEvent);
|
||||||
|
const clearRemoveElement = useCanvas((s) => s.clearRemoveElement);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (styleEditEvent?.artboardId === id && isReadyRef.current) {
|
if (styleEditEvent?.artboardId === id && isReadyRef.current) {
|
||||||
@@ -137,6 +143,13 @@ export function LiveArtboard({
|
|||||||
}
|
}
|
||||||
}, [id, styleEditEvent, sendMessage, 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 ────────────────────────────────
|
// ── Sync selection changes into the iframe ────────────────────────────────
|
||||||
// Sends SELECT_COMPONENT on every selectedComponentId change so the blue
|
// Sends SELECT_COMPONENT on every selectedComponentId change so the blue
|
||||||
// highlight ring stays in sync with the canvas selection store.
|
// highlight ring stays in sync with the canvas selection store.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useHistory } from '@/store/history';
|
import { useHistory } from '@/store/history';
|
||||||
|
import { useCanvas } from '@/store/canvas';
|
||||||
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
|
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
|
||||||
import type { PropChange } from '@originmain/diff-engine';
|
import type { PropChange } from '@originmain/diff-engine';
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export function SelectionOverlay({
|
|||||||
const [selected, setSelected] = useState<SelectionState | null>(null);
|
const [selected, setSelected] = useState<SelectionState | null>(null);
|
||||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||||
const pushEdit = useHistory(s => s.pushEdit);
|
const pushEdit = useHistory(s => s.pushEdit);
|
||||||
|
const { dispatchRemoveElement } = useCanvas();
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
@@ -80,8 +82,13 @@ export function SelectionOverlay({
|
|||||||
setSelected(null);
|
setSelected(null);
|
||||||
onSelectionChange?.(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 (
|
return (
|
||||||
@@ -98,10 +105,11 @@ export function SelectionOverlay({
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
zIndex: 5,
|
zIndex: 5,
|
||||||
cursor: 'crosshair',
|
cursor: 'default',
|
||||||
|
outline: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hoveredId && fiberRoot && (
|
{hoveredId && hoveredId !== selected?.nodeId && fiberRoot && (
|
||||||
<HoverHighlight fiberRoot={fiberRoot} nodeId={hoveredId} />
|
<HoverHighlight fiberRoot={fiberRoot} nodeId={hoveredId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -109,6 +117,7 @@ export function SelectionOverlay({
|
|||||||
<SelectionHandles
|
<SelectionHandles
|
||||||
artboardId={artboardId}
|
artboardId={artboardId}
|
||||||
selection={selected}
|
selection={selected}
|
||||||
|
onSelectionChange={(s) => { setSelected(s); onSelectionChange?.(s); }}
|
||||||
onResizeCommit={(changes) => {
|
onResizeCommit={(changes) => {
|
||||||
pushEdit(artboardId, {
|
pushEdit(artboardId, {
|
||||||
componentId: selected.nodeId,
|
componentId: selected.nodeId,
|
||||||
@@ -137,8 +146,9 @@ function HoverHighlight({ fiberRoot, nodeId }: { fiberRoot: FiberNode; nodeId: s
|
|||||||
top: y,
|
top: y,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
border: '1px solid rgba(51,133,255,0.4)',
|
border: '1.5px solid rgba(51,133,255,0.5)',
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
|
background: 'rgba(51,133,255,0.04)',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -150,22 +160,26 @@ function HoverHighlight({ fiberRoot, nodeId }: { fiberRoot: FiberNode; nodeId: s
|
|||||||
interface SelectionHandlesProps {
|
interface SelectionHandlesProps {
|
||||||
artboardId: string;
|
artboardId: string;
|
||||||
selection: SelectionState;
|
selection: SelectionState;
|
||||||
|
onSelectionChange: (s: SelectionState | null) => void;
|
||||||
onResizeCommit: (changes: PropChange[]) => void;
|
onResizeCommit: (changes: PropChange[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps) {
|
function SelectionHandles({ artboardId, selection, onSelectionChange, onResizeCommit }: SelectionHandlesProps) {
|
||||||
const { rect, nodeName } = selection;
|
const { rect, nodeName, nodeId } = selection;
|
||||||
const startRect = useRef<DOMRectLike | null>(null);
|
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 liveRectRef = useRef<DOMRectLike>(rect);
|
||||||
const [liveRect, setLiveRect] = useState(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);
|
const onResizeCommitRef = useRef(onResizeCommit);
|
||||||
useEffect(() => { onResizeCommitRef.current = 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<{
|
const dragListenersRef = useRef<{
|
||||||
move: (e: MouseEvent) => void;
|
move: (e: MouseEvent) => void;
|
||||||
up: (e: MouseEvent) => void;
|
up: (e: MouseEvent) => void;
|
||||||
@@ -186,7 +200,6 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
|
|||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Capture the rect at drag-start from the ref (always current).
|
|
||||||
startRect.current = { ...liveRectRef.current };
|
startRect.current = { ...liveRectRef.current };
|
||||||
const startX = e.clientX;
|
const startX = e.clientX;
|
||||||
const startY = e.clientY;
|
const startY = e.clientY;
|
||||||
@@ -195,11 +208,13 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
|
|||||||
if (!startRect.current) return;
|
if (!startRect.current) return;
|
||||||
const dx = ev.clientX - startX;
|
const dx = ev.clientX - startX;
|
||||||
const dy = ev.clientY - startY;
|
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);
|
const next = adjustRect(startRect.current, corner, dx, dy);
|
||||||
liveRectRef.current = next;
|
liveRectRef.current = next;
|
||||||
setLiveRect(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 = () => {
|
const onMouseUp = () => {
|
||||||
@@ -208,26 +223,15 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
|
|||||||
dragListenersRef.current = null;
|
dragListenersRef.current = null;
|
||||||
|
|
||||||
if (!startRect.current) return;
|
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 heightChange = liveRectRef.current.height - startRect.current.height;
|
||||||
|
|
||||||
const changes: PropChange[] = [];
|
const changes: PropChange[] = [];
|
||||||
if (Math.abs(widthChange) > 0.5) {
|
if (Math.abs(widthChange) > 0.5) {
|
||||||
changes.push({
|
changes.push({ key: 'width', before: startRect.current.width, after: liveRectRef.current.width, changeType: 'modified' });
|
||||||
key: 'width',
|
|
||||||
before: startRect.current.width,
|
|
||||||
after: liveRectRef.current.width,
|
|
||||||
changeType: 'modified',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (Math.abs(heightChange) > 0.5) {
|
if (Math.abs(heightChange) > 0.5) {
|
||||||
changes.push({
|
changes.push({ key: 'height', before: startRect.current.height, after: liveRectRef.current.height, changeType: 'modified' });
|
||||||
key: 'height',
|
|
||||||
before: startRect.current.height,
|
|
||||||
after: liveRectRef.current.height,
|
|
||||||
changeType: 'modified',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (changes.length > 0) onResizeCommitRef.current(changes);
|
if (changes.length > 0) onResizeCommitRef.current(changes);
|
||||||
startRect.current = null;
|
startRect.current = null;
|
||||||
@@ -237,11 +241,19 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
|
|||||||
window.addEventListener('mousemove', onMouseMove);
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
window.addEventListener('mouseup', onMouseUp);
|
window.addEventListener('mouseup', onMouseUp);
|
||||||
},
|
},
|
||||||
[] // No reactive deps — all values read from refs
|
[artboardId, nodeId, patchStyleEdit]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { x, y, width, height } = liveRect;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -258,25 +270,67 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
|
|||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Label */}
|
|
||||||
|
{/* Label + action bar */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: x,
|
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",
|
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: '#3385FF',
|
color: '#3385FF',
|
||||||
pointerEvents: 'none',
|
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
letterSpacing: '-0.01em',
|
||||||
>
|
}}>
|
||||||
{nodeName}
|
{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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕ delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Corner handles */}
|
|
||||||
|
{/* Corner resize handles */}
|
||||||
{(['tl', 'tr', 'bl', 'br'] as const).map(corner => {
|
{(['tl', 'tr', 'bl', 'br'] as const).map(corner => {
|
||||||
const cx = corner.includes('l') ? x - HANDLE / 2 : x + width - HANDLE / 2;
|
const cx = corner.includes('l') ? x - H / 2 : x + width - H / 2;
|
||||||
const cy = corner.includes('t') ? y - HANDLE / 2 : y + height - HANDLE / 2;
|
const cy = corner.includes('t') ? y - H / 2 : y + height - H / 2;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={corner}
|
key={corner}
|
||||||
@@ -285,8 +339,8 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: cx,
|
left: cx,
|
||||||
top: cy,
|
top: cy,
|
||||||
width: HANDLE,
|
width: H,
|
||||||
height: HANDLE,
|
height: H,
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
border: '2px solid #3385FF',
|
border: '2px solid #3385FF',
|
||||||
borderRadius: 2,
|
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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -329,9 +402,9 @@ function adjustRect(
|
|||||||
dy: number
|
dy: number
|
||||||
): DOMRectLike {
|
): DOMRectLike {
|
||||||
let { x, y, width, height } = rect;
|
let { x, y, width, height } = rect;
|
||||||
if (corner.includes('l')) { x += dx; 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); }
|
else { width = Math.max(8, width + dx); }
|
||||||
if (corner.includes('t')) { y += dy; height = Math.max(8, height - dy); }
|
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 };
|
return { x, y, width, height };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,68 +147,379 @@ export function Inspector() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Design tab ───────────────────────────────────────────── */
|
/* ── Design tab ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
// Groups of CSS properties shown in the Design panel, ordered as in Figma.
|
/** Strip the numeric portion from a CSS value: "320px" → "320", "1.5" → "1.5" */
|
||||||
const DESIGN_SECTIONS: Array<{
|
function parseCssNum(val: string | undefined): string {
|
||||||
label: string;
|
if (!val) return '';
|
||||||
props: Array<{ key: string; label: string; type: 'color' | 'text' | 'number' }>;
|
const m = val.match(/^(-?[\d.]+)/);
|
||||||
}> = [
|
return m?.[1] ?? '';
|
||||||
{
|
}
|
||||||
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' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 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 {
|
function rgbToHex(rgb: string): string {
|
||||||
const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||||
if (!m) return '#000000';
|
if (!m) return '#000000';
|
||||||
const r = parseInt(m[1] ?? '0').toString(16).padStart(2, '0');
|
return '#' + [m[1], m[2], m[3]]
|
||||||
const g = parseInt(m[2] ?? '0').toString(16).padStart(2, '0');
|
.map(n => parseInt(n ?? '0').toString(16).padStart(2, '0'))
|
||||||
const b = parseInt(m[3] ?? '0').toString(16).padStart(2, '0');
|
.join('');
|
||||||
return `#${r}${g}${b}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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({
|
function DesignTab({
|
||||||
artboardId,
|
artboardId,
|
||||||
componentId,
|
componentId,
|
||||||
@@ -223,11 +534,11 @@ function DesignTab({
|
|||||||
|
|
||||||
if (!artboardId) {
|
if (!artboardId) {
|
||||||
return (
|
return (
|
||||||
<Section label="Design">
|
<div style={{ padding: '32px 16px', textAlign: 'center' }}>
|
||||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
|
||||||
Select an artboard
|
Select an artboard
|
||||||
</span>
|
</span>
|
||||||
</Section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +550,7 @@ function DesignTab({
|
|||||||
<circle cx="14" cy="14" r="4" stroke="white" strokeWidth="1.4"/>
|
<circle cx="14" cy="14" r="4" stroke="white" strokeWidth="1.4"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em', lineHeight: 1.6 }}>
|
<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 & edit
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -247,156 +558,253 @@ function DesignTab({
|
|||||||
|
|
||||||
if (!styles) {
|
if (!styles) {
|
||||||
return (
|
return (
|
||||||
<Section label="Design">
|
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
|
||||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
|
||||||
Fetching styles…
|
Fetching styles…
|
||||||
</span>
|
</span>
|
||||||
</Section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const patch = (property: string, value: string) => {
|
const patch = (prop: string, val: string) => {
|
||||||
if (!artboardId || !componentId) return;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{DESIGN_SECTIONS.map((section) => {
|
{/* ── Dimensions ────────────────────────────────────── */}
|
||||||
// Only render sections that have at least one property present
|
<div style={{ padding: '10px 14px 8px' }}>
|
||||||
const populated = section.props.filter((p) => styles[p.key]);
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 10px' }}>
|
||||||
if (populated.length === 0) return null;
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
return (
|
<FieldLabel>W</FieldLabel>
|
||||||
<div key={section.label}>
|
<NumInput value={s['width'] ?? '0px'} propKey="width" onPatch={patch} inputWidth={88} />
|
||||||
<Section label={section.label}>
|
|
||||||
{populated.map((p) => (
|
|
||||||
<DesignRow
|
|
||||||
key={p.key}
|
|
||||||
label={p.label}
|
|
||||||
propKey={p.key}
|
|
||||||
value={styles[p.key] ?? ''}
|
|
||||||
type={p.type}
|
|
||||||
onPatch={patch}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Section>
|
|
||||||
<HSep />
|
|
||||||
</div>
|
</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 />
|
||||||
|
|
||||||
function DesignRow({
|
{/* ── Fill ─────────────────────────────────────────── */}
|
||||||
label,
|
{hasFill && (
|
||||||
propKey,
|
<>
|
||||||
value,
|
<div style={{ padding: '8px 14px' }}>
|
||||||
type,
|
<DesignSectionLabel>Fill</DesignSectionLabel>
|
||||||
onPatch,
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
}: {
|
<ColorInput value={s['background-color'] ?? ''} propKey="background-color" onPatch={patch} />
|
||||||
label: string;
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0 }}>
|
||||||
propKey: string;
|
<FieldLabel>A%</FieldLabel>
|
||||||
value: string;
|
<NumInput
|
||||||
type: 'color' | 'text' | 'number';
|
value={rgbaAlpha(s['background-color'] ?? '') + '%'}
|
||||||
onPatch: (property: string, value: string) => void;
|
propKey="_bgAlpha"
|
||||||
}) {
|
onPatch={(_, v) => {
|
||||||
const T = useCanvasTheme();
|
const pct = parseFloat(v.replace('%', ''));
|
||||||
const [editing, setEditing] = useState(false);
|
if (!isNaN(pct)) patch('opacity', String(Math.min(1, Math.max(0, pct / 100))));
|
||||||
const [draft, setDraft] = useState(value);
|
}}
|
||||||
|
inputWidth={44}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HSep />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
// Keep draft in sync when the incoming value changes (e.g. component re-selected)
|
{/* ── Text colour ───────────────────────────────────── */}
|
||||||
const prevValue = useRef(value);
|
{hasTextColor && (
|
||||||
if (prevValue.current !== value) {
|
<>
|
||||||
prevValue.current = value;
|
<div style={{ padding: '8px 14px' }}>
|
||||||
setDraft(value);
|
<DesignSectionLabel>Text Color</DesignSectionLabel>
|
||||||
}
|
<ColorInput value={s['color'] ?? ''} propKey="color" onPatch={patch} />
|
||||||
|
</div>
|
||||||
|
<HSep />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
const commit = () => {
|
{/* ── Typography ────────────────────────────────────── */}
|
||||||
setEditing(false);
|
{hasTypography && (
|
||||||
if (draft.trim() !== value) onPatch(propKey, draft.trim());
|
<>
|
||||||
};
|
<div style={{ padding: '8px 14px' }}>
|
||||||
|
<DesignSectionLabel>Typography</DesignSectionLabel>
|
||||||
|
|
||||||
// Color swatch for color-type props
|
{s['font-family'] && (
|
||||||
const hexColor = type === 'color' ? rgbToHex(value) : null;
|
<div style={{ marginBottom: 6 }}>
|
||||||
|
<TextInput value={s['font-family']} propKey="font-family" onPatch={patch} fullWidth />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{/* Size / Weight / Line-height */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8, gap: 6 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 4, marginBottom: 6 }}>
|
||||||
<span style={{
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
<FieldLabel>Size</FieldLabel>
|
||||||
fontSize: '0.5625rem',
|
<NumInput value={s['font-size'] ?? '14px'} propKey="font-size" onPatch={patch} />
|
||||||
color: T.key,
|
</div>
|
||||||
flexShrink: 0,
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
width: 56,
|
<FieldLabel>Weight</FieldLabel>
|
||||||
letterSpacing: '-0.01em',
|
<NumInput value={s['font-weight'] ?? '400'} propKey="font-weight" onPatch={patch} />
|
||||||
}}>
|
</div>
|
||||||
{label}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
</span>
|
<FieldLabel>Line H</FieldLabel>
|
||||||
|
<NumInput value={s['line-height'] ?? 'normal'} propKey="line-height" onPatch={patch} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{editing ? (
|
{/* Letter-spacing + text-align toggles */}
|
||||||
<div style={{ flex: 1, display: 'flex', gap: 3 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
|
||||||
<input
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
autoFocus
|
<FieldLabel>Track</FieldLabel>
|
||||||
value={draft}
|
<NumInput value={s['letter-spacing'] ?? '0px'} propKey="letter-spacing" onPatch={patch} inputWidth={52} />
|
||||||
onChange={e => {
|
</div>
|
||||||
setDraft(e.target.value);
|
<TextAlignToggle
|
||||||
// Live preview on every keystroke
|
value={s['text-align'] ?? 'left'}
|
||||||
onPatch(propKey, e.target.value);
|
onPatch={(v) => patch('text-align', v)}
|
||||||
}}
|
/>
|
||||||
onBlur={commit}
|
</div>
|
||||||
onKeyDown={e => {
|
</div>
|
||||||
if (e.key === 'Enter') commit();
|
<HSep />
|
||||||
if (e.key === 'Escape') { setEditing(false); setDraft(value); onPatch(propKey, value); }
|
</>
|
||||||
e.stopPropagation();
|
)}
|
||||||
}}
|
|
||||||
style={{
|
{/* ── Layout ────────────────────────────────────────── */}
|
||||||
flex: 1, minWidth: 0,
|
<div style={{ padding: '8px 14px' }}>
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
<DesignSectionLabel>Layout</DesignSectionLabel>
|
||||||
fontSize: '0.5625rem',
|
|
||||||
background: T.bgDeep,
|
{/* Display */}
|
||||||
border: `1px solid ${T.accent}`,
|
<div style={{ display: 'flex', gap: 4, marginBottom: 6, alignItems: 'center' }}>
|
||||||
borderRadius: 4,
|
<FieldLabel>Display</FieldLabel>
|
||||||
padding: '2px 6px',
|
<CssSelect
|
||||||
color: T.fg,
|
value={s['display'] ?? 'block'}
|
||||||
outline: 'none',
|
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}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div
|
{/* Flex controls */}
|
||||||
onClick={() => { setEditing(true); setDraft(value); }}
|
{isFlexLayout && (
|
||||||
style={{
|
<>
|
||||||
flex: 1, display: 'flex', alignItems: 'center', gap: 4,
|
<div style={{ display: 'flex', gap: 6, marginBottom: 6, alignItems: 'flex-end' }}>
|
||||||
cursor: 'text',
|
<FlexDirToggle
|
||||||
padding: '2px 4px',
|
value={s['flex-direction'] ?? 'row'}
|
||||||
borderRadius: 4,
|
onPatch={(v) => patch('flex-direction', v)}
|
||||||
border: '1px solid transparent',
|
/>
|
||||||
transition: 'border-color 0.1s',
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
}}
|
<FieldLabel>Gap</FieldLabel>
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.borderColor = T.border; }}
|
<NumInput value={s['gap'] ?? '0px'} propKey="gap" onPatch={patch} inputWidth={48} />
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.borderColor = 'transparent'; }}
|
</div>
|
||||||
>
|
</div>
|
||||||
{hexColor && (
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, marginBottom: 6 }}>
|
||||||
<span style={{
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
width: 10, height: 10, borderRadius: 2, flexShrink: 0,
|
<FieldLabel>Align</FieldLabel>
|
||||||
background: hexColor,
|
<CssSelect
|
||||||
border: '1px solid rgba(255,255,255,0.15)',
|
value={s['align-items'] ?? 'stretch'}
|
||||||
}} />
|
propKey="align-items"
|
||||||
)}
|
options={[
|
||||||
<span style={{
|
{ val: 'flex-start', label: 'start' },
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
{ val: 'center', label: 'center' },
|
||||||
fontSize: '0.5625rem',
|
{ val: 'flex-end', label: 'end' },
|
||||||
color: T.fgMuted,
|
{ val: 'stretch', label: 'stretch' },
|
||||||
overflow: 'hidden',
|
{ val: 'baseline', label: 'baseline' },
|
||||||
textOverflow: 'ellipsis',
|
]}
|
||||||
whiteSpace: 'nowrap',
|
onPatch={patch}
|
||||||
letterSpacing: '-0.01em',
|
/>
|
||||||
}}>
|
</div>
|
||||||
{value}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
</span>
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
)}
|
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ interface CanvasStore {
|
|||||||
styleEditEvent: { artboardId: string; nodeId: string; property: string; value: string } | null;
|
styleEditEvent: { artboardId: string; nodeId: string; property: string; value: string } | null;
|
||||||
patchStyleEdit: (artboardId: string, nodeId: string, property: string, value: string) => void;
|
patchStyleEdit: (artboardId: string, nodeId: string, property: string, value: string) => void;
|
||||||
clearStyleEdit: () => 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) => ({
|
export const useCanvas = create<CanvasStore>((set) => ({
|
||||||
@@ -80,4 +85,9 @@ export const useCanvas = create<CanvasStore>((set) => ({
|
|||||||
patchStyleEdit: (artboardId, nodeId, property, value) =>
|
patchStyleEdit: (artboardId, nodeId, property, value) =>
|
||||||
set({ styleEditEvent: { artboardId, nodeId, property, value } }),
|
set({ styleEditEvent: { artboardId, nodeId, property, value } }),
|
||||||
clearStyleEdit: () => set({ styleEditEvent: null }),
|
clearStyleEdit: () => set({ styleEditEvent: null }),
|
||||||
|
|
||||||
|
removeElementEvent: null,
|
||||||
|
dispatchRemoveElement: (artboardId, nodeId) =>
|
||||||
|
set({ removeElementEvent: { artboardId, nodeId } }),
|
||||||
|
clearRemoveElement: () => set({ removeElementEvent: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ function installFiberHook(): void {
|
|||||||
path?: string;
|
path?: string;
|
||||||
nodeId?: string;
|
nodeId?: string;
|
||||||
property?: string;
|
property?: string;
|
||||||
value?: string;
|
value?: string | undefined;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
if (!data || data.source !== HOST_SOURCE) return;
|
if (!data || data.source !== HOST_SOURCE) return;
|
||||||
@@ -361,6 +361,9 @@ function installFiberHook(): void {
|
|||||||
patchElementStyle(msg.nodeId, msg.property, msg.value ?? '');
|
patchElementStyle(msg.nodeId, msg.property, msg.value ?? '');
|
||||||
}
|
}
|
||||||
break;
|
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
|
// element. Non-destructive — does NOT modify source files. The change is
|
||||||
// immediately visible in the live render and can be recorded as a diff.
|
// 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 {
|
function patchElementStyle(nodeId: string, property: string, value: string): void {
|
||||||
const info = nodeMap.get(nodeId);
|
const info = nodeMap.get(nodeId);
|
||||||
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ export type HostMessage =
|
|||||||
| { type: 'REQUEST_ELEMENT_STYLES'; nodeId: string }
|
| { type: 'REQUEST_ELEMENT_STYLES'; nodeId: string }
|
||||||
/** Apply a single CSS property override directly to the component's DOM element.
|
/** Apply a single CSS property override directly to the component's DOM element.
|
||||||
* Non-destructive — sets inline style only; source files are unchanged. */
|
* 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 {
|
export interface HostEnvelope {
|
||||||
source: typeof HOST_SOURCE;
|
source: typeof HOST_SOURCE;
|
||||||
@@ -50,7 +52,10 @@ export type RendererMessage =
|
|||||||
| { type: 'COMPONENT_DESELECTED' }
|
| { type: 'COMPONENT_DESELECTED' }
|
||||||
| { type: 'ERROR'; message: string }
|
| { type: 'ERROR'; message: string }
|
||||||
/** Response to REQUEST_ELEMENT_STYLES — computed CSS properties for the node. */
|
/** 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 {
|
export interface RendererEnvelope {
|
||||||
source: typeof RENDERER_SOURCE;
|
source: typeof RENDERER_SOURCE;
|
||||||
|
|||||||
Reference in New Issue
Block a user