diff --git a/CLAUDE.md b/CLAUDE.md index 4c4afc636..e627099a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,28 @@ React Components (Toolbar, LayerPanel, PropertyPanel) - User edits in panels → update document-store → `use-canvas-sync` updates Fabric - `canvas-sync-lock.ts` prevents circular updates when Fabric events write to the store +### Design Variables Architecture + +``` +PenDocument (source of truth) + ├── variables: Record ($color-1, $spacing-md, ...) + ├── themes: Record ({Theme-1: ["Default","Dark"]}) + └── children: PenNode[] (nodes with $variable refs) + │ + ┌──────────┴──────────┐ + ▼ ▼ + Canvas Sync Code Generation + resolveNodeForCanvas() $ref → var(--name) + $ref → concrete value CSS Variables block +``` + +- **`$variable` references are preserved** in the document store (e.g. `$color-1` in fill color) +- `normalize-pen-file.ts` does NOT resolve `$refs` — only fixes format issues +- `resolveNodeForCanvas()` resolves `$refs` on-the-fly before Fabric.js rendering +- Code generators output `var(--name)` for `$ref` values +- Multiple theme axes supported (e.g. Theme-1 with Light/Dark, Theme-2 with Compact/Comfortable) +- Each theme axis has variants; variables can have per-variant values (`ThemedValue[]`) + ### Key Modules - **`src/canvas/`** — Fabric.js integration (16 files): @@ -51,7 +73,7 @@ React Components (Toolbar, LayerPanel, PropertyPanel) - `canvas-controls.ts` — Custom rotation controls and cursor styling - `canvas-constants.ts` — Default colors, zoom limits, stroke widths - `use-canvas-events.ts` — Drawing events, shape creation, smart guides activation, tool-based `skipTargetFind` management - - `use-canvas-sync.ts` — Bidirectional PenDocument ↔ Fabric.js sync, node flattening with parent offsets + - `use-canvas-sync.ts` — Bidirectional PenDocument ↔ Fabric.js sync, node flattening with parent offsets, variable resolution via `resolveNodeForCanvas()` - `use-canvas-viewport.ts` — Wheel zoom, space+drag panning, tool cursor switching, selection toggling per tool - `use-canvas-selection.ts` — Selection sync between Fabric objects and canvas-store - `use-canvas-guides.ts` — Smart alignment guides with snapping @@ -60,39 +82,45 @@ React Components (Toolbar, LayerPanel, PropertyPanel) - `parent-child-transform.ts` — Propagates parent transforms (move/scale/rotate) to children proportionally - `use-dimension-label.ts` — Shows size/position labels during object manipulation - `use-frame-labels.ts` — Renders frame names and boundaries on canvas +- **`src/variables/`** — Design variables system (2 files): + - `resolve-variables.ts` — Core resolution utilities: `resolveVariableRef`, `resolveNodeForCanvas`, `getDefaultTheme`, `isVariableRef`; resolves `$variable` references to concrete values for canvas rendering with circular reference guards + - `replace-refs.ts` — `replaceVariableRefsInTree`: recursively walk node tree to replace/resolve `$refs` when renaming or deleting variables (covers opacity, gap, padding, fills, strokes, effects, text) - **`src/stores/`** — Zustand stores (5 files): - - `canvas-store.ts` — UI/tool/selection/viewport/clipboard/interaction state - - `document-store.ts` — PenDocument tree CRUD: `addNode`, `updateNode`, `removeNode`, `moveNode`, `reorderNode`, `duplicateNode`, `groupNodes`, `ungroupNode`, `toggleVisibility`, `toggleLock`, `scaleDescendantsInStore`, `rotateDescendantsInStore`, `getNodeById`, `getParentOf`, `getFlatNodes`, `isDescendantOf` + - `canvas-store.ts` — UI/tool/selection/viewport/clipboard/interaction state, `variablesPanelOpen` toggle + - `document-store.ts` — PenDocument tree CRUD: `addNode`, `updateNode`, `removeNode`, `moveNode`, `reorderNode`, `duplicateNode`, `groupNodes`, `ungroupNode`, `toggleVisibility`, `toggleLock`, `scaleDescendantsInStore`, `rotateDescendantsInStore`, `getNodeById`, `getParentOf`, `getFlatNodes`, `isDescendantOf`; Variable CRUD: `setVariable`, `removeVariable`, `renameVariable`, `setThemes` (all with history support) - `history-store.ts` — Undo/redo (max 300 states), batch mode for grouped operations - `ai-store.ts` — Chat messages, streaming state, generated code, model selection - `agent-settings-store.ts` — AI provider config (Anthropic/OpenAI), MCP CLI integrations, localStorage persistence - **`src/types/`** — Type system: - - `pen.ts` — PenDocument/PenNode (frame, group, rectangle, ellipse, line, polygon, path, text, image, ref), ContainerProps + - `pen.ts` — PenDocument/PenNode (frame, group, rectangle, ellipse, line, polygon, path, text, image, ref), ContainerProps; `PenDocument.variables` and `PenDocument.themes` - `canvas.ts` — ToolType (select, frame, rectangle, ellipse, line, polygon, path, text, hand), ViewportState, SelectionState - `styles.ts` — PenFill (solid, linear_gradient, radial_gradient), PenStroke, PenEffect (shadow, blur) - - `variables.ts` — VariableDefinition for design tokens + - `variables.ts` — `VariableDefinition` (type + value), `ThemedValue` (value per theme), `VariableValue` - `agent-settings.ts` — AI provider config types -- **`src/components/editor/`** — Editor UI (6 files): editor-layout, toolbar, tool-button, shape-tool-dropdown (rectangle/ellipse/line/path + icon picker + image import), top-bar, status-bar -- **`src/components/panels/`** — Panels (15 files): +- **`src/components/editor/`** — Editor UI (6 files): editor-layout, toolbar (with variables panel toggle), tool-button, shape-tool-dropdown (rectangle/ellipse/line/path + icon picker + image import), top-bar, status-bar +- **`src/components/panels/`** — Panels (17 files): - `layer-panel.tsx` / `layer-item.tsx` / `layer-context-menu.tsx` — Tree view with drag-and-drop reordering and drop-into-children (above/below/inside), visibility/lock toggles, context menu, rename - `property-panel.tsx` — Unified property panel - - `fill-section.tsx` — Solid + gradient fill - - `stroke-section.tsx` — Stroke color/width/dash + - `fill-section.tsx` — Solid + gradient fill, variable picker integration for color binding + - `stroke-section.tsx` — Stroke color/width/dash, variable picker for stroke color binding - `corner-radius-section.tsx` — Unified or 4-point corner radius - `size-section.tsx` — Position, size, rotation - `text-section.tsx` — Font, size, weight, spacing, alignment - `effects-section.tsx` — Shadow and blur - - `layout-section.tsx` — Auto-layout (none/vertical/horizontal), gap, padding, justify, align - - `appearance-section.tsx` — Opacity, visibility, lock, flip + - `layout-section.tsx` — Auto-layout (none/vertical/horizontal), gap, padding, justify, align; variable picker for gap/padding binding + - `appearance-section.tsx` — Opacity, visibility, lock, flip; variable picker for opacity binding - `ai-chat-panel.tsx` / `chat-message.tsx` — AI chat with markdown, design block collapse, apply design - - `code-panel.tsx` — Code generation output (React/Tailwind and HTML/CSS) -- **`src/components/shared/`** — Reusable UI (8 files): ColorPicker, NumberInput, DropdownSelect, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog -- **`src/components/ui/`** — shadcn/ui primitives: Button, Select, Separator, Slider, Toggle, Tooltip + - `code-panel.tsx` — Code generation output (React/Tailwind, HTML/CSS, CSS Variables) + - `variables-panel.tsx` — Design variables management: theme axes as tabs, variant columns, resizable floating panel, add/rename/delete themes and variants + - `variable-row.tsx` — Individual variable row: type icon, editable name, per-theme-variant value cells (color picker, number input, text input), context menu +- **`src/components/shared/`** — Reusable UI (9 files): ColorPicker, NumberInput, DropdownSelect, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog, VariablePicker +- **`src/components/icons/`** — Provider logos: ClaudeLogo, OpenAILogo +- **`src/components/ui/`** — shadcn/ui primitives: Button, Select, Separator, Slider, Switch, Toggle, Tooltip - **`src/services/ai/`** — AI chat service, design prompts, design-to-node generation, AI types -- **`src/services/codegen/`** — React+Tailwind and HTML+CSS code generators +- **`src/services/codegen/`** — React+Tailwind and HTML+CSS code generators (output `var(--name)` for `$variable` refs), CSS variables generator - **`src/hooks/`** — `use-keyboard-shortcuts` (global keyboard event handling: tools, clipboard, undo/redo, save, select all, delete, arrow nudge, z-order) - **`src/lib/`** — Utility functions (`utils.ts` with `cn()` for class merging) -- **`src/utils/`** — File operations (save/open .pen), export (PNG/SVG), node clone, pen file normalization, SVG parser (import SVG to editable PenNodes), syntax highlight +- **`src/utils/`** — File operations (save/open .pen), export (PNG/SVG), node clone, pen file normalization (format fixes only, preserves `$variable` refs), SVG parser (import SVG to editable PenNodes), syntax highlight - **`server/api/ai/`** — Nitro server API: `chat.ts` (streaming SSE with thinking state), `generate.ts` (non-streaming generation), `connect-agent.ts` (Claude Code/Codex CLI connection), `models.ts` (model definitions). Supports Anthropic API key or Claude Agent SDK (local OAuth) as dual providers ### Fabric.js v7 Gotchas @@ -158,7 +186,7 @@ Tailwind CSS v4 imported via `src/styles.css`. UI primitives from shadcn/ui (`sr ### Scope -按模块划分:`editor`、`canvas`、`panels`、`history`、`ai`、`codegen`、`store`、`types`。 +按模块划分:`editor`、`canvas`、`panels`、`history`、`ai`、`codegen`、`store`、`types`、`variables`。 ### 规则 diff --git a/README.md b/README.md index 553075f34..9ec8b9251 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,17 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative - Opacity, visibility, lock, flip (horizontal/vertical) - Effects: shadow and blur - Auto-layout: direction, gap, padding, justify-content, align-items +- Variable binding: bind any property to a design variable via variable picker + +### Design Variables & Tokens + +- **Variables panel**: Floating resizable panel with theme management (Cmd+Shift+V) +- **Variable types**: Color (picker + hex + opacity), Number, String +- **Multi-theme support**: Create multiple theme axes (e.g. Theme-1, Theme-2), each with variants (e.g. Default, Dark, High Contrast) +- **`$variable` references**: Bind node properties (fill, stroke, opacity, gap, padding) to variables +- **CSS sync**: Auto-generate CSS custom properties (`:root { --color-1: #fff; }`) with per-theme variant blocks +- **Code generation**: React/Tailwind and HTML/CSS output uses `var(--name)` for variable-bound properties +- **Live resolution**: Variables resolved on-the-fly for canvas rendering, preserved as `$refs` in document ### Layer Panel @@ -65,6 +76,7 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative - React + Tailwind CSS code from designs - HTML + CSS code from designs +- CSS Variables from design tokens - View in code panel (Cmd+Shift+C) ### AI Assistant @@ -76,6 +88,12 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative - Dual provider: Anthropic API or local Claude Code (OAuth) - Multi-provider settings: Claude Code, Codex CLI +### Editor UI + +- Dark / light theme toggle (persisted to localStorage) +- Fullscreen mode +- Draggable, snap-to-corner AI chat panel + ### Keyboard Shortcuts | Shortcut | Action | @@ -86,6 +104,7 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative | L | Line | | T | Text | | F | Frame | +| P | Path (pen tool) | | H | Hand (pan) | | Cmd+A | Select all | | Cmd+Z | Undo | @@ -96,7 +115,9 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative | Cmd+S | Save | | Cmd+Shift+E | Export | | Cmd+Shift+C | Code panel | +| Cmd+Shift+V | Variables panel | | Cmd+J | AI chat | +| Cmd+, | Agent settings | | Delete/Backspace | Delete selected | | Arrow keys | Nudge (1px, +Shift = 10px) | | [ / ] | Reorder layers | @@ -153,7 +174,7 @@ src/ canvas-controls Custom rotation controls and cursors canvas-constants Default colors, zoom limits use-canvas-events Drawing events, tool management - use-canvas-sync Bidirectional PenDocument ↔ Fabric sync + use-canvas-sync Bidirectional PenDocument ↔ Fabric sync + variable resolution use-canvas-viewport Zoom, pan, tool cursor switching use-canvas-selection Selection sync Fabric ↔ store use-canvas-guides Smart alignment guides @@ -162,16 +183,21 @@ src/ parent-child-transform Parent transform propagation to children use-dimension-label Size/position labels during manipulation use-frame-labels Frame name/boundary rendering + variables/ # Design variables/tokens system + resolve-variables Core $variable resolution for canvas rendering + replace-refs Replace/resolve $refs on rename/delete components/ editor/ # Editor layout, toolbar, tool buttons, status bar - panels/ # Layer panel, property panel (15 files), AI chat, code panel - shared/ # ColorPicker, NumberInput, ExportDialog, IconPickerDialog, etc. - ui/ # shadcn/ui primitives (Button, Select, Slider, etc.) + panels/ # Layer panel, property panel (17 files), AI chat, code panel, + # variables panel, variable row + shared/ # ColorPicker, NumberInput, VariablePicker, ExportDialog, etc. + icons/ # Provider logos (Claude, OpenAI) + ui/ # shadcn/ui primitives (Button, Select, Slider, Switch, etc.) hooks/ # Keyboard shortcuts lib/ # Utility functions (cn class merging) services/ ai/ # AI chat service, prompts, design generation - codegen/ # React+Tailwind and HTML+CSS code generators + codegen/ # React+Tailwind, HTML+CSS, and CSS variables generators stores/ # Zustand stores (canvas, document, history, AI, agent-settings) types/ # PenDocument/PenNode types, style types, variables, agent settings utils/ # File operations, export, node clone, SVG parser, syntax highlight @@ -183,7 +209,7 @@ server/ ## Roadmap - [ ] Component system (reusable components with instances & overrides) -- [ ] Design variables/tokens with CSS sync +- [x] Design variables/tokens with CSS sync - [ ] Boolean operations (union, subtract, intersect) - [ ] Multi-page support - [ ] Collaborative editing diff --git a/src/canvas/canvas-controls.ts b/src/canvas/canvas-controls.ts index 2073c1f9e..4f7b9a90a 100644 --- a/src/canvas/canvas-controls.ts +++ b/src/canvas/canvas-controls.ts @@ -1,7 +1,9 @@ import * as fabric from 'fabric' function rotationCursorSvg(angleDeg: number): string { - const svg = `` + // 270° clockwise arc (radius 4) with small arrowhead — Figma-style minimal. + // Uses single quotes so the SVG doesn't break the outer CSS url("..."). + const svg = `` return `url("data:image/svg+xml,${svg}") 12 12, crosshair` } @@ -12,11 +14,14 @@ const CURSORS = { bl: rotationCursorSvg(225), } +const ROTATION_OFFSET = 14 +const ROTATION_SIZE = 14 + const ROTATION_POSITIONS = [ - { key: 'rtl', x: -0.5, y: -0.5, ox: -10, oy: -10, cursor: CURSORS.tl }, - { key: 'rtr', x: 0.5, y: -0.5, ox: 10, oy: -10, cursor: CURSORS.tr }, - { key: 'rbr', x: 0.5, y: 0.5, ox: 10, oy: 10, cursor: CURSORS.br }, - { key: 'rbl', x: -0.5, y: 0.5, ox: -10, oy: 10, cursor: CURSORS.bl }, + { key: 'rtl', x: -0.5, y: -0.5, ox: -ROTATION_OFFSET, oy: -ROTATION_OFFSET, cursor: CURSORS.tl }, + { key: 'rtr', x: 0.5, y: -0.5, ox: ROTATION_OFFSET, oy: -ROTATION_OFFSET, cursor: CURSORS.tr }, + { key: 'rbr', x: 0.5, y: 0.5, ox: ROTATION_OFFSET, oy: ROTATION_OFFSET, cursor: CURSORS.br }, + { key: 'rbl', x: -0.5, y: 0.5, ox: -ROTATION_OFFSET, oy: ROTATION_OFFSET, cursor: CURSORS.bl }, ] export function applyRotationControls(obj: fabric.FabricObject) { @@ -26,8 +31,8 @@ export function applyRotationControls(obj: fabric.FabricObject) { y: pos.y, offsetX: pos.ox, offsetY: pos.oy, - sizeX: 20, - sizeY: 20, + sizeX: ROTATION_SIZE, + sizeY: ROTATION_SIZE, actionName: 'rotate', actionHandler: fabric.controlsUtils.rotationWithSnapping, cursorStyleHandler: () => pos.cursor, @@ -35,3 +40,43 @@ export function applyRotationControls(obj: fabric.FabricObject) { }) } } + +/** + * Fabric.js `_setCursorFromEvent` only checks controls on the `target` found + * by `findTarget` (the object directly under the mouse). Rotation controls + * are offset outside the object boundary, so `findTarget` returns null there + * and the rotation cursor never shows. + * + * Fix: patch `_setCursorFromEvent` to also check the active object's controls + * before falling through to the default behavior. + */ +export function setupRotationCursorHandler(canvas: fabric.Canvas) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const c = canvas as any + const original = c._setCursorFromEvent + + c._setCursorFromEvent = function ( + e: MouseEvent, + target: fabric.FabricObject | undefined, + ) { + // Check the active object's rotation controls first + const activeObject = this.getActiveObject() + if (activeObject) { + const pointer = this.getViewportPoint(e) + const found = activeObject.findControl(pointer) + if (found && found.control.actionName === 'rotate') { + this.setCursor( + found.control.cursorStyleHandler( + e, + found.control, + activeObject, + found.coord, + ), + ) + return + } + } + // No rotation control hit — fall through to default behavior + original.call(this, e, target) + } +} diff --git a/src/canvas/use-canvas-events.ts b/src/canvas/use-canvas-events.ts index d0bd7f4b5..00fc29576 100644 --- a/src/canvas/use-canvas-events.ts +++ b/src/canvas/use-canvas-events.ts @@ -4,7 +4,7 @@ import { useCanvasStore } from '@/stores/canvas-store' import { useDocumentStore, generateId } from '@/stores/document-store' import { useHistoryStore } from '@/stores/history-store' import { useAIStore } from '@/stores/ai-store' -import type { PenNode } from '@/types/pen' +import type { PenDocument, PenNode } from '@/types/pen' import type { ToolType } from '@/types/canvas' import { DEFAULT_FILL, @@ -445,6 +445,13 @@ export function useCanvasEvents() { upperEl.addEventListener('pointerup', onPointerUp) upperEl.addEventListener('dblclick', onDoubleClick) + // --- Drag session setup (layout reorder + parent-child propagation) --- + // We capture the document snapshot here (before any modification) so that + // `object:modified` can use it as the undo base state. History batching + // lives in `object:modified` — NOT here — so that click-to-select without + // modification never creates a no-op undo entry. + let preModificationDoc: PenDocument | null = null + // --- History batching for drag/resize/rotate --- let transformBatchActive = false let pendingBatchCloseRaf: number | null = null @@ -465,10 +472,16 @@ export function useCanvasEvents() { } clipPathsCleared = false + preModificationDoc = null const tool = useCanvasStore.getState().activeTool if (tool !== 'select') return const target = opt.target as FabricObjectWithPenId | null if (!target?.penNodeId) return + + + // Snapshot the document BEFORE any drag/resize/rotate begins. + // structuredClone ensures we have a deep copy unaffected by later mutations. + preModificationDoc = structuredClone(useDocumentStore.getState().document) useHistoryStore .getState() .startBatch(useDocumentStore.getState().document) @@ -501,6 +514,7 @@ export function useCanvasEvents() { // commit and cleanup. In Fabric.js v7 mouse:up can fire before // object:modified, which would clear the session prematurely. endParentDrag() + // Defer batch close one frame so object:modified can run first. if (transformBatchActive) { if (pendingBatchCloseRaf !== null) { @@ -657,7 +671,11 @@ export function useCanvasEvents() { } }) - // Final sync: reset scale to 1 and bake into width/height + // Final sync: reset scale to 1 and bake into width/height. + // History batching lives here (not in mouse:down/mouse:up) so that + // click-to-select without modification never creates a no-op undo + // entry. We use the pre-modification snapshot captured in mouse:down + // as the batch base to guarantee a correct undo point. canvas.on('object:modified', (opt) => { if (pendingBatchCloseRaf !== null) { cancelAnimationFrame(pendingBatchCloseRaf) @@ -667,6 +685,19 @@ export function useCanvasEvents() { clearGuides() const target = opt.target + // Use the snapshot from mouse:down if available; otherwise fall back + // to the current document (e.g. programmatic modifications). + const baseDoc = preModificationDoc ?? useDocumentStore.getState().document + preModificationDoc = null + + // Open a history batch for this modification when no outer batch + // (e.g. AI generation) is active. + const needsBatch = useHistoryStore.getState().batchDepth === 0 + if (needsBatch) { + useHistoryStore.getState().startBatch(baseDoc) + } + + try { // Single object -- bake scale and sync const asPen = target as FabricObjectWithPenId if (asPen.penNodeId) { @@ -760,6 +791,12 @@ export function useCanvasEvents() { // committed (e.g. cursor left the container on the final move frame). cancelDragInto() + } finally { + if (needsBatch) { + useHistoryStore.getState().endBatch() + } + } + // Force re-sync so clip paths (which use absolute coordinates) are // recomputed from the new node positions. Without this, children of // a dragged frame stay clipped to the old parent frame bounds. diff --git a/src/canvas/use-canvas-sync.ts b/src/canvas/use-canvas-sync.ts index ad1dde552..5812cdb58 100644 --- a/src/canvas/use-canvas-sync.ts +++ b/src/canvas/use-canvas-sync.ts @@ -10,6 +10,7 @@ import { import { syncFabricObject } from './canvas-object-sync' import { isFabricSyncLocked, setFabricSyncLock } from './canvas-sync-lock' import { pendingAnimationNodes, getNextStaggerDelay } from '@/services/ai/design-animation' +import { resolveNodeForCanvas, getDefaultTheme } from '@/variables/resolve-variables' // --------------------------------------------------------------------------- // Clip info — tracks parent frame bounds for child clipping @@ -523,29 +524,39 @@ export function useCanvasSync() { // changes (drag positions, edited text) with stale store data if those // changes failed to write back to the store for any reason. let prevChildren = useDocumentStore.getState().document.children + let prevVariables = useDocumentStore.getState().document.variables + let prevThemes = useDocumentStore.getState().document.themes const unsub = useDocumentStore.subscribe((state) => { - // Always track the latest children reference — even when the sync lock + // Always track the latest references — even when the sync lock // is active — so that unrelated store updates (e.g. markClean setting // isDirty) don't trigger a stale re-sync that overwrites canvas state. const childrenChanged = state.document.children !== prevChildren + const variablesChanged = state.document.variables !== prevVariables + const themesChanged = state.document.themes !== prevThemes prevChildren = state.document.children + prevVariables = state.document.variables + prevThemes = state.document.themes if (isFabricSyncLocked()) return // Skip re-sync when only non-document fields changed (isDirty, fileName, etc.) - if (!childrenChanged) return + if (!childrenChanged && !variablesChanged && !themesChanged) return const canvas = useCanvasStore.getState().fabricCanvas if (!canvas) return + // Build variable resolution context + const variables = state.document.variables ?? {} + const activeTheme = getDefaultTheme(state.document.themes) + const clipMap = new Map() nodeRenderInfo.clear() rootFrameBounds.clear() layoutContainerBounds.clear() const flatNodes = flattenNodes( state.document.children, 0, 0, undefined, undefined, undefined, clipMap, - ) + ).map((node) => resolveNodeForCanvas(node, variables, activeTheme)) const nodeMap = new Map(flatNodes.map((n) => [n.id, n])) const objects = canvas.getObjects() as FabricObjectWithPenId[] const objMap = new Map( diff --git a/src/canvas/use-canvas-viewport.ts b/src/canvas/use-canvas-viewport.ts index 2147e4d34..72b30c9e2 100644 --- a/src/canvas/use-canvas-viewport.ts +++ b/src/canvas/use-canvas-viewport.ts @@ -1,11 +1,12 @@ import { useEffect } from 'react' +import { Point } from 'fabric' import { useCanvasStore } from '@/stores/canvas-store' import { MIN_ZOOM, MAX_ZOOM } from './canvas-constants' import type { ToolType } from '@/types/canvas' // Precise crosshair cursor (thin +) const CROSSHAIR_CURSOR = (() => { - const svg = `` + const svg = `` return `url("data:image/svg+xml,${svg}") 12 12, crosshair` })() @@ -45,7 +46,7 @@ export function useCanvasViewport() { newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom)) const rect = canvas.upperCanvasEl.getBoundingClientRect() - const point = { x: e.clientX - rect.left, y: e.clientY - rect.top } + const point = new Point(e.clientX - rect.left, e.clientY - rect.top) canvas.zoomToPoint(point, newZoom) const vpt = canvas.viewportTransform diff --git a/src/canvas/use-fabric-canvas.ts b/src/canvas/use-fabric-canvas.ts index 7acc13861..9c7177931 100644 --- a/src/canvas/use-fabric-canvas.ts +++ b/src/canvas/use-fabric-canvas.ts @@ -4,6 +4,7 @@ import { useCanvasStore } from '@/stores/canvas-store' import { useDocumentStore } from '@/stores/document-store' import type { PenNode } from '@/types/pen' import { getCanvasBackground, SELECTION_BLUE, MIN_ZOOM, MAX_ZOOM } from './canvas-constants' +import { setupRotationCursorHandler } from './canvas-controls' const FIT_PADDING = 64 @@ -120,6 +121,7 @@ export function useFabricCanvas( canvas.selectionLineWidth = 1 useCanvasStore.getState().setFabricCanvas(canvas) + setupRotationCursorHandler(canvas) canvas.requestRenderAll() // Center viewport on the default frame after a tick (sync needs to run first) diff --git a/src/components/editor/editor-layout.tsx b/src/components/editor/editor-layout.tsx index 8dfb27c3f..b7195620c 100644 --- a/src/components/editor/editor-layout.tsx +++ b/src/components/editor/editor-layout.tsx @@ -7,6 +7,7 @@ import LayerPanel from '@/components/panels/layer-panel' import PropertyPanel from '@/components/panels/property-panel' import AIChatPanel, { AIChatMinimizedBar } from '@/components/panels/ai-chat-panel' import CodePanel from '@/components/panels/code-panel' +import VariablesPanel from '@/components/panels/variables-panel' import ExportDialog from '@/components/shared/export-dialog' import SaveDialog from '@/components/shared/save-dialog' import AgentSettingsDialog from '@/components/shared/agent-settings-dialog' @@ -21,6 +22,7 @@ export default function EditorLayout() { const toggleMinimize = useAIStore((s) => s.toggleMinimize) const hasSelection = useCanvasStore((s) => s.selection.activeId !== null) const layerPanelOpen = useCanvasStore((s) => s.layerPanelOpen) + const variablesPanelOpen = useCanvasStore((s) => s.variablesPanelOpen) const saveDialogOpen = useDocumentStore((s) => s.saveDialogOpen) const closeSaveDialog = useCallback(() => { useDocumentStore.getState().setSaveDialogOpen(false) @@ -61,6 +63,13 @@ export default function EditorLayout() { return } + // Cmd+Shift+V: toggle variables panel + if (isMod && e.shiftKey && e.key.toLowerCase() === 'v') { + e.preventDefault() + useCanvasStore.getState().toggleVariablesPanel() + return + } + // Cmd+,: open agent settings if (isMod && e.key === ',') { e.preventDefault() @@ -96,6 +105,9 @@ export default function EditorLayout() { + {/* Floating variables panel — anchored to the right of the toolbar */} + {variablesPanelOpen && } + {/* Bottom bar: minimized AI (left) + zoom controls (right) */}
diff --git a/src/components/editor/toolbar.tsx b/src/components/editor/toolbar.tsx index e6ba5e230..8426fb00d 100644 --- a/src/components/editor/toolbar.tsx +++ b/src/components/editor/toolbar.tsx @@ -6,6 +6,7 @@ import { Hand, Undo2, Redo2, + SlidersHorizontal, } from 'lucide-react' import ToolButton from './tool-button' import ShapeToolDropdown from './shape-tool-dropdown' @@ -25,6 +26,8 @@ import IconPickerDialog from '@/components/shared/icon-picker-dialog' export default function Toolbar() { const canUndo = useHistoryStore((s) => s.undoStack.length > 0) const canRedo = useHistoryStore((s) => s.redoStack.length > 0) + const variablesPanelOpen = useCanvasStore((s) => s.variablesPanelOpen) + const toggleVariablesPanel = useCanvasStore((s) => s.toggleVariablesPanel) const fileInputRef = useRef(null) const [iconPickerOpen, setIconPickerOpen] = useState(false) @@ -226,6 +229,33 @@ export default function Toolbar() { + + + {/* Variables */} + + + + + + Variables + + {'\u2318\u21e7'}V + + + + {/* Hidden file input + icon picker dialog */} - onUpdate({ opacity: v / 100 })} - min={0} - max={100} - suffix="%" - /> +
+
+ {isBound ? ( +
+ {rawOpacity} +
+ ) : ( + onUpdate({ opacity: v / 100 })} + min={0} + max={100} + suffix="%" + /> + )} +
+ onUpdate({ opacity: ref as unknown as number })} + onUnbind={(val) => onUpdate({ opacity: Number(val) })} + /> +
) } diff --git a/src/components/panels/chat-message.tsx b/src/components/panels/chat-message.tsx index cc717357c..b595eccb6 100644 --- a/src/components/panels/chat-message.tsx +++ b/src/components/panels/chat-message.tsx @@ -65,7 +65,7 @@ function ActionSteps({ steps }: { steps: string[] }) { if (steps.length === 0) return null return ( -
+
{steps.map((step, i) => { const title = parseStepTitle(step) const content = parseStepContent(step) @@ -110,7 +110,7 @@ function ActionStepItem({ // Pencil-like style: Rounded pill/card with subtle border return ( -
+
+ {showMenu && ( +
+ + +
+ )} +
+
+ ) +} + +// --- Color cell --- + +function ColorCell({ + value, + opacity, + onChange, +}: { + value: string + opacity: number + onChange: (color: string) => void +}) { + const [hexInput, setHexInput] = useState(value.slice(0, 7)) + + useEffect(() => { setHexInput(value.slice(0, 7)) }, [value]) + + const handleHexChange = (e: React.ChangeEvent) => { + const v = e.target.value + setHexInput(v) + if (/^#[0-9a-fA-F]{6}$/.test(v)) onChange(v) + } + + const handleBlur = () => { + if (!/^#[0-9a-fA-F]{6}$/.test(hexInput)) setHexInput(value.slice(0, 7)) + } + + return ( +
+ onChange(e.target.value)} + className="w-5 h-5 rounded border border-input/40 cursor-pointer bg-transparent p-0 shrink-0" + /> + + + {opacity} % + +
+ ) +} diff --git a/src/components/panels/variables-panel.tsx b/src/components/panels/variables-panel.tsx new file mode 100644 index 000000000..eaab91526 --- /dev/null +++ b/src/components/panels/variables-panel.tsx @@ -0,0 +1,496 @@ +import { useState, useMemo, useCallback, useRef, useEffect } from 'react' +import { X, Plus, ChevronDown, Search, Pencil, Trash2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import { useDocumentStore } from '@/stores/document-store' +import { useCanvasStore } from '@/stores/canvas-store' +import VariableRow from './variable-row' +import type { VariableDefinition, ThemedValue } from '@/types/variables' + +const DEFAULT_THEME_AXIS = 'Theme-1' +const DEFAULT_THEME_VALUES = ['Default'] +const MIN_WIDTH = 480 +const MIN_HEIGHT = 240 +const DEFAULT_WIDTH = 820 +const DEFAULT_HEIGHT = 480 + +export default function VariablesPanel() { + const variables = useDocumentStore((s) => s.document.variables) + const themes = useDocumentStore((s) => s.document.themes) + const setVariable = useDocumentStore((s) => s.setVariable) + const removeVariable = useDocumentStore((s) => s.removeVariable) + const renameVariable = useDocumentStore((s) => s.renameVariable) + const setThemes = useDocumentStore((s) => s.setThemes) + const toggleVariablesPanel = useCanvasStore((s) => s.toggleVariablesPanel) + + const [search, setSearch] = useState('') + const [showAddMenu, setShowAddMenu] = useState(false) + const [activeAxis, setActiveAxis] = useState(null) + // Theme tab dropdown (Rename/Delete) + const [activeThemeMenu, setActiveThemeMenu] = useState(null) + const [renamingTheme, setRenamingTheme] = useState(null) + const [renameThemeValue, setRenameThemeValue] = useState('') + // Variant column dropdown (Rename/Delete) + const [activeColumnMenu, setActiveColumnMenu] = useState(null) + const [renamingColumn, setRenamingColumn] = useState(null) + const [renameColumnValue, setRenameColumnValue] = useState('') + // Panel size + const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH) + const [panelHeight, setPanelHeight] = useState(DEFAULT_HEIGHT) + + const themeMenuRef = useRef(null) + const addMenuRef = useRef(null) + const columnMenuRef = useRef(null) + const renameInputRef = useRef(null) + const themeRenameInputRef = useRef(null) + const panelRef = useRef(null) + const resizeRef = useRef<{ + edge: 'right' | 'bottom' | 'corner' + startX: number; startY: number; startW: number; startH: number + } | null>(null) + + // Close menus on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (activeThemeMenu && themeMenuRef.current && !themeMenuRef.current.contains(e.target as Node)) + { setActiveThemeMenu(null); setRenamingTheme(null) } + if (showAddMenu && addMenuRef.current && !addMenuRef.current.contains(e.target as Node)) + setShowAddMenu(false) + if (activeColumnMenu && columnMenuRef.current && !columnMenuRef.current.contains(e.target as Node)) + setActiveColumnMenu(null) + } + if (activeThemeMenu || showAddMenu || activeColumnMenu) { + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + } + }, [activeThemeMenu, showAddMenu, activeColumnMenu]) + + useEffect(() => { + if (renamingColumn && renameInputRef.current) { + renameInputRef.current.focus() + renameInputRef.current.select() + } + }, [renamingColumn]) + + useEffect(() => { + if (renamingTheme && themeRenameInputRef.current) { + themeRenameInputRef.current.focus() + themeRenameInputRef.current.select() + } + }, [renamingTheme]) + + /* --- Resize --- */ + const handleResizeStart = useCallback((edge: 'right' | 'bottom' | 'corner', e: React.PointerEvent) => { + e.preventDefault() + e.stopPropagation() + resizeRef.current = { edge, startX: e.clientX, startY: e.clientY, startW: panelWidth, startH: panelHeight } + e.currentTarget.setPointerCapture(e.pointerId) + }, [panelWidth, panelHeight]) + + const handleResizeMove = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return + e.preventDefault() + const { edge, startX, startY, startW, startH } = resizeRef.current + const container = panelRef.current?.parentElement + const maxW = container ? container.clientWidth - 72 : 1400 + const maxH = container ? container.clientHeight - 16 : 900 + if (edge === 'right' || edge === 'corner') + setPanelWidth(Math.max(MIN_WIDTH, Math.min(maxW, startW + e.clientX - startX))) + if (edge === 'bottom' || edge === 'corner') + setPanelHeight(Math.max(MIN_HEIGHT, Math.min(maxH, startH + e.clientY - startY))) + }, []) + + const handleResizeEnd = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return + resizeRef.current = null + e.currentTarget.releasePointerCapture(e.pointerId) + }, []) + + /* --- Theme axes & variants --- */ + const themeAxes = useMemo(() => { + if (!themes) return [] + return Object.keys(themes) + }, [themes]) + + const currentAxis = useMemo(() => { + if (activeAxis && themes?.[activeAxis]) return activeAxis + if (themeAxes.length > 0) return themeAxes[0] + return null + }, [activeAxis, themes, themeAxes]) + + const themeValues = useMemo(() => { + if (!currentAxis || !themes?.[currentAxis]) return DEFAULT_THEME_VALUES + return themes[currentAxis].length > 0 ? themes[currentAxis] : DEFAULT_THEME_VALUES + }, [themes, currentAxis]) + + const themeAxis = currentAxis ?? DEFAULT_THEME_AXIS + + const ensureThemes = useCallback(() => { + if (!themes || Object.keys(themes).length === 0) { + setThemes({ [DEFAULT_THEME_AXIS]: DEFAULT_THEME_VALUES }) + } + }, [themes, setThemes]) + + const entries = useMemo(() => { + if (!variables) return [] + return Object.entries(variables) + .filter(([n]) => !search || n.toLowerCase().includes(search.toLowerCase())) + .sort(([a], [b]) => a.localeCompare(b)) + }, [variables, search]) + + /* --- Theme actions --- */ + const handleAddTheme = () => { + const current = themes ?? {} + let counter = 1 + let name = `Theme-${counter}` + while (current[name]) { counter++; name = `Theme-${counter}` } + setThemes({ ...current, [name]: ['Default'] }) + setActiveAxis(name) + } + + const handleDeleteTheme = (axis: string) => { + if (!themes) return + const updated = { ...themes } + delete updated[axis] + setThemes(updated) + if (activeAxis === axis) setActiveAxis(null) + setActiveThemeMenu(null) + } + + const handleRenameTheme = (oldName: string, newName: string) => { + setRenamingTheme(null) + setActiveThemeMenu(null) + if (!newName.trim() || newName === oldName) return + if (themes?.[newName]) return + const current = themes ?? {} + const values = current[oldName] ?? DEFAULT_THEME_VALUES + const updated: Record = {} + for (const key of Object.keys(current)) { + if (key === oldName) updated[newName] = values + else updated[key] = current[key] + } + setThemes(updated) + if (activeAxis === oldName) setActiveAxis(newName) + } + + /* --- Variant actions --- */ + const handleAddVariant = () => { + ensureThemes() + const axis = currentAxis ?? DEFAULT_THEME_AXIS + const currentValues = themes?.[axis] ?? DEFAULT_THEME_VALUES + let counter = 1 + let n = `Variant-${counter}` + while (currentValues.includes(n)) { counter++; n = `Variant-${counter}` } + const updatedThemes = { ...(themes ?? { [DEFAULT_THEME_AXIS]: DEFAULT_THEME_VALUES }) } + updatedThemes[axis] = [...currentValues, n] + setThemes(updatedThemes) + } + + const handleRemoveVariant = (value: string) => { + if (!currentAxis || !themes) return + const currentValues = themes[currentAxis] ?? [] + if (currentValues.length <= 1) return + setThemes({ ...themes, [currentAxis]: currentValues.filter((v) => v !== value) }) + setActiveColumnMenu(null) + } + + const handleRenameVariant = (oldName: string, newName: string) => { + if (!newName.trim() || newName === oldName) { setRenamingColumn(null); return } + if (!currentAxis || !themes) { setRenamingColumn(null); return } + const currentValues = themes[currentAxis] ?? [] + if (currentValues.includes(newName)) { setRenamingColumn(null); return } + setThemes({ ...themes, [currentAxis]: currentValues.map((v) => v === oldName ? newName : v) }) + setRenamingColumn(null) + } + + const startRenameVariant = (tv: string) => { + setRenameColumnValue(tv) + setRenamingColumn(tv) + setActiveColumnMenu(null) + } + + /* --- Add variable --- */ + const handleAdd = (type: VariableDefinition['type']) => { + ensureThemes() + const existing = variables ? Object.keys(variables) : [] + let counter = 1 + const baseName = type === 'color' ? 'color' : type === 'number' ? 'number' : 'string' + let varName = `${baseName}-${counter}` + while (existing.includes(varName)) { counter++; varName = `${baseName}-${counter}` } + const currentTV = themes?.[themeAxis] ?? DEFAULT_THEME_VALUES + let defaultValue: VariableDefinition['value'] + if (currentTV.length > 1) { + defaultValue = currentTV.map((tv) => ({ + value: type === 'color' ? '#000000' : type === 'number' ? 0 : '', + theme: { [themeAxis]: tv }, + })) as ThemedValue[] + } else { + defaultValue = type === 'color' ? '#000000' : type === 'number' ? 0 : '' + } + setVariable(varName, { type, value: defaultValue }) + setShowAddMenu(false) + } + + return ( +
+ {/* Background layer with rounded corners — sits behind everything */} +
+ + {/* ── Header: Theme-1 | Theme-2 | ... | + | spacer | X ── */} +
+ {/* Theme tabs — all equal, active one has chevron dropdown */} + {themeAxes.map((axis) => ( +
+ {renamingTheme === axis ? ( + setRenameThemeValue(e.target.value)} + onBlur={() => handleRenameTheme(axis, renameThemeValue)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRenameTheme(axis, renameThemeValue) + if (e.key === 'Escape') { setRenamingTheme(null); setActiveThemeMenu(null) } + }} + className="text-[13px] text-foreground bg-secondary px-2 py-0.5 rounded-lg border border-ring focus:outline-none w-24" + /> + ) : ( + + )} + {/* Theme dropdown: Rename / Delete */} + {activeThemeMenu === axis && !renamingTheme && ( +
+ + {themeAxes.length > 1 && ( + + )} +
+ )} +
+ ))} + + {/* + add theme */} + + +
+ + +
+ + {/* ── Column headers: Name | Default | Variant-1 | ... | + ── */} +
+
+ Name +
+ {themeValues.map((tv) => ( +
+ {renamingColumn === tv ? ( + setRenameColumnValue(e.target.value)} + onBlur={() => handleRenameVariant(tv, renameColumnValue)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRenameVariant(tv, renameColumnValue) + if (e.key === 'Escape') setRenamingColumn(null) + }} + className="text-[13px] font-medium text-foreground bg-secondary px-1.5 py-0.5 rounded border border-ring focus:outline-none w-32" + /> + ) : ( + + )} + {activeColumnMenu === tv && ( +
+ + {themeValues.length > 1 && ( + + )} +
+ )} +
+ ))} +
+ +
+
+ + {/* ── Search ── */} + {entries.length > 6 && ( +
+
+ + setSearch(e.target.value)} + className="flex-1 bg-transparent text-foreground text-[12px] focus:outline-none placeholder:text-muted-foreground/40" + /> +
+
+ )} + + {/* ── Variable rows ── */} +
+ {entries.length === 0 && ( +
+ + {search ? 'No variables match your search' : 'No variables defined'} + +
+ )} + {entries.map(([varName, def]) => ( + setVariable(n, d)} + onRename={(o, n) => renameVariable(o, n)} + onDelete={(n) => removeVariable(n)} + /> + ))} +
+ + {/* ── Footer ── */} +
+ + {showAddMenu && ( +
+ {(['color', 'number', 'string'] as const).map((t) => ( + + ))} +
+ )} +
+ + {/* ── Resize handles ── */} +
handleResizeStart('right', e)} + onPointerMove={handleResizeMove} + onPointerUp={handleResizeEnd} + /> +
handleResizeStart('bottom', e)} + onPointerMove={handleResizeMove} + onPointerUp={handleResizeEnd} + /> +
handleResizeStart('corner', e)} + onPointerMove={handleResizeMove} + onPointerUp={handleResizeEnd} + /> +
+ ) +} diff --git a/src/components/shared/number-input.tsx b/src/components/shared/number-input.tsx index 2ac5ce8e2..22c2847d8 100644 --- a/src/components/shared/number-input.tsx +++ b/src/components/shared/number-input.tsx @@ -1,5 +1,7 @@ import { useState, useRef, useCallback, useEffect } from 'react' import { cn } from '@/lib/utils' +import { useHistoryStore } from '@/stores/history-store' +import { useDocumentStore } from '@/stores/document-store' interface NumberInputProps { value: number @@ -72,6 +74,9 @@ export default function NumberInput({ dragStartY.current = e.clientY dragStartValue.current = value + // Batch all scrub-drag onChange calls into a single undo entry + useHistoryStore.getState().startBatch(useDocumentStore.getState().document) + const handleMouseMove = (ev: MouseEvent) => { const delta = dragStartY.current - ev.clientY const newValue = clamp(dragStartValue.current + delta * step) @@ -80,6 +85,7 @@ export default function NumberInput({ const handleMouseUp = () => { setIsDragging(false) + useHistoryStore.getState().endBatch() document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } diff --git a/src/components/shared/variable-picker.tsx b/src/components/shared/variable-picker.tsx new file mode 100644 index 000000000..b52627fc3 --- /dev/null +++ b/src/components/shared/variable-picker.tsx @@ -0,0 +1,152 @@ +import { useState, useRef, useEffect, useMemo } from 'react' +import { Braces, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import { useDocumentStore } from '@/stores/document-store' +import { isVariableRef, resolveVariableRef, getDefaultTheme } from '@/variables/resolve-variables' +import type { VariableDefinition } from '@/types/variables' + +interface VariablePickerProps { + /** Variable type to filter by */ + type: 'color' | 'number' | 'string' + /** Current value — if it starts with '$', it's a variable reference */ + currentValue?: string | number + /** Called when a variable is selected — value will be '$variableName' */ + onBind: (ref: string) => void + /** Called when the variable binding is removed — should set the resolved concrete value */ + onUnbind: (resolvedValue: string | number) => void + className?: string +} + +export default function VariablePicker({ + type, + currentValue, + onBind, + onUnbind, + className, +}: VariablePickerProps) { + const [open, setOpen] = useState(false) + const popoverRef = useRef(null) + const variables = useDocumentStore((s) => s.document.variables) + const themes = useDocumentStore((s) => s.document.themes) + + const isBound = typeof currentValue === 'string' && isVariableRef(currentValue) + const boundName = isBound ? (currentValue as string).slice(1) : null + + // Filter variables by matching type + const matchingVars = useMemo(() => { + if (!variables) return [] + return Object.entries(variables) + .filter(([, def]) => def.type === type) + .sort(([a], [b]) => a.localeCompare(b)) + }, [variables, type]) + + // Close on outside click + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + const handleBind = (name: string) => { + onBind(`$${name}`) + setOpen(false) + } + + const handleUnbind = () => { + if (!boundName || !variables?.[boundName]) return + const activeTheme = getDefaultTheme(themes) + const resolved = resolveVariableRef(`$${boundName}`, variables, activeTheme) + const fallback: string | number = type === 'color' ? '#000000' : type === 'number' ? 0 : '' + const val = resolved != null && typeof resolved !== 'boolean' ? resolved : fallback + onUnbind(val) + setOpen(false) + } + + const getPreview = (def: VariableDefinition): string => { + const val = def.value + if (!Array.isArray(val)) return String(val) + // Show first theme value + return val[0]?.value != null ? String(val[0].value) : '' + } + + if (matchingVars.length === 0 && !isBound) return null + + return ( +
+ {isBound ? ( + + ) : ( + + )} + + {open && ( +
+ {isBound && ( + <> + +
+ + )} + {matchingVars.length === 0 ? ( +
+ No {type} variables defined +
+ ) : ( + matchingVars.map(([name, def]) => ( + + )) + )} +
+ )} +
+ ) +} diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts index a178bb817..a6e3b820a 100644 --- a/src/hooks/use-keyboard-shortcuts.ts +++ b/src/hooks/use-keyboard-shortcuts.ts @@ -61,6 +61,13 @@ export function useKeyboardShortcuts() { if (prev) { useDocumentStore.getState().applyHistoryState(prev) } + // Deselect so Fabric re-renders objects at their restored dimensions + useCanvasStore.getState().clearSelection() + const canvas = useCanvasStore.getState().fabricCanvas + if (canvas) { + canvas.discardActiveObject() + canvas.requestRenderAll() + } return } @@ -72,6 +79,12 @@ export function useKeyboardShortcuts() { if (next) { useDocumentStore.getState().applyHistoryState(next) } + useCanvasStore.getState().clearSelection() + const canvas = useCanvasStore.getState().fabricCanvas + if (canvas) { + canvas.discardActiveObject() + canvas.requestRenderAll() + } return } diff --git a/src/services/ai/design-generator.ts b/src/services/ai/design-generator.ts index 6d1b76cc3..144a18993 100644 --- a/src/services/ai/design-generator.ts +++ b/src/services/ai/design-generator.ts @@ -171,11 +171,20 @@ export async function generateDesign( useHistoryStore.getState().startBatch(useDocumentStore.getState().document) } + let isThinking = false + try { for await (const chunk of streamChat(DESIGN_GENERATOR_PROMPT, [ { role: 'user', content: userMessage }, ], undefined, DESIGN_STREAM_TIMEOUTS)) { - if (chunk.type === 'text') { + if (chunk.type === 'thinking') { + // Show a "Thinking" step so the UI isn't stuck on the empty indicator + if (!isThinking && !fullResponse) { + isThinking = true + callbacks?.onTextUpdate?.('Analyzing your design request...') + } + } else if (chunk.type === 'text') { + isThinking = false fullResponse += chunk.content if (callbacks?.onTextUpdate) { callbacks.onTextUpdate(fullResponse) @@ -265,7 +274,9 @@ export async function generateDesignModification( for await (const chunk of streamChat(DESIGN_MODIFIER_PROMPT, [ { role: 'user', content: userMessage }, ], undefined, DESIGN_STREAM_TIMEOUTS)) { - if (chunk.type === 'text') { + if (chunk.type === 'thinking') { + // Ignore thinking chunks for modification — caller already shows progress + } else if (chunk.type === 'text') { fullResponse += chunk.content } else if (chunk.type === 'error') { streamError = chunk.content @@ -527,7 +538,8 @@ function sanitizeScreenFrameBounds(node: PenNode): void { function isScreenFrame(node: PenNode): boolean { if (node.type !== 'frame') return false - if (typeof node.width !== 'number' || typeof node.height !== 'number') return false + if (!('width' in node) || typeof node.width !== 'number') return false + if (!('height' in node) || typeof node.height !== 'number') return false const w = node.width const h = node.height const isMobileLike = w >= 320 && w <= 480 && h >= 640 @@ -538,10 +550,13 @@ function isScreenFrame(node: PenNode): boolean { function clampChildrenIntoScreen(frame: PenNode): void { if (!('children' in frame) || !Array.isArray(frame.children)) return if ('layout' in frame && frame.layout && frame.layout !== 'none') return - if (typeof frame.width !== 'number' || typeof frame.height !== 'number') return + if (!('width' in frame) || typeof frame.width !== 'number') return + if (!('height' in frame) || typeof frame.height !== 'number') return - const maxBleedX = frame.width * 0.1 - const maxBleedY = frame.height * 0.1 + const frameW = frame.width + const frameH = frame.height + const maxBleedX = frameW * 0.1 + const maxBleedY = frameH * 0.1 for (const child of frame.children) { const childWidth = 'width' in child && typeof child.width === 'number' ? child.width : null @@ -556,9 +571,9 @@ function clampChildrenIntoScreen(frame: PenNode): void { } const minX = -maxBleedX - const maxX = frame.width - childWidth + maxBleedX + const maxX = frameW - childWidth + maxBleedX const minY = -maxBleedY - const maxY = frame.height - childHeight + maxBleedY + const maxY = frameH - childHeight + maxBleedY child.x = clamp(child.x, minX, maxX) child.y = clamp(child.y, minY, maxY) diff --git a/src/services/codegen/css-variables-generator.ts b/src/services/codegen/css-variables-generator.ts new file mode 100644 index 000000000..74a2b1c92 --- /dev/null +++ b/src/services/codegen/css-variables-generator.ts @@ -0,0 +1,138 @@ +/** + * Generates CSS custom properties from PenDocument variables. + * + * Produces `:root { ... }` blocks with one block per theme variant. + */ + +import type { PenDocument } from '@/types/pen' +import type { VariableDefinition, ThemedValue } from '@/types/variables' + +/** Sanitise a variable name into a valid CSS custom property name. */ +export function variableNameToCSS(name: string): string { + const sanitised = name + .replace(/^\$/, '') + .replace(/\s+/g, '-') + .replace(/[^a-zA-Z0-9_-]/g, '') + .toLowerCase() + return `--${sanitised}` +} + +/** Whether a numeric variable should be output without a unit (e.g. opacity). */ +function isUnitless(name: string): boolean { + const lower = name.toLowerCase() + return ( + lower.includes('opacity') || + lower.includes('weight') || + lower.includes('scale') || + lower.includes('ratio') || + lower.includes('z-index') || + lower.includes('line-height') + ) +} + +/** Format a variable value as a CSS value string. */ +function formatValue( + value: string | number | boolean, + name: string, + type: VariableDefinition['type'], +): string | null { + if (type === 'boolean') return null + if (type === 'color') return String(value) + if (type === 'number') { + if (typeof value !== 'number') return String(value) + return isUnitless(name) ? String(value) : `${value}px` + } + // string + return String(value) +} + +/** Resolve a single themed value for a given theme context. */ +function resolveForTheme( + def: VariableDefinition, + theme: Record, +): string | number | boolean | undefined { + const val = def.value + if (!Array.isArray(val)) return val + const match = (val as ThemedValue[]).find((v) => { + if (!v.theme) return false + return Object.entries(theme).every( + ([key, expected]) => v.theme?.[key] === expected, + ) + }) + return match?.value ?? (val as ThemedValue[])[0]?.value +} + +/** + * Generate CSS custom properties from a PenDocument's variables and themes. + * + * Returns a string containing `:root { ... }` blocks. + */ +export function generateCSSVariables(doc: PenDocument): string { + const variables = doc.variables + if (!variables || Object.keys(variables).length === 0) { + return '/* No design variables defined */\n' + } + + const themes = doc.themes ?? {} + const themeAxes = Object.entries(themes) + + // Build default theme (first value per axis) + const defaultTheme: Record = {} + for (const [key, values] of themeAxes) { + if (values.length > 0) defaultTheme[key] = values[0] + } + + const hasThemes = themeAxes.length > 0 && themeAxes.some(([, v]) => v.length > 1) + + // Generate default :root block + const lines: string[] = [] + lines.push(':root {') + + const varEntries = Object.entries(variables).sort(([a], [b]) => a.localeCompare(b)) + for (const [name, def] of varEntries) { + const value = Array.isArray(def.value) + ? resolveForTheme(def, defaultTheme) + : def.value + if (value === undefined) continue + const css = formatValue(value, name, def.type) + if (css === null) continue + lines.push(` ${variableNameToCSS(name)}: ${css};`) + } + + lines.push('}') + + // Generate per-theme variant blocks + if (hasThemes) { + // For simplicity, iterate over each axis independently + // (e.g. mode: light/dark generates :root[data-theme="dark"] { ... }) + for (const [axis, values] of themeAxes) { + // Skip the default (first) value + for (let i = 1; i < values.length; i++) { + const themeValue = values[i] + const themeContext = { ...defaultTheme, [axis]: themeValue } + + const block: string[] = [] + for (const [name, def] of varEntries) { + if (!Array.isArray(def.value)) continue + const resolvedForThis = resolveForTheme(def, themeContext) + const resolvedForDefault = resolveForTheme(def, defaultTheme) + // Only include if different from default + if (resolvedForThis === resolvedForDefault) continue + if (resolvedForThis === undefined) continue + const css = formatValue(resolvedForThis, name, def.type) + if (css === null) continue + block.push(` ${variableNameToCSS(name)}: ${css};`) + } + + if (block.length > 0) { + lines.push('') + lines.push(`:root[data-theme="${themeValue}"] {`) + lines.push(...block) + lines.push('}') + } + } + } + } + + return lines.join('\n') + '\n' +} diff --git a/src/services/codegen/html-generator.ts b/src/services/codegen/html-generator.ts index ff4b7167e..ef4e9dcc7 100644 --- a/src/services/codegen/html-generator.ts +++ b/src/services/codegen/html-generator.ts @@ -1,10 +1,20 @@ import type { PenDocument, PenNode, ContainerProps, TextNode } from '@/types/pen' import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles' +import { isVariableRef } from '@/variables/resolve-variables' +import { variableNameToCSS, generateCSSVariables } from '@/services/codegen/css-variables-generator' /** * Converts PenDocument nodes to HTML + CSS. + * $variable references are output as var(--name) CSS custom properties. */ +function varOrLiteral(value: string): string { + if (isVariableRef(value)) { + return `var(${variableNameToCSS(value.slice(1))})` + } + return value +} + let classCounter = 0 function resetClassCounter() { @@ -24,15 +34,15 @@ function fillToCSS(fills: PenFill[] | undefined): Record { if (!fills || fills.length === 0) return {} const fill = fills[0] if (fill.type === 'solid') { - return { background: fill.color } + return { background: varOrLiteral(fill.color) } } if (fill.type === 'linear_gradient') { const angle = fill.angle ?? 180 - const stops = fill.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ') + const stops = fill.stops.map((s) => `${varOrLiteral(s.color)} ${Math.round(s.offset * 100)}%`).join(', ') return { background: `linear-gradient(${angle}deg, ${stops})` } } if (fill.type === 'radial_gradient') { - const stops = fill.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ') + const stops = fill.stops.map((s) => `${varOrLiteral(s.color)} ${Math.round(s.offset * 100)}%`).join(', ') return { background: `radial-gradient(circle, ${stops})` } } return {} @@ -41,15 +51,19 @@ function fillToCSS(fills: PenFill[] | undefined): Record { function strokeToCSS(stroke: PenStroke | undefined): Record { if (!stroke) return {} const css: Record = {} - const thickness = typeof stroke.thickness === 'number' - ? stroke.thickness - : stroke.thickness[0] - css['border-width'] = `${thickness}px` + if (typeof stroke.thickness === 'string' && isVariableRef(stroke.thickness)) { + css['border-width'] = varOrLiteral(stroke.thickness) + } else { + const thickness = typeof stroke.thickness === 'number' + ? stroke.thickness + : stroke.thickness[0] + css['border-width'] = `${thickness}px` + } css['border-style'] = 'solid' if (stroke.fill && stroke.fill.length > 0) { const sf = stroke.fill[0] if (sf.type === 'solid') { - css['border-color'] = sf.color + css['border-color'] = varOrLiteral(sf.color) } } return css @@ -90,11 +104,17 @@ function layoutToCSS(node: ContainerProps): Record { css.display = 'flex' css['flex-direction'] = 'row' } - if (node.gap !== undefined && typeof node.gap === 'number') { - css.gap = `${node.gap}px` + if (node.gap !== undefined) { + if (typeof node.gap === 'string' && isVariableRef(node.gap)) { + css.gap = varOrLiteral(node.gap) + } else if (typeof node.gap === 'number') { + css.gap = `${node.gap}px` + } } if (node.padding !== undefined) { - if (typeof node.padding === 'number') { + if (typeof node.padding === 'string' && isVariableRef(node.padding)) { + css.padding = varOrLiteral(node.padding) + } else if (typeof node.padding === 'number') { css.padding = `${node.padding}px` } else if (Array.isArray(node.padding)) { css.padding = node.padding.map((p) => `${p}px`).join(' ') @@ -155,8 +175,12 @@ function generateNodeHTML( } // Opacity - if (node.opacity !== undefined && node.opacity !== 1 && typeof node.opacity === 'number') { - css.opacity = String(node.opacity) + if (node.opacity !== undefined && node.opacity !== 1) { + if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) { + css.opacity = varOrLiteral(node.opacity) + } else if (typeof node.opacity === 'number') { + css.opacity = String(node.opacity) + } } // Rotation @@ -207,7 +231,7 @@ function generateNodeHTML( if (typeof node.height === 'number') css.height = `${node.height}px` if (node.fill) { const fill = node.fill[0] - if (fill?.type === 'solid') css.color = fill.color + if (fill?.type === 'solid') css.color = varOrLiteral(fill.color) } if (node.fontSize) css['font-size'] = `${node.fontSize}px` if (node.fontWeight) css['font-weight'] = String(node.fontWeight) @@ -240,7 +264,7 @@ function generateNodeHTML( css['border-top-style'] = 'solid' if (node.stroke.fill && node.stroke.fill.length > 0) { const sf = node.stroke.fill[0] - if (sf.type === 'solid') css['border-top-color'] = sf.color + if (sf.type === 'solid') css['border-top-color'] = varOrLiteral(sf.color) } } const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'line') @@ -258,7 +282,7 @@ function generateNodeHTML( if (node.type === 'path') { const w = typeof node.width === 'number' ? node.width : 100 const h = typeof node.height === 'number' ? node.height : 100 - const fillColor = node.fill?.[0]?.type === 'solid' ? node.fill[0].color : 'currentColor' + const fillColor = node.fill?.[0]?.type === 'solid' ? varOrLiteral(node.fill[0].color) : 'currentColor' return `${pad}\n${pad} \n${pad}` } return `${pad}
` @@ -322,5 +346,12 @@ export function generateHTMLCode(nodes: PenNode[]): { html: string; css: string } export function generateHTMLFromDocument(doc: PenDocument): { html: string; css: string } { - return generateHTMLCode(doc.children) + const result = generateHTMLCode(doc.children) + const varsCSS = doc.variables && Object.keys(doc.variables).length > 0 + ? generateCSSVariables(doc) + : '' + return { + html: result.html, + css: varsCSS ? `${varsCSS}\n${result.css}` : result.css, + } } diff --git a/src/services/codegen/react-generator.ts b/src/services/codegen/react-generator.ts index e3c463d03..2d2983d32 100644 --- a/src/services/codegen/react-generator.ts +++ b/src/services/codegen/react-generator.ts @@ -1,10 +1,21 @@ import type { PenDocument, PenNode, ContainerProps, TextNode } from '@/types/pen' import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles' +import { isVariableRef } from '@/variables/resolve-variables' +import { variableNameToCSS } from '@/services/codegen/css-variables-generator' /** * Converts PenDocument nodes to React + Tailwind code. + * $variable references are output as var(--name) CSS custom properties. */ +/** Convert a `$variable` ref to `var(--name)`, or return the raw value. */ +function varOrLiteral(value: string): string { + if (isVariableRef(value)) { + return `var(${variableNameToCSS(value.slice(1))})` + } + return value +} + function indent(depth: number): string { return ' '.repeat(depth) } @@ -13,7 +24,7 @@ function fillToTailwind(fills: PenFill[] | undefined): string[] { if (!fills || fills.length === 0) return [] const fill = fills[0] if (fill.type === 'solid') { - return [`bg-[${fill.color}]`] + return [`bg-[${varOrLiteral(fill.color)}]`] } return [] } @@ -22,7 +33,7 @@ function fillToTextColor(fills: PenFill[] | undefined): string[] { if (!fills || fills.length === 0) return [] const fill = fills[0] if (fill.type === 'solid') { - return [`text-[${fill.color}]`] + return [`text-[${varOrLiteral(fill.color)}]`] } return [] } @@ -30,14 +41,18 @@ function fillToTextColor(fills: PenFill[] | undefined): string[] { function strokeToTailwind(stroke: PenStroke | undefined): string[] { if (!stroke) return [] const classes: string[] = [] - const thickness = typeof stroke.thickness === 'number' - ? stroke.thickness - : stroke.thickness[0] - classes.push('border', `border-[${thickness}px]`) + if (typeof stroke.thickness === 'string' && isVariableRef(stroke.thickness)) { + classes.push('border', `border-[${varOrLiteral(stroke.thickness)}]`) + } else { + const thickness = typeof stroke.thickness === 'number' + ? stroke.thickness + : stroke.thickness[0] + classes.push('border', `border-[${thickness}px]`) + } if (stroke.fill && stroke.fill.length > 0) { const sf = stroke.fill[0] if (sf.type === 'solid') { - classes.push(`border-[${sf.color}]`) + classes.push(`border-[${varOrLiteral(sf.color)}]`) } } return classes @@ -77,11 +92,17 @@ function layoutToTailwind(node: ContainerProps): string[] { } else if (node.layout === 'horizontal') { classes.push('flex', 'flex-row') } - if (node.gap !== undefined && typeof node.gap === 'number' && node.gap > 0) { - classes.push(`gap-[${node.gap}px]`) + if (node.gap !== undefined) { + if (typeof node.gap === 'string' && isVariableRef(node.gap)) { + classes.push(`gap-[${varOrLiteral(node.gap)}]`) + } else if (typeof node.gap === 'number' && node.gap > 0) { + classes.push(`gap-[${node.gap}px]`) + } } if (node.padding !== undefined) { - if (typeof node.padding === 'number') { + if (typeof node.padding === 'string' && isVariableRef(node.padding)) { + classes.push(`p-[${varOrLiteral(node.padding)}]`) + } else if (typeof node.padding === 'number') { classes.push(`p-[${node.padding}px]`) } else if (Array.isArray(node.padding)) { if (node.padding.length === 2) { @@ -129,6 +150,9 @@ function sizeToTailwind( function opacityToTailwind(opacity: number | string | undefined): string[] { if (opacity === undefined || opacity === 1) return [] + if (typeof opacity === 'string' && isVariableRef(opacity)) { + return [`opacity-[${varOrLiteral(opacity)}]`] + } if (typeof opacity === 'number') { const pct = Math.round(opacity * 100) return [`opacity-[${pct}%]`] @@ -245,12 +269,16 @@ function generateNodeJSX(node: PenNode, depth: number): string { if (node.stroke) { const thickness = typeof node.stroke.thickness === 'number' ? node.stroke.thickness - : node.stroke.thickness[0] - classes.push(`border-t-[${thickness}px]`) + : typeof node.stroke.thickness === 'string' ? node.stroke.thickness : node.stroke.thickness[0] + if (typeof thickness === 'string' && isVariableRef(thickness)) { + classes.push(`border-t-[${varOrLiteral(thickness)}]`) + } else { + classes.push(`border-t-[${thickness}px]`) + } if (node.stroke.fill && node.stroke.fill.length > 0) { const sf = node.stroke.fill[0] if (sf.type === 'solid') { - classes.push(`border-[${sf.color}]`) + classes.push(`border-[${varOrLiteral(sf.color)}]`) } } } @@ -264,7 +292,7 @@ function generateNodeJSX(node: PenNode, depth: number): string { if (node.type === 'path') { const w = typeof node.width === 'number' ? node.width : 100 const h = typeof node.height === 'number' ? node.height : 100 - const fillColor = node.fill?.[0]?.type === 'solid' ? node.fill[0].color : 'currentColor' + const fillColor = node.fill?.[0]?.type === 'solid' ? varOrLiteral(node.fill[0].color) : 'currentColor' return `${pad}\n${pad} \n${pad}` } classes.push(...fillToTailwind(node.fill)) @@ -327,5 +355,5 @@ ${childrenJSX} } export function generateReactFromDocument(doc: PenDocument): string { - return generateReactCode(doc.children) + return generateReactCode(doc.children, 'GeneratedDesign') } diff --git a/src/stores/canvas-store.ts b/src/stores/canvas-store.ts index 2704c8b29..9811ae75f 100644 --- a/src/stores/canvas-store.ts +++ b/src/stores/canvas-store.ts @@ -16,6 +16,7 @@ interface CanvasStoreState { fabricCanvas: Canvas | null clipboard: PenNode[] layerPanelOpen: boolean + variablesPanelOpen: boolean setActiveTool: (tool: ToolType) => void setZoom: (zoom: number) => void @@ -30,6 +31,7 @@ interface CanvasStoreState { setFabricCanvas: (canvas: Canvas | null) => void setClipboard: (nodes: PenNode[]) => void toggleLayerPanel: () => void + toggleVariablesPanel: () => void } export const useCanvasStore = create((set) => ({ @@ -45,6 +47,7 @@ export const useCanvasStore = create((set) => ({ fabricCanvas: null, clipboard: [], layerPanelOpen: true, + variablesPanelOpen: false, setActiveTool: (tool) => set({ activeTool: tool }), @@ -110,4 +113,5 @@ export const useCanvasStore = create((set) => ({ setClipboard: (clipboard) => set({ clipboard }), toggleLayerPanel: () => set((s) => ({ layerPanelOpen: !s.layerPanelOpen })), + toggleVariablesPanel: () => set((s) => ({ variablesPanelOpen: !s.variablesPanelOpen })), })) diff --git a/src/stores/document-store.ts b/src/stores/document-store.ts index 2e8c97a90..476fc60a7 100644 --- a/src/stores/document-store.ts +++ b/src/stores/document-store.ts @@ -1,7 +1,10 @@ import { create } from 'zustand' import { nanoid } from 'nanoid' import type { PenDocument, PenNode, GroupNode } from '@/types/pen' +import type { VariableDefinition } from '@/types/variables' import { useHistoryStore } from '@/stores/history-store' +import { getDefaultTheme } from '@/variables/resolve-variables' +import { replaceVariableRefsInTree } from '@/variables/replace-refs' export const DEFAULT_FRAME_ID = 'root-frame' @@ -231,6 +234,12 @@ interface DocumentStoreState { getFlatNodes: () => PenNode[] isDescendantOf: (nodeId: string, ancestorId: string) => boolean + // Variable management + setVariable: (name: string, definition: VariableDefinition) => void + removeVariable: (name: string) => void + renameVariable: (oldName: string, newName: string) => void + setThemes: (themes: Record) => void + applyHistoryState: (doc: PenDocument) => void loadDocument: ( doc: PenDocument, @@ -587,6 +596,78 @@ export const useDocumentStore = create( isDescendantOf: (nodeId, ancestorId) => isDescendantOf(get().document.children, nodeId, ancestorId), + // --- Variable management --- + + setVariable: (name, definition) => { + useHistoryStore.getState().pushState(get().document) + set((s) => ({ + document: { + ...s.document, + variables: { ...(s.document.variables ?? {}), [name]: definition }, + }, + isDirty: true, + })) + }, + + removeVariable: (name) => { + const state = get() + const vars = state.document.variables + if (!vars || !(name in vars)) return + useHistoryStore.getState().pushState(state.document) + const { [name]: _removed, ...rest } = vars + const activeTheme = getDefaultTheme(state.document.themes) + const newChildren = replaceVariableRefsInTree( + state.document.children, + name, + null, + vars, + activeTheme, + ) + set({ + document: { + ...state.document, + variables: Object.keys(rest).length > 0 ? rest : undefined, + children: newChildren, + }, + isDirty: true, + }) + }, + + renameVariable: (oldName, newName) => { + if (oldName === newName) return + const state = get() + const vars = state.document.variables + if (!vars || !(oldName in vars)) return + useHistoryStore.getState().pushState(state.document) + const def = vars[oldName] + const { [oldName]: _removed, ...rest } = vars + const newVars = { ...rest, [newName]: def } + const activeTheme = getDefaultTheme(state.document.themes) + const newChildren = replaceVariableRefsInTree( + state.document.children, + oldName, + newName, + vars, + activeTheme, + ) + set({ + document: { + ...state.document, + variables: newVars, + children: newChildren, + }, + isDirty: true, + }) + }, + + setThemes: (themes) => { + useHistoryStore.getState().pushState(get().document) + set((s) => ({ + document: { ...s.document, themes }, + isDirty: true, + })) + }, + applyHistoryState: (doc) => set({ document: doc, isDirty: true }), diff --git a/src/utils/normalize-pen-file.ts b/src/utils/normalize-pen-file.ts index be229d151..86979d2df 100644 --- a/src/utils/normalize-pen-file.ts +++ b/src/utils/normalize-pen-file.ts @@ -1,40 +1,29 @@ /** * Normalize a Pencil.dev .pen document into OpenPencil's internal format. * - * Handles: + * Handles format normalization ONLY — does NOT resolve $variable references: * - fill type: "color" → "solid" * - fill shorthand string "#hex" → [{ type: "solid", color }] * - gradient type: "gradient" → "linear_gradient" / "radial_gradient" * - gradient stops { color, position } → { offset, color } - * - $variable references → resolved values (first/default theme) * - sizing "fit_content(N)" / "fill_container(N)" → fallback number + * - padding array normalization + * + * Variable resolution is handled separately by `resolve-variables.ts` at + * canvas render time, preserving $variable bindings in the document. */ import type { PenDocument, PenNode } from '@/types/pen' -import type { PenFill, PenStroke, PenEffect, GradientStop } from '@/types/styles' -import type { VariableDefinition } from '@/types/variables' - -type Vars = Record - -// Module-level default theme map, set per normalizePenDocument call -let _defaultTheme: Record = {} +import type { PenFill, PenStroke, GradientStop } from '@/types/styles' // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export function normalizePenDocument(doc: PenDocument): PenDocument { - const vars: Vars = doc.variables ?? {} - // Build default theme: first entry of each theme collection - _defaultTheme = {} - if (doc.themes) { - for (const [key, values] of Object.entries(doc.themes)) { - if (values.length > 0) _defaultTheme[key] = values[0] - } - } return { ...doc, - children: doc.children.map((n) => normalizeNode(n, vars)), + children: doc.children.map((n) => normalizeNode(n)), } } @@ -42,44 +31,35 @@ export function normalizePenDocument(doc: PenDocument): PenDocument { // Node normalizer (recursive) // --------------------------------------------------------------------------- -function normalizeNode(node: PenNode, vars: Vars): PenNode { +function normalizeNode(node: PenNode): PenNode { const out: Record = { ...node } // fill if ('fill' in out && out.fill !== undefined) { - out.fill = normalizeFills(out.fill, vars) + out.fill = normalizeFills(out.fill) } // stroke if ('stroke' in out && out.stroke != null) { - out.stroke = normalizeStroke(out.stroke as Record, vars) + out.stroke = normalizeStroke(out.stroke as Record) } - // effects - if ('effects' in out && Array.isArray(out.effects)) { - out.effects = normalizeEffects(out.effects as Record[], vars) - } + // effects — pass through (no format changes needed) // sizing if ('width' in out) out.width = normalizeSizing(out.width) if ('height' in out) out.height = normalizeSizing(out.height) - // gap / padding may be variable refs - if ('gap' in out) out.gap = resolveNumeric(out.gap, vars) - if ('padding' in out) out.padding = normalizePadding(out.padding, vars) + // gap — pass through ($variable strings preserved) - // opacity - if ('opacity' in out) out.opacity = resolveNumeric(out.opacity, vars) ?? 1 + // padding — normalize array format only (not variable resolution) + if ('padding' in out) out.padding = normalizePadding(out.padding) - // text content — resolve variable in content if it's a $ref - if (out.type === 'text' && typeof out.content === 'string' && (out.content as string).startsWith('$')) { - const resolved = resolveVar(out.content as string, vars) - if (typeof resolved === 'string') out.content = resolved - } + // opacity — pass through ($variable strings preserved) // children if ('children' in out && Array.isArray(out.children)) { - out.children = (out.children as PenNode[]).map((c) => normalizeNode(c, vars)) + out.children = (out.children as PenNode[]).map((c) => normalizeNode(c)) } return out as unknown as PenNode @@ -89,23 +69,22 @@ function normalizeNode(node: PenNode, vars: Vars): PenNode { // Fill normalization // --------------------------------------------------------------------------- -function normalizeFills(raw: unknown, vars: Vars): PenFill[] { +function normalizeFills(raw: unknown): PenFill[] { if (!raw) return [] - // String shorthand: "#hex" or "$variable" + // String shorthand: "#hex" or "$variable" → solid fill if (typeof raw === 'string') { - const color = resolveColor(raw, vars) - return color ? [{ type: 'solid', color }] : [] + return [{ type: 'solid', color: raw }] } // Array of fills if (Array.isArray(raw)) { - return raw.map((f) => normalizeSingleFill(f, vars)).filter(Boolean) as PenFill[] + return raw.map((f) => normalizeSingleFill(f)).filter(Boolean) as PenFill[] } // Single fill object if (typeof raw === 'object') { - const f = normalizeSingleFill(raw as Record, vars) + const f = normalizeSingleFill(raw as Record) return f ? [f] : [] } @@ -114,7 +93,6 @@ function normalizeFills(raw: unknown, vars: Vars): PenFill[] { function normalizeSingleFill( raw: Record, - vars: Vars, ): PenFill | null { if (!raw || typeof raw !== 'object') return null const t = raw.type as string | undefined @@ -123,14 +101,14 @@ function normalizeSingleFill( if (t === 'color' || t === 'solid') { return { type: 'solid', - color: resolveColor(raw.color, vars) ?? '#000000', + color: typeof raw.color === 'string' ? raw.color : '#000000', } } // Pencil "gradient" → split by gradientType if (t === 'gradient') { const gt = (raw.gradientType as string) ?? 'linear' - const stops = normalizeGradientStops(raw.colors as unknown[], vars) + const stops = normalizeGradientStops(raw.colors as unknown[]) if (gt === 'radial') { const center = raw.center as Record | undefined @@ -154,9 +132,9 @@ function normalizeSingleFill( if (t === 'linear_gradient' || t === 'radial_gradient') { const stops = 'stops' in raw - ? normalizeGradientStops(raw.stops as unknown[], vars) + ? normalizeGradientStops(raw.stops as unknown[]) : 'colors' in raw - ? normalizeGradientStops(raw.colors as unknown[], vars) + ? normalizeGradientStops(raw.colors as unknown[]) : [] return { ...(raw as unknown as PenFill), stops } as PenFill } @@ -168,7 +146,7 @@ function normalizeSingleFill( if ('color' in raw) { return { type: 'solid', - color: resolveColor(raw.color, vars) ?? '#000000', + color: typeof raw.color === 'string' ? raw.color : '#000000', } } @@ -177,7 +155,6 @@ function normalizeSingleFill( function normalizeGradientStops( raw: unknown[] | undefined, - vars: Vars, ): GradientStop[] { if (!Array.isArray(raw)) return [] return raw.map((s: unknown) => { @@ -189,7 +166,7 @@ function normalizeGradientStops( : typeof stop.position === 'number' ? stop.position : 0, - color: resolveColor(stop.color, vars) ?? '#000000', + color: typeof stop.color === 'string' ? stop.color : '#000000', } }) } @@ -200,51 +177,33 @@ function normalizeGradientStops( function normalizeStroke( raw: Record, - vars: Vars, ): PenStroke | undefined { if (!raw) return undefined const out = { ...raw } // Normalize fill inside stroke if ('fill' in out) { - out.fill = normalizeFills(out.fill, vars) + out.fill = normalizeFills(out.fill) } // Pencil may use "color" directly on stroke if ('color' in out && typeof out.color === 'string') { - out.fill = [{ type: 'solid', color: resolveColor(out.color, vars) ?? '#000000' }] + out.fill = [{ type: 'solid', color: out.color as string }] delete out.color } - // Normalize thickness variable ref + // Thickness: leave $variable strings as-is, normalise plain number strings if (typeof out.thickness === 'string') { - out.thickness = resolveNumeric(out.thickness, vars) ?? 1 + const str = out.thickness as string + if (!str.startsWith('$')) { + const num = parseFloat(str) + out.thickness = isNaN(num) ? 1 : num + } } return out as unknown as PenStroke } -// --------------------------------------------------------------------------- -// Effects normalization -// --------------------------------------------------------------------------- - -function normalizeEffects( - raw: Record[], - vars: Vars, -): PenEffect[] { - return raw.map((e) => { - const out = { ...e } - if (typeof out.color === 'string') { - out.color = resolveColor(out.color, vars) ?? '#000000' - } - if (typeof out.blur === 'string') out.blur = resolveNumeric(out.blur, vars) ?? 0 - if (typeof out.offsetX === 'string') out.offsetX = resolveNumeric(out.offsetX, vars) ?? 0 - if (typeof out.offsetY === 'string') out.offsetY = resolveNumeric(out.offsetY, vars) ?? 0 - if (typeof out.spread === 'string') out.spread = resolveNumeric(out.spread, vars) ?? 0 - return out as unknown as PenEffect - }) -} - // --------------------------------------------------------------------------- // Sizing normalization // --------------------------------------------------------------------------- @@ -253,6 +212,9 @@ function normalizeSizing(value: unknown): number | string { if (typeof value === 'number') return value if (typeof value !== 'string') return 0 + // $variable — pass through + if (value.startsWith('$')) return value + // fill_container must always resolve dynamically from parent dimensions if (value.startsWith('fill_container')) return 'fill_container' @@ -270,64 +232,23 @@ function normalizeSizing(value: unknown): number | string { function normalizePadding( value: unknown, - vars: Vars, -): number | [number, number] | [number, number, number, number] | undefined { +): number | [number, number] | [number, number, number, number] | string | undefined { if (typeof value === 'number') return value - if (typeof value === 'string') return (resolveNumeric(value, vars) as number) ?? 0 + if (typeof value === 'string') { + // $variable — pass through + if (value.startsWith('$')) return value + const num = parseFloat(value) + return isNaN(num) ? 0 : num + } if (Array.isArray(value)) { - return value.map((v) => - typeof v === 'number' ? v : (resolveNumeric(v, vars) as number) ?? 0, - ) as [number, number] | [number, number, number, number] - } - return undefined -} - -// --------------------------------------------------------------------------- -// Variable resolution -// --------------------------------------------------------------------------- - -function resolveVar(ref: string, vars: Vars): unknown { - if (!ref.startsWith('$')) return ref - const name = ref.slice(1) - const def = vars[name] - if (!def) return ref - - const val = def.value - if (Array.isArray(val)) { - // Try to find value matching the default theme (first entry per collection) - if (Object.keys(_defaultTheme).length > 0) { - const matching = val.find((v) => { - if (!v.theme) return false - return Object.entries(_defaultTheme).every( - ([key, expected]) => v.theme?.[key] === expected, - ) - }) - if (matching) return matching.value - } - // Fallback to first value - return val[0]?.value ?? ref - } - return val -} - -function resolveColor(raw: unknown, vars: Vars): string | null { - if (typeof raw !== 'string') return null - if (raw.startsWith('$')) { - const resolved = resolveVar(raw, vars) - return typeof resolved === 'string' ? resolved : '#000000' - } - return raw -} - -function resolveNumeric(raw: unknown, vars: Vars): number | undefined { - if (typeof raw === 'number') return raw - if (typeof raw === 'string') { - if (raw.startsWith('$')) { - const resolved = resolveVar(raw, vars) - return typeof resolved === 'number' ? resolved : undefined - } - const num = parseFloat(raw) - return isNaN(num) ? undefined : num + return value.map((v) => { + if (typeof v === 'number') return v + if (typeof v === 'string') { + const num = parseFloat(v) + return isNaN(num) ? 0 : num + } + return 0 + }) as [number, number] | [number, number, number, number] } return undefined } diff --git a/src/variables/replace-refs.ts b/src/variables/replace-refs.ts new file mode 100644 index 000000000..b37c4b427 --- /dev/null +++ b/src/variables/replace-refs.ts @@ -0,0 +1,149 @@ +/** + * Recursively replace `$variable` references in a PenNode tree. + * + * Used when renaming or deleting a variable to keep the tree consistent. + */ + +import type { PenNode } from '@/types/pen' +import type { PenFill } from '@/types/styles' +import type { VariableDefinition } from '@/types/variables' +import { resolveVariableRef } from './resolve-variables' + +/** + * Replace all occurrences of `$oldRef` with `$newRef` in the node tree. + * When `newRef` is null (variable deleted), resolves to the concrete value. + */ +export function replaceVariableRefsInTree( + nodes: PenNode[], + oldRef: string, + newRef: string | null, + variables: Record, + activeTheme: Record, +): PenNode[] { + const oldToken = `$${oldRef}` + const replacement = newRef ? `$${newRef}` : undefined + + function resolveOrReplace(val: string): string { + if (val !== oldToken) return val + if (replacement) return replacement + const resolved = resolveVariableRef(oldToken, variables, activeTheme) + return typeof resolved === 'string' ? resolved : val + } + + function resolveOrReplaceNumeric(val: string | number): string | number { + if (typeof val !== 'string' || val !== oldToken) return val + if (replacement) return replacement + const resolved = resolveVariableRef(oldToken, variables, activeTheme) + return typeof resolved === 'number' ? resolved : val + } + + function replaceFills(fills: PenFill[] | undefined): PenFill[] | undefined { + if (!fills) return fills + return fills.map((f) => { + if (f.type === 'solid' && f.color === oldToken) { + return { ...f, color: resolveOrReplace(f.color) } + } + if (f.type === 'linear_gradient' || f.type === 'radial_gradient') { + const newStops = f.stops.map((s) => + s.color === oldToken ? { ...s, color: resolveOrReplace(s.color) } : s, + ) + return { ...f, stops: newStops } + } + return f + }) + } + + function replaceInNode(node: PenNode): PenNode { + const out: Record = { ...node } + let changed = false + + // Opacity + if (typeof node.opacity === 'string' && node.opacity === oldToken) { + out.opacity = resolveOrReplaceNumeric(node.opacity) + changed = true + } + + // Gap + if ('gap' in node && (node as unknown as Record).gap === oldToken) { + out.gap = resolveOrReplaceNumeric(oldToken) + changed = true + } + + // Padding + if ('padding' in node && (node as unknown as Record).padding === oldToken) { + out.padding = resolveOrReplaceNumeric(oldToken) + changed = true + } + + // Fill + if ('fill' in node && (node as unknown as Record).fill) { + const fills = (node as unknown as Record).fill as PenFill[] + const newFills = replaceFills(fills) + if (newFills !== fills) { + out.fill = newFills + changed = true + } + } + + // Stroke fill & thickness + if ('stroke' in node && (node as unknown as Record).stroke) { + const stroke = (node as unknown as Record).stroke as Record + const newStroke = { ...stroke } + let strokeChanged = false + if (typeof stroke.thickness === 'string' && stroke.thickness === oldToken) { + newStroke.thickness = resolveOrReplaceNumeric(oldToken) + strokeChanged = true + } + if (stroke.fill) { + const newFill = replaceFills(stroke.fill as PenFill[]) + if (newFill !== stroke.fill) { + newStroke.fill = newFill + strokeChanged = true + } + } + if (strokeChanged) { + out.stroke = newStroke + changed = true + } + } + + // Effects + if ('effects' in node && Array.isArray((node as unknown as Record).effects)) { + const effects = (node as unknown as Record).effects as Record[] + const newEffects = effects.map((e) => { + const ne = { ...e } + let ec = false + if (typeof e.color === 'string' && e.color === oldToken) { + ne.color = resolveOrReplace(e.color as string) + ec = true + } + for (const key of ['blur', 'offsetX', 'offsetY', 'spread']) { + if (typeof e[key] === 'string' && e[key] === oldToken) { + ne[key] = resolveOrReplaceNumeric(oldToken) + ec = true + } + } + return ec ? ne : e + }) + out.effects = newEffects + changed = true + } + + // Text content + if (node.type === 'text' && typeof node.content === 'string' && node.content === oldToken) { + out.content = resolveOrReplace(node.content) + changed = true + } + + // Recurse children + if ('children' in node && (node as unknown as Record).children) { + const children = (node as unknown as Record).children as PenNode[] + out.children = replaceVariableRefsInTree(children, oldRef, newRef, variables, activeTheme) + changed = true + } + + return changed ? (out as unknown as PenNode) : node + } + + return nodes.map(replaceInNode) +} diff --git a/src/variables/resolve-variables.ts b/src/variables/resolve-variables.ts new file mode 100644 index 000000000..d8e7814e8 --- /dev/null +++ b/src/variables/resolve-variables.ts @@ -0,0 +1,283 @@ +/** + * Variable resolution utilities. + * + * Resolves `$variableName` references against a VariableDefinition map, + * optionally matching themed values to an active theme context. + */ + +import type { PenNode } from '@/types/pen' +import type { PenFill, PenStroke, PenEffect } from '@/types/styles' +import type { VariableDefinition, ThemedValue } from '@/types/variables' + +type Vars = Record +type Theme = Record + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Check whether a value is a `$variable` reference string. */ +export function isVariableRef(value: unknown): value is string { + return typeof value === 'string' && value.startsWith('$') +} + +/** Build the default theme map (first value per axis) from PenDocument.themes. */ +export function getDefaultTheme( + themes: Record | undefined, +): Theme { + const result: Theme = {} + if (!themes) return result + for (const [key, values] of Object.entries(themes)) { + if (values.length > 0) result[key] = values[0] + } + return result +} + +// --------------------------------------------------------------------------- +// Core resolution +// --------------------------------------------------------------------------- + +/** Pick the concrete value from a `ThemedValue[]` for the given theme. */ +function resolveThemedValue( + values: ThemedValue[], + activeTheme?: Theme, +): string | number | boolean | undefined { + if (activeTheme && Object.keys(activeTheme).length > 0) { + const match = values.find((v) => { + if (!v.theme) return false + return Object.entries(activeTheme).every( + ([key, expected]) => v.theme?.[key] === expected, + ) + }) + if (match) return match.value + } + return values[0]?.value +} + +/** + * Resolve a single `$variableName` reference to its concrete value. + * Returns `undefined` if the variable does not exist or has an incompatible type. + */ +export function resolveVariableRef( + ref: string, + variables: Vars, + activeTheme?: Theme, +): string | number | boolean | undefined { + if (!ref.startsWith('$')) return undefined + const name = ref.slice(1) + const def = variables[name] + if (!def) return undefined + + const val = def.value + if (Array.isArray(val)) { + const resolved = resolveThemedValue(val, activeTheme) + // Circular guard: if resolved value is also a $ref, stop + if (typeof resolved === 'string' && resolved.startsWith('$')) return undefined + return resolved + } + // Circular guard + if (typeof val === 'string' && val.startsWith('$')) return undefined + return val +} + +/** + * Resolve a color string that may be a `$variable` reference. + * Returns the original string if it's not a ref, or the resolved color. + */ +export function resolveColorRef( + color: string | undefined, + variables: Vars, + activeTheme?: Theme, +): string | undefined { + if (color === undefined) return undefined + if (!isVariableRef(color)) return color + const resolved = resolveVariableRef(color, variables, activeTheme) + return typeof resolved === 'string' ? resolved : undefined +} + +/** + * Resolve a numeric value that may be a `$variable` reference. + * Returns the original number if it's not a ref. + */ +export function resolveNumericRef( + value: unknown, + variables: Vars, + activeTheme?: Theme, +): number | undefined { + if (typeof value === 'number') return value + if (typeof value === 'string') { + if (isVariableRef(value)) { + const resolved = resolveVariableRef(value, variables, activeTheme) + return typeof resolved === 'number' ? resolved : undefined + } + const num = parseFloat(value) + return isNaN(num) ? undefined : num + } + return undefined +} + +// --------------------------------------------------------------------------- +// Fill / stroke / effect resolution +// --------------------------------------------------------------------------- + +function resolveFillsForCanvas( + fills: PenFill[] | undefined, + vars: Vars, + theme?: Theme, +): PenFill[] | undefined { + if (!fills) return fills + return fills.map((fill) => { + if (fill.type === 'solid') { + const color = resolveColorRef(fill.color, vars, theme) + return color !== fill.color ? { ...fill, color: color ?? '#000000' } : fill + } + if (fill.type === 'linear_gradient' || fill.type === 'radial_gradient') { + const newStops = fill.stops.map((stop) => { + const color = resolveColorRef(stop.color, vars, theme) + return color !== stop.color ? { ...stop, color: color ?? '#000000' } : stop + }) + return newStops !== fill.stops ? { ...fill, stops: newStops } : fill + } + return fill + }) +} + +function resolveStrokeForCanvas( + stroke: PenStroke | undefined, + vars: Vars, + theme?: Theme, +): PenStroke | undefined { + if (!stroke) return stroke + let changed = false + const out: Record = { ...stroke } + + // Resolve thickness + if (typeof stroke.thickness === 'string' && isVariableRef(stroke.thickness)) { + out.thickness = resolveNumericRef(stroke.thickness, vars, theme) ?? 1 + changed = true + } + + // Resolve stroke fill colors + if (stroke.fill) { + const resolved = resolveFillsForCanvas(stroke.fill, vars, theme) + if (resolved !== stroke.fill) { + out.fill = resolved + changed = true + } + } + + return changed ? (out as unknown as PenStroke) : stroke +} + +function resolveEffectsForCanvas( + effects: PenEffect[] | undefined, + vars: Vars, + theme?: Theme, +): PenEffect[] | undefined { + if (!effects) return effects + return effects.map((effect) => { + if (effect.type !== 'shadow') return effect + let changed = false + const out: Record = { ...effect } + + if (typeof effect.color === 'string' && isVariableRef(effect.color)) { + out.color = resolveColorRef(effect.color, vars, theme) ?? '#000000' + changed = true + } + for (const key of ['blur', 'offsetX', 'offsetY', 'spread'] as const) { + const val = effect[key] + if (typeof val === 'string' && isVariableRef(val)) { + out[key] = resolveNumericRef(val, vars, theme) ?? 0 + changed = true + } + } + + return changed ? (out as unknown as PenEffect) : effect + }) +} + +// --------------------------------------------------------------------------- +// Full node resolution for canvas rendering +// --------------------------------------------------------------------------- + +/** + * Resolve all `$variable` references in a PenNode, returning a new node + * with concrete values suitable for Fabric.js rendering. + * + * Returns the same object reference when no variables are present. + */ +export function resolveNodeForCanvas( + node: PenNode, + variables: Vars, + activeTheme?: Theme, +): PenNode { + if (!variables || Object.keys(variables).length === 0) return node + + let changed = false + const out: Record = { ...node } + + // Opacity + if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) { + out.opacity = resolveNumericRef(node.opacity, variables, activeTheme) ?? 1 + changed = true + } + + // Gap + if ('gap' in node && typeof (node as unknown as Record).gap === 'string') { + const gap = (node as unknown as Record).gap as string + if (isVariableRef(gap)) { + out.gap = resolveNumericRef(gap, variables, activeTheme) ?? 0 + changed = true + } + } + + // Padding + if ('padding' in node) { + const padding = (node as unknown as Record).padding + if (typeof padding === 'string' && isVariableRef(padding)) { + out.padding = resolveNumericRef(padding, variables, activeTheme) ?? 0 + changed = true + } + } + + // Fill + if ('fill' in node && (node as unknown as Record).fill) { + const fills = (node as unknown as Record).fill as PenFill[] + const resolved = resolveFillsForCanvas(fills, variables, activeTheme) + if (resolved !== fills) { + out.fill = resolved + changed = true + } + } + + // Stroke + if ('stroke' in node && (node as unknown as Record).stroke) { + const stroke = (node as unknown as Record).stroke as PenStroke + const resolved = resolveStrokeForCanvas(stroke, variables, activeTheme) + if (resolved !== stroke) { + out.stroke = resolved + changed = true + } + } + + // Effects + if ('effects' in node && (node as unknown as Record).effects) { + const effects = (node as unknown as Record).effects as PenEffect[] + const resolved = resolveEffectsForCanvas(effects, variables, activeTheme) + if (resolved !== effects) { + out.effects = resolved + changed = true + } + } + + // Text content + if (node.type === 'text' && typeof node.content === 'string' && isVariableRef(node.content)) { + const resolved = resolveVariableRef(node.content, variables, activeTheme) + if (typeof resolved === 'string') { + out.content = resolved + changed = true + } + } + + return changed ? (out as unknown as PenNode) : node +}