fix(canvas,store): history dedup, AI streaming guards, and drag reparent for nested containers
- Increase undo history to 300 states, deduplicate identical snapshots - endBatch accepts optional currentDoc to skip no-op batches - Add AI streaming guards: disable canvas interaction during generation - Defer transform batch close via rAF so object:modified fires before endBatch - Restrict layout reorder to move drags only (not scale/rotate handles) - Extend checkDragReparent to work for any parent container, not just root frames - Wrap design apply/modify in history batches for proper undo support
This commit is contained in:
parent
ef157f906a
commit
125d55dfc8
64
CLAUDE.md
64
CLAUDE.md
|
|
@ -41,28 +41,6 @@ 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):
|
||||
|
|
@ -73,7 +51,7 @@ PenDocument (source of truth)
|
|||
- `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, variable resolution via `resolveNodeForCanvas()`
|
||||
- `use-canvas-sync.ts` — Bidirectional PenDocument ↔ Fabric.js sync, node flattening with parent offsets
|
||||
- `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
|
||||
|
|
@ -82,45 +60,39 @@ PenDocument (source of truth)
|
|||
- `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, `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 100 states), batch mode for grouped operations
|
||||
- `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`
|
||||
- `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; `PenDocument.variables` and `PenDocument.themes`
|
||||
- `pen.ts` — PenDocument/PenNode (frame, group, rectangle, ellipse, line, polygon, path, text, image, ref), ContainerProps
|
||||
- `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` (type + value), `ThemedValue` (value per theme), `VariableValue`
|
||||
- `variables.ts` — VariableDefinition for design tokens
|
||||
- `agent-settings.ts` — AI provider config types
|
||||
- **`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):
|
||||
- **`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):
|
||||
- `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, variable picker integration for color binding
|
||||
- `stroke-section.tsx` — Stroke color/width/dash, variable picker for stroke color binding
|
||||
- `fill-section.tsx` — Solid + gradient fill
|
||||
- `stroke-section.tsx` — Stroke color/width/dash
|
||||
- `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; variable picker for gap/padding binding
|
||||
- `appearance-section.tsx` — Opacity, visibility, lock, flip; variable picker for opacity binding
|
||||
- `layout-section.tsx` — Auto-layout (none/vertical/horizontal), gap, padding, justify, align
|
||||
- `appearance-section.tsx` — Opacity, visibility, lock, flip
|
||||
- `ai-chat-panel.tsx` / `chat-message.tsx` — AI chat with markdown, design block collapse, apply design
|
||||
- `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
|
||||
- `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
|
||||
- **`src/services/ai/`** — AI chat service, design prompts, design-to-node generation, AI types
|
||||
- **`src/services/codegen/`** — React+Tailwind and HTML+CSS code generators (output `var(--name)` for `$variable` refs), CSS variables generator
|
||||
- **`src/services/codegen/`** — React+Tailwind and HTML+CSS code generators
|
||||
- **`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 (format fixes only, preserves `$variable` refs), SVG parser (import SVG to editable PenNodes), syntax highlight
|
||||
- **`src/utils/`** — File operations (save/open .pen), export (PNG/SVG), node clone, pen file normalization, 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
|
||||
|
|
@ -186,7 +158,7 @@ Tailwind CSS v4 imported via `src/styles.css`. UI primitives from shadcn/ui (`sr
|
|||
|
||||
### Scope
|
||||
|
||||
按模块划分:`editor`、`canvas`、`panels`、`history`、`ai`、`codegen`、`store`、`types`、`variables`。
|
||||
按模块划分:`editor`、`canvas`、`panels`、`history`、`ai`、`codegen`、`store`、`types`。
|
||||
|
||||
### 规则
|
||||
|
||||
|
|
|
|||
40
README.md
40
README.md
|
|
@ -29,17 +29,6 @@ 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
|
||||
|
||||
|
|
@ -59,7 +48,7 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
|
|||
### History
|
||||
|
||||
- Undo/Redo with batched drag operations (Cmd+Z / Cmd+Shift+Z)
|
||||
- Up to 100 history states
|
||||
- Up to 300 history states
|
||||
|
||||
### Clipboard & Grouping
|
||||
|
||||
|
|
@ -76,7 +65,6 @@ 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
|
||||
|
|
@ -88,12 +76,6 @@ 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 |
|
||||
|
|
@ -104,7 +86,6 @@ 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 |
|
||||
|
|
@ -115,9 +96,7 @@ 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 |
|
||||
|
|
@ -174,7 +153,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 + variable resolution
|
||||
use-canvas-sync Bidirectional PenDocument ↔ Fabric sync
|
||||
use-canvas-viewport Zoom, pan, tool cursor switching
|
||||
use-canvas-selection Selection sync Fabric ↔ store
|
||||
use-canvas-guides Smart alignment guides
|
||||
|
|
@ -183,21 +162,16 @@ 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 (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.)
|
||||
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.)
|
||||
hooks/ # Keyboard shortcuts
|
||||
lib/ # Utility functions (cn class merging)
|
||||
services/
|
||||
ai/ # AI chat service, prompts, design generation
|
||||
codegen/ # React+Tailwind, HTML+CSS, and CSS variables generators
|
||||
codegen/ # React+Tailwind and HTML+CSS code 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
|
||||
|
|
@ -209,7 +183,7 @@ server/
|
|||
## Roadmap
|
||||
|
||||
- [ ] Component system (reusable components with instances & overrides)
|
||||
- [x] Design variables/tokens with CSS sync
|
||||
- [ ] Design variables/tokens with CSS sync
|
||||
- [ ] Boolean operations (union, subtract, intersect)
|
||||
- [ ] Multi-page support
|
||||
- [ ] Collaborative editing
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import * as fabric from 'fabric'
|
||||
|
||||
function rotationCursorSvg(angleDeg: number): string {
|
||||
// 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>`
|
||||
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>`
|
||||
return `url("data:image/svg+xml,${svg}") 12 12, crosshair`
|
||||
}
|
||||
|
||||
|
|
@ -14,14 +12,11 @@ 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: -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 },
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
export function applyRotationControls(obj: fabric.FabricObject) {
|
||||
|
|
@ -31,8 +26,8 @@ export function applyRotationControls(obj: fabric.FabricObject) {
|
|||
y: pos.y,
|
||||
offsetX: pos.ox,
|
||||
offsetY: pos.oy,
|
||||
sizeX: ROTATION_SIZE,
|
||||
sizeY: ROTATION_SIZE,
|
||||
sizeX: 20,
|
||||
sizeY: 20,
|
||||
actionName: 'rotate',
|
||||
actionHandler: fabric.controlsUtils.rotationWithSnapping,
|
||||
cursorStyleHandler: () => pos.cursor,
|
||||
|
|
@ -40,43 +35,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useDocumentStore } from '@/stores/document-store'
|
||||
import { setFabricSyncLock } from './canvas-sync-lock'
|
||||
import { rootFrameBounds } from './use-canvas-sync'
|
||||
import { nodeRenderInfo, rootFrameBounds, layoutContainerBounds } from './use-canvas-sync'
|
||||
import type { FabricObjectWithPenId } from './canvas-object-factory'
|
||||
|
||||
interface Bounds {
|
||||
|
|
@ -31,11 +31,48 @@ function overlapArea(a: Bounds, b: Bounds): number {
|
|||
return overlapX * overlapY
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string') {
|
||||
const n = parseFloat(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function getParentBounds(parentId: string, parentNode: unknown): Bounds | null {
|
||||
const root = rootFrameBounds.get(parentId)
|
||||
if (root) return root
|
||||
|
||||
const layout = layoutContainerBounds.get(parentId)
|
||||
if (layout) {
|
||||
return { x: layout.x, y: layout.y, w: layout.w, h: layout.h }
|
||||
}
|
||||
|
||||
if (
|
||||
!parentNode ||
|
||||
typeof parentNode !== 'object' ||
|
||||
!('x' in parentNode) ||
|
||||
!('y' in parentNode)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parentInfo = nodeRenderInfo.get(parentId)
|
||||
const x = toNumber((parentNode as { x?: unknown }).x) + (parentInfo?.parentOffsetX ?? 0)
|
||||
const y = toNumber((parentNode as { y?: unknown }).y) + (parentInfo?.parentOffsetY ?? 0)
|
||||
const w = toNumber((parentNode as { width?: unknown }).width)
|
||||
const h = toNumber((parentNode as { height?: unknown }).height)
|
||||
|
||||
if (w <= 0 || h <= 0) return null
|
||||
return { x, y, w, h }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Fabric object was dragged outside its parent root frame.
|
||||
* If so, reparent it to the overlapping root frame or to the root level.
|
||||
* Check if a Fabric object was dragged outside its current parent container.
|
||||
* If so, reparent it to the overlapping root frame (if any), or to root level.
|
||||
*
|
||||
* Only triggers for direct children of root frames (MVP scope).
|
||||
* Works for layout and non-layout containers.
|
||||
* Returns true if reparenting occurred.
|
||||
*/
|
||||
export function checkDragReparent(obj: FabricObjectWithPenId): boolean {
|
||||
|
|
@ -46,9 +83,8 @@ export function checkDragReparent(obj: FabricObjectWithPenId): boolean {
|
|||
const parent = store.getParentOf(nodeId)
|
||||
if (!parent) return false // Already root-level
|
||||
|
||||
// Only handle direct children of root frames
|
||||
const parentBounds = rootFrameBounds.get(parent.id)
|
||||
if (!parentBounds) return false // Parent is not a root frame
|
||||
const parentBounds = getParentBounds(parent.id, parent)
|
||||
if (!parentBounds) return false
|
||||
|
||||
// Compute object's absolute bounds from Fabric
|
||||
const objBounds: Bounds = {
|
||||
|
|
@ -66,7 +102,6 @@ export function checkDragReparent(obj: FabricObjectWithPenId): boolean {
|
|||
let bestOverlap = 0
|
||||
|
||||
for (const [frameId, frameBounds] of rootFrameBounds) {
|
||||
if (frameId === parent.id) continue // Skip current parent
|
||||
const area = overlapArea(objBounds, frameBounds)
|
||||
if (area > bestOverlap) {
|
||||
bestOverlap = area
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import * as fabric from 'fabric'
|
|||
import { useCanvasStore } from '@/stores/canvas-store'
|
||||
import { useDocumentStore, generateId } from '@/stores/document-store'
|
||||
import { useHistoryStore } from '@/stores/history-store'
|
||||
import type { PenDocument, PenNode } from '@/types/pen'
|
||||
import { useAIStore } from '@/stores/ai-store'
|
||||
import type { PenNode } from '@/types/pen'
|
||||
import type { ToolType } from '@/types/canvas'
|
||||
import {
|
||||
DEFAULT_FILL,
|
||||
|
|
@ -163,6 +164,30 @@ export function useCanvasEvents() {
|
|||
if (!upperEl) return
|
||||
|
||||
// --- Tool change: toggle selection ---
|
||||
const applyInteractivityState = () => {
|
||||
const tool = useCanvasStore.getState().activeTool
|
||||
const isStreaming = useAIStore.getState().isStreaming
|
||||
|
||||
if (isStreaming) {
|
||||
canvas.selection = false
|
||||
canvas.skipTargetFind = true
|
||||
canvas.discardActiveObject()
|
||||
useCanvasStore.getState().clearSelection()
|
||||
canvas.requestRenderAll()
|
||||
return
|
||||
}
|
||||
|
||||
if (isDrawingTool(tool)) {
|
||||
canvas.selection = false
|
||||
canvas.skipTargetFind = true
|
||||
canvas.discardActiveObject()
|
||||
canvas.requestRenderAll()
|
||||
} else if (tool === 'select') {
|
||||
canvas.selection = true
|
||||
canvas.skipTargetFind = false
|
||||
}
|
||||
}
|
||||
|
||||
let prevTool = useCanvasStore.getState().activeTool
|
||||
const unsubTool = useCanvasStore.subscribe((state) => {
|
||||
if (state.activeTool === prevTool) return
|
||||
|
|
@ -172,20 +197,19 @@ export function useCanvasEvents() {
|
|||
}
|
||||
prevTool = state.activeTool
|
||||
if (!state.fabricCanvas) return
|
||||
if (isDrawingTool(state.activeTool)) {
|
||||
state.fabricCanvas.selection = false
|
||||
state.fabricCanvas.skipTargetFind = true
|
||||
state.fabricCanvas.discardActiveObject()
|
||||
state.fabricCanvas.requestRenderAll()
|
||||
} else if (state.activeTool === 'select') {
|
||||
state.fabricCanvas.selection = true
|
||||
state.fabricCanvas.skipTargetFind = false
|
||||
}
|
||||
applyInteractivityState()
|
||||
})
|
||||
const unsubStreaming = useAIStore.subscribe((state) => {
|
||||
void state.isStreaming
|
||||
applyInteractivityState()
|
||||
})
|
||||
applyInteractivityState()
|
||||
|
||||
// --- Drawing via native pointer events on the upper canvas ---
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (useAIStore.getState().isStreaming) return
|
||||
|
||||
const tool = useCanvasStore.getState().activeTool
|
||||
if (!isDrawingTool(tool)) return
|
||||
const { isPanning } = useCanvasStore.getState().interaction
|
||||
|
|
@ -265,6 +289,8 @@ export function useCanvasEvents() {
|
|||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (useAIStore.getState().isStreaming) return
|
||||
|
||||
// Pen tool has its own move handling
|
||||
if (isPenToolActive()) {
|
||||
const pointer = toScene(canvas, e)
|
||||
|
|
@ -310,6 +336,14 @@ export function useCanvasEvents() {
|
|||
}
|
||||
|
||||
const onPointerUp = (_e: PointerEvent) => {
|
||||
if (useAIStore.getState().isStreaming) {
|
||||
if (tempObj) canvas.remove(tempObj)
|
||||
tempObj = null
|
||||
drawing = false
|
||||
startPoint = null
|
||||
return
|
||||
}
|
||||
|
||||
// Pen tool: end handle drag
|
||||
if (isPenToolActive()) {
|
||||
penToolPointerUp(canvas)
|
||||
|
|
@ -357,6 +391,8 @@ export function useCanvasEvents() {
|
|||
}
|
||||
|
||||
const onDoubleClick = (e: MouseEvent) => {
|
||||
if (useAIStore.getState().isStreaming) return
|
||||
|
||||
if (isPenToolActive()) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
|
@ -409,27 +445,51 @@ 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
|
||||
const closeTransformBatch = () => {
|
||||
if (!transformBatchActive) return
|
||||
useHistoryStore
|
||||
.getState()
|
||||
.endBatch(useDocumentStore.getState().document)
|
||||
transformBatchActive = false
|
||||
}
|
||||
|
||||
canvas.on('mouse:down', (opt) => {
|
||||
if (useAIStore.getState().isStreaming) return
|
||||
|
||||
if (pendingBatchCloseRaf !== null) {
|
||||
cancelAnimationFrame(pendingBatchCloseRaf)
|
||||
pendingBatchCloseRaf = null
|
||||
}
|
||||
|
||||
clipPathsCleared = false
|
||||
preModificationDoc = null
|
||||
const tool = useCanvasStore.getState().activeTool
|
||||
if (tool !== 'select') return
|
||||
const target = opt.target as FabricObjectWithPenId | null
|
||||
if (!target?.penNodeId) return
|
||||
useHistoryStore
|
||||
.getState()
|
||||
.startBatch(useDocumentStore.getState().document)
|
||||
transformBatchActive = true
|
||||
|
||||
// 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)
|
||||
|
||||
// Try to start layout reorder drag first
|
||||
beginLayoutDrag(target.penNodeId)
|
||||
// Only start layout reorder for actual move drags.
|
||||
// Scale/rotate handles on layout children should follow normal transform sync.
|
||||
const transform = (opt as unknown as {
|
||||
transform?: { action?: string; corner?: string | null }
|
||||
}).transform
|
||||
const action = transform?.action
|
||||
const corner = transform?.corner
|
||||
const isHandleTransform = typeof corner === 'string' && corner.length > 0
|
||||
const isMoveAction =
|
||||
!isHandleTransform &&
|
||||
(action === undefined || action === 'drag' || action === 'move')
|
||||
if (isMoveAction) {
|
||||
beginLayoutDrag(target.penNodeId)
|
||||
} else {
|
||||
cancelLayoutDrag()
|
||||
}
|
||||
|
||||
// Start parent-child drag session (still needed for child propagation)
|
||||
beginParentDrag(target.penNodeId, canvas)
|
||||
|
|
@ -441,6 +501,16 @@ 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) {
|
||||
cancelAnimationFrame(pendingBatchCloseRaf)
|
||||
}
|
||||
pendingBatchCloseRaf = requestAnimationFrame(() => {
|
||||
pendingBatchCloseRaf = null
|
||||
closeTransformBatch()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// --- Object modifications (drag, resize, rotate) via Fabric events ---
|
||||
|
|
@ -587,28 +657,16 @@ export function useCanvasEvents() {
|
|||
}
|
||||
})
|
||||
|
||||
// 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.
|
||||
// Final sync: reset scale to 1 and bake into width/height
|
||||
canvas.on('object:modified', (opt) => {
|
||||
if (pendingBatchCloseRaf !== null) {
|
||||
cancelAnimationFrame(pendingBatchCloseRaf)
|
||||
pendingBatchCloseRaf = null
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -623,10 +681,12 @@ export function useCanvasEvents() {
|
|||
useDocumentStore.setState({
|
||||
document: { ...doc, children: [...doc.children] },
|
||||
})
|
||||
closeTransformBatch()
|
||||
return
|
||||
}
|
||||
endLayoutDrag(asPen, canvas)
|
||||
rebuildNodeRenderInfo()
|
||||
closeTransformBatch()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -634,6 +694,7 @@ export function useCanvasEvents() {
|
|||
if (isDragIntoActive()) {
|
||||
commitDragInto(asPen, canvas)
|
||||
rebuildNodeRenderInfo()
|
||||
closeTransformBatch()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -699,12 +760,6 @@ 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.
|
||||
|
|
@ -713,6 +768,8 @@ export function useCanvasEvents() {
|
|||
useDocumentStore.setState({
|
||||
document: { ...currentDoc, children: [...currentDoc.children] },
|
||||
})
|
||||
|
||||
closeTransformBatch()
|
||||
})
|
||||
|
||||
// --- Text editing: sync edited content back to document store ---
|
||||
|
|
@ -732,7 +789,12 @@ export function useCanvasEvents() {
|
|||
})
|
||||
|
||||
return () => {
|
||||
if (pendingBatchCloseRaf !== null) {
|
||||
cancelAnimationFrame(pendingBatchCloseRaf)
|
||||
}
|
||||
closeTransformBatch()
|
||||
unsubTool()
|
||||
unsubStreaming()
|
||||
upperEl.removeEventListener('pointerdown', onPointerDown)
|
||||
upperEl.removeEventListener('pointermove', onPointerMove)
|
||||
upperEl.removeEventListener('pointerup', onPointerUp)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ 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
|
||||
|
|
@ -524,39 +523,29 @@ 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 references — even when the sync lock
|
||||
// Always track the latest children reference — 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 && !variablesChanged && !themesChanged) return
|
||||
if (!childrenChanged) 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(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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`
|
||||
})()
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ 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
|
||||
|
||||
|
|
@ -121,7 +120,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ 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'
|
||||
|
|
@ -22,7 +21,6 @@ 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)
|
||||
|
|
@ -63,13 +61,6 @@ 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()
|
||||
|
|
@ -105,9 +96,6 @@ 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">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
Hand,
|
||||
Undo2,
|
||||
Redo2,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react'
|
||||
import ToolButton from './tool-button'
|
||||
import ShapeToolDropdown from './shape-tool-dropdown'
|
||||
|
|
@ -26,8 +25,6 @@ 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)
|
||||
|
||||
|
|
@ -229,33 +226,6 @@ 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}
|
||||
|
|
|
|||
|
|
@ -206,16 +206,8 @@ 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 === '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
|
||||
if (chunk.type === 'text') {
|
||||
accumulated += chunk.content
|
||||
updateLastMessage(accumulated)
|
||||
} else if (chunk.type === 'error') {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
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 {
|
||||
|
|
@ -13,37 +11,20 @@ export default function AppearanceSection({
|
|||
node,
|
||||
onUpdate,
|
||||
}: AppearanceSectionProps) {
|
||||
const rawOpacity = node.opacity
|
||||
const isBound = typeof rawOpacity === 'string' && isVariableRef(rawOpacity)
|
||||
const opacity = typeof rawOpacity === 'number' ? rawOpacity * 100 : 100
|
||||
const opacity =
|
||||
typeof node.opacity === 'number' ? node.opacity * 100 : 100
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<SectionHeader title="Layer" />
|
||||
<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>
|
||||
<NumberInput
|
||||
label="Opacity"
|
||||
value={Math.round(opacity)}
|
||||
onChange={(v) => onUpdate({ opacity: v / 100 })}
|
||||
min={0}
|
||||
max={100}
|
||||
suffix="%"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function ActionSteps({ steps }: { steps: string[] }) {
|
|||
if (steps.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className="flex flex-col gap-2 my-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">
|
||||
<div className="group mb-1.5 last:mb-0">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
|
|
@ -460,7 +460,7 @@ function DesignJsonBlock({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="group mt-0.5 w-full">
|
||||
<div className="group my-1.5 first:mt-0 last:mb-0 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 mt-2')}>
|
||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
{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">
|
||||
<div className="whitespace-pre-wrap mb-2">
|
||||
{parseMarkdown(displayContent, onApplyDesign, isApplied, isStreaming && !isEmpty)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ 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' | 'css-vars'
|
||||
type CodeTab = 'react' | 'html'
|
||||
|
||||
export default function CodePanel({ onClose }: { onClose: () => void }) {
|
||||
const [activeTab, setActiveTab] = useState<CodeTab>('react')
|
||||
|
|
@ -32,23 +31,15 @@ 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, document])
|
||||
}, [activeTab, targetNodes])
|
||||
|
||||
const highlightedHTML = useMemo(() => {
|
||||
if (activeTab === 'css-vars') {
|
||||
return highlightCode(generatedCode, 'css')
|
||||
}
|
||||
if (activeTab === 'react') {
|
||||
return highlightCode(generatedCode, 'jsx')
|
||||
}
|
||||
|
|
@ -77,7 +68,6 @@ 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 (
|
||||
|
|
@ -131,11 +121,9 @@ 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">
|
||||
{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'}
|
||||
{selectedIds.length > 0
|
||||
? `Generating code for ${selectedIds.length} selected element${selectedIds.length > 1 ? 's' : ''}`
|
||||
: 'Generating code for entire document'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@ 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'
|
||||
|
||||
|
|
@ -168,23 +166,7 @@ export default function FillSection({
|
|||
)}
|
||||
|
||||
{fillType === 'solid' && (
|
||||
<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>
|
||||
<ColorPicker value={currentColor} onChange={handleColorChange} />
|
||||
)}
|
||||
|
||||
{(fillType === 'linear_gradient' ||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
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 {
|
||||
|
|
@ -65,15 +63,11 @@ function ToggleButton({
|
|||
|
||||
export default function LayoutSection({ node, onUpdate }: LayoutSectionProps) {
|
||||
const layout = node.layout ?? 'none'
|
||||
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]
|
||||
const gap = typeof node.gap === 'number' ? node.gap : 0
|
||||
const padding = typeof node.padding === 'number'
|
||||
? node.padding
|
||||
: Array.isArray(node.padding)
|
||||
? node.padding[0]
|
||||
: 0
|
||||
const justifyContent = node.justifyContent ?? 'start'
|
||||
const alignItems = node.alignItems ?? 'start'
|
||||
|
|
@ -116,51 +110,19 @@ export default function LayoutSection({ node, onUpdate }: LayoutSectionProps) {
|
|||
{hasLayout && (
|
||||
<>
|
||||
{/* Gap & Padding */}
|
||||
<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 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>
|
||||
|
||||
{/* Justify Content */}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
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'
|
||||
|
||||
|
|
@ -49,27 +47,7 @@ export default function StrokeSection({
|
|||
<div className="space-y-1.5">
|
||||
<SectionHeader title="Stroke" />
|
||||
<div className="flex items-center gap-1">
|
||||
<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>)
|
||||
}}
|
||||
/>
|
||||
<ColorPicker value={strokeColor} onChange={handleColorChange} />
|
||||
<NumberInput
|
||||
value={strokeWidth}
|
||||
onChange={handleWidthChange}
|
||||
|
|
|
|||
|
|
@ -1,242 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,496 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
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
|
||||
|
|
@ -74,9 +72,6 @@ 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)
|
||||
|
|
@ -85,7 +80,6 @@ export default function NumberInput({
|
|||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
useHistoryStore.getState().endBatch()
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -61,13 +61,6 @@ 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
|
||||
}
|
||||
|
||||
|
|
@ -79,12 +72,6 @@ export function useKeyboardShortcuts() {
|
|||
if (next) {
|
||||
useDocumentStore.getState().applyHistoryState(next)
|
||||
}
|
||||
useCanvasStore.getState().clearSelection()
|
||||
const canvas = useCanvasStore.getState().fabricCanvas
|
||||
if (canvas) {
|
||||
canvas.discardActiveObject()
|
||||
canvas.requestRenderAll()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -297,7 +284,9 @@ export function useKeyboardShortcuts() {
|
|||
useDocumentStore.getState().removeNode(id)
|
||||
}
|
||||
if (selectedIds.length > 1) {
|
||||
useHistoryStore.getState().endBatch()
|
||||
useHistoryStore
|
||||
.getState()
|
||||
.endBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
useCanvasStore.getState().clearSelection()
|
||||
const canvas = useCanvasStore.getState().fabricCanvas
|
||||
|
|
@ -348,7 +337,9 @@ export function useKeyboardShortcuts() {
|
|||
useDocumentStore.getState().reorderNode(id, 'down')
|
||||
}
|
||||
if (selectedIds.length > 1) {
|
||||
useHistoryStore.getState().endBatch()
|
||||
useHistoryStore
|
||||
.getState()
|
||||
.endBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -366,7 +357,9 @@ export function useKeyboardShortcuts() {
|
|||
useDocumentStore.getState().reorderNode(id, 'up')
|
||||
}
|
||||
if (selectedIds.length > 1) {
|
||||
useHistoryStore.getState().endBatch()
|
||||
useHistoryStore
|
||||
.getState()
|
||||
.endBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -396,7 +389,9 @@ export function useKeyboardShortcuts() {
|
|||
useDocumentStore.getState().updateNode(id, updates)
|
||||
}
|
||||
if (selectedIds.length > 1) {
|
||||
useHistoryStore.getState().endBatch()
|
||||
useHistoryStore
|
||||
.getState()
|
||||
.endBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,20 +171,11 @@ 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 === '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
|
||||
if (chunk.type === 'text') {
|
||||
fullResponse += chunk.content
|
||||
if (callbacks?.onTextUpdate) {
|
||||
callbacks.onTextUpdate(fullResponse)
|
||||
|
|
@ -226,7 +217,7 @@ export async function generateDesign(
|
|||
}
|
||||
} finally {
|
||||
if (animated) {
|
||||
useHistoryStore.getState().endBatch()
|
||||
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -274,9 +265,7 @@ export async function generateDesignModification(
|
|||
for await (const chunk of streamChat(DESIGN_MODIFIER_PROMPT, [
|
||||
{ role: 'user', content: userMessage },
|
||||
], undefined, DESIGN_STREAM_TIMEOUTS)) {
|
||||
if (chunk.type === 'thinking') {
|
||||
// Ignore thinking chunks for modification — caller already shows progress
|
||||
} else if (chunk.type === 'text') {
|
||||
if (chunk.type === 'text') {
|
||||
fullResponse += chunk.content
|
||||
} else if (chunk.type === 'error') {
|
||||
streamError = chunk.content
|
||||
|
|
@ -413,7 +402,7 @@ export function animateNodesToCanvas(nodes: PenNode[]): void {
|
|||
|
||||
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
|
||||
upsertPreparedNodes(prepared)
|
||||
useHistoryStore.getState().endBatch()
|
||||
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
|
||||
function sanitizeNodesForInsert(
|
||||
|
|
@ -632,7 +621,12 @@ export function extractAndApplyDesign(responseText: string): number {
|
|||
const nodes = extractJsonFromResponse(responseText)
|
||||
if (!nodes || nodes.length === 0) return 0
|
||||
|
||||
applyNodesToCanvas(nodes)
|
||||
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
|
||||
try {
|
||||
applyNodesToCanvas(nodes)
|
||||
} finally {
|
||||
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
return nodes.length
|
||||
}
|
||||
|
||||
|
|
@ -647,19 +641,24 @@ export function extractAndApplyDesignModification(responseText: string): number
|
|||
const { addNode, updateNode, getNodeById } = useDocumentStore.getState()
|
||||
let count = 0
|
||||
|
||||
for (const node of nodes) {
|
||||
const existing = getNodeById(node.id)
|
||||
if (existing) {
|
||||
// Update existing node
|
||||
updateNode(node.id, node)
|
||||
count++
|
||||
} else {
|
||||
// It's a new node implied by the modification (e.g. "add a button")
|
||||
const rootFrame = getNodeById(DEFAULT_FRAME_ID)
|
||||
const parentId = rootFrame ? DEFAULT_FRAME_ID : null
|
||||
addNode(parentId, node)
|
||||
count++
|
||||
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
|
||||
try {
|
||||
for (const node of nodes) {
|
||||
const existing = getNodeById(node.id)
|
||||
if (existing) {
|
||||
// Update existing node
|
||||
updateNode(node.id, node)
|
||||
count++
|
||||
} else {
|
||||
// It's a new node implied by the modification (e.g. "add a button")
|
||||
const rootFrame = getNodeById(DEFAULT_FRAME_ID)
|
||||
const parentId = rootFrame ? DEFAULT_FRAME_ID : null
|
||||
addNode(parentId, node)
|
||||
count++
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
/**
|
||||
* 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'
|
||||
}
|
||||
|
|
@ -1,20 +1,10 @@
|
|||
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() {
|
||||
|
|
@ -34,15 +24,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: varOrLiteral(fill.color) }
|
||||
return { background: fill.color }
|
||||
}
|
||||
if (fill.type === 'linear_gradient') {
|
||||
const angle = fill.angle ?? 180
|
||||
const stops = fill.stops.map((s) => `${varOrLiteral(s.color)} ${Math.round(s.offset * 100)}%`).join(', ')
|
||||
const stops = fill.stops.map((s) => `${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) => `${varOrLiteral(s.color)} ${Math.round(s.offset * 100)}%`).join(', ')
|
||||
const stops = fill.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ')
|
||||
return { background: `radial-gradient(circle, ${stops})` }
|
||||
}
|
||||
return {}
|
||||
|
|
@ -51,19 +41,15 @@ function fillToCSS(fills: PenFill[] | undefined): Record<string, string> {
|
|||
function strokeToCSS(stroke: PenStroke | undefined): Record<string, string> {
|
||||
if (!stroke) return {}
|
||||
const css: Record<string, string> = {}
|
||||
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`
|
||||
}
|
||||
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'] = varOrLiteral(sf.color)
|
||||
css['border-color'] = sf.color
|
||||
}
|
||||
}
|
||||
return css
|
||||
|
|
@ -104,17 +90,11 @@ function layoutToCSS(node: ContainerProps): Record<string, string> {
|
|||
css.display = 'flex'
|
||||
css['flex-direction'] = 'row'
|
||||
}
|
||||
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.gap !== undefined && typeof node.gap === 'number') {
|
||||
css.gap = `${node.gap}px`
|
||||
}
|
||||
if (node.padding !== undefined) {
|
||||
if (typeof node.padding === 'string' && isVariableRef(node.padding)) {
|
||||
css.padding = varOrLiteral(node.padding)
|
||||
} else if (typeof node.padding === 'number') {
|
||||
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(' ')
|
||||
|
|
@ -175,12 +155,8 @@ function generateNodeHTML(
|
|||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
if (node.opacity !== undefined && node.opacity !== 1 && typeof node.opacity === 'number') {
|
||||
css.opacity = String(node.opacity)
|
||||
}
|
||||
|
||||
// Rotation
|
||||
|
|
@ -231,7 +207,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 = varOrLiteral(fill.color)
|
||||
if (fill?.type === 'solid') css.color = fill.color
|
||||
}
|
||||
if (node.fontSize) css['font-size'] = `${node.fontSize}px`
|
||||
if (node.fontWeight) css['font-weight'] = String(node.fontWeight)
|
||||
|
|
@ -264,7 +240,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'] = varOrLiteral(sf.color)
|
||||
if (sf.type === 'solid') css['border-top-color'] = sf.color
|
||||
}
|
||||
}
|
||||
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'line')
|
||||
|
|
@ -282,7 +258,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' ? varOrLiteral(node.fill[0].color) : 'currentColor'
|
||||
const fillColor = node.fill?.[0]?.type === 'solid' ? 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>`
|
||||
|
|
@ -346,12 +322,5 @@ export function generateHTMLCode(nodes: PenNode[]): { html: string; css: string
|
|||
}
|
||||
|
||||
export function generateHTMLFromDocument(doc: PenDocument): { html: string; css: string } {
|
||||
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,
|
||||
}
|
||||
return generateHTMLCode(doc.children)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,10 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -24,7 +13,7 @@ function fillToTailwind(fills: PenFill[] | undefined): string[] {
|
|||
if (!fills || fills.length === 0) return []
|
||||
const fill = fills[0]
|
||||
if (fill.type === 'solid') {
|
||||
return [`bg-[${varOrLiteral(fill.color)}]`]
|
||||
return [`bg-[${fill.color}]`]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
|
@ -33,7 +22,7 @@ function fillToTextColor(fills: PenFill[] | undefined): string[] {
|
|||
if (!fills || fills.length === 0) return []
|
||||
const fill = fills[0]
|
||||
if (fill.type === 'solid') {
|
||||
return [`text-[${varOrLiteral(fill.color)}]`]
|
||||
return [`text-[${fill.color}]`]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
|
@ -41,18 +30,14 @@ function fillToTextColor(fills: PenFill[] | undefined): string[] {
|
|||
function strokeToTailwind(stroke: PenStroke | undefined): string[] {
|
||||
if (!stroke) return []
|
||||
const classes: string[] = []
|
||||
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]`)
|
||||
}
|
||||
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-[${varOrLiteral(sf.color)}]`)
|
||||
classes.push(`border-[${sf.color}]`)
|
||||
}
|
||||
}
|
||||
return classes
|
||||
|
|
@ -92,17 +77,11 @@ function layoutToTailwind(node: ContainerProps): string[] {
|
|||
} else if (node.layout === 'horizontal') {
|
||||
classes.push('flex', 'flex-row')
|
||||
}
|
||||
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.gap !== undefined && typeof node.gap === 'number' && node.gap > 0) {
|
||||
classes.push(`gap-[${node.gap}px]`)
|
||||
}
|
||||
if (node.padding !== undefined) {
|
||||
if (typeof node.padding === 'string' && isVariableRef(node.padding)) {
|
||||
classes.push(`p-[${varOrLiteral(node.padding)}]`)
|
||||
} else if (typeof node.padding === 'number') {
|
||||
if (typeof node.padding === 'number') {
|
||||
classes.push(`p-[${node.padding}px]`)
|
||||
} else if (Array.isArray(node.padding)) {
|
||||
if (node.padding.length === 2) {
|
||||
|
|
@ -150,9 +129,6 @@ 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}%]`]
|
||||
|
|
@ -269,16 +245,12 @@ function generateNodeJSX(node: PenNode, depth: number): string {
|
|||
if (node.stroke) {
|
||||
const thickness = typeof node.stroke.thickness === 'number'
|
||||
? node.stroke.thickness
|
||||
: 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]`)
|
||||
}
|
||||
: node.stroke.thickness[0]
|
||||
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-[${varOrLiteral(sf.color)}]`)
|
||||
classes.push(`border-[${sf.color}]`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -292,7 +264,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' ? varOrLiteral(node.fill[0].color) : 'currentColor'
|
||||
const fillColor = node.fill?.[0]?.type === 'solid' ? 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))
|
||||
|
|
@ -355,5 +327,5 @@ ${childrenJSX}
|
|||
}
|
||||
|
||||
export function generateReactFromDocument(doc: PenDocument): string {
|
||||
return generateReactCode(doc.children, 'GeneratedDesign')
|
||||
return generateReactCode(doc.children)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ interface CanvasStoreState {
|
|||
fabricCanvas: Canvas | null
|
||||
clipboard: PenNode[]
|
||||
layerPanelOpen: boolean
|
||||
variablesPanelOpen: boolean
|
||||
|
||||
setActiveTool: (tool: ToolType) => void
|
||||
setZoom: (zoom: number) => void
|
||||
|
|
@ -31,7 +30,6 @@ interface CanvasStoreState {
|
|||
setFabricCanvas: (canvas: Canvas | null) => void
|
||||
setClipboard: (nodes: PenNode[]) => void
|
||||
toggleLayerPanel: () => void
|
||||
toggleVariablesPanel: () => void
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasStoreState>((set) => ({
|
||||
|
|
@ -47,7 +45,6 @@ export const useCanvasStore = create<CanvasStoreState>((set) => ({
|
|||
fabricCanvas: null,
|
||||
clipboard: [],
|
||||
layerPanelOpen: true,
|
||||
variablesPanelOpen: false,
|
||||
|
||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||
|
||||
|
|
@ -113,5 +110,4 @@ export const useCanvasStore = create<CanvasStoreState>((set) => ({
|
|||
setClipboard: (clipboard) => set({ clipboard }),
|
||||
|
||||
toggleLayerPanel: () => set((s) => ({ layerPanelOpen: !s.layerPanelOpen })),
|
||||
toggleVariablesPanel: () => set((s) => ({ variablesPanelOpen: !s.variablesPanelOpen })),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
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'
|
||||
|
||||
|
|
@ -234,12 +231,6 @@ 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,
|
||||
|
|
@ -596,78 +587,6 @@ 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 }),
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { create } from 'zustand'
|
||||
import type { PenDocument, PenNode } from '@/types/pen'
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
const MAX_HISTORY = 300
|
||||
|
||||
function areDocumentsEqual(a: PenDocument, b: PenDocument): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
|
||||
interface HistoryStoreState {
|
||||
undoStack: PenDocument[]
|
||||
|
|
@ -16,7 +20,7 @@ interface HistoryStoreState {
|
|||
canRedo: () => boolean
|
||||
clear: () => void
|
||||
startBatch: (doc: PenDocument) => void
|
||||
endBatch: () => void
|
||||
endBatch: (currentDoc?: PenDocument) => void
|
||||
|
||||
// Legacy API compatibility (used by some canvas event handlers)
|
||||
beginBatch: (currentChildren: PenNode[]) => void
|
||||
|
|
@ -34,11 +38,17 @@ export const useHistoryStore = create<HistoryStoreState>(
|
|||
const { batchDepth } = get()
|
||||
if (batchDepth > 0) return
|
||||
|
||||
const clone = structuredClone(doc)
|
||||
set((s) => ({
|
||||
undoStack: [...s.undoStack.slice(-(MAX_HISTORY - 1)), clone],
|
||||
redoStack: [],
|
||||
}))
|
||||
set((s) => {
|
||||
const last = s.undoStack[s.undoStack.length - 1]
|
||||
if (last && areDocumentsEqual(last, doc)) {
|
||||
return { redoStack: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
undoStack: [...s.undoStack.slice(-(MAX_HISTORY - 1)), structuredClone(doc)],
|
||||
redoStack: [],
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
undo: (currentDoc) => {
|
||||
|
|
@ -81,11 +91,20 @@ export const useHistoryStore = create<HistoryStoreState>(
|
|||
}
|
||||
},
|
||||
|
||||
endBatch: () => {
|
||||
endBatch: (currentDoc) => {
|
||||
const { batchDepth, batchBaseState } = get()
|
||||
if (batchDepth <= 0) return
|
||||
|
||||
if (batchDepth === 1 && batchBaseState) {
|
||||
const hasNoChanges = currentDoc
|
||||
? areDocumentsEqual(batchBaseState, currentDoc)
|
||||
: false
|
||||
|
||||
if (hasNoChanges) {
|
||||
set({ batchDepth: 0, batchBaseState: null })
|
||||
return
|
||||
}
|
||||
|
||||
set((s) => ({
|
||||
undoStack: [...s.undoStack.slice(-(MAX_HISTORY - 1)), batchBaseState],
|
||||
redoStack: [],
|
||||
|
|
|
|||
|
|
@ -1,29 +1,40 @@
|
|||
/**
|
||||
* Normalize a Pencil.dev .pen document into OpenPencil's internal format.
|
||||
*
|
||||
* Handles format normalization ONLY — does NOT resolve $variable references:
|
||||
* Handles:
|
||||
* - 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, GradientStop } from '@/types/styles'
|
||||
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> = {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)),
|
||||
children: doc.children.map((n) => normalizeNode(n, vars)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -31,35 +42,44 @@ export function normalizePenDocument(doc: PenDocument): PenDocument {
|
|||
// Node normalizer (recursive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeNode(node: PenNode): PenNode {
|
||||
function normalizeNode(node: PenNode, vars: Vars): PenNode {
|
||||
const out: Record<string, unknown> = { ...node }
|
||||
|
||||
// fill
|
||||
if ('fill' in out && out.fill !== undefined) {
|
||||
out.fill = normalizeFills(out.fill)
|
||||
out.fill = normalizeFills(out.fill, vars)
|
||||
}
|
||||
|
||||
// stroke
|
||||
if ('stroke' in out && out.stroke != null) {
|
||||
out.stroke = normalizeStroke(out.stroke as Record<string, unknown>)
|
||||
out.stroke = normalizeStroke(out.stroke as Record<string, unknown>, vars)
|
||||
}
|
||||
|
||||
// effects — pass through (no format changes needed)
|
||||
// effects
|
||||
if ('effects' in out && Array.isArray(out.effects)) {
|
||||
out.effects = normalizeEffects(out.effects as Record<string, unknown>[], vars)
|
||||
}
|
||||
|
||||
// sizing
|
||||
if ('width' in out) out.width = normalizeSizing(out.width)
|
||||
if ('height' in out) out.height = normalizeSizing(out.height)
|
||||
|
||||
// gap — pass through ($variable strings preserved)
|
||||
// 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)
|
||||
|
||||
// padding — normalize array format only (not variable resolution)
|
||||
if ('padding' in out) out.padding = normalizePadding(out.padding)
|
||||
// opacity
|
||||
if ('opacity' in out) out.opacity = resolveNumeric(out.opacity, vars) ?? 1
|
||||
|
||||
// opacity — pass through ($variable strings preserved)
|
||||
// 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
|
||||
}
|
||||
|
||||
// children
|
||||
if ('children' in out && Array.isArray(out.children)) {
|
||||
out.children = (out.children as PenNode[]).map((c) => normalizeNode(c))
|
||||
out.children = (out.children as PenNode[]).map((c) => normalizeNode(c, vars))
|
||||
}
|
||||
|
||||
return out as unknown as PenNode
|
||||
|
|
@ -69,22 +89,23 @@ function normalizeNode(node: PenNode): PenNode {
|
|||
// Fill normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeFills(raw: unknown): PenFill[] {
|
||||
function normalizeFills(raw: unknown, vars: Vars): PenFill[] {
|
||||
if (!raw) return []
|
||||
|
||||
// String shorthand: "#hex" or "$variable" → solid fill
|
||||
// String shorthand: "#hex" or "$variable"
|
||||
if (typeof raw === 'string') {
|
||||
return [{ type: 'solid', color: raw }]
|
||||
const color = resolveColor(raw, vars)
|
||||
return color ? [{ type: 'solid', color }] : []
|
||||
}
|
||||
|
||||
// Array of fills
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((f) => normalizeSingleFill(f)).filter(Boolean) as PenFill[]
|
||||
return raw.map((f) => normalizeSingleFill(f, vars)).filter(Boolean) as PenFill[]
|
||||
}
|
||||
|
||||
// Single fill object
|
||||
if (typeof raw === 'object') {
|
||||
const f = normalizeSingleFill(raw as Record<string, unknown>)
|
||||
const f = normalizeSingleFill(raw as Record<string, unknown>, vars)
|
||||
return f ? [f] : []
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +114,7 @@ function normalizeFills(raw: unknown): 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
|
||||
|
|
@ -101,14 +123,14 @@ function normalizeSingleFill(
|
|||
if (t === 'color' || t === 'solid') {
|
||||
return {
|
||||
type: 'solid',
|
||||
color: typeof raw.color === 'string' ? raw.color : '#000000',
|
||||
color: resolveColor(raw.color, vars) ?? '#000000',
|
||||
}
|
||||
}
|
||||
|
||||
// Pencil "gradient" → split by gradientType
|
||||
if (t === 'gradient') {
|
||||
const gt = (raw.gradientType as string) ?? 'linear'
|
||||
const stops = normalizeGradientStops(raw.colors as unknown[])
|
||||
const stops = normalizeGradientStops(raw.colors as unknown[], vars)
|
||||
|
||||
if (gt === 'radial') {
|
||||
const center = raw.center as Record<string, unknown> | undefined
|
||||
|
|
@ -132,9 +154,9 @@ function normalizeSingleFill(
|
|||
if (t === 'linear_gradient' || t === 'radial_gradient') {
|
||||
const stops =
|
||||
'stops' in raw
|
||||
? normalizeGradientStops(raw.stops as unknown[])
|
||||
? normalizeGradientStops(raw.stops as unknown[], vars)
|
||||
: 'colors' in raw
|
||||
? normalizeGradientStops(raw.colors as unknown[])
|
||||
? normalizeGradientStops(raw.colors as unknown[], vars)
|
||||
: []
|
||||
return { ...(raw as unknown as PenFill), stops } as PenFill
|
||||
}
|
||||
|
|
@ -146,7 +168,7 @@ function normalizeSingleFill(
|
|||
if ('color' in raw) {
|
||||
return {
|
||||
type: 'solid',
|
||||
color: typeof raw.color === 'string' ? raw.color : '#000000',
|
||||
color: resolveColor(raw.color, vars) ?? '#000000',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +177,7 @@ function normalizeSingleFill(
|
|||
|
||||
function normalizeGradientStops(
|
||||
raw: unknown[] | undefined,
|
||||
vars: Vars,
|
||||
): GradientStop[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((s: unknown) => {
|
||||
|
|
@ -166,7 +189,7 @@ function normalizeGradientStops(
|
|||
: typeof stop.position === 'number'
|
||||
? stop.position
|
||||
: 0,
|
||||
color: typeof stop.color === 'string' ? stop.color : '#000000',
|
||||
color: resolveColor(stop.color, vars) ?? '#000000',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -177,33 +200,51 @@ 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)
|
||||
out.fill = normalizeFills(out.fill, vars)
|
||||
}
|
||||
|
||||
// Pencil may use "color" directly on stroke
|
||||
if ('color' in out && typeof out.color === 'string') {
|
||||
out.fill = [{ type: 'solid', color: out.color as string }]
|
||||
out.fill = [{ type: 'solid', color: resolveColor(out.color, vars) ?? '#000000' }]
|
||||
delete out.color
|
||||
}
|
||||
|
||||
// Thickness: leave $variable strings as-is, normalise plain number strings
|
||||
// Normalize thickness variable ref
|
||||
if (typeof out.thickness === 'string') {
|
||||
const str = out.thickness as string
|
||||
if (!str.startsWith('$')) {
|
||||
const num = parseFloat(str)
|
||||
out.thickness = isNaN(num) ? 1 : num
|
||||
}
|
||||
out.thickness = resolveNumeric(out.thickness, vars) ?? 1
|
||||
}
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -212,9 +253,6 @@ 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'
|
||||
|
||||
|
|
@ -232,23 +270,64 @@ function normalizeSizing(value: unknown): number | string {
|
|||
|
||||
function normalizePadding(
|
||||
value: unknown,
|
||||
): number | [number, number] | [number, number, number, number] | string | undefined {
|
||||
vars: Vars,
|
||||
): number | [number, number] | [number, number, number, number] | undefined {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string') {
|
||||
// $variable — pass through
|
||||
if (value.startsWith('$')) return value
|
||||
const num = parseFloat(value)
|
||||
return isNaN(num) ? 0 : num
|
||||
}
|
||||
if (typeof value === 'string') return (resolveNumeric(value, vars) as number) ?? 0
|
||||
if (Array.isArray(value)) {
|
||||
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 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 undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
/**
|
||||
* 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)
|
||||
}
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
/**
|
||||
* 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
|
||||
}
|
||||
Loading…
Reference in a new issue