feat(history): increase maximum undo/redo states and enhance batch handling

- Update history store to support up to 300 undo/redo states, improving user experience during complex editing sessions.
- Refactor batch handling in various components to ensure accurate history tracking and prevent unnecessary entries.
- Implement checks to avoid adding duplicate states to the undo stack, optimizing performance and memory usage.
- Enhance event handling in canvas and keyboard shortcuts to maintain consistent history state during user interactions.
This commit is contained in:
Kayshen-X 2026-02-20 15:41:45 +08:00
parent 125d55dfc8
commit e8f7b597bf
30 changed files with 2118 additions and 250 deletions

View file

@ -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<string, VariableDefinition> ($color-1, $spacing-md, ...)
├── themes: Record<string, string[]> ({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`
### 规则

View file

@ -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

View file

@ -1,7 +1,9 @@
import * as fabric from 'fabric'
function rotationCursorSvg(angleDeg: number): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g transform="rotate(${angleDeg} 12 12)"><path d="M12 5a7 7 0 0 1 7 7" fill="none" stroke="%23111" stroke-width="1.5" stroke-linecap="round"/><polyline points="16 4 19 7.5 15 8" fill="none" stroke="%23111" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g></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 = `<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><g transform='rotate(${angleDeg} 12 12)' fill='none' stroke='%23333' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><path d='M16 12 A4 4 0 1 1 12 8'/><polyline points='10 6.5 12 8 10 9.5'/></g></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)
}
}

View file

@ -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.

View file

@ -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<string, ClipInfo>()
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(

View file

@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><line x1="12" y1="2" x2="12" y2="10" stroke="%23222" stroke-width="1"/><line x1="12" y1="14" x2="12" y2="22" stroke="%23222" stroke-width="1"/><line x1="2" y1="12" x2="10" y2="12" stroke="%23222" stroke-width="1"/><line x1="14" y1="12" x2="22" y2="12" stroke="%23222" stroke-width="1"/></svg>`
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><line x1='12' y1='2' x2='12' y2='10' stroke='%23222' stroke-width='1'/><line x1='12' y1='14' x2='12' y2='22' stroke='%23222' stroke-width='1'/><line x1='2' y1='12' x2='10' y2='12' stroke='%23222' stroke-width='1'/><line x1='14' y1='12' x2='22' y2='12' stroke='%23222' stroke-width='1'/></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

View file

@ -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)

View file

@ -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() {
</Suspense>
<Toolbar />
{/* Floating variables panel — anchored to the right of the toolbar */}
{variablesPanelOpen && <VariablesPanel />}
{/* Bottom bar: minimized AI (left) + zoom controls (right) */}
<div className="absolute bottom-2 left-2 right-2 z-10 flex items-center justify-between pointer-events-none">
<div className="pointer-events-auto">

View file

@ -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<HTMLInputElement>(null)
const [iconPickerOpen, setIconPickerOpen] = useState(false)
@ -226,6 +229,33 @@ export default function Toolbar() {
</TooltipContent>
</Tooltip>
<Separator className="my-1 w-8" />
{/* Variables */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleVariablesPanel}
aria-label="Variables"
aria-pressed={variablesPanelOpen}
className={`inline-flex items-center justify-center h-8 min-w-8 px-1.5 rounded-lg transition-colors [&_svg]:size-5 [&_svg]:shrink-0 ${
variablesPanelOpen
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<SlidersHorizontal size={20} />
</button>
</TooltipTrigger>
<TooltipContent side="right">
Variables
<kbd className="ml-1.5 inline-flex h-4 items-center rounded border border-border/50 bg-muted px-1 font-mono text-[10px] text-muted-foreground">
{'\u2318\u21e7'}V
</kbd>
</TooltipContent>
</Tooltip>
{/* Hidden file input + icon picker dialog */}
<input
ref={fileInputRef}

View file

@ -206,8 +206,16 @@ function useChatHandlers() {
} else {
// --- CHAT MODE ---
chatHistory.push({ role: 'user', content: fullUserMessage })
let chatThinking = false
for await (const chunk of streamChat(CHAT_SYSTEM_PROMPT, chatHistory, model)) {
if (chunk.type === 'text') {
if (chunk.type === 'thinking') {
// Show a brief thinking indicator so the UI isn't stuck on empty
if (!chatThinking && !accumulated) {
chatThinking = true
updateLastMessage('*Thinking...*')
}
} else if (chunk.type === 'text') {
chatThinking = false
accumulated += chunk.content
updateLastMessage(accumulated)
} else if (chunk.type === 'error') {

View file

@ -1,5 +1,7 @@
import NumberInput from '@/components/shared/number-input'
import SectionHeader from '@/components/shared/section-header'
import VariablePicker from '@/components/shared/variable-picker'
import { isVariableRef } from '@/variables/resolve-variables'
import type { PenNode } from '@/types/pen'
interface AppearanceSectionProps {
@ -11,20 +13,37 @@ export default function AppearanceSection({
node,
onUpdate,
}: AppearanceSectionProps) {
const opacity =
typeof node.opacity === 'number' ? node.opacity * 100 : 100
const rawOpacity = node.opacity
const isBound = typeof rawOpacity === 'string' && isVariableRef(rawOpacity)
const opacity = typeof rawOpacity === 'number' ? rawOpacity * 100 : 100
return (
<div className="space-y-1.5">
<SectionHeader title="Layer" />
<NumberInput
label="Opacity"
value={Math.round(opacity)}
onChange={(v) => onUpdate({ opacity: v / 100 })}
min={0}
max={100}
suffix="%"
/>
<div className="flex items-center gap-1">
<div className="flex-1">
{isBound ? (
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
{rawOpacity}
</div>
) : (
<NumberInput
label="Opacity"
value={Math.round(opacity)}
onChange={(v) => onUpdate({ opacity: v / 100 })}
min={0}
max={100}
suffix="%"
/>
)}
</div>
<VariablePicker
type="number"
currentValue={isBound ? String(rawOpacity) : undefined}
onBind={(ref) => onUpdate({ opacity: ref as unknown as number })}
onUnbind={(val) => onUpdate({ opacity: Number(val) })}
/>
</div>
</div>
)
}

View file

@ -65,7 +65,7 @@ function ActionSteps({ steps }: { steps: string[] }) {
if (steps.length === 0) return null
return (
<div className="flex flex-col gap-2 my-1 w-full">
<div className="flex flex-col gap-1 w-full">
{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 (
<div className="group mb-1.5 last:mb-0">
<div className="group">
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
@ -460,7 +460,7 @@ function DesignJsonBlock({
}
return (
<div className="group my-1.5 first:mt-0 last:mb-0 w-full">
<div className="group mt-0.5 w-full">
<button
type="button"
onClick={() => setExpanded(!expanded)}
@ -563,7 +563,7 @@ export default function ChatMessage({
}
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start mt-2')}>
{isUser ? (
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap bg-primary text-primary-foreground rounded-br-sm">
{content}
@ -581,7 +581,7 @@ export default function ChatMessage({
</span>
</div>
) : (
<div className="whitespace-pre-wrap mb-2">
<div className="whitespace-pre-wrap">
{parseMarkdown(displayContent, onApplyDesign, isApplied, isStreaming && !isEmpty)}
</div>
)}

View file

@ -6,10 +6,11 @@ import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { generateReactCode } from '@/services/codegen/react-generator'
import { generateHTMLCode } from '@/services/codegen/html-generator'
import { generateCSSVariables } from '@/services/codegen/css-variables-generator'
import { highlightCode } from '@/utils/syntax-highlight'
import type { PenNode } from '@/types/pen'
type CodeTab = 'react' | 'html'
type CodeTab = 'react' | 'html' | 'css-vars'
export default function CodePanel({ onClose }: { onClose: () => void }) {
const [activeTab, setActiveTab] = useState<CodeTab>('react')
@ -31,15 +32,23 @@ export default function CodePanel({ onClose }: { onClose: () => void }) {
return children
}, [selectedIds, children, getNodeById])
const document = useDocumentStore((s) => s.document)
const generatedCode = useMemo(() => {
if (activeTab === 'css-vars') {
return generateCSSVariables(document)
}
if (activeTab === 'react') {
return generateReactCode(targetNodes)
}
const { html, css } = generateHTMLCode(targetNodes)
return `<!-- HTML -->\n${html}\n\n/* CSS */\n${css}`
}, [activeTab, targetNodes])
}, [activeTab, targetNodes, document])
const highlightedHTML = useMemo(() => {
if (activeTab === 'css-vars') {
return highlightCode(generatedCode, 'css')
}
if (activeTab === 'react') {
return highlightCode(generatedCode, 'jsx')
}
@ -68,6 +77,7 @@ export default function CodePanel({ onClose }: { onClose: () => void }) {
const tabs: { key: CodeTab; label: string }[] = [
{ key: 'react', label: 'React + Tailwind' },
{ key: 'html', label: 'HTML + CSS' },
{ key: 'css-vars', label: 'CSS Variables' },
]
return (
@ -121,9 +131,11 @@ export default function CodePanel({ onClose }: { onClose: () => void }) {
{/* Footer info */}
<div className="h-6 flex items-center px-3 border-t border-border shrink-0">
<span className="text-[10px] text-muted-foreground">
{selectedIds.length > 0
? `Generating code for ${selectedIds.length} selected element${selectedIds.length > 1 ? 's' : ''}`
: 'Generating code for entire document'}
{activeTab === 'css-vars'
? 'Generating CSS variables for entire document'
: selectedIds.length > 0
? `Generating code for ${selectedIds.length} selected element${selectedIds.length > 1 ? 's' : ''}`
: 'Generating code for entire document'}
</span>
</div>
</div>

View file

@ -3,8 +3,10 @@ import ColorPicker from '@/components/shared/color-picker'
import NumberInput from '@/components/shared/number-input'
import DropdownSelect from '@/components/shared/dropdown-select'
import SectionHeader from '@/components/shared/section-header'
import VariablePicker from '@/components/shared/variable-picker'
import { Button } from '@/components/ui/button'
import { Plus, X } from 'lucide-react'
import { isVariableRef } from '@/variables/resolve-variables'
import type { PenNode } from '@/types/pen'
import type { PenFill, GradientStop } from '@/types/styles'
@ -166,7 +168,23 @@ export default function FillSection({
)}
{fillType === 'solid' && (
<ColorPicker value={currentColor} onChange={handleColorChange} />
<div className="flex items-center gap-1">
<div className="flex-1">
{isVariableRef(currentColor) ? (
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
{currentColor}
</div>
) : (
<ColorPicker value={currentColor} onChange={handleColorChange} />
)}
</div>
<VariablePicker
type="color"
currentValue={currentColor}
onBind={(ref) => onUpdate({ fill: [{ type: 'solid', color: ref }] } as Partial<PenNode>)}
onUnbind={(val) => onUpdate({ fill: [{ type: 'solid', color: String(val) }] } as Partial<PenNode>)}
/>
</div>
)}
{(fillType === 'linear_gradient' ||

View file

@ -1,5 +1,7 @@
import NumberInput from '@/components/shared/number-input'
import DropdownSelect from '@/components/shared/dropdown-select'
import VariablePicker from '@/components/shared/variable-picker'
import { isVariableRef } from '@/variables/resolve-variables'
import type { PenNode, ContainerProps } from '@/types/pen'
import { cn } from '@/lib/utils'
import {
@ -63,11 +65,15 @@ function ToggleButton({
export default function LayoutSection({ node, onUpdate }: LayoutSectionProps) {
const layout = node.layout ?? 'none'
const gap = typeof node.gap === 'number' ? node.gap : 0
const padding = typeof node.padding === 'number'
? node.padding
: Array.isArray(node.padding)
? node.padding[0]
const rawGap = node.gap
const rawPadding = node.padding
const gapIsBound = typeof rawGap === 'string' && isVariableRef(rawGap)
const paddingIsBound = typeof rawPadding === 'string' && isVariableRef(rawPadding)
const gap = typeof rawGap === 'number' ? rawGap : 0
const padding = typeof rawPadding === 'number'
? rawPadding
: Array.isArray(rawPadding)
? rawPadding[0]
: 0
const justifyContent = node.justifyContent ?? 'start'
const alignItems = node.alignItems ?? 'start'
@ -110,19 +116,51 @@ export default function LayoutSection({ node, onUpdate }: LayoutSectionProps) {
{hasLayout && (
<>
{/* Gap & Padding */}
<div className="grid grid-cols-2 gap-1">
<NumberInput
label="Gap"
value={gap}
onChange={(v) => onUpdate({ gap: v } as Partial<PenNode>)}
min={0}
/>
<NumberInput
label="Pad"
value={padding}
onChange={(v) => onUpdate({ padding: v } as Partial<PenNode>)}
min={0}
/>
<div className="space-y-1">
<div className="flex items-center gap-1">
<div className="flex-1">
{gapIsBound ? (
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
{String(rawGap)}
</div>
) : (
<NumberInput
label="Gap"
value={gap}
onChange={(v) => onUpdate({ gap: v } as Partial<PenNode>)}
min={0}
/>
)}
</div>
<VariablePicker
type="number"
currentValue={gapIsBound ? String(rawGap) : undefined}
onBind={(ref) => onUpdate({ gap: ref as unknown as number } as Partial<PenNode>)}
onUnbind={(val) => onUpdate({ gap: Number(val) } as Partial<PenNode>)}
/>
</div>
<div className="flex items-center gap-1">
<div className="flex-1">
{paddingIsBound ? (
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
{String(rawPadding)}
</div>
) : (
<NumberInput
label="Pad"
value={padding}
onChange={(v) => onUpdate({ padding: v } as Partial<PenNode>)}
min={0}
/>
)}
</div>
<VariablePicker
type="number"
currentValue={paddingIsBound ? String(rawPadding) : undefined}
onBind={(ref) => onUpdate({ padding: ref as unknown as number } as Partial<PenNode>)}
onUnbind={(val) => onUpdate({ padding: Number(val) } as Partial<PenNode>)}
/>
</div>
</div>
{/* Justify Content */}

View file

@ -1,6 +1,8 @@
import ColorPicker from '@/components/shared/color-picker'
import NumberInput from '@/components/shared/number-input'
import SectionHeader from '@/components/shared/section-header'
import VariablePicker from '@/components/shared/variable-picker'
import { isVariableRef } from '@/variables/resolve-variables'
import type { PenNode } from '@/types/pen'
import type { PenStroke, PenFill } from '@/types/styles'
@ -47,7 +49,27 @@ export default function StrokeSection({
<div className="space-y-1.5">
<SectionHeader title="Stroke" />
<div className="flex items-center gap-1">
<ColorPicker value={strokeColor} onChange={handleColorChange} />
<div className="flex-1">
{isVariableRef(strokeColor) ? (
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
{strokeColor}
</div>
) : (
<ColorPicker value={strokeColor} onChange={handleColorChange} />
)}
</div>
<VariablePicker
type="color"
currentValue={strokeColor}
onBind={(ref) => {
const newFill: PenFill[] = [{ type: 'solid', color: ref }]
onUpdate({ stroke: { ...(stroke ?? { thickness: 1 }), fill: newFill } } as Partial<PenNode>)
}}
onUnbind={(val) => {
const newFill: PenFill[] = [{ type: 'solid', color: String(val) }]
onUpdate({ stroke: { ...(stroke ?? { thickness: 1 }), fill: newFill } } as Partial<PenNode>)
}}
/>
<NumberInput
value={strokeWidth}
onChange={handleWidthChange}

View file

@ -0,0 +1,242 @@
import { useState, useRef, useEffect } from 'react'
import { MoreHorizontal, Palette, Hash, Type, Pencil, Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import NumberInput from '@/components/shared/number-input'
import type { VariableDefinition, ThemedValue } from '@/types/variables'
const TYPE_ICONS: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
color: Palette,
number: Hash,
string: Type,
boolean: Type,
}
interface VariableRowProps {
name: string
definition: VariableDefinition
themeValues: string[]
themeAxis: string
onUpdateValue: (name: string, definition: VariableDefinition) => void
onRename: (oldName: string, newName: string) => void
onDelete: (name: string) => void
}
export default function VariableRow({
name,
definition,
themeValues,
themeAxis,
onUpdateValue,
onRename,
onDelete,
}: VariableRowProps) {
const [isEditing, setIsEditing] = useState(false)
const [editName, setEditName] = useState(name)
const [showMenu, setShowMenu] = useState(false)
const nameInputRef = useRef<HTMLInputElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => { setEditName(name) }, [name])
useEffect(() => {
if (isEditing && nameInputRef.current) {
nameInputRef.current.focus()
nameInputRef.current.select()
}
}, [isEditing])
useEffect(() => {
if (!showMenu) return
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setShowMenu(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [showMenu])
const handleNameBlur = () => {
setIsEditing(false)
if (editName && editName !== name) onRename(name, editName)
else setEditName(name)
}
const handleNameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleNameBlur()
if (e.key === 'Escape') { setEditName(name); setIsEditing(false) }
}
function getValueForTheme(themeValue: string): string | number | boolean {
const val = definition.value
if (!Array.isArray(val)) return val
const match = (val as ThemedValue[]).find((v) => v.theme?.[themeAxis] === themeValue)
return match?.value ?? (val as ThemedValue[])[0]?.value ?? ''
}
function setValueForTheme(themeValue: string, newValue: string | number | boolean) {
const val = definition.value
if (!Array.isArray(val)) {
const themed: ThemedValue[] = themeValues.map((tv) => ({
value: tv === themeValue ? newValue : val,
theme: { [themeAxis]: tv },
}))
onUpdateValue(name, { ...definition, value: themed })
return
}
const themed = [...(val as ThemedValue[])]
const idx = themed.findIndex((v) => v.theme?.[themeAxis] === themeValue)
if (idx >= 0) themed[idx] = { ...themed[idx], value: newValue }
else themed.push({ value: newValue, theme: { [themeAxis]: themeValue } })
onUpdateValue(name, { ...definition, value: themed })
}
function getOpacityForTheme(themeValue: string): number {
const val = getValueForTheme(themeValue)
if (typeof val === 'string' && val.length === 9) {
return Math.round((parseInt(val.slice(7, 9), 16) / 255) * 100)
}
return 100
}
const Icon = TYPE_ICONS[definition.type] ?? Type
return (
<div className="flex items-center group hover:bg-secondary/20 transition-colors rounded-lg px-2 min-h-[44px]">
{/* Name column */}
<div className="w-[220px] shrink-0 flex items-center gap-2 py-1">
<Icon size={15} className="text-muted-foreground/60 shrink-0" />
{isEditing ? (
<input
ref={nameInputRef}
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleNameBlur}
onKeyDown={handleNameKeyDown}
className="bg-secondary text-foreground text-[13px] font-mono px-2 py-1 rounded-lg border border-ring w-full min-w-0 focus:outline-none"
/>
) : (
<div
className="bg-secondary/50 text-foreground/80 text-[13px] font-mono px-2 py-1 rounded-lg truncate cursor-default flex-1 min-w-0"
onDoubleClick={() => setIsEditing(true)}
title={`--${name}`}
>
--{name}
</div>
)}
</div>
{/* Theme value columns */}
{themeValues.map((tv) => (
<div key={tv} className="flex-1 min-w-0 flex items-center gap-2.5 pl-4 py-1">
{definition.type === 'color' && (
<ColorCell
value={String(getValueForTheme(tv))}
opacity={getOpacityForTheme(tv)}
onChange={(color) => setValueForTheme(tv, color)}
/>
)}
{definition.type === 'number' && (
<NumberInput
value={Number(getValueForTheme(tv)) || 0}
onChange={(v) => setValueForTheme(tv, v)}
className="flex-1 max-w-[140px]"
/>
)}
{definition.type === 'string' && (
<input
type="text"
value={String(getValueForTheme(tv))}
onChange={(e) => setValueForTheme(tv, e.target.value)}
className="flex-1 max-w-[180px] bg-secondary/50 text-foreground text-[13px] px-2 py-1 rounded-lg border border-transparent hover:border-input focus:border-ring focus:outline-none font-mono min-w-0"
/>
)}
</div>
))}
{/* Actions column */}
<div className="w-[44px] shrink-0 flex items-center justify-center relative">
<button
type="button"
onClick={() => setShowMenu(!showMenu)}
className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground/40 opacity-0 group-hover:opacity-100 transition-all"
>
<MoreHorizontal size={15} />
</button>
{showMenu && (
<div
ref={menuRef}
className="absolute right-0 top-full z-50 mt-1 w-40 bg-popover border border-border rounded-xl shadow-xl py-1 animate-in fade-in slide-in-from-top-1 duration-150"
>
<button
type="button"
onClick={() => { setIsEditing(true); setShowMenu(false) }}
className="w-full text-left flex items-center gap-2.5 px-3 py-2 text-[13px] hover:bg-secondary/60 rounded-lg transition-colors"
>
<Pencil size={13} className="text-muted-foreground" />
Rename
</button>
<button
type="button"
onClick={() => { onDelete(name); setShowMenu(false) }}
className="w-full text-left flex items-center gap-2.5 px-3 py-2 text-[13px] text-destructive hover:bg-secondary/60 rounded-lg transition-colors"
>
<Trash2 size={13} />
Delete
</button>
</div>
)}
</div>
</div>
)
}
// --- 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<HTMLInputElement>) => {
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 (
<div className="flex items-center gap-2.5 flex-1">
<input
type="color"
value={value.slice(0, 7)}
onChange={(e) => onChange(e.target.value)}
className="w-5 h-5 rounded border border-input/40 cursor-pointer bg-transparent p-0 shrink-0"
/>
<input
type="text"
value={hexInput}
onChange={handleHexChange}
onBlur={handleBlur}
className="w-[76px] bg-transparent text-foreground/80 text-[13px] font-mono focus:outline-none"
placeholder="#000000"
/>
<span className={cn(
'text-[13px] tabular-nums whitespace-nowrap',
opacity < 100 ? 'text-foreground/80' : 'text-muted-foreground/50',
)}>
{opacity} %
</span>
</div>
)
}

View file

@ -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<string | null>(null)
// Theme tab dropdown (Rename/Delete)
const [activeThemeMenu, setActiveThemeMenu] = useState<string | null>(null)
const [renamingTheme, setRenamingTheme] = useState<string | null>(null)
const [renameThemeValue, setRenameThemeValue] = useState('')
// Variant column dropdown (Rename/Delete)
const [activeColumnMenu, setActiveColumnMenu] = useState<string | null>(null)
const [renamingColumn, setRenamingColumn] = useState<string | null>(null)
const [renameColumnValue, setRenameColumnValue] = useState('')
// Panel size
const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH)
const [panelHeight, setPanelHeight] = useState(DEFAULT_HEIGHT)
const themeMenuRef = useRef<HTMLDivElement>(null)
const addMenuRef = useRef<HTMLDivElement>(null)
const columnMenuRef = useRef<HTMLDivElement>(null)
const renameInputRef = useRef<HTMLInputElement>(null)
const themeRenameInputRef = useRef<HTMLInputElement>(null)
const panelRef = useRef<HTMLDivElement>(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<string, string[]> = {}
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 (
<div
ref={panelRef}
className="absolute left-14 top-2 z-20 flex flex-col select-none"
style={{ width: panelWidth, height: panelHeight }}
>
{/* Background layer with rounded corners — sits behind everything */}
<div className="absolute inset-0 bg-card/95 backdrop-blur-sm border border-border/80 rounded-2xl shadow-2xl pointer-events-none" />
{/* ── Header: Theme-1 | Theme-2 | ... | + | spacer | X ── */}
<div className="relative h-11 flex items-center px-4 shrink-0 gap-1 z-10">
{/* Theme tabs — all equal, active one has chevron dropdown */}
{themeAxes.map((axis) => (
<div key={axis} className="relative shrink-0" ref={activeThemeMenu === axis ? themeMenuRef : undefined}>
{renamingTheme === axis ? (
<input
ref={themeRenameInputRef}
type="text"
value={renameThemeValue}
onChange={(e) => 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"
/>
) : (
<button
type="button"
onClick={() => {
if (axis === currentAxis) {
// Already active → toggle dropdown
setActiveThemeMenu(activeThemeMenu === axis ? null : axis)
} else {
// Switch to this theme
setActiveAxis(axis)
setActiveThemeMenu(null)
}
}}
className={cn(
'flex items-center gap-1 text-[13px] px-2 py-1 rounded-lg transition-colors whitespace-nowrap',
axis === currentAxis
? 'text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
)}
>
{axis}
{axis === currentAxis && (
<ChevronDown size={11} className={cn(
'text-muted-foreground/60 transition-transform',
activeThemeMenu === axis && 'rotate-180',
)} />
)}
</button>
)}
{/* Theme dropdown: Rename / Delete */}
{activeThemeMenu === axis && !renamingTheme && (
<div className="absolute left-0 top-full z-50 mt-1 w-44 bg-popover border border-border rounded-xl shadow-xl py-1 animate-in fade-in slide-in-from-top-1 duration-150">
<button
type="button"
onClick={() => { setRenameThemeValue(axis); setRenamingTheme(axis); setActiveThemeMenu(null) }}
className="w-full text-left flex items-center gap-2.5 px-3 py-2 text-[13px] hover:bg-secondary/60 rounded-lg transition-colors"
>
<Pencil size={14} className="text-muted-foreground" />
Rename
</button>
{themeAxes.length > 1 && (
<button
type="button"
onClick={() => handleDeleteTheme(axis)}
className="w-full text-left flex items-center gap-2.5 px-3 py-2 text-[13px] hover:bg-secondary/60 rounded-lg transition-colors"
>
<Trash2 size={14} className="text-muted-foreground" />
Delete
</button>
)}
</div>
)}
</div>
))}
{/* + add theme */}
<button
type="button"
onClick={handleAddTheme}
className="p-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors shrink-0"
title="Add theme"
>
<Plus size={15} />
</button>
<div className="flex-1" />
<button
type="button"
onClick={toggleVariablesPanel}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors shrink-0"
title="Close (⌘⇧V)"
>
<X size={16} />
</button>
</div>
{/* ── Column headers: Name | Default | Variant-1 | ... | + ── */}
<div className="relative flex items-center px-4 h-9 shrink-0 border-t border-b border-border/40 z-10">
<div className="w-[220px] shrink-0">
<span className="text-[13px] font-medium text-muted-foreground">Name</span>
</div>
{themeValues.map((tv) => (
<div key={tv} className="flex-1 min-w-0 pl-4 relative" ref={activeColumnMenu === tv ? columnMenuRef : undefined}>
{renamingColumn === tv ? (
<input
ref={renameInputRef}
type="text"
value={renameColumnValue}
onChange={(e) => 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"
/>
) : (
<button
type="button"
onClick={() => setActiveColumnMenu(activeColumnMenu === tv ? null : tv)}
className="flex items-center gap-1 text-[13px] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{tv}
<ChevronDown size={11} className={cn(
'text-muted-foreground/60 transition-transform',
activeColumnMenu === tv && 'rotate-180',
)} />
</button>
)}
{activeColumnMenu === tv && (
<div className="absolute left-4 top-full z-50 mt-1 w-44 bg-popover border border-border rounded-xl shadow-xl py-1 animate-in fade-in slide-in-from-top-1 duration-150">
<button
type="button"
onClick={() => startRenameVariant(tv)}
className="w-full text-left flex items-center gap-2.5 px-3 py-2 text-[13px] hover:bg-secondary/60 rounded-lg transition-colors"
>
<Pencil size={14} className="text-muted-foreground" />
Rename
</button>
{themeValues.length > 1 && (
<button
type="button"
onClick={() => handleRemoveVariant(tv)}
className="w-full text-left flex items-center gap-2.5 px-3 py-2 text-[13px] hover:bg-secondary/60 rounded-lg transition-colors"
>
<Trash2 size={14} className="text-muted-foreground" />
Delete
</button>
)}
</div>
)}
</div>
))}
<div className="w-[44px] shrink-0 flex justify-center">
<button
type="button"
onClick={handleAddVariant}
className="p-1 rounded text-muted-foreground/50 hover:text-foreground transition-colors"
title="Add variant"
>
<Plus size={14} />
</button>
</div>
</div>
{/* ── Search ── */}
{entries.length > 6 && (
<div className="relative px-4 py-2 shrink-0 border-b border-border/30">
<div className="flex items-center gap-2 bg-secondary/40 rounded-lg px-2.5 h-7 border border-transparent focus-within:border-ring transition-colors">
<Search size={13} className="text-muted-foreground/60 shrink-0" />
<input
type="text"
placeholder="Search variables..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-transparent text-foreground text-[12px] focus:outline-none placeholder:text-muted-foreground/40"
/>
</div>
</div>
)}
{/* ── Variable rows ── */}
<div className="relative flex-1 overflow-y-auto overflow-x-auto min-h-0 px-2 py-0.5">
{entries.length === 0 && (
<div className="flex flex-col items-center justify-center h-full gap-1.5">
<span className="text-[13px] text-muted-foreground/50">
{search ? 'No variables match your search' : 'No variables defined'}
</span>
</div>
)}
{entries.map(([varName, def]) => (
<VariableRow
key={varName}
name={varName}
definition={def}
themeValues={themeValues}
themeAxis={themeAxis}
onUpdateValue={(n, d) => setVariable(n, d)}
onRename={(o, n) => renameVariable(o, n)}
onDelete={(n) => removeVariable(n)}
/>
))}
</div>
{/* ── Footer ── */}
<div className="relative h-10 flex items-center px-4 shrink-0 border-t border-border/30 z-10" ref={addMenuRef}>
<button
type="button"
onClick={() => setShowAddMenu(!showAddMenu)}
className={cn(
'flex items-center gap-2 text-[13px] transition-colors',
showAddMenu ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
<Plus size={14} />
Add variable
<ChevronDown size={11} className={cn('transition-transform', showAddMenu && 'rotate-180')} />
</button>
{showAddMenu && (
<div className="absolute left-4 bottom-full z-50 mb-1.5 w-44 bg-popover border border-border rounded-xl shadow-xl py-1 animate-in fade-in slide-in-from-bottom-1 duration-150">
{(['color', 'number', 'string'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => handleAdd(t)}
className="w-full text-left px-3 py-2 text-[13px] hover:bg-secondary/60 rounded-lg capitalize transition-colors"
>
{t}
</button>
))}
</div>
)}
</div>
{/* ── Resize handles ── */}
<div
className="absolute top-0 right-0 w-1.5 h-full cursor-ew-resize hover:bg-primary/10 transition-colors z-10"
onPointerDown={(e) => handleResizeStart('right', e)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
/>
<div
className="absolute bottom-0 left-0 w-full h-1.5 cursor-ns-resize hover:bg-primary/10 transition-colors z-10"
onPointerDown={(e) => handleResizeStart('bottom', e)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
/>
<div
className="absolute bottom-0 right-0 w-3 h-3 cursor-nwse-resize hover:bg-primary/15 transition-colors rounded-br-2xl z-10"
onPointerDown={(e) => handleResizeStart('corner', e)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
/>
</div>
)
}

View file

@ -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)
}

View file

@ -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<HTMLDivElement>(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 (
<div className={cn('relative inline-flex', className)}>
{isBound ? (
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center gap-1 h-5 px-1.5 rounded bg-primary/15 text-primary text-[10px] font-mono hover:bg-primary/25 transition-colors"
title={`Bound to --${boundName}`}
>
<Braces size={10} />
<span className="truncate max-w-[80px]">--{boundName}</span>
</button>
) : (
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-center w-5 h-5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
title="Bind to variable"
>
<Braces size={12} />
</button>
)}
{open && (
<div
ref={popoverRef}
className="absolute right-0 top-full z-50 mt-1 w-52 bg-popover border border-border rounded-md shadow-md py-1 max-h-48 overflow-y-auto"
>
{isBound && (
<>
<button
type="button"
onClick={handleUnbind}
className="w-full text-left px-3 py-1.5 text-xs text-destructive hover:bg-secondary flex items-center gap-1.5"
>
<X size={12} />
Unbind variable
</button>
<div className="border-t border-border my-1" />
</>
)}
{matchingVars.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
No {type} variables defined
</div>
) : (
matchingVars.map(([name, def]) => (
<button
key={name}
type="button"
onClick={() => handleBind(name)}
className={cn(
'w-full text-left px-3 py-1.5 text-xs hover:bg-secondary flex items-center gap-2',
boundName === name && 'bg-secondary',
)}
>
{type === 'color' && (
<span
className="w-3 h-3 rounded-sm border border-input/50 shrink-0"
style={{ backgroundColor: String(getPreview(def)) }}
/>
)}
<span className="font-mono truncate">--{name}</span>
<span className="text-muted-foreground ml-auto shrink-0">
{type === 'color' ? getPreview(def) : getPreview(def)}
</span>
</button>
))
)}
</div>
)}
</div>
)
}

View file

@ -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
}

View file

@ -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?.('<step title="Thinking">Analyzing your design request...</step>')
}
} 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)

View file

@ -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, string>,
): 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<string, string> = {}
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'
}

View file

@ -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<string, string> {
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<string, string> {
function strokeToCSS(stroke: PenStroke | undefined): Record<string, string> {
if (!stroke) return {}
const css: Record<string, string> = {}
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<string, string> {
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}<svg class="${className}" viewBox="0 0 ${w} ${h}">\n${pad} <path d="${node.d}" fill="${fillColor}" />\n${pad}</svg>`
}
return `${pad}<div class="${className}"></div>`
@ -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,
}
}

View file

@ -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}<svg className="${classes.join(' ')}" viewBox="0 0 ${w} ${h}">\n${pad} <path d="${node.d}" fill="${fillColor}" />\n${pad}</svg>`
}
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')
}

View file

@ -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<CanvasStoreState>((set) => ({
@ -45,6 +47,7 @@ export const useCanvasStore = create<CanvasStoreState>((set) => ({
fabricCanvas: null,
clipboard: [],
layerPanelOpen: true,
variablesPanelOpen: false,
setActiveTool: (tool) => set({ activeTool: tool }),
@ -110,4 +113,5 @@ export const useCanvasStore = create<CanvasStoreState>((set) => ({
setClipboard: (clipboard) => set({ clipboard }),
toggleLayerPanel: () => set((s) => ({ layerPanelOpen: !s.layerPanelOpen })),
toggleVariablesPanel: () => set((s) => ({ variablesPanelOpen: !s.variablesPanelOpen })),
}))

View file

@ -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<string, string[]>) => void
applyHistoryState: (doc: PenDocument) => void
loadDocument: (
doc: PenDocument,
@ -587,6 +596,78 @@ export const useDocumentStore = create<DocumentStoreState>(
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 }),

View file

@ -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<string, VariableDefinition>
// Module-level default theme map, set per normalizePenDocument call
let _defaultTheme: Record<string, string> = {}
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<string, unknown> = { ...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<string, unknown>, vars)
out.stroke = normalizeStroke(out.stroke as Record<string, unknown>)
}
// effects
if ('effects' in out && Array.isArray(out.effects)) {
out.effects = normalizeEffects(out.effects as Record<string, unknown>[], 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<string, unknown>, vars)
const f = normalizeSingleFill(raw as Record<string, unknown>)
return f ? [f] : []
}
@ -114,7 +93,6 @@ function normalizeFills(raw: unknown, vars: Vars): PenFill[] {
function normalizeSingleFill(
raw: Record<string, unknown>,
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<string, unknown> | 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<string, unknown>,
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<string, unknown>[],
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
}

View file

@ -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<string, VariableDefinition>,
activeTheme: Record<string, string>,
): 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<string, unknown> = { ...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<string, unknown>).gap === oldToken) {
out.gap = resolveOrReplaceNumeric(oldToken)
changed = true
}
// Padding
if ('padding' in node && (node as unknown as Record<string, unknown>).padding === oldToken) {
out.padding = resolveOrReplaceNumeric(oldToken)
changed = true
}
// Fill
if ('fill' in node && (node as unknown as Record<string, unknown>).fill) {
const fills = (node as unknown as Record<string, unknown>).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<string, unknown>).stroke) {
const stroke = (node as unknown as Record<string, unknown>).stroke as Record<string, unknown>
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<string, unknown>).effects)) {
const effects = (node as unknown as Record<string, unknown>).effects as Record<string, unknown>[]
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<string, unknown>).children) {
const children = (node as unknown as Record<string, unknown>).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)
}

View file

@ -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<string, VariableDefinition>
type Theme = Record<string, string>
// ---------------------------------------------------------------------------
// 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<string, string[]> | 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<string, unknown> = { ...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<string, unknown> = { ...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<string, unknown> = { ...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<string, unknown>).gap === 'string') {
const gap = (node as unknown as Record<string, unknown>).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<string, unknown>).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<string, unknown>).fill) {
const fills = (node as unknown as Record<string, unknown>).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<string, unknown>).stroke) {
const stroke = (node as unknown as Record<string, unknown>).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<string, unknown>).effects) {
const effects = (node as unknown as Record<string, unknown>).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
}