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