made tiny updates
This commit is contained in:
@@ -43,7 +43,7 @@ const DIFF_STATUS_BADGE: Record<string, { color: string; bg: string; label: stri
|
||||
export function Artboard({ id, label, x, y, width, height, renderUrl, route, onRoutesDiscovered }: ArtboardProps) {
|
||||
const {
|
||||
selectedArtboardId, selectArtboard, workspaceId, projectId,
|
||||
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles,
|
||||
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles, setComponentTextFlags,
|
||||
selectedComponentId,
|
||||
} = useCanvas();
|
||||
const selected = selectedArtboardId === id;
|
||||
@@ -81,14 +81,20 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
||||
selectComponent(nodeId, node ?? null);
|
||||
}, [localFiberRoot, selectComponent, setComponentStyles]);
|
||||
|
||||
const handleComponentStylesUpdate = useCallback((nodeId: string, styles: Record<string, string>) => {
|
||||
const handleComponentStylesUpdate = useCallback((
|
||||
nodeId: string,
|
||||
styles: Record<string, string>,
|
||||
hasDirectText: boolean,
|
||||
hasParagraphChildren: boolean,
|
||||
) => {
|
||||
// Guard against stale responses arriving after the user has already clicked
|
||||
// a different component — only apply if artboard and node both still match.
|
||||
const { selectedComponentId: currentId, selectedArtboardId: currentArtboard } = useCanvas.getState();
|
||||
if (currentArtboard === id && currentId === nodeId) {
|
||||
setComponentStyles(styles);
|
||||
setComponentTextFlags(hasDirectText, hasParagraphChildren);
|
||||
}
|
||||
}, [id, setComponentStyles]);
|
||||
}, [id, setComponentStyles, setComponentTextFlags]);
|
||||
|
||||
// ── Drag to reposition ─────────────────────────────────────────────────────
|
||||
const isDragging = useRef(false);
|
||||
@@ -144,8 +150,14 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
||||
fetch(`/api/artboards/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// H-6 fix: `route` was omitted from the PATCH body, so every drag
|
||||
// silently reset the artboard's SPA route back to undefined (root '/').
|
||||
body: JSON.stringify({
|
||||
metadata_jsonb: { x: newX, y: newY, width, height, ...(renderUrl ? { renderUrl } : {}) },
|
||||
metadata_jsonb: {
|
||||
x: newX, y: newY, width, height,
|
||||
...(renderUrl ? { renderUrl } : {}),
|
||||
...(route ? { route } : {}),
|
||||
},
|
||||
}),
|
||||
}).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||
|
||||
@@ -27,8 +27,9 @@ export interface LiveArtboardProps {
|
||||
onReady?: () => void;
|
||||
onFiberTreeUpdate?: (root: FiberNode) => void;
|
||||
onComponentSelected?: (nodeId: string) => void;
|
||||
/** Called when the iframe responds with computed CSS properties for a selected element. */
|
||||
onComponentStylesUpdate?: (nodeId: string, styles: Record<string, string>) => void;
|
||||
/** Called when the iframe responds with computed CSS properties for a selected element.
|
||||
* Also carries structural flags from the ELEMENT_STYLES message. */
|
||||
onComponentStylesUpdate?: (nodeId: string, styles: Record<string, string>, hasDirectText: boolean, hasParagraphChildren: boolean) => void;
|
||||
/** Called when the iframe discovers routes in the running app. */
|
||||
onRoutesDiscovered?: (routes: Array<{ path: string; label: string }>) => void;
|
||||
/** Called when READY fires but no React commits arrive within 4 s — signals a
|
||||
@@ -140,7 +141,7 @@ export function LiveArtboard({
|
||||
onComponentSelected?.('');
|
||||
break;
|
||||
case 'ELEMENT_STYLES':
|
||||
onComponentStylesUpdate?.(msg.nodeId, msg.styles);
|
||||
onComponentStylesUpdate?.(msg.nodeId, msg.styles, msg.hasDirectText, msg.hasParagraphChildren);
|
||||
break;
|
||||
case 'ROUTES_DISCOVERED':
|
||||
onRoutesDiscovered?.(msg.routes);
|
||||
@@ -164,6 +165,8 @@ export function LiveArtboard({
|
||||
// simultaneous width + height patches from resize are both delivered.
|
||||
const styleEditQueue = useCanvas((s) => s.styleEditQueue);
|
||||
const clearStyleEdits = useCanvas((s) => s.clearStyleEdits);
|
||||
const childrenStyleEditQueue = useCanvas((s) => s.childrenStyleEditQueue);
|
||||
const clearChildrenStyleEdits = useCanvas((s) => s.clearChildrenStyleEdits);
|
||||
const removeElementEvent = useCanvas((s) => s.removeElementEvent);
|
||||
const clearRemoveElement = useCanvas((s) => s.clearRemoveElement);
|
||||
|
||||
@@ -176,6 +179,16 @@ export function LiveArtboard({
|
||||
clearStyleEdits(id);
|
||||
}, [id, styleEditQueue, sendMessage, clearStyleEdits]);
|
||||
|
||||
// ── Children style edit queue (PATCH_CHILDREN_STYLE — paragraph spacing etc.) ──
|
||||
useEffect(() => {
|
||||
const mine = childrenStyleEditQueue.filter((e) => e.artboardId === id);
|
||||
if (mine.length === 0 || !isReadyRef.current) return;
|
||||
for (const e of mine) {
|
||||
sendMessage('PATCH_CHILDREN_STYLE', { parentNodeId: e.parentNodeId, selector: e.selector, property: e.property, value: e.value });
|
||||
}
|
||||
clearChildrenStyleEdits(id);
|
||||
}, [id, childrenStyleEditQueue, sendMessage, clearChildrenStyleEdits]);
|
||||
|
||||
useEffect(() => {
|
||||
if (removeElementEvent?.artboardId === id && isReadyRef.current) {
|
||||
sendMessage('REMOVE_ELEMENT', { nodeId: removeElementEvent.nodeId });
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useHistory } from '@/store/history';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import { useViewport } from '@/store/viewport';
|
||||
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
|
||||
import type { PropChange } from '@originmain/diff-engine';
|
||||
|
||||
@@ -37,6 +38,11 @@ export function SelectionOverlay({
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const pushEdit = useHistory(s => s.pushEdit);
|
||||
const { dispatchRemoveElement } = useCanvas();
|
||||
// C-3 fix: domRect values come from getBoundingClientRect() inside the iframe —
|
||||
// they are in the iframe's own unscaled coordinate space (0..frameWidth/Height).
|
||||
// Mouse events on the overlay are in screen space, which is scaled by canvas zoom.
|
||||
// We must divide by zoom to convert screen-space click coords to iframe-space.
|
||||
const zoom = useViewport(s => s.zoom);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -48,8 +54,9 @@ export function SelectionOverlay({
|
||||
}
|
||||
|
||||
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const clickX = e.clientX - overlayRect.left;
|
||||
const clickY = e.clientY - overlayRect.top;
|
||||
// Convert screen-space coords to iframe-space by dividing by zoom.
|
||||
const clickX = (e.clientX - overlayRect.left) / zoom;
|
||||
const clickY = (e.clientY - overlayRect.top) / zoom;
|
||||
|
||||
const hit = hitTestFiber(fiberRoot, clickX, clickY);
|
||||
if (hit) {
|
||||
@@ -61,19 +68,19 @@ export function SelectionOverlay({
|
||||
onSelectionChange?.(null);
|
||||
}
|
||||
},
|
||||
[fiberRoot, onSelectionChange]
|
||||
[fiberRoot, onSelectionChange, zoom]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!fiberRoot) return;
|
||||
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const x = e.clientX - overlayRect.left;
|
||||
const y = e.clientY - overlayRect.top;
|
||||
const x = (e.clientX - overlayRect.left) / zoom;
|
||||
const y = (e.clientY - overlayRect.top) / zoom;
|
||||
const hit = hitTestFiber(fiberRoot, x, y);
|
||||
setHoveredId(hit?.id ?? null);
|
||||
},
|
||||
[fiberRoot]
|
||||
[fiberRoot, zoom]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useViewport } from '@/store/viewport';
|
||||
import { useTheme } from '@/store/theme';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { useWalkthrough } from '@/store/walkthrough';
|
||||
import { useIndexer } from '@/hooks/useIndexer';
|
||||
|
||||
interface AppChromeProps {
|
||||
workspaceId?: string;
|
||||
@@ -29,6 +30,9 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
||||
const CT = useCanvasTheme();
|
||||
const startTour = useWalkthrough((s) => s.start);
|
||||
|
||||
// Connect to the CLI AST indexer (reads window.__OM_INDEX_URL__, no-op if absent)
|
||||
useIndexer();
|
||||
|
||||
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
|
||||
useEffect(() => {
|
||||
if (workspaceId && projectId) setContext(workspaceId, projectId);
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
'use client';
|
||||
|
||||
// ── Shared Design Panel Primitives ────────────────────────────────────────────
|
||||
// Small, reusable input components for the Design Panel sections.
|
||||
// All accept an `onPatch(property, value)` callback that flows up to
|
||||
// useCanvas().patchStyleEdit → PATCH_ELEMENT_STYLE → fiber hook.
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
|
||||
// ── Number / CSS value helpers ────────────────────────────────────────────────
|
||||
|
||||
export function parseCssNum(val: string | undefined): string {
|
||||
if (!val) return '';
|
||||
const m = val.match(/^(-?[\d.]+)/);
|
||||
return m?.[1] ?? '';
|
||||
}
|
||||
|
||||
export function parseCssUnit(val: string | undefined): string {
|
||||
if (!val) return 'px';
|
||||
const m = val.match(/^-?[\d.]+(.*)$/);
|
||||
return m?.[1]?.trim() ?? '';
|
||||
}
|
||||
|
||||
export function rgbToHex(rgb: string): string {
|
||||
const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (!m) return '#000000';
|
||||
return '#' + [m[1], m[2], m[3]]
|
||||
.map(n => parseInt(n ?? '0').toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export 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));
|
||||
}
|
||||
|
||||
export function isTransparent(val: string | undefined): boolean {
|
||||
if (!val) return true;
|
||||
// M-1 fix: browsers differ in their getComputedStyle representation of
|
||||
// transparent. Chrome: "rgba(0, 0, 0, 0)", Firefox: "rgba(0,0,0,0)",
|
||||
// Safari: "transparent". Match all three with a regex.
|
||||
return val === 'transparent' || /^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(val);
|
||||
}
|
||||
|
||||
// ── Section separator ─────────────────────────────────────────────────────────
|
||||
|
||||
export function HSep() {
|
||||
const T = useCanvasTheme();
|
||||
return <div style={{ height: 1, background: T.sep, flexShrink: 0 }} />;
|
||||
}
|
||||
|
||||
// ── Section header with collapse toggle ──────────────────────────────────────
|
||||
|
||||
export function SectionHeader({
|
||||
label,
|
||||
expanded,
|
||||
onToggle,
|
||||
action,
|
||||
}: {
|
||||
label: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
const T = useCanvasTheme();
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '7px 14px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: T.label,
|
||||
flex: 1,
|
||||
textAlign: 'left',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
{action}
|
||||
<span style={{ color: T.dim, fontSize: '0.55rem', marginLeft: 6 }}>
|
||||
{expanded ? '▾' : '▸'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Field label ───────────────────────────────────────────────────────────────
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Numeric stepper input ─────────────────────────────────────────────────────
|
||||
|
||||
export function NumInput({
|
||||
value,
|
||||
propKey,
|
||||
onPatch,
|
||||
inputWidth = 60,
|
||||
readOnly,
|
||||
title,
|
||||
}: {
|
||||
value: string;
|
||||
propKey: string;
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
inputWidth?: number;
|
||||
readOnly?: boolean;
|
||||
title?: string;
|
||||
}) {
|
||||
const T = useCanvasTheme();
|
||||
const unit = parseCssUnit(value);
|
||||
const [draft, setDraft] = useState(parseCssNum(value));
|
||||
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
|
||||
readOnly={readOnly}
|
||||
title={title}
|
||||
value={draft}
|
||||
onChange={e => !readOnly && setDraft(e.target.value)}
|
||||
onBlur={() => !readOnly && commit(draft)}
|
||||
onKeyDown={e => {
|
||||
if (readOnly) return;
|
||||
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: readOnly ? T.bgDeep : T.bgDeep,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 4,
|
||||
color: readOnly ? T.dim : T.fg,
|
||||
padding: '3px 6px',
|
||||
width: inputWidth,
|
||||
outline: 'none',
|
||||
textAlign: 'right',
|
||||
boxSizing: 'border-box',
|
||||
cursor: readOnly ? 'default' : 'text',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plain text input ──────────────────────────────────────────────────────────
|
||||
|
||||
export function TextInput({
|
||||
value,
|
||||
propKey,
|
||||
onPatch,
|
||||
fullWidth,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
propKey: string;
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
fullWidth?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
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}
|
||||
placeholder={placeholder}
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
export 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 }}>
|
||||
<div
|
||||
title="Pick color"
|
||||
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>
|
||||
<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 styled select ──────────────────────────────────────────────────────
|
||||
|
||||
export function CssSelect({
|
||||
value,
|
||||
propKey,
|
||||
options,
|
||||
onPatch,
|
||||
width,
|
||||
}: {
|
||||
value: string;
|
||||
propKey: string;
|
||||
options: Array<{ val: string; label: string }>;
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
width?: number | string;
|
||||
}) {
|
||||
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: width ? undefined : 1,
|
||||
width: width,
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{options.map(o => <option key={o.val} value={o.val}>{o.label}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Icon toggle group ─────────────────────────────────────────────────────────
|
||||
|
||||
export function IconToggleGroup<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onPatch,
|
||||
}: {
|
||||
value: string;
|
||||
options: Array<{ val: T; icon: string; title?: string }>;
|
||||
onPatch: (val: T) => void;
|
||||
}) {
|
||||
const T = useCanvasTheme();
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 2 }}>
|
||||
{options.map(o => (
|
||||
<button
|
||||
key={o.val}
|
||||
title={o.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,
|
||||
}}
|
||||
>
|
||||
{o.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,14 @@ import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import type { PropChange } from '@originmain/diff-engine';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
import type { Artboard, IntentDiff } from '@originmain/origin-graph';
|
||||
import { FrameSection } from './sections/FrameSection';
|
||||
import { LayoutSection } from './sections/LayoutSection';
|
||||
import { FillSection } from './sections/FillSection';
|
||||
import { StrokeSection } from './sections/StrokeSection';
|
||||
import { EffectsSection } from './sections/EffectsSection';
|
||||
import { TypographySection } from './sections/TypographySection';
|
||||
import { BoxModelSection } from './sections/BoxModelSection';
|
||||
import { ConstraintsSection } from './sections/ConstraintsSection';
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
s: '#7DD3A8',
|
||||
@@ -99,6 +107,7 @@ export function Inspector() {
|
||||
<DesignTab
|
||||
artboardId={selectedArtboardId}
|
||||
componentId={selectedComponentId}
|
||||
componentData={selectedComponentData}
|
||||
styles={selectedComponentStyles}
|
||||
/>
|
||||
) : tab === 'props' ? (
|
||||
@@ -523,14 +532,16 @@ function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
function DesignTab({
|
||||
artboardId,
|
||||
componentId,
|
||||
componentData,
|
||||
styles,
|
||||
}: {
|
||||
artboardId: string | null;
|
||||
componentId: string | null;
|
||||
componentData: FiberNode | null;
|
||||
styles: Record<string, string> | null;
|
||||
}) {
|
||||
const T = useCanvasTheme();
|
||||
const { patchStyleEdit } = useCanvas();
|
||||
const { patchStyleEdit, patchChildrenStyleEdit, indexerStatus, selectedComponentHasDirectText, selectedComponentHasParagraphChildren } = useCanvas();
|
||||
|
||||
if (!artboardId) {
|
||||
return (
|
||||
@@ -571,239 +582,98 @@ function DesignTab({
|
||||
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');
|
||||
const patchChildren = (selector: string, prop: string, val: string) => {
|
||||
if (!artboardId || !componentId) return;
|
||||
patchChildrenStyleEdit(artboardId, componentId, selector, prop, val);
|
||||
};
|
||||
|
||||
// ── Derive call-site display ──────────────────────────────────────
|
||||
const callSite = componentData?.callSite;
|
||||
const callSiteLabel = callSite
|
||||
? (() => {
|
||||
// Show the last two path segments for readability: "app/page.tsx:34"
|
||||
const parts = callSite.fileName.replace(/\\/g, '/').split('/');
|
||||
const short = parts.slice(-2).join('/');
|
||||
return `${short}:${callSite.lineNumber}`;
|
||||
})()
|
||||
: null;
|
||||
|
||||
// ── Indexer status dot ────────────────────────────────────────────
|
||||
const indexerDot = {
|
||||
offline: { color: T.dim, title: 'CLI indexer offline' },
|
||||
indexing: { color: '#FFBA7B', title: 'Indexing…' },
|
||||
ready: { color: '#7DD3A8', title: 'Indexer ready' },
|
||||
}[indexerStatus];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Dimensions ────────────────────────────────────── */}
|
||||
<div style={{ padding: '10px 14px 8px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 10px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>W</FieldLabel>
|
||||
<NumInput value={s['width'] ?? '0px'} propKey="width" onPatch={patch} inputWidth={88} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>H</FieldLabel>
|
||||
<NumInput value={s['height'] ?? '0px'} propKey="height" onPatch={patch} inputWidth={88} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HSep />
|
||||
|
||||
{/* ── Fill ─────────────────────────────────────────── */}
|
||||
{hasFill && (
|
||||
<>
|
||||
<div style={{ padding: '8px 14px' }}>
|
||||
<DesignSectionLabel>Fill</DesignSectionLabel>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<ColorInput value={s['background-color'] ?? ''} propKey="background-color" onPatch={patch} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0 }}>
|
||||
<FieldLabel>A%</FieldLabel>
|
||||
<NumInput
|
||||
value={rgbaAlpha(s['background-color'] ?? '') + '%'}
|
||||
propKey="_bgAlpha"
|
||||
onPatch={(_, v) => {
|
||||
const pct = parseFloat(v.replace('%', ''));
|
||||
if (!isNaN(pct)) patch('opacity', String(Math.min(1, Math.max(0, pct / 100))));
|
||||
}}
|
||||
inputWidth={44}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HSep />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Text colour ───────────────────────────────────── */}
|
||||
{hasTextColor && (
|
||||
<>
|
||||
<div style={{ padding: '8px 14px' }}>
|
||||
<DesignSectionLabel>Text Color</DesignSectionLabel>
|
||||
<ColorInput value={s['color'] ?? ''} propKey="color" onPatch={patch} />
|
||||
</div>
|
||||
<HSep />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Typography ────────────────────────────────────── */}
|
||||
{hasTypography && (
|
||||
<>
|
||||
<div style={{ padding: '8px 14px' }}>
|
||||
<DesignSectionLabel>Typography</DesignSectionLabel>
|
||||
|
||||
{s['font-family'] && (
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<TextInput value={s['font-family']} propKey="font-family" onPatch={patch} fullWidth />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Size / Weight / Line-height */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 4, marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Size</FieldLabel>
|
||||
<NumInput value={s['font-size'] ?? '14px'} propKey="font-size" onPatch={patch} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Weight</FieldLabel>
|
||||
<NumInput value={s['font-weight'] ?? '400'} propKey="font-weight" onPatch={patch} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Line H</FieldLabel>
|
||||
<NumInput value={s['line-height'] ?? 'normal'} propKey="line-height" onPatch={patch} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Letter-spacing + text-align toggles */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Track</FieldLabel>
|
||||
<NumInput value={s['letter-spacing'] ?? '0px'} propKey="letter-spacing" onPatch={patch} inputWidth={52} />
|
||||
</div>
|
||||
<TextAlignToggle
|
||||
value={s['text-align'] ?? 'left'}
|
||||
onPatch={(v) => patch('text-align', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HSep />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Layout ────────────────────────────────────────── */}
|
||||
<div style={{ padding: '8px 14px' }}>
|
||||
<DesignSectionLabel>Layout</DesignSectionLabel>
|
||||
|
||||
{/* Display */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, alignItems: 'center' }}>
|
||||
<FieldLabel>Display</FieldLabel>
|
||||
<CssSelect
|
||||
value={s['display'] ?? 'block'}
|
||||
propKey="display"
|
||||
options={[
|
||||
{ val: 'block', label: 'block' },
|
||||
{ val: 'flex', label: 'flex' },
|
||||
{ val: 'inline-flex', label: 'inline-flex' },
|
||||
{ val: 'grid', label: 'grid' },
|
||||
{ val: 'inline-block', label: 'inline-block' },
|
||||
{ val: 'inline', label: 'inline' },
|
||||
{ val: 'none', label: 'none' },
|
||||
]}
|
||||
onPatch={patch}
|
||||
{/* ── Component identity header ────────────────────────────── */}
|
||||
<div style={{
|
||||
padding: '10px 14px 8px',
|
||||
borderBottom: `1px solid ${T.sep}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{/* Component name */}
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 600,
|
||||
color: T.fg,
|
||||
letterSpacing: '-0.01em',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{componentData?.name ?? componentId}
|
||||
</span>
|
||||
{/* Indexer dot */}
|
||||
<div
|
||||
title={indexerDot.title}
|
||||
style={{
|
||||
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
|
||||
background: indexerDot.color,
|
||||
boxShadow: indexerStatus === 'ready' ? `0 0 5px ${indexerDot.color}` : 'none',
|
||||
transition: 'background 0.3s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
{/* Call-site breadcrumb — "used in app/page.tsx:34" */}
|
||||
{callSiteLabel && (
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
color: T.dim,
|
||||
letterSpacing: '0.02em',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={`${callSite?.fileName}:${callSite?.lineNumber}`}
|
||||
>
|
||||
↳ {callSiteLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Section components ───────────────────────────────────── */}
|
||||
<FrameSection styles={styles} onPatch={patch} />
|
||||
<ConstraintsSection styles={styles} onPatch={patch} />
|
||||
<LayoutSection styles={styles} onPatch={patch} />
|
||||
<FillSection styles={styles} onPatch={patch} />
|
||||
<StrokeSection styles={styles} onPatch={patch} />
|
||||
<TypographySection
|
||||
styles={styles}
|
||||
hasDirectText={selectedComponentHasDirectText}
|
||||
hasParagraphChildren={selectedComponentHasParagraphChildren}
|
||||
onPatch={patch}
|
||||
onPatchChildren={patchChildren}
|
||||
/>
|
||||
<EffectsSection styles={styles} onPatch={patch} />
|
||||
<BoxModelSection styles={styles} onPatch={patch} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
// ── Box Model Section — Margin / Padding visual diagram ───────────────────────
|
||||
// Renders a concentric-rectangle diagram (like browser DevTools' box model view).
|
||||
// Outer ring = margin, middle ring = border, inner ring = padding, center = W×H.
|
||||
// Each editable quad shows the current value; clicking focuses an inline input.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { SectionHeader, HSep, parseCssNum, parseCssUnit } from '../DesignInputs';
|
||||
|
||||
interface BoxModelSectionProps {
|
||||
styles: Record<string, string>;
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
}
|
||||
|
||||
// Parse a shorthand-or-individual CSS value for one side.
|
||||
function getSide(styles: Record<string, string>, base: string, side: string): string {
|
||||
const individual = styles[`${base}-${side}`];
|
||||
if (individual) return individual;
|
||||
return styles[base] ?? '0px';
|
||||
}
|
||||
|
||||
// Tiny inline editable cell for a box model value.
|
||||
function BoxCell({
|
||||
prop,
|
||||
value,
|
||||
onPatch,
|
||||
mini = false,
|
||||
style: styleProp,
|
||||
}: {
|
||||
prop: string;
|
||||
value: string;
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
mini?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const T = useCanvasTheme();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const num = parseCssNum(value);
|
||||
const unit = parseCssUnit(value);
|
||||
|
||||
const display = num || '0';
|
||||
|
||||
function commit(raw: string) {
|
||||
const n = parseFloat(raw);
|
||||
if (!isNaN(n)) onPatch(prop, `${n}${unit || 'px'}`);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={() => commit(draft)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commit(draft);
|
||||
if (e.key === 'Escape') setEditing(false);
|
||||
}}
|
||||
style={{
|
||||
width: mini ? 28 : 36,
|
||||
textAlign: 'center',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5625rem',
|
||||
background: T.bgDeep,
|
||||
border: `1px solid ${T.accent}`,
|
||||
borderRadius: 3,
|
||||
color: T.fg,
|
||||
outline: 'none',
|
||||
padding: '1px 3px',
|
||||
...styleProp,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={() => { setEditing(true); setDraft(num || '0'); }}
|
||||
title={`${prop}: ${value}`}
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5625rem',
|
||||
color: T.fg,
|
||||
cursor: 'text',
|
||||
minWidth: mini ? 20 : 28,
|
||||
textAlign: 'center',
|
||||
display: 'inline-block',
|
||||
padding: '1px 2px',
|
||||
borderRadius: 2,
|
||||
transition: 'background 0.1s',
|
||||
...styleProp,
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = T.hoverBg ?? 'rgba(255,255,255,0.06)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoxModelSection({ styles, onPatch }: BoxModelSectionProps) {
|
||||
const T = useCanvasTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Margin values
|
||||
const mt = getSide(styles, 'margin', 'top');
|
||||
const mr = getSide(styles, 'margin', 'right');
|
||||
const mb = getSide(styles, 'margin', 'bottom');
|
||||
const ml = getSide(styles, 'margin', 'left');
|
||||
|
||||
// Padding values
|
||||
const pt = getSide(styles, 'padding', 'top');
|
||||
const pr = getSide(styles, 'padding', 'right');
|
||||
const pb = getSide(styles, 'padding', 'bottom');
|
||||
const pl = getSide(styles, 'padding', 'left');
|
||||
|
||||
// Border values (display only — editable via StrokeSection)
|
||||
const bw = styles['border-width'] ?? '0px';
|
||||
|
||||
// Dimensions
|
||||
const w = parseCssNum(styles['width'] ?? '0px') || '—';
|
||||
const h = parseCssNum(styles['height'] ?? '0px') || '—';
|
||||
|
||||
// Shared ring styles
|
||||
const ringBase: React.CSSProperties = {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.4375rem',
|
||||
color: T.dim,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
left: 6,
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader label="Box Model" expanded={open} onToggle={() => setOpen(!open)} />
|
||||
{open && (
|
||||
<div style={{ padding: '8px 14px 12px' }}>
|
||||
{/* Outermost = margin */}
|
||||
<div style={{
|
||||
...ringBase,
|
||||
background: 'rgba(255,200,100,0.07)',
|
||||
border: `1px solid rgba(255,200,100,0.18)`,
|
||||
borderRadius: 5,
|
||||
padding: '18px 20px',
|
||||
minHeight: 130,
|
||||
}}>
|
||||
<span style={labelStyle}>margin</span>
|
||||
|
||||
{/* Top margin */}
|
||||
<BoxCell prop="margin-top" value={mt} onPatch={onPatch}
|
||||
mini style={{ position: 'absolute', top: 6, left: '50%', transform: 'translateX(-50%)' } as React.CSSProperties} />
|
||||
{/* Bottom margin */}
|
||||
<BoxCell prop="margin-bottom" value={mb} onPatch={onPatch}
|
||||
mini style={{ position: 'absolute', bottom: 6, left: '50%', transform: 'translateX(-50%)' } as React.CSSProperties} />
|
||||
{/* Left margin */}
|
||||
<BoxCell prop="margin-left" value={ml} onPatch={onPatch}
|
||||
mini style={{ position: 'absolute', left: 4, top: '50%', transform: 'translateY(-50%)' } as React.CSSProperties} />
|
||||
{/* Right margin */}
|
||||
<BoxCell prop="margin-right" value={mr} onPatch={onPatch}
|
||||
mini style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' } as React.CSSProperties} />
|
||||
|
||||
{/* Border ring */}
|
||||
<div style={{
|
||||
...ringBase,
|
||||
background: 'rgba(130,170,255,0.07)',
|
||||
border: `1px solid rgba(130,170,255,0.22)`,
|
||||
borderRadius: 4,
|
||||
width: '100%',
|
||||
minHeight: 96,
|
||||
padding: '14px 16px',
|
||||
}}>
|
||||
<span style={{ ...labelStyle, color: 'rgba(130,170,255,0.55)' }}>border</span>
|
||||
{/* Border width — read-only hint */}
|
||||
<span style={{
|
||||
position: 'absolute', top: 6, left: '50%', transform: 'translateX(-50%)',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
|
||||
color: 'rgba(130,170,255,0.55)',
|
||||
}}>
|
||||
{parseCssNum(bw) || '0'}
|
||||
</span>
|
||||
|
||||
{/* Padding ring */}
|
||||
<div style={{
|
||||
...ringBase,
|
||||
background: 'rgba(100,200,130,0.07)',
|
||||
border: `1px solid rgba(100,200,130,0.22)`,
|
||||
borderRadius: 3,
|
||||
width: '100%',
|
||||
minHeight: 60,
|
||||
padding: '10px 12px',
|
||||
}}>
|
||||
<span style={{ ...labelStyle, color: 'rgba(100,200,130,0.55)' }}>padding</span>
|
||||
<BoxCell prop="padding-top" value={pt} onPatch={onPatch}
|
||||
mini style={{ position: 'absolute', top: 4, left: '50%', transform: 'translateX(-50%)' } as React.CSSProperties} />
|
||||
<BoxCell prop="padding-bottom" value={pb} onPatch={onPatch}
|
||||
mini style={{ position: 'absolute', bottom: 4, left: '50%', transform: 'translateX(-50%)' } as React.CSSProperties} />
|
||||
<BoxCell prop="padding-left" value={pl} onPatch={onPatch}
|
||||
mini style={{ position: 'absolute', left: 2, top: '50%', transform: 'translateY(-50%)' } as React.CSSProperties} />
|
||||
<BoxCell prop="padding-right" value={pr} onPatch={onPatch}
|
||||
mini style={{ position: 'absolute', right: 2, top: '50%', transform: 'translateY(-50%)' } as React.CSSProperties} />
|
||||
|
||||
{/* Content dimensions */}
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5625rem',
|
||||
color: T.fg,
|
||||
opacity: 0.7,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{w} × {h}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HSep />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
'use client';
|
||||
|
||||
// ── Constraints Section ───────────────────────────────────────────────────────
|
||||
// Figma-style constraint anchors: 3×3 grid for horizontal and vertical pinning.
|
||||
// Only shown when the element's position is 'absolute' or 'fixed'.
|
||||
//
|
||||
// Horizontal options: Left | Center | Right | Left+Right | Scale
|
||||
// Vertical options: Top | Center | Bottom | Top+Bottom | Scale
|
||||
//
|
||||
// Selecting a constraint writes the appropriate CSS properties:
|
||||
// Left → left: Xpx, removes right
|
||||
// Right → right: Xpx, removes left
|
||||
// Center → left: 50%, transform: translateX(-50%)
|
||||
// Left+Right → both left and right set
|
||||
// Scale → width: X% (relative to parent)
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { SectionHeader, HSep } from '../DesignInputs';
|
||||
|
||||
type HConstraint = 'left' | 'center' | 'right' | 'left+right' | 'scale';
|
||||
type VConstraint = 'top' | 'center' | 'bottom' | 'top+bottom' | 'scale';
|
||||
|
||||
interface ConstraintsSectionProps {
|
||||
styles: Record<string, string>;
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
}
|
||||
|
||||
// ── Transform composition helpers ────────────────────────────────────────────
|
||||
// These keep centering (translateX/Y) from clobbering other transforms such
|
||||
// as CSS `rotate` or `scale` that may be set on the same element. (BUG-4)
|
||||
|
||||
/**
|
||||
* Replace or insert a single CSS translate function inside an existing
|
||||
* transform string without disturbing other functions (rotate, scale, etc.).
|
||||
*/
|
||||
function withTranslateFn(
|
||||
existing: string,
|
||||
fn: 'translateX' | 'translateY',
|
||||
value: string,
|
||||
): string {
|
||||
const newFn = `${fn}(${value})`;
|
||||
if (!existing || existing === 'none') return newFn;
|
||||
const re = new RegExp(`${fn}\\([^)]*\\)`, 'g');
|
||||
if (re.test(existing)) return existing.replace(re, newFn);
|
||||
return `${existing} ${newFn}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all translateX/Y functions from a transform string.
|
||||
* Used when switching to a pin or scale constraint that shouldn't centre.
|
||||
*/
|
||||
function withoutTranslateFns(existing: string): string {
|
||||
if (!existing || existing === 'none') return 'none';
|
||||
const cleaned = existing.replace(/translate[XY]\([^)]*\)\s*/g, '').trim();
|
||||
return cleaned || 'none';
|
||||
}
|
||||
|
||||
// ── Constraint inference ──────────────────────────────────────────────────────
|
||||
// NOTE: `styles` comes from getComputedStyle, so percentage values are
|
||||
// resolved to pixels. We can't detect 'center' from left==='50%' (BUG-5).
|
||||
// Instead we use the presence of a translateX/Y in the transform string as a
|
||||
// proxy — applyH/applyV always add them when centering. This is still
|
||||
// best-effort: after a page reload the inline style is gone and the element
|
||||
// will appear as 'left'/'top' until the user re-applies a constraint.
|
||||
|
||||
/** Infer current H constraint from computed styles */
|
||||
function inferHConstraint(styles: Record<string, string>): HConstraint {
|
||||
const left = styles['left'] ?? 'auto';
|
||||
const right = styles['right'] ?? 'auto';
|
||||
const transform = styles['transform'] ?? '';
|
||||
// left+right must be checked before center to handle the edge case where
|
||||
// both sides are pinned and a translateX somehow also exists.
|
||||
if (left !== 'auto' && right !== 'auto') return 'left+right';
|
||||
// Detect center via translateX in the transform (set by applyH).
|
||||
// The '50%' check is retained as a secondary signal for inline styles.
|
||||
if (transform.includes('translateX') || left.includes('50%')) return 'center';
|
||||
if (right !== 'auto' && left === 'auto') return 'right';
|
||||
return 'left';
|
||||
}
|
||||
|
||||
/** Infer current V constraint from computed styles */
|
||||
function inferVConstraint(styles: Record<string, string>): VConstraint {
|
||||
const top = styles['top'] ?? 'auto';
|
||||
const bottom = styles['bottom'] ?? 'auto';
|
||||
const transform = styles['transform'] ?? '';
|
||||
if (top !== 'auto' && bottom !== 'auto') return 'top+bottom';
|
||||
if (transform.includes('translateY') || top.includes('50%')) return 'center';
|
||||
if (bottom !== 'auto' && top === 'auto') return 'bottom';
|
||||
return 'top';
|
||||
}
|
||||
|
||||
// A single dot in the 3×3 constraint grid.
|
||||
function ConstraintDot({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
const T = useCanvasTheme();
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: active ? T.accent : 'transparent',
|
||||
border: `1.5px solid ${active ? T.accent : 'rgba(255,255,255,0.22)'}`,
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
transition: 'background 0.1s, border-color 0.1s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConstraintsSection({ styles, onPatch }: ConstraintsSectionProps) {
|
||||
const T = useCanvasTheme();
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const isPositioned = styles['position'] === 'absolute' || styles['position'] === 'fixed';
|
||||
if (!isPositioned) return null;
|
||||
|
||||
const hConstraint = inferHConstraint(styles);
|
||||
const vConstraint = inferVConstraint(styles);
|
||||
|
||||
function applyH(c: HConstraint) {
|
||||
const curLeft = styles['left'] ?? '0px';
|
||||
const curRight = styles['right'] ?? '0px';
|
||||
const curTransform = styles['transform'] ?? 'none';
|
||||
switch (c) {
|
||||
case 'left':
|
||||
onPatch('left', curLeft === 'auto' ? '0px' : curLeft);
|
||||
onPatch('right', 'auto');
|
||||
// BUG-15 fix: clear any translateX that was set by a previous center constraint.
|
||||
onPatch('transform', withoutTranslateFns(curTransform));
|
||||
break;
|
||||
case 'right':
|
||||
onPatch('right', curRight === 'auto' ? '0px' : curRight);
|
||||
onPatch('left', 'auto');
|
||||
onPatch('transform', withoutTranslateFns(curTransform));
|
||||
break;
|
||||
case 'center':
|
||||
onPatch('left', '50%');
|
||||
onPatch('right', 'auto');
|
||||
// BUG-4 fix: compose translateX into the existing transform rather than
|
||||
// replacing it, so rotate/scale on the element are preserved.
|
||||
onPatch('transform', withTranslateFn(curTransform, 'translateX', '-50%'));
|
||||
break;
|
||||
case 'left+right':
|
||||
onPatch('left', curLeft === 'auto' ? '0px' : curLeft);
|
||||
onPatch('right', curRight === 'auto' ? '0px' : curRight);
|
||||
onPatch('transform', withoutTranslateFns(curTransform));
|
||||
break;
|
||||
case 'scale':
|
||||
onPatch('width', '100%');
|
||||
onPatch('left', '0px');
|
||||
onPatch('right', 'auto');
|
||||
// BUG-15 fix: clear translateX so a previously-centred element doesn't
|
||||
// remain shifted after switching to scale mode.
|
||||
onPatch('transform', withoutTranslateFns(curTransform));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function applyV(c: VConstraint) {
|
||||
const curTop = styles['top'] ?? '0px';
|
||||
const curBottom = styles['bottom'] ?? '0px';
|
||||
const curTransform = styles['transform'] ?? 'none';
|
||||
switch (c) {
|
||||
case 'top':
|
||||
onPatch('top', curTop === 'auto' ? '0px' : curTop);
|
||||
onPatch('bottom', 'auto');
|
||||
onPatch('transform', withoutTranslateFns(curTransform));
|
||||
break;
|
||||
case 'bottom':
|
||||
onPatch('bottom', curBottom === 'auto' ? '0px' : curBottom);
|
||||
onPatch('top', 'auto');
|
||||
onPatch('transform', withoutTranslateFns(curTransform));
|
||||
break;
|
||||
case 'center':
|
||||
onPatch('top', '50%');
|
||||
onPatch('bottom', 'auto');
|
||||
// BUG-4 fix: compose translateY, preserving other transform functions.
|
||||
onPatch('transform', withTranslateFn(curTransform, 'translateY', '-50%'));
|
||||
break;
|
||||
case 'top+bottom':
|
||||
onPatch('top', curTop === 'auto' ? '0px' : curTop);
|
||||
onPatch('bottom', curBottom === 'auto' ? '0px' : curBottom);
|
||||
onPatch('transform', withoutTranslateFns(curTransform));
|
||||
break;
|
||||
case 'scale':
|
||||
onPatch('height', '100%');
|
||||
onPatch('top', '0px');
|
||||
onPatch('bottom', 'auto');
|
||||
onPatch('transform', withoutTranslateFns(curTransform));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The 3×3 grid encodes:
|
||||
// Row/col 0 = start (left/top)
|
||||
// Row/col 1 = center
|
||||
// Row/col 2 = end (right/bottom)
|
||||
// Both H and V dots share the same grid; the horizontal line represents H constraints
|
||||
// and the vertical line represents V constraints.
|
||||
//
|
||||
// TL T TR ← these dots are the 9 "anchor" positions
|
||||
// L C R
|
||||
// BL B BR
|
||||
|
||||
type GridPos = [number, number]; // [col, row]
|
||||
|
||||
const hPositions: Array<{ pos: GridPos; h: HConstraint; label: string }> = [
|
||||
{ pos: [0, 1], h: 'left', label: 'Pin left' },
|
||||
{ pos: [1, 1], h: 'center', label: 'Center horizontally' },
|
||||
{ pos: [2, 1], h: 'right', label: 'Pin right' },
|
||||
];
|
||||
|
||||
const vPositions: Array<{ pos: GridPos; v: VConstraint; label: string }> = [
|
||||
{ pos: [1, 0], v: 'top', label: 'Pin top' },
|
||||
{ pos: [1, 1], v: 'center', label: 'Center vertically' },
|
||||
{ pos: [1, 2], v: 'bottom', label: 'Pin bottom' },
|
||||
];
|
||||
|
||||
const cornerLabels: Record<string, string> = {
|
||||
'0,0': 'Pin top-left', '1,0': '', '2,0': 'Pin top-right',
|
||||
'0,1': '', '1,1': '', '2,1': '',
|
||||
'0,2': 'Pin bottom-left', '1,2': '', '2,2': 'Pin bottom-right',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader label="Constraints" expanded={open} onToggle={() => setOpen(!open)} />
|
||||
{open && (
|
||||
<div style={{ padding: '8px 14px 12px' }}>
|
||||
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
|
||||
{/* 3×3 dot grid */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 10px)',
|
||||
gridTemplateRows: 'repeat(3, 10px)',
|
||||
gap: 7,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Guide lines inside the grid */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: '50%', top: 4, bottom: 4,
|
||||
width: 1, background: 'rgba(255,255,255,0.08)',
|
||||
transform: 'translateX(-50%)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%', left: 4, right: 4,
|
||||
height: 1, background: 'rgba(255,255,255,0.08)',
|
||||
transform: 'translateY(-50%)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* Render 9 dots */}
|
||||
{[0, 1, 2].flatMap(row =>
|
||||
[0, 1, 2].map(col => {
|
||||
const key = `${col},${row}`;
|
||||
const hMatch = hPositions.find(p => p.pos[0] === col && p.pos[1] === row);
|
||||
const vMatch = vPositions.find(p => p.pos[0] === col && p.pos[1] === row);
|
||||
const isActiveH = hMatch ? hConstraint === hMatch.h : false;
|
||||
const isActiveV = vMatch ? vConstraint === vMatch.v : false;
|
||||
const isActive = isActiveH || isActiveV;
|
||||
const label = hMatch?.label ?? vMatch?.label ?? cornerLabels[key] ?? '';
|
||||
return (
|
||||
<ConstraintDot
|
||||
key={key}
|
||||
active={isActive}
|
||||
label={label}
|
||||
onClick={() => {
|
||||
if (hMatch) applyH(hMatch.h);
|
||||
if (vMatch) applyV(vMatch.v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text labels for current constraints */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem', color: T.dim, width: 18, flexShrink: 0,
|
||||
}}>H</span>
|
||||
<div style={{ display: 'flex', gap: 3 }}>
|
||||
{(['left', 'center', 'right', 'left+right', 'scale'] as HConstraint[]).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => applyH(c)}
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
padding: '2px 5px',
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${hConstraint === c ? T.accent : 'rgba(255,255,255,0.12)'}`,
|
||||
background: hConstraint === c ? `${T.accent}22` : 'transparent',
|
||||
color: hConstraint === c ? T.accent : T.dim,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.1s',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{c === 'left+right' ? '↔' : c === 'scale' ? '%' : c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem', color: T.dim, width: 18, flexShrink: 0,
|
||||
}}>V</span>
|
||||
<div style={{ display: 'flex', gap: 3 }}>
|
||||
{(['top', 'center', 'bottom', 'top+bottom', 'scale'] as VConstraint[]).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => applyV(c)}
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
padding: '2px 5px',
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${vConstraint === c ? T.accent : 'rgba(255,255,255,0.12)'}`,
|
||||
background: vConstraint === c ? `${T.accent}22` : 'transparent',
|
||||
color: vConstraint === c ? T.accent : T.dim,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.1s',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{c === 'top+bottom' ? '↕' : c === 'scale' ? '%' : c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HSep />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
// ── Effects Section — Box Shadow & Filters ────────────────────────────────────
|
||||
// Surfaces box-shadow (drop shadow / inner shadow), filter: blur, backdrop-filter.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { NumInput, ColorInput, FieldLabel, SectionHeader, HSep } from '../DesignInputs';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
|
||||
// ── Filter helpers ────────────────────────────────────────────────────────────
|
||||
// BUG-9 fix: naive .replace('blur(','') breaks for multi-function filter
|
||||
// strings like "brightness(1.1) blur(4px)". Use regex-based extraction and
|
||||
// surgical replacement instead.
|
||||
|
||||
/** Extract the numeric argument of blur() from a CSS filter string. */
|
||||
function extractBlurValue(filter: string): string {
|
||||
const m = filter.match(/\bblur\(([\d.]+[a-z%]*)\)/);
|
||||
return m?.[1] ?? '0px';
|
||||
}
|
||||
|
||||
/** Replace (or insert) the blur() function in a filter string, preserving others. */
|
||||
function patchBlurInFilter(filter: string, newVal: string): string {
|
||||
const fn = `blur(${newVal})`;
|
||||
if (!filter || filter === 'none') return fn;
|
||||
if (/\bblur\(/.test(filter)) return filter.replace(/\bblur\([^)]*\)/, fn);
|
||||
return `${filter} ${fn}`;
|
||||
}
|
||||
|
||||
interface EffectsSectionProps {
|
||||
styles: Record<string, string>;
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
}
|
||||
|
||||
export function EffectsSection({ styles, onPatch }: EffectsSectionProps) {
|
||||
const T = useCanvasTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const boxShadow = styles['box-shadow'] ?? '';
|
||||
const filterBlur = styles['filter'] ?? '';
|
||||
const backdropBlur = styles['backdrop-filter'] ?? '';
|
||||
|
||||
const hasEffect = boxShadow && boxShadow !== 'none'
|
||||
|| filterBlur && filterBlur !== 'none'
|
||||
|| backdropBlur && backdropBlur !== 'none';
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader label="Effects" expanded={open} onToggle={() => setOpen(!open)} />
|
||||
{open && (
|
||||
<div style={{ padding: '0 14px 10px' }}>
|
||||
{/* Box shadow display */}
|
||||
{boxShadow && boxShadow !== 'none' ? (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<FieldLabel>Shadow</FieldLabel>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
color: T.fgMuted,
|
||||
background: T.bgDeep,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 4,
|
||||
padding: '4px 6px',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{boxShadow}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onPatch('box-shadow', 'none')}
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'rgba(255,80,80,0.7)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Remove shadow
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onPatch('box-shadow', '0 2px 8px rgba(0,0,0,0.3)')}
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
padding: '4px 8px',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px dashed rgba(255,255,255,0.15)',
|
||||
borderRadius: 4,
|
||||
color: 'rgba(255,255,255,0.3)',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.06em',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
+ Add shadow
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Layer blur */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<FieldLabel>Layer blur</FieldLabel>
|
||||
<NumInput
|
||||
value={extractBlurValue(filterBlur)}
|
||||
propKey="filter"
|
||||
onPatch={(_, v) => onPatch('filter', patchBlurInFilter(filterBlur, v))}
|
||||
inputWidth={52}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Background blur */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FieldLabel>BG blur</FieldLabel>
|
||||
<NumInput
|
||||
value={extractBlurValue(backdropBlur)}
|
||||
propKey="backdrop-filter"
|
||||
onPatch={(_, v) => onPatch('backdrop-filter', patchBlurInFilter(backdropBlur, v))}
|
||||
inputWidth={52}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HSep />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
// ── Fill Section — Background Color & Opacity ─────────────────────────────────
|
||||
// Shows fill color, opacity, and a "no fill" empty state.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { isTransparent, ColorInput, NumInput, FieldLabel, SectionHeader, HSep } from '../DesignInputs';
|
||||
|
||||
interface FillSectionProps {
|
||||
styles: Record<string, string>;
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
}
|
||||
|
||||
export function FillSection({ styles, onPatch }: FillSectionProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const bg = styles['background-color'] ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader label="Fill" expanded={open} onToggle={() => setOpen(!open)} />
|
||||
{open && (
|
||||
<div style={{ padding: '0 14px 10px' }}>
|
||||
{isTransparent(bg) ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => onPatch('background-color', '#ffffff')}
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
padding: '4px 8px',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px dashed rgba(255,255,255,0.15)',
|
||||
borderRadius: 4,
|
||||
color: 'rgba(255,255,255,0.3)',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.06em',
|
||||
}}
|
||||
>
|
||||
+ Add fill
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ColorInput value={bg} propKey="background-color" onPatch={onPatch} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0 }}>
|
||||
<FieldLabel>Opacity</FieldLabel>
|
||||
<NumInput
|
||||
value={styles['opacity'] !== undefined ? `${Math.round(parseFloat(styles['opacity'] ?? '1') * 100)}%` : '100%'}
|
||||
propKey="_opacity"
|
||||
onPatch={(_, v) => {
|
||||
const pct = parseFloat(v.replace('%', ''));
|
||||
if (!isNaN(pct)) onPatch('opacity', String(Math.min(1, Math.max(0, pct / 100))));
|
||||
}}
|
||||
inputWidth={44}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<HSep />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
// ── Frame Section — Position & Size ──────────────────────────────────────────
|
||||
// Shows X, Y (read-only for static; editable for absolute/fixed), W, H,
|
||||
// rotation, and corner radius.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { NumInput, FieldLabel, SectionHeader, HSep } from '../DesignInputs';
|
||||
|
||||
// ── Rotation helpers ──────────────────────────────────────────────────────────
|
||||
// We use the CSS Transforms Level 2 `rotate` property rather than the
|
||||
// `transform` shorthand. This keeps rotation orthogonal to any
|
||||
// translateX(-50%) centering applied by the Constraints section.
|
||||
//
|
||||
// For display purposes we also try to parse an existing `transform: matrix(…)`
|
||||
// so pre-existing rotated elements show their angle on first selection.
|
||||
|
||||
/** Extract rotation degrees from the CSS `rotate` property or a `matrix()` transform. */
|
||||
function readRotationDeg(styles: Record<string, string>): string {
|
||||
// CSS Transforms Level 2: `rotate: 45deg` — use this if present.
|
||||
const rotateProp = styles['rotate'];
|
||||
if (rotateProp && rotateProp !== 'none') {
|
||||
const m = rotateProp.match(/^(-?[\d.]+)deg$/);
|
||||
if (m) return m[1] ?? '0';
|
||||
}
|
||||
// Fallback: extract angle from a computed matrix(a,b,c,d,tx,ty).
|
||||
const transform = styles['transform'];
|
||||
if (transform && transform.startsWith('matrix(')) {
|
||||
const parts = transform.slice(7, -1).split(',');
|
||||
const a = parseFloat(parts[0] ?? '1');
|
||||
const b = parseFloat(parts[1] ?? '0');
|
||||
const deg = Math.round(Math.atan2(b, a) * (180 / Math.PI));
|
||||
return String(deg);
|
||||
}
|
||||
return '0';
|
||||
}
|
||||
|
||||
interface FrameSectionProps {
|
||||
styles: Record<string, string>;
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
}
|
||||
|
||||
export function FrameSection({ styles, onPatch }: FrameSectionProps) {
|
||||
const T = useCanvasTheme();
|
||||
const [open, setOpen] = useState(true);
|
||||
const isPositioned = styles['position'] === 'absolute' || styles['position'] === 'fixed';
|
||||
|
||||
// Corner radius: parse uniform value
|
||||
const borderRadius = styles['border-radius'] ?? '0px';
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader label="Frame" expanded={open} onToggle={() => setOpen(!open)} />
|
||||
{open && (
|
||||
<div style={{ padding: '0 14px 10px' }}>
|
||||
{/* X / Y */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>X</FieldLabel>
|
||||
<NumInput
|
||||
value={styles['left'] ?? '0px'}
|
||||
propKey="left"
|
||||
onPatch={onPatch}
|
||||
inputWidth={80}
|
||||
readOnly={!isPositioned}
|
||||
{...(!isPositioned && { title: 'Set position: absolute to edit' })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Y</FieldLabel>
|
||||
<NumInput
|
||||
value={styles['top'] ?? '0px'}
|
||||
propKey="top"
|
||||
onPatch={onPatch}
|
||||
inputWidth={80}
|
||||
readOnly={!isPositioned}
|
||||
{...(!isPositioned && { title: 'Set position: absolute to edit' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* W / H */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>W</FieldLabel>
|
||||
<NumInput value={styles['width'] ?? '0px'} propKey="width" onPatch={onPatch} inputWidth={80} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>H</FieldLabel>
|
||||
<NumInput value={styles['height'] ?? '0px'} propKey="height" onPatch={onPatch} inputWidth={80} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rotation — writes to the `rotate` CSS property (Transforms Level 2)
|
||||
so it doesn't clobber translateX(-50%) from Constraints centering. */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Rotation °</FieldLabel>
|
||||
<NumInput
|
||||
value={readRotationDeg(styles) + 'deg'}
|
||||
propKey="rotate"
|
||||
onPatch={onPatch}
|
||||
inputWidth={80}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Radius</FieldLabel>
|
||||
<NumInput value={borderRadius} propKey="border-radius" onPatch={onPatch} inputWidth={80} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overflow / clip */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FieldLabel>Overflow</FieldLabel>
|
||||
<select
|
||||
value={styles['overflow'] ?? 'visible'}
|
||||
onChange={e => onPatch('overflow', 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',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="visible">visible</option>
|
||||
<option value="hidden">hidden</option>
|
||||
<option value="auto">auto</option>
|
||||
<option value="scroll">scroll</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HSep />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
// ── Layout Section — Display / Flex / Grid / Padding ─────────────────────────
|
||||
// Adapts to the element's display mode: block (collapsed), flex, grid.
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { NumInput, TextInput, FieldLabel, SectionHeader, CssSelect, IconToggleGroup, HSep } from '../DesignInputs';
|
||||
|
||||
interface LayoutSectionProps {
|
||||
styles: Record<string, string>;
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
}
|
||||
|
||||
export function LayoutSection({ styles, onPatch }: LayoutSectionProps) {
|
||||
const display = styles['display'] ?? 'block';
|
||||
// BUG-20 fix: useState only runs its initialiser once. When the user selects
|
||||
// a different component the `display` prop changes but `open` would stay
|
||||
// stale. We sync it on display changes while preserving explicit user
|
||||
// toggles: if display changes (new component selected), auto-open for
|
||||
// flex/grid, auto-close for block.
|
||||
const [open, setOpen] = useState(display === 'flex' || display === 'grid' || display === 'inline-flex');
|
||||
const prevDisplayRef = useRef(display);
|
||||
useEffect(() => {
|
||||
if (prevDisplayRef.current !== display) {
|
||||
prevDisplayRef.current = display;
|
||||
setOpen(display === 'flex' || display === 'grid' || display === 'inline-flex' || display === 'inline-grid');
|
||||
}
|
||||
}, [display]);
|
||||
|
||||
const isFlex = display === 'flex' || display === 'inline-flex';
|
||||
const isGrid = display === 'grid' || display === 'inline-grid';
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader label="Layout" expanded={open} onToggle={() => setOpen(!open)} />
|
||||
{open && (
|
||||
<div style={{ padding: '0 14px 10px' }}>
|
||||
{/* Display mode */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||
<FieldLabel>Display</FieldLabel>
|
||||
<CssSelect
|
||||
value={display}
|
||||
propKey="display"
|
||||
options={[
|
||||
{ val: 'block', label: 'block' },
|
||||
{ val: 'flex', label: 'flex' },
|
||||
{ val: 'inline-flex', label: 'inline-flex' },
|
||||
{ val: 'grid', label: 'grid' },
|
||||
{ val: 'inline-grid', label: 'inline-grid' },
|
||||
{ val: 'inline-block', label: 'inline-block' },
|
||||
{ val: 'inline', label: 'inline' },
|
||||
{ val: 'none', label: 'none' },
|
||||
]}
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Flex-specific controls */}
|
||||
{isFlex && (
|
||||
<>
|
||||
{/* Direction + Wrap */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
|
||||
<FieldLabel>Direction</FieldLabel>
|
||||
<IconToggleGroup
|
||||
value={styles['flex-direction'] ?? 'row'}
|
||||
options={[
|
||||
{ val: 'row', icon: '→', title: 'Row' },
|
||||
{ val: 'column', icon: '↓', title: 'Column' },
|
||||
{ val: 'row-reverse', icon: '←', title: 'Row reverse' },
|
||||
{ val: 'column-reverse', icon: '↑', title: 'Column reverse' },
|
||||
]}
|
||||
onPatch={v => onPatch('flex-direction', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
|
||||
<FieldLabel>Wrap</FieldLabel>
|
||||
<IconToggleGroup
|
||||
value={styles['flex-wrap'] ?? 'nowrap'}
|
||||
options={[
|
||||
{ val: 'nowrap', icon: '⟷', title: 'No wrap' },
|
||||
{ val: 'wrap', icon: '↩', title: 'Wrap' },
|
||||
]}
|
||||
onPatch={v => onPatch('flex-wrap', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Align items */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
|
||||
<FieldLabel>Align</FieldLabel>
|
||||
<CssSelect
|
||||
value={styles['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={onPatch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Justify content */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
|
||||
<FieldLabel>Justify</FieldLabel>
|
||||
<CssSelect
|
||||
value={styles['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={onPatch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gap */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
|
||||
<FieldLabel>Gap</FieldLabel>
|
||||
<NumInput value={styles['gap'] ?? '0px'} propKey="gap" onPatch={onPatch} inputWidth={60} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Grid-specific controls */}
|
||||
{isGrid && (
|
||||
<>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<FieldLabel>Columns</FieldLabel>
|
||||
<TextInput value={styles['grid-template-columns'] ?? ''} propKey="grid-template-columns" onPatch={onPatch} fullWidth placeholder="1fr 1fr" />
|
||||
</div>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<FieldLabel>Rows</FieldLabel>
|
||||
<TextInput value={styles['grid-template-rows'] ?? ''} propKey="grid-template-rows" onPatch={onPatch} fullWidth placeholder="auto" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
|
||||
<FieldLabel>Col gap</FieldLabel>
|
||||
<NumInput value={styles['column-gap'] ?? '0px'} propKey="column-gap" onPatch={onPatch} inputWidth={60} />
|
||||
<FieldLabel>Row gap</FieldLabel>
|
||||
<NumInput value={styles['row-gap'] ?? '0px'} propKey="row-gap" onPatch={onPatch} inputWidth={60} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
|
||||
<FieldLabel>Align</FieldLabel>
|
||||
<CssSelect
|
||||
value={styles['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' },
|
||||
]}
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Padding (always shown) */}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<FieldLabel>Padding</FieldLabel>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 6px', marginTop: 4 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Top</FieldLabel>
|
||||
<NumInput value={styles['padding-top'] ?? '0px'} propKey="padding-top" onPatch={onPatch} inputWidth={72} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Right</FieldLabel>
|
||||
<NumInput value={styles['padding-right'] ?? '0px'} propKey="padding-right" onPatch={onPatch} inputWidth={72} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Bottom</FieldLabel>
|
||||
<NumInput value={styles['padding-bottom'] ?? '0px'} propKey="padding-bottom" onPatch={onPatch} inputWidth={72} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Left</FieldLabel>
|
||||
<NumInput value={styles['padding-left'] ?? '0px'} propKey="padding-left" onPatch={onPatch} inputWidth={72} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HSep />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
// ── Stroke Section — Border ────────────────────────────────────────────────────
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ColorInput, NumInput, FieldLabel, SectionHeader, CssSelect, HSep } from '../DesignInputs';
|
||||
|
||||
interface StrokeSectionProps {
|
||||
styles: Record<string, string>;
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
}
|
||||
|
||||
export function StrokeSection({ styles, onPatch }: StrokeSectionProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// M-3 fix: getComputedStyle returns `border-width` as a 4-value shorthand
|
||||
// (e.g., "0px 0px 0px 0px" or "1px 1px 1px 1px"), not a single value.
|
||||
// Read the more reliable `border-top-width` longhand for the presence check,
|
||||
// and display the top width (uniform borders are by far the common case).
|
||||
const bw = styles['border-top-width'] ?? styles['border-width'] ?? '0px';
|
||||
// A border exists when any individual side width is non-zero.
|
||||
const hasBorder = bw !== '' && bw !== '0px' && !bw.split(' ').every(v => v === '0px' || v === '0');
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader label="Stroke" expanded={open} onToggle={() => setOpen(!open)} />
|
||||
{open && (
|
||||
<div style={{ padding: '0 14px 10px' }}>
|
||||
{!hasBorder ? (
|
||||
<button
|
||||
onClick={() => { onPatch('border-width', '1px'); onPatch('border-style', 'solid'); onPatch('border-color', 'rgba(0,0,0,1)'); }}
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
padding: '4px 8px',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px dashed rgba(255,255,255,0.15)',
|
||||
borderRadius: 4,
|
||||
color: 'rgba(255,255,255,0.3)',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.06em',
|
||||
}}
|
||||
>
|
||||
+ Add stroke
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<ColorInput value={styles['border-color'] ?? ''} propKey="border-color" onPatch={onPatch} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Width</FieldLabel>
|
||||
<NumInput value={bw} propKey="border-width" onPatch={onPatch} inputWidth={72} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Style</FieldLabel>
|
||||
<CssSelect
|
||||
value={styles['border-style'] ?? 'solid'}
|
||||
propKey="border-style"
|
||||
options={[
|
||||
{ val: 'solid', label: 'Solid' },
|
||||
{ val: 'dashed', label: 'Dashed' },
|
||||
{ val: 'dotted', label: 'Dotted' },
|
||||
{ val: 'none', label: 'None' },
|
||||
]}
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<HSep />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
// ── Typography Section ────────────────────────────────────────────────────────
|
||||
// Shown only when the element has direct text content (hasDirectText === true).
|
||||
// Includes font family, size, weight, line height, letter spacing, paragraph
|
||||
// spacing (when hasParagraphChildren), text-align, decoration, transform, color.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { NumInput, TextInput, FieldLabel, SectionHeader, CssSelect, ColorInput, IconToggleGroup, HSep } from '../DesignInputs';
|
||||
|
||||
interface TypographySectionProps {
|
||||
styles: Record<string, string>;
|
||||
hasDirectText: boolean;
|
||||
hasParagraphChildren: boolean;
|
||||
/** Fires PATCH_ELEMENT_STYLE for normal properties */
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
/** Fires PATCH_CHILDREN_STYLE for paragraph-spacing */
|
||||
onPatchChildren: (selector: string, prop: string, val: string) => void;
|
||||
}
|
||||
|
||||
export function TypographySection({
|
||||
styles,
|
||||
hasDirectText,
|
||||
hasParagraphChildren,
|
||||
onPatch,
|
||||
onPatchChildren,
|
||||
}: TypographySectionProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
// Only show this section when the element has direct text content
|
||||
if (!hasDirectText) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader label="Typography" expanded={open} onToggle={() => setOpen(!open)} />
|
||||
{open && (
|
||||
<div style={{ padding: '0 14px 10px' }}>
|
||||
{/* Font family */}
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<FieldLabel>Family</FieldLabel>
|
||||
<TextInput
|
||||
value={styles['font-family'] ?? ''}
|
||||
propKey="font-family"
|
||||
onPatch={onPatch}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Size / Weight / Line height */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '4px 6px', marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Size</FieldLabel>
|
||||
<NumInput value={styles['font-size'] ?? '14px'} propKey="font-size" onPatch={onPatch} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Weight</FieldLabel>
|
||||
<NumInput value={styles['font-weight'] ?? '400'} propKey="font-weight" onPatch={onPatch} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Line H</FieldLabel>
|
||||
<NumInput value={styles['line-height'] ?? 'normal'} propKey="line-height" onPatch={onPatch} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Letter spacing + text align */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Tracking</FieldLabel>
|
||||
<NumInput value={styles['letter-spacing'] ?? '0px'} propKey="letter-spacing" onPatch={onPatch} inputWidth={52} />
|
||||
</div>
|
||||
<IconToggleGroup
|
||||
value={styles['text-align'] ?? 'left'}
|
||||
options={[
|
||||
{ val: 'left', icon: 'L', title: 'Left' },
|
||||
{ val: 'center', icon: 'C', title: 'Center' },
|
||||
{ val: 'right', icon: 'R', title: 'Right' },
|
||||
{ val: 'justify', icon: 'J', title: 'Justify' },
|
||||
]}
|
||||
onPatch={v => onPatch('text-align', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<FieldLabel>Color</FieldLabel>
|
||||
<ColorInput value={styles['color'] ?? '#000000'} propKey="color" onPatch={onPatch} />
|
||||
</div>
|
||||
|
||||
{/* Decoration + Transform */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<FieldLabel>Decoration</FieldLabel>
|
||||
<CssSelect
|
||||
value={styles['text-decoration'] ?? 'none'}
|
||||
propKey="text-decoration"
|
||||
options={[
|
||||
{ val: 'none', label: 'None' },
|
||||
{ val: 'underline', label: 'Underline' },
|
||||
{ val: 'line-through', label: 'Strikethrough' },
|
||||
{ val: 'overline', label: 'Overline' },
|
||||
]}
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<FieldLabel>Transform</FieldLabel>
|
||||
<CssSelect
|
||||
value={styles['text-transform'] ?? 'none'}
|
||||
propKey="text-transform"
|
||||
options={[
|
||||
{ val: 'none', label: 'None' },
|
||||
{ val: 'uppercase', label: 'Uppercase' },
|
||||
{ val: 'lowercase', label: 'Lowercase' },
|
||||
{ val: 'capitalize', label: 'Capitalize' },
|
||||
]}
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paragraph spacing — only when <p> children exist */}
|
||||
{hasParagraphChildren && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FieldLabel>Paragraph spacing (applies to <p> children)</FieldLabel>
|
||||
<NumInput
|
||||
value={styles['margin-bottom'] ?? '0px'}
|
||||
propKey="p-margin-bottom"
|
||||
onPatch={(_, val) => onPatchChildren('p', 'margin-bottom', val)}
|
||||
inputWidth={72}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<HSep />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
// ── useIndexer ────────────────────────────────────────────────────────────────
|
||||
// Connects the canvas app to the CLI AST indexer server.
|
||||
//
|
||||
// On mount it:
|
||||
// 1. Reads window.__OM_INDEX_URL__ injected by the CLI proxy. If absent,
|
||||
// the indexer stays 'offline' and the hook is a no-op.
|
||||
// 2. Fetches GET /health to hydrate projectMeta (framework, tailwind, etc.)
|
||||
// and sets indexerStatus → 'ready'.
|
||||
// 3. Opens a GET /events SSE subscription that updates indexerStatus to
|
||||
// 'indexing' during re-scans and back to 'ready' when done.
|
||||
//
|
||||
// Exports fetchComponents(name) and fetchFile(path) for use by the Props tab
|
||||
// and future Code tab.
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import type { ProjectMeta } from '@/store/canvas';
|
||||
|
||||
interface ComponentEntry {
|
||||
name: string;
|
||||
filePath: string;
|
||||
line: number;
|
||||
cssImports: string[];
|
||||
}
|
||||
|
||||
interface IndexerHealthResponse {
|
||||
status: string;
|
||||
projectMeta: ProjectMeta;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OM_INDEX_URL__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export function useIndexer() {
|
||||
const { setIndexerStatus, setProjectMeta } = useCanvas();
|
||||
const baseUrlRef = useRef<string | null>(null);
|
||||
|
||||
// ── 1. Detect indexer URL and fetch /health ────────────────────────────��───
|
||||
useEffect(() => {
|
||||
const base = typeof window !== 'undefined' ? (window.__OM_INDEX_URL__ ?? null) : null;
|
||||
if (!base) {
|
||||
setIndexerStatus('offline');
|
||||
return;
|
||||
}
|
||||
// Store for use by SSE effect and fetch helpers. Both effects run in the
|
||||
// same render cycle so the ref is populated before the SSE effect opens.
|
||||
baseUrlRef.current = base;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchHealth() {
|
||||
try {
|
||||
const res = await fetch(`${base}/health`);
|
||||
if (!res.ok) throw new Error(`/health ${res.status}`);
|
||||
const data = await res.json() as IndexerHealthResponse;
|
||||
if (!cancelled) {
|
||||
setProjectMeta(data.projectMeta);
|
||||
setIndexerStatus('ready');
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.warn('[useIndexer] /health failed — indexer offline', err);
|
||||
setIndexerStatus('offline');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void fetchHealth();
|
||||
return () => { cancelled = true; };
|
||||
}, [setIndexerStatus, setProjectMeta]);
|
||||
|
||||
// ── 2. SSE subscription for live re-index events ───────────────────────────
|
||||
useEffect(() => {
|
||||
// BUG-11 fix: read from ref, not window, for consistency and to avoid
|
||||
// a second window access after the first effect already resolved the URL.
|
||||
const base = baseUrlRef.current;
|
||||
if (!base) return;
|
||||
|
||||
const es = new EventSource(`${base}/events`);
|
||||
|
||||
// BUG-1 fix: `onopen` fires on EVERY connection, including the very first
|
||||
// one. We must distinguish initial open (health already fetched by the
|
||||
// sibling useEffect above) from a *reconnection* after an error. Calling
|
||||
// /health again on the initial open produces two concurrent fetches racing
|
||||
// to set projectMeta/indexerStatus, and the second fetch has no cleanup.
|
||||
let isFirstOpen = true;
|
||||
// Track whether an onopen-triggered health fetch is in-flight so that the
|
||||
// effect cleanup can cancel it if the component unmounts before it resolves.
|
||||
let reopenCancelled = false;
|
||||
|
||||
es.addEventListener('INDEX_START', () => {
|
||||
setIndexerStatus('indexing');
|
||||
});
|
||||
|
||||
es.addEventListener('INDEX_UPDATED', () => {
|
||||
setIndexerStatus('ready');
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
// Connection dropped — mark offline. The browser will auto-reconnect;
|
||||
// onopen will fire again and we will re-sync health at that point.
|
||||
setIndexerStatus('offline');
|
||||
};
|
||||
|
||||
es.onopen = () => {
|
||||
if (isFirstOpen) {
|
||||
// Initial connection: health was already fetched by the sibling effect.
|
||||
isFirstOpen = false;
|
||||
return;
|
||||
}
|
||||
// Reconnection after an error — re-fetch /health to sync projectMeta
|
||||
// (the CLI may have been restarted with a different project).
|
||||
reopenCancelled = false;
|
||||
void fetch(`${base}/health`)
|
||||
.then(r => r.json() as Promise<IndexerHealthResponse>)
|
||||
.then(data => {
|
||||
if (!reopenCancelled) {
|
||||
setProjectMeta(data.projectMeta);
|
||||
setIndexerStatus('ready');
|
||||
}
|
||||
})
|
||||
.catch(() => { if (!reopenCancelled) setIndexerStatus('offline'); });
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
reopenCancelled = true;
|
||||
};
|
||||
}, [setIndexerStatus, setProjectMeta]);
|
||||
|
||||
// ── 3. Data-fetching helpers exposed to consuming components ───────────────
|
||||
|
||||
/** Fetch all component entries matching a display name from the index. */
|
||||
const fetchComponents = useCallback(async (name: string): Promise<ComponentEntry[]> => {
|
||||
const base = baseUrlRef.current;
|
||||
if (!base) return [];
|
||||
try {
|
||||
const res = await fetch(`${base}/components?name=${encodeURIComponent(name)}`);
|
||||
if (!res.ok) return [];
|
||||
return await res.json() as ComponentEntry[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Fetch the raw source text of a file by absolute path. */
|
||||
const fetchFile = useCallback(async (filePath: string): Promise<string | null> => {
|
||||
const base = baseUrlRef.current;
|
||||
if (!base) return null;
|
||||
try {
|
||||
const res = await fetch(`${base}/file?path=${encodeURIComponent(filePath)}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { fetchComponents, fetchFile };
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
|
||||
/** Framework and CSS strategy detected by the CLI AST indexer at startup. */
|
||||
export interface ProjectMeta {
|
||||
framework: 'next' | 'vite' | 'remix' | 'generic';
|
||||
tailwind: boolean;
|
||||
cssModules: boolean;
|
||||
styledComponents: boolean;
|
||||
}
|
||||
|
||||
export type Tool = 'select' | 'pan' | 'artboard' | 'zone';
|
||||
|
||||
interface CanvasStore {
|
||||
@@ -36,6 +44,13 @@ interface CanvasStore {
|
||||
selectedComponentStyles: Record<string, string> | null;
|
||||
setComponentStyles: (styles: Record<string, string> | null) => void;
|
||||
|
||||
/** True if the selected element has a direct TEXT_NODE child (gates Typography section). */
|
||||
selectedComponentHasDirectText: boolean;
|
||||
/** True if the selected element has at least one direct <p> child (gates paragraph spacing). */
|
||||
selectedComponentHasParagraphChildren: boolean;
|
||||
/** Set both structural text flags together — always called alongside setComponentStyles. */
|
||||
setComponentTextFlags: (hasDirectText: boolean, hasParagraphChildren: boolean) => void;
|
||||
|
||||
// ── Style edit queue ────────────────────────────────────────────────────────
|
||||
// The Design tab and resize handles push patches here; each owning
|
||||
// LiveArtboard drains entries addressed to it, then removes them.
|
||||
@@ -45,10 +60,29 @@ interface CanvasStore {
|
||||
patchStyleEdit: (artboardId: string, nodeId: string, property: string, value: string) => void;
|
||||
clearStyleEdits: (artboardId: string) => void;
|
||||
|
||||
// ── Children style edit queue (PATCH_CHILDREN_STYLE — paragraph spacing etc.) ──
|
||||
childrenStyleEditQueue: Array<{ artboardId: string; parentNodeId: string; selector: string; property: string; value: string }>;
|
||||
patchChildrenStyleEdit: (artboardId: string, parentNodeId: string, selector: string, property: string, value: string) => void;
|
||||
clearChildrenStyleEdits: (artboardId: string) => void;
|
||||
|
||||
// ── Element removal mailbox ─────────────────────────────────────────────────
|
||||
removeElementEvent: { artboardId: string; nodeId: string } | null;
|
||||
dispatchRemoveElement: (artboardId: string, nodeId: string) => void;
|
||||
clearRemoveElement: () => void;
|
||||
|
||||
// ── Selected element mode (Phase 2) ─────────────────────────────────────────
|
||||
/** 'component' = capitalized React component; 'element' = lowercase DOM tag; null = nothing selected */
|
||||
selectedElementMode: 'component' | 'element' | null;
|
||||
setSelectedElementMode: (mode: 'component' | 'element' | null) => void;
|
||||
|
||||
// ── CLI AST indexer integration (Phase 3) ────────────────────────────────────
|
||||
/** Status of the CLI AST indexer; drives Props tab and Code tab behavior */
|
||||
indexerStatus: 'offline' | 'indexing' | 'ready';
|
||||
setIndexerStatus: (status: 'offline' | 'indexing' | 'ready') => void;
|
||||
|
||||
/** Project metadata fetched from GET /health on CLI connection */
|
||||
projectMeta: ProjectMeta | null;
|
||||
setProjectMeta: (meta: ProjectMeta | null) => void;
|
||||
}
|
||||
|
||||
export const useCanvas = create<CanvasStore>((set) => ({
|
||||
@@ -78,19 +112,47 @@ export const useCanvas = create<CanvasStore>((set) => ({
|
||||
selectedComponentId: null,
|
||||
selectedComponentData: null,
|
||||
selectComponent: (id, data) =>
|
||||
set({ selectedComponentId: id, selectedComponentData: data, selectedComponentStyles: null }),
|
||||
// BUG-2 fix: reset text flags alongside styles so a component without
|
||||
// direct text doesn't inherit the previous selection's Typography section.
|
||||
set({
|
||||
selectedComponentId: id,
|
||||
selectedComponentData: data,
|
||||
selectedComponentStyles: null,
|
||||
selectedComponentHasDirectText: false,
|
||||
selectedComponentHasParagraphChildren: false,
|
||||
}),
|
||||
|
||||
selectedComponentStyles: null,
|
||||
setComponentStyles: (styles) => set({ selectedComponentStyles: styles }),
|
||||
|
||||
selectedComponentHasDirectText: false,
|
||||
selectedComponentHasParagraphChildren: false,
|
||||
setComponentTextFlags: (hasDirectText, hasParagraphChildren) =>
|
||||
set({ selectedComponentHasDirectText: hasDirectText, selectedComponentHasParagraphChildren: hasParagraphChildren }),
|
||||
|
||||
styleEditQueue: [],
|
||||
patchStyleEdit: (artboardId, nodeId, property, value) =>
|
||||
set((s) => ({ styleEditQueue: [...s.styleEditQueue, { artboardId, nodeId, property, value }] })),
|
||||
clearStyleEdits: (artboardId) =>
|
||||
set((s) => ({ styleEditQueue: s.styleEditQueue.filter((e) => e.artboardId !== artboardId) })),
|
||||
|
||||
childrenStyleEditQueue: [],
|
||||
patchChildrenStyleEdit: (artboardId, parentNodeId, selector, property, value) =>
|
||||
set((s) => ({ childrenStyleEditQueue: [...s.childrenStyleEditQueue, { artboardId, parentNodeId, selector, property, value }] })),
|
||||
clearChildrenStyleEdits: (artboardId) =>
|
||||
set((s) => ({ childrenStyleEditQueue: s.childrenStyleEditQueue.filter((e) => e.artboardId !== artboardId) })),
|
||||
|
||||
removeElementEvent: null,
|
||||
dispatchRemoveElement: (artboardId, nodeId) =>
|
||||
set({ removeElementEvent: { artboardId, nodeId } }),
|
||||
clearRemoveElement: () => set({ removeElementEvent: null }),
|
||||
|
||||
selectedElementMode: null,
|
||||
setSelectedElementMode: (mode) => set({ selectedElementMode: mode }),
|
||||
|
||||
indexerStatus: 'offline',
|
||||
setIndexerStatus: (status) => set({ indexerStatus: status }),
|
||||
|
||||
projectMeta: null,
|
||||
setProjectMeta: (meta) => set({ projectMeta: meta }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user