From 125d55dfc8bb1e2e1f864a21fed69e805ef56f98 Mon Sep 17 00:00:00 2001 From: Fini Date: Fri, 20 Feb 2026 15:27:05 +0800 Subject: [PATCH] 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 --- CLAUDE.md | 64 +-- README.md | 40 +- src/canvas/canvas-controls.ts | 59 +-- src/canvas/drag-reparent.ts | 51 +- src/canvas/use-canvas-events.ts | 156 ++++-- src/canvas/use-canvas-sync.ts | 17 +- src/canvas/use-canvas-viewport.ts | 2 +- src/canvas/use-fabric-canvas.ts | 2 - src/components/editor/editor-layout.tsx | 12 - src/components/editor/toolbar.tsx | 30 -- src/components/panels/ai-chat-panel.tsx | 10 +- src/components/panels/appearance-section.tsx | 39 +- src/components/panels/chat-message.tsx | 10 +- src/components/panels/code-panel.tsx | 22 +- src/components/panels/fill-section.tsx | 20 +- src/components/panels/layout-section.tsx | 74 +-- src/components/panels/stroke-section.tsx | 24 +- src/components/panels/variable-row.tsx | 242 --------- src/components/panels/variables-panel.tsx | 496 ------------------ src/components/shared/number-input.tsx | 6 - src/components/shared/variable-picker.tsx | 152 ------ src/hooks/use-keyboard-shortcuts.ts | 29 +- src/services/ai/design-generator.ts | 55 +- .../codegen/css-variables-generator.ts | 138 ----- src/services/codegen/html-generator.ts | 65 +-- src/services/codegen/react-generator.ts | 58 +- src/stores/canvas-store.ts | 4 - src/stores/document-store.ts | 81 --- src/stores/history-store.ts | 35 +- src/utils/normalize-pen-file.ts | 185 +++++-- src/variables/replace-refs.ts | 149 ------ src/variables/resolve-variables.ts | 283 ---------- 32 files changed, 459 insertions(+), 2151 deletions(-) delete mode 100644 src/components/panels/variable-row.tsx delete mode 100644 src/components/panels/variables-panel.tsx delete mode 100644 src/components/shared/variable-picker.tsx delete mode 100644 src/services/codegen/css-variables-generator.ts delete mode 100644 src/variables/replace-refs.ts delete mode 100644 src/variables/resolve-variables.ts diff --git a/CLAUDE.md b/CLAUDE.md index e1762edd1..4c4afc636 100644 --- a/CLAUDE.md +++ b/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 ($color-1, $spacing-md, ...) - ├── themes: Record ({Theme-1: ["Default","Dark"]}) - └── children: PenNode[] (nodes with $variable refs) - │ - ┌──────────┴──────────┐ - ▼ ▼ - Canvas Sync Code Generation - resolveNodeForCanvas() $ref → var(--name) - $ref → concrete value CSS Variables block -``` - -- **`$variable` references are preserved** in the document store (e.g. `$color-1` in fill color) -- `normalize-pen-file.ts` does NOT resolve `$refs` — only fixes format issues -- `resolveNodeForCanvas()` resolves `$refs` on-the-fly before Fabric.js rendering -- Code generators output `var(--name)` for `$ref` values -- Multiple theme axes supported (e.g. Theme-1 with Light/Dark, Theme-2 with Compact/Comfortable) -- Each theme axis has variants; variables can have per-variant values (`ThemedValue[]`) - ### Key Modules - **`src/canvas/`** — Fabric.js integration (16 files): @@ -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`。 ### 规则 diff --git a/README.md b/README.md index f6dadfcae..553075f34 100644 --- a/README.md +++ b/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 diff --git a/src/canvas/canvas-controls.ts b/src/canvas/canvas-controls.ts index 4f7b9a90a..2073c1f9e 100644 --- a/src/canvas/canvas-controls.ts +++ b/src/canvas/canvas-controls.ts @@ -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 = `` + const 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) - } -} diff --git a/src/canvas/drag-reparent.ts b/src/canvas/drag-reparent.ts index 51b5c0173..63f95aa2b 100644 --- a/src/canvas/drag-reparent.ts +++ b/src/canvas/drag-reparent.ts @@ -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 diff --git a/src/canvas/use-canvas-events.ts b/src/canvas/use-canvas-events.ts index c1a80810b..d0bd7f4b5 100644 --- a/src/canvas/use-canvas-events.ts +++ b/src/canvas/use-canvas-events.ts @@ -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) diff --git a/src/canvas/use-canvas-sync.ts b/src/canvas/use-canvas-sync.ts index 5812cdb58..ad1dde552 100644 --- a/src/canvas/use-canvas-sync.ts +++ b/src/canvas/use-canvas-sync.ts @@ -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() nodeRenderInfo.clear() rootFrameBounds.clear() layoutContainerBounds.clear() const flatNodes = flattenNodes( state.document.children, 0, 0, undefined, undefined, undefined, clipMap, - ).map((node) => resolveNodeForCanvas(node, variables, activeTheme)) + ) const nodeMap = new Map(flatNodes.map((n) => [n.id, n])) const objects = canvas.getObjects() as FabricObjectWithPenId[] const objMap = new Map( diff --git a/src/canvas/use-canvas-viewport.ts b/src/canvas/use-canvas-viewport.ts index 4f822fcc6..2147e4d34 100644 --- a/src/canvas/use-canvas-viewport.ts +++ b/src/canvas/use-canvas-viewport.ts @@ -5,7 +5,7 @@ import type { ToolType } from '@/types/canvas' // Precise crosshair cursor (thin +) const CROSSHAIR_CURSOR = (() => { - const svg = `` + const svg = `` return `url("data:image/svg+xml,${svg}") 12 12, crosshair` })() diff --git a/src/canvas/use-fabric-canvas.ts b/src/canvas/use-fabric-canvas.ts index 9c7177931..7acc13861 100644 --- a/src/canvas/use-fabric-canvas.ts +++ b/src/canvas/use-fabric-canvas.ts @@ -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) diff --git a/src/components/editor/editor-layout.tsx b/src/components/editor/editor-layout.tsx index b7195620c..8dfb27c3f 100644 --- a/src/components/editor/editor-layout.tsx +++ b/src/components/editor/editor-layout.tsx @@ -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() { - {/* Floating variables panel — anchored to the right of the toolbar */} - {variablesPanelOpen && } - {/* Bottom bar: minimized AI (left) + zoom controls (right) */}
diff --git a/src/components/editor/toolbar.tsx b/src/components/editor/toolbar.tsx index 8426fb00d..e6ba5e230 100644 --- a/src/components/editor/toolbar.tsx +++ b/src/components/editor/toolbar.tsx @@ -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(null) const [iconPickerOpen, setIconPickerOpen] = useState(false) @@ -229,33 +226,6 @@ export default function Toolbar() { - - - {/* Variables */} - - - - - - Variables - - {'\u2318\u21e7'}V - - - - {/* Hidden file input + icon picker dialog */} -
-
- {isBound ? ( -
- {rawOpacity} -
- ) : ( - onUpdate({ opacity: v / 100 })} - min={0} - max={100} - suffix="%" - /> - )} -
- onUpdate({ opacity: ref as unknown as number })} - onUnbind={(val) => onUpdate({ opacity: Number(val) })} - /> -
+ onUpdate({ opacity: v / 100 })} + min={0} + max={100} + suffix="%" + />
) } diff --git a/src/components/panels/chat-message.tsx b/src/components/panels/chat-message.tsx index b595eccb6..cc717357c 100644 --- a/src/components/panels/chat-message.tsx +++ b/src/components/panels/chat-message.tsx @@ -65,7 +65,7 @@ function ActionSteps({ steps }: { steps: string[] }) { if (steps.length === 0) return null return ( -
+
{steps.map((step, i) => { const title = parseStepTitle(step) const content = parseStepContent(step) @@ -110,7 +110,7 @@ function ActionStepItem({ // Pencil-like style: Rounded pill/card with subtle border return ( -
+
- {showMenu && ( -
- - -
- )} -
-
- ) -} - -// --- Color cell --- - -function ColorCell({ - value, - opacity, - onChange, -}: { - value: string - opacity: number - onChange: (color: string) => void -}) { - const [hexInput, setHexInput] = useState(value.slice(0, 7)) - - useEffect(() => { setHexInput(value.slice(0, 7)) }, [value]) - - const handleHexChange = (e: React.ChangeEvent) => { - const v = e.target.value - setHexInput(v) - if (/^#[0-9a-fA-F]{6}$/.test(v)) onChange(v) - } - - const handleBlur = () => { - if (!/^#[0-9a-fA-F]{6}$/.test(hexInput)) setHexInput(value.slice(0, 7)) - } - - return ( -
- onChange(e.target.value)} - className="w-5 h-5 rounded border border-input/40 cursor-pointer bg-transparent p-0 shrink-0" - /> - - - {opacity} % - -
- ) -} diff --git a/src/components/panels/variables-panel.tsx b/src/components/panels/variables-panel.tsx deleted file mode 100644 index eaab91526..000000000 --- a/src/components/panels/variables-panel.tsx +++ /dev/null @@ -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(null) - // Theme tab dropdown (Rename/Delete) - const [activeThemeMenu, setActiveThemeMenu] = useState(null) - const [renamingTheme, setRenamingTheme] = useState(null) - const [renameThemeValue, setRenameThemeValue] = useState('') - // Variant column dropdown (Rename/Delete) - const [activeColumnMenu, setActiveColumnMenu] = useState(null) - const [renamingColumn, setRenamingColumn] = useState(null) - const [renameColumnValue, setRenameColumnValue] = useState('') - // Panel size - const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH) - const [panelHeight, setPanelHeight] = useState(DEFAULT_HEIGHT) - - const themeMenuRef = useRef(null) - const addMenuRef = useRef(null) - const columnMenuRef = useRef(null) - const renameInputRef = useRef(null) - const themeRenameInputRef = useRef(null) - const panelRef = useRef(null) - const resizeRef = useRef<{ - edge: 'right' | 'bottom' | 'corner' - startX: number; startY: number; startW: number; startH: number - } | null>(null) - - // Close menus on outside click - useEffect(() => { - const handler = (e: MouseEvent) => { - if (activeThemeMenu && themeMenuRef.current && !themeMenuRef.current.contains(e.target as Node)) - { setActiveThemeMenu(null); setRenamingTheme(null) } - if (showAddMenu && addMenuRef.current && !addMenuRef.current.contains(e.target as Node)) - setShowAddMenu(false) - if (activeColumnMenu && columnMenuRef.current && !columnMenuRef.current.contains(e.target as Node)) - setActiveColumnMenu(null) - } - if (activeThemeMenu || showAddMenu || activeColumnMenu) { - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - } - }, [activeThemeMenu, showAddMenu, activeColumnMenu]) - - useEffect(() => { - if (renamingColumn && renameInputRef.current) { - renameInputRef.current.focus() - renameInputRef.current.select() - } - }, [renamingColumn]) - - useEffect(() => { - if (renamingTheme && themeRenameInputRef.current) { - themeRenameInputRef.current.focus() - themeRenameInputRef.current.select() - } - }, [renamingTheme]) - - /* --- Resize --- */ - const handleResizeStart = useCallback((edge: 'right' | 'bottom' | 'corner', e: React.PointerEvent) => { - e.preventDefault() - e.stopPropagation() - resizeRef.current = { edge, startX: e.clientX, startY: e.clientY, startW: panelWidth, startH: panelHeight } - e.currentTarget.setPointerCapture(e.pointerId) - }, [panelWidth, panelHeight]) - - const handleResizeMove = useCallback((e: React.PointerEvent) => { - if (!resizeRef.current) return - e.preventDefault() - const { edge, startX, startY, startW, startH } = resizeRef.current - const container = panelRef.current?.parentElement - const maxW = container ? container.clientWidth - 72 : 1400 - const maxH = container ? container.clientHeight - 16 : 900 - if (edge === 'right' || edge === 'corner') - setPanelWidth(Math.max(MIN_WIDTH, Math.min(maxW, startW + e.clientX - startX))) - if (edge === 'bottom' || edge === 'corner') - setPanelHeight(Math.max(MIN_HEIGHT, Math.min(maxH, startH + e.clientY - startY))) - }, []) - - const handleResizeEnd = useCallback((e: React.PointerEvent) => { - if (!resizeRef.current) return - resizeRef.current = null - e.currentTarget.releasePointerCapture(e.pointerId) - }, []) - - /* --- Theme axes & variants --- */ - const themeAxes = useMemo(() => { - if (!themes) return [] - return Object.keys(themes) - }, [themes]) - - const currentAxis = useMemo(() => { - if (activeAxis && themes?.[activeAxis]) return activeAxis - if (themeAxes.length > 0) return themeAxes[0] - return null - }, [activeAxis, themes, themeAxes]) - - const themeValues = useMemo(() => { - if (!currentAxis || !themes?.[currentAxis]) return DEFAULT_THEME_VALUES - return themes[currentAxis].length > 0 ? themes[currentAxis] : DEFAULT_THEME_VALUES - }, [themes, currentAxis]) - - const themeAxis = currentAxis ?? DEFAULT_THEME_AXIS - - const ensureThemes = useCallback(() => { - if (!themes || Object.keys(themes).length === 0) { - setThemes({ [DEFAULT_THEME_AXIS]: DEFAULT_THEME_VALUES }) - } - }, [themes, setThemes]) - - const entries = useMemo(() => { - if (!variables) return [] - return Object.entries(variables) - .filter(([n]) => !search || n.toLowerCase().includes(search.toLowerCase())) - .sort(([a], [b]) => a.localeCompare(b)) - }, [variables, search]) - - /* --- Theme actions --- */ - const handleAddTheme = () => { - const current = themes ?? {} - let counter = 1 - let name = `Theme-${counter}` - while (current[name]) { counter++; name = `Theme-${counter}` } - setThemes({ ...current, [name]: ['Default'] }) - setActiveAxis(name) - } - - const handleDeleteTheme = (axis: string) => { - if (!themes) return - const updated = { ...themes } - delete updated[axis] - setThemes(updated) - if (activeAxis === axis) setActiveAxis(null) - setActiveThemeMenu(null) - } - - const handleRenameTheme = (oldName: string, newName: string) => { - setRenamingTheme(null) - setActiveThemeMenu(null) - if (!newName.trim() || newName === oldName) return - if (themes?.[newName]) return - const current = themes ?? {} - const values = current[oldName] ?? DEFAULT_THEME_VALUES - const updated: Record = {} - for (const key of Object.keys(current)) { - if (key === oldName) updated[newName] = values - else updated[key] = current[key] - } - setThemes(updated) - if (activeAxis === oldName) setActiveAxis(newName) - } - - /* --- Variant actions --- */ - const handleAddVariant = () => { - ensureThemes() - const axis = currentAxis ?? DEFAULT_THEME_AXIS - const currentValues = themes?.[axis] ?? DEFAULT_THEME_VALUES - let counter = 1 - let n = `Variant-${counter}` - while (currentValues.includes(n)) { counter++; n = `Variant-${counter}` } - const updatedThemes = { ...(themes ?? { [DEFAULT_THEME_AXIS]: DEFAULT_THEME_VALUES }) } - updatedThemes[axis] = [...currentValues, n] - setThemes(updatedThemes) - } - - const handleRemoveVariant = (value: string) => { - if (!currentAxis || !themes) return - const currentValues = themes[currentAxis] ?? [] - if (currentValues.length <= 1) return - setThemes({ ...themes, [currentAxis]: currentValues.filter((v) => v !== value) }) - setActiveColumnMenu(null) - } - - const handleRenameVariant = (oldName: string, newName: string) => { - if (!newName.trim() || newName === oldName) { setRenamingColumn(null); return } - if (!currentAxis || !themes) { setRenamingColumn(null); return } - const currentValues = themes[currentAxis] ?? [] - if (currentValues.includes(newName)) { setRenamingColumn(null); return } - setThemes({ ...themes, [currentAxis]: currentValues.map((v) => v === oldName ? newName : v) }) - setRenamingColumn(null) - } - - const startRenameVariant = (tv: string) => { - setRenameColumnValue(tv) - setRenamingColumn(tv) - setActiveColumnMenu(null) - } - - /* --- Add variable --- */ - const handleAdd = (type: VariableDefinition['type']) => { - ensureThemes() - const existing = variables ? Object.keys(variables) : [] - let counter = 1 - const baseName = type === 'color' ? 'color' : type === 'number' ? 'number' : 'string' - let varName = `${baseName}-${counter}` - while (existing.includes(varName)) { counter++; varName = `${baseName}-${counter}` } - const currentTV = themes?.[themeAxis] ?? DEFAULT_THEME_VALUES - let defaultValue: VariableDefinition['value'] - if (currentTV.length > 1) { - defaultValue = currentTV.map((tv) => ({ - value: type === 'color' ? '#000000' : type === 'number' ? 0 : '', - theme: { [themeAxis]: tv }, - })) as ThemedValue[] - } else { - defaultValue = type === 'color' ? '#000000' : type === 'number' ? 0 : '' - } - setVariable(varName, { type, value: defaultValue }) - setShowAddMenu(false) - } - - return ( -
- {/* Background layer with rounded corners — sits behind everything */} -
- - {/* ── Header: Theme-1 | Theme-2 | ... | + | spacer | X ── */} -
- {/* Theme tabs — all equal, active one has chevron dropdown */} - {themeAxes.map((axis) => ( -
- {renamingTheme === axis ? ( - setRenameThemeValue(e.target.value)} - onBlur={() => handleRenameTheme(axis, renameThemeValue)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleRenameTheme(axis, renameThemeValue) - if (e.key === 'Escape') { setRenamingTheme(null); setActiveThemeMenu(null) } - }} - className="text-[13px] text-foreground bg-secondary px-2 py-0.5 rounded-lg border border-ring focus:outline-none w-24" - /> - ) : ( - - )} - {/* Theme dropdown: Rename / Delete */} - {activeThemeMenu === axis && !renamingTheme && ( -
- - {themeAxes.length > 1 && ( - - )} -
- )} -
- ))} - - {/* + add theme */} - - -
- - -
- - {/* ── Column headers: Name | Default | Variant-1 | ... | + ── */} -
-
- Name -
- {themeValues.map((tv) => ( -
- {renamingColumn === tv ? ( - setRenameColumnValue(e.target.value)} - onBlur={() => handleRenameVariant(tv, renameColumnValue)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleRenameVariant(tv, renameColumnValue) - if (e.key === 'Escape') setRenamingColumn(null) - }} - className="text-[13px] font-medium text-foreground bg-secondary px-1.5 py-0.5 rounded border border-ring focus:outline-none w-32" - /> - ) : ( - - )} - {activeColumnMenu === tv && ( -
- - {themeValues.length > 1 && ( - - )} -
- )} -
- ))} -
- -
-
- - {/* ── Search ── */} - {entries.length > 6 && ( -
-
- - setSearch(e.target.value)} - className="flex-1 bg-transparent text-foreground text-[12px] focus:outline-none placeholder:text-muted-foreground/40" - /> -
-
- )} - - {/* ── Variable rows ── */} -
- {entries.length === 0 && ( -
- - {search ? 'No variables match your search' : 'No variables defined'} - -
- )} - {entries.map(([varName, def]) => ( - setVariable(n, d)} - onRename={(o, n) => renameVariable(o, n)} - onDelete={(n) => removeVariable(n)} - /> - ))} -
- - {/* ── Footer ── */} -
- - {showAddMenu && ( -
- {(['color', 'number', 'string'] as const).map((t) => ( - - ))} -
- )} -
- - {/* ── Resize handles ── */} -
handleResizeStart('right', e)} - onPointerMove={handleResizeMove} - onPointerUp={handleResizeEnd} - /> -
handleResizeStart('bottom', e)} - onPointerMove={handleResizeMove} - onPointerUp={handleResizeEnd} - /> -
handleResizeStart('corner', e)} - onPointerMove={handleResizeMove} - onPointerUp={handleResizeEnd} - /> -
- ) -} diff --git a/src/components/shared/number-input.tsx b/src/components/shared/number-input.tsx index 22c2847d8..2ac5ce8e2 100644 --- a/src/components/shared/number-input.tsx +++ b/src/components/shared/number-input.tsx @@ -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) } diff --git a/src/components/shared/variable-picker.tsx b/src/components/shared/variable-picker.tsx deleted file mode 100644 index b52627fc3..000000000 --- a/src/components/shared/variable-picker.tsx +++ /dev/null @@ -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(null) - const variables = useDocumentStore((s) => s.document.variables) - const themes = useDocumentStore((s) => s.document.themes) - - const isBound = typeof currentValue === 'string' && isVariableRef(currentValue) - const boundName = isBound ? (currentValue as string).slice(1) : null - - // Filter variables by matching type - const matchingVars = useMemo(() => { - if (!variables) return [] - return Object.entries(variables) - .filter(([, def]) => def.type === type) - .sort(([a], [b]) => a.localeCompare(b)) - }, [variables, type]) - - // Close on outside click - useEffect(() => { - if (!open) return - const handler = (e: MouseEvent) => { - if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { - setOpen(false) - } - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [open]) - - const handleBind = (name: string) => { - onBind(`$${name}`) - setOpen(false) - } - - const handleUnbind = () => { - if (!boundName || !variables?.[boundName]) return - const activeTheme = getDefaultTheme(themes) - const resolved = resolveVariableRef(`$${boundName}`, variables, activeTheme) - const fallback: string | number = type === 'color' ? '#000000' : type === 'number' ? 0 : '' - const val = resolved != null && typeof resolved !== 'boolean' ? resolved : fallback - onUnbind(val) - setOpen(false) - } - - const getPreview = (def: VariableDefinition): string => { - const val = def.value - if (!Array.isArray(val)) return String(val) - // Show first theme value - return val[0]?.value != null ? String(val[0].value) : '' - } - - if (matchingVars.length === 0 && !isBound) return null - - return ( -
- {isBound ? ( - - ) : ( - - )} - - {open && ( -
- {isBound && ( - <> - -
- - )} - {matchingVars.length === 0 ? ( -
- No {type} variables defined -
- ) : ( - matchingVars.map(([name, def]) => ( - - )) - )} -
- )} -
- ) -} diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts index dba745f1d..a178bb817 100644 --- a/src/hooks/use-keyboard-shortcuts.ts +++ b/src/hooks/use-keyboard-shortcuts.ts @@ -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 } diff --git a/src/services/ai/design-generator.ts b/src/services/ai/design-generator.ts index feda000bd..6d1b76cc3 100644 --- a/src/services/ai/design-generator.ts +++ b/src/services/ai/design-generator.ts @@ -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?.('Analyzing your design request...') - } - } 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 } diff --git a/src/services/codegen/css-variables-generator.ts b/src/services/codegen/css-variables-generator.ts deleted file mode 100644 index 74a2b1c92..000000000 --- a/src/services/codegen/css-variables-generator.ts +++ /dev/null @@ -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 | number | boolean | undefined { - const val = def.value - if (!Array.isArray(val)) return val - const match = (val as ThemedValue[]).find((v) => { - if (!v.theme) return false - return Object.entries(theme).every( - ([key, expected]) => v.theme?.[key] === expected, - ) - }) - return match?.value ?? (val as ThemedValue[])[0]?.value -} - -/** - * Generate CSS custom properties from a PenDocument's variables and themes. - * - * Returns a string containing `:root { ... }` blocks. - */ -export function generateCSSVariables(doc: PenDocument): string { - const variables = doc.variables - if (!variables || Object.keys(variables).length === 0) { - return '/* No design variables defined */\n' - } - - const themes = doc.themes ?? {} - const themeAxes = Object.entries(themes) - - // Build default theme (first value per axis) - const defaultTheme: Record = {} - for (const [key, values] of themeAxes) { - if (values.length > 0) defaultTheme[key] = values[0] - } - - const hasThemes = themeAxes.length > 0 && themeAxes.some(([, v]) => v.length > 1) - - // Generate default :root block - const lines: string[] = [] - lines.push(':root {') - - const varEntries = Object.entries(variables).sort(([a], [b]) => a.localeCompare(b)) - for (const [name, def] of varEntries) { - const value = Array.isArray(def.value) - ? resolveForTheme(def, defaultTheme) - : def.value - if (value === undefined) continue - const css = formatValue(value, name, def.type) - if (css === null) continue - lines.push(` ${variableNameToCSS(name)}: ${css};`) - } - - lines.push('}') - - // Generate per-theme variant blocks - if (hasThemes) { - // For simplicity, iterate over each axis independently - // (e.g. mode: light/dark generates :root[data-theme="dark"] { ... }) - for (const [axis, values] of themeAxes) { - // Skip the default (first) value - for (let i = 1; i < values.length; i++) { - const themeValue = values[i] - const themeContext = { ...defaultTheme, [axis]: themeValue } - - const block: string[] = [] - for (const [name, def] of varEntries) { - if (!Array.isArray(def.value)) continue - const resolvedForThis = resolveForTheme(def, themeContext) - const resolvedForDefault = resolveForTheme(def, defaultTheme) - // Only include if different from default - if (resolvedForThis === resolvedForDefault) continue - if (resolvedForThis === undefined) continue - const css = formatValue(resolvedForThis, name, def.type) - if (css === null) continue - block.push(` ${variableNameToCSS(name)}: ${css};`) - } - - if (block.length > 0) { - lines.push('') - lines.push(`:root[data-theme="${themeValue}"] {`) - lines.push(...block) - lines.push('}') - } - } - } - } - - return lines.join('\n') + '\n' -} diff --git a/src/services/codegen/html-generator.ts b/src/services/codegen/html-generator.ts index ef4e9dcc7..ff4b7167e 100644 --- a/src/services/codegen/html-generator.ts +++ b/src/services/codegen/html-generator.ts @@ -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 { 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 { function strokeToCSS(stroke: PenStroke | undefined): Record { if (!stroke) return {} const css: Record = {} - 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 { 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}\n${pad} \n${pad}` } return `${pad}
` @@ -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) } diff --git a/src/services/codegen/react-generator.ts b/src/services/codegen/react-generator.ts index 2d2983d32..e3c463d03 100644 --- a/src/services/codegen/react-generator.ts +++ b/src/services/codegen/react-generator.ts @@ -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}\n${pad} \n${pad}` } 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) } diff --git a/src/stores/canvas-store.ts b/src/stores/canvas-store.ts index 9811ae75f..2704c8b29 100644 --- a/src/stores/canvas-store.ts +++ b/src/stores/canvas-store.ts @@ -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((set) => ({ @@ -47,7 +45,6 @@ export const useCanvasStore = create((set) => ({ fabricCanvas: null, clipboard: [], layerPanelOpen: true, - variablesPanelOpen: false, setActiveTool: (tool) => set({ activeTool: tool }), @@ -113,5 +110,4 @@ export const useCanvasStore = create((set) => ({ setClipboard: (clipboard) => set({ clipboard }), toggleLayerPanel: () => set((s) => ({ layerPanelOpen: !s.layerPanelOpen })), - toggleVariablesPanel: () => set((s) => ({ variablesPanelOpen: !s.variablesPanelOpen })), })) diff --git a/src/stores/document-store.ts b/src/stores/document-store.ts index 476fc60a7..2e8c97a90 100644 --- a/src/stores/document-store.ts +++ b/src/stores/document-store.ts @@ -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) => void - applyHistoryState: (doc: PenDocument) => void loadDocument: ( doc: PenDocument, @@ -596,78 +587,6 @@ export const useDocumentStore = create( isDescendantOf: (nodeId, ancestorId) => isDescendantOf(get().document.children, nodeId, ancestorId), - // --- Variable management --- - - setVariable: (name, definition) => { - useHistoryStore.getState().pushState(get().document) - set((s) => ({ - document: { - ...s.document, - variables: { ...(s.document.variables ?? {}), [name]: definition }, - }, - isDirty: true, - })) - }, - - removeVariable: (name) => { - const state = get() - const vars = state.document.variables - if (!vars || !(name in vars)) return - useHistoryStore.getState().pushState(state.document) - const { [name]: _removed, ...rest } = vars - const activeTheme = getDefaultTheme(state.document.themes) - const newChildren = replaceVariableRefsInTree( - state.document.children, - name, - null, - vars, - activeTheme, - ) - set({ - document: { - ...state.document, - variables: Object.keys(rest).length > 0 ? rest : undefined, - children: newChildren, - }, - isDirty: true, - }) - }, - - renameVariable: (oldName, newName) => { - if (oldName === newName) return - const state = get() - const vars = state.document.variables - if (!vars || !(oldName in vars)) return - useHistoryStore.getState().pushState(state.document) - const def = vars[oldName] - const { [oldName]: _removed, ...rest } = vars - const newVars = { ...rest, [newName]: def } - const activeTheme = getDefaultTheme(state.document.themes) - const newChildren = replaceVariableRefsInTree( - state.document.children, - oldName, - newName, - vars, - activeTheme, - ) - set({ - document: { - ...state.document, - variables: newVars, - children: newChildren, - }, - isDirty: true, - }) - }, - - setThemes: (themes) => { - useHistoryStore.getState().pushState(get().document) - set((s) => ({ - document: { ...s.document, themes }, - isDirty: true, - })) - }, - applyHistoryState: (doc) => set({ document: doc, isDirty: true }), diff --git a/src/stores/history-store.ts b/src/stores/history-store.ts index f87656af9..107477db3 100644 --- a/src/stores/history-store.ts +++ b/src/stores/history-store.ts @@ -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( 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( } }, - 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: [], diff --git a/src/utils/normalize-pen-file.ts b/src/utils/normalize-pen-file.ts index 86979d2df..be229d151 100644 --- a/src/utils/normalize-pen-file.ts +++ b/src/utils/normalize-pen-file.ts @@ -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 + +// Module-level default theme map, set per normalizePenDocument call +let _defaultTheme: Record = {} // --------------------------------------------------------------------------- // 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 = { ...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) + out.stroke = normalizeStroke(out.stroke as Record, vars) } - // effects — pass through (no format changes needed) + // effects + if ('effects' in out && Array.isArray(out.effects)) { + out.effects = normalizeEffects(out.effects as Record[], 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) + const f = normalizeSingleFill(raw as Record, vars) return f ? [f] : [] } @@ -93,6 +114,7 @@ function normalizeFills(raw: unknown): PenFill[] { function normalizeSingleFill( raw: Record, + 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 | 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, + 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[], + 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 } diff --git a/src/variables/replace-refs.ts b/src/variables/replace-refs.ts deleted file mode 100644 index b37c4b427..000000000 --- a/src/variables/replace-refs.ts +++ /dev/null @@ -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, - activeTheme: Record, -): PenNode[] { - const oldToken = `$${oldRef}` - const replacement = newRef ? `$${newRef}` : undefined - - function resolveOrReplace(val: string): string { - if (val !== oldToken) return val - if (replacement) return replacement - const resolved = resolveVariableRef(oldToken, variables, activeTheme) - return typeof resolved === 'string' ? resolved : val - } - - function resolveOrReplaceNumeric(val: string | number): string | number { - if (typeof val !== 'string' || val !== oldToken) return val - if (replacement) return replacement - const resolved = resolveVariableRef(oldToken, variables, activeTheme) - return typeof resolved === 'number' ? resolved : val - } - - function replaceFills(fills: PenFill[] | undefined): PenFill[] | undefined { - if (!fills) return fills - return fills.map((f) => { - if (f.type === 'solid' && f.color === oldToken) { - return { ...f, color: resolveOrReplace(f.color) } - } - if (f.type === 'linear_gradient' || f.type === 'radial_gradient') { - const newStops = f.stops.map((s) => - s.color === oldToken ? { ...s, color: resolveOrReplace(s.color) } : s, - ) - return { ...f, stops: newStops } - } - return f - }) - } - - function replaceInNode(node: PenNode): PenNode { - const out: Record = { ...node } - let changed = false - - // Opacity - if (typeof node.opacity === 'string' && node.opacity === oldToken) { - out.opacity = resolveOrReplaceNumeric(node.opacity) - changed = true - } - - // Gap - if ('gap' in node && (node as unknown as Record).gap === oldToken) { - out.gap = resolveOrReplaceNumeric(oldToken) - changed = true - } - - // Padding - if ('padding' in node && (node as unknown as Record).padding === oldToken) { - out.padding = resolveOrReplaceNumeric(oldToken) - changed = true - } - - // Fill - if ('fill' in node && (node as unknown as Record).fill) { - const fills = (node as unknown as Record).fill as PenFill[] - const newFills = replaceFills(fills) - if (newFills !== fills) { - out.fill = newFills - changed = true - } - } - - // Stroke fill & thickness - if ('stroke' in node && (node as unknown as Record).stroke) { - const stroke = (node as unknown as Record).stroke as Record - const newStroke = { ...stroke } - let strokeChanged = false - if (typeof stroke.thickness === 'string' && stroke.thickness === oldToken) { - newStroke.thickness = resolveOrReplaceNumeric(oldToken) - strokeChanged = true - } - if (stroke.fill) { - const newFill = replaceFills(stroke.fill as PenFill[]) - if (newFill !== stroke.fill) { - newStroke.fill = newFill - strokeChanged = true - } - } - if (strokeChanged) { - out.stroke = newStroke - changed = true - } - } - - // Effects - if ('effects' in node && Array.isArray((node as unknown as Record).effects)) { - const effects = (node as unknown as Record).effects as Record[] - const newEffects = effects.map((e) => { - const ne = { ...e } - let ec = false - if (typeof e.color === 'string' && e.color === oldToken) { - ne.color = resolveOrReplace(e.color as string) - ec = true - } - for (const key of ['blur', 'offsetX', 'offsetY', 'spread']) { - if (typeof e[key] === 'string' && e[key] === oldToken) { - ne[key] = resolveOrReplaceNumeric(oldToken) - ec = true - } - } - return ec ? ne : e - }) - out.effects = newEffects - changed = true - } - - // Text content - if (node.type === 'text' && typeof node.content === 'string' && node.content === oldToken) { - out.content = resolveOrReplace(node.content) - changed = true - } - - // Recurse children - if ('children' in node && (node as unknown as Record).children) { - const children = (node as unknown as Record).children as PenNode[] - out.children = replaceVariableRefsInTree(children, oldRef, newRef, variables, activeTheme) - changed = true - } - - return changed ? (out as unknown as PenNode) : node - } - - return nodes.map(replaceInNode) -} diff --git a/src/variables/resolve-variables.ts b/src/variables/resolve-variables.ts deleted file mode 100644 index d8e7814e8..000000000 --- a/src/variables/resolve-variables.ts +++ /dev/null @@ -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 -type Theme = Record - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Check whether a value is a `$variable` reference string. */ -export function isVariableRef(value: unknown): value is string { - return typeof value === 'string' && value.startsWith('$') -} - -/** Build the default theme map (first value per axis) from PenDocument.themes. */ -export function getDefaultTheme( - themes: Record | undefined, -): Theme { - const result: Theme = {} - if (!themes) return result - for (const [key, values] of Object.entries(themes)) { - if (values.length > 0) result[key] = values[0] - } - return result -} - -// --------------------------------------------------------------------------- -// Core resolution -// --------------------------------------------------------------------------- - -/** Pick the concrete value from a `ThemedValue[]` for the given theme. */ -function resolveThemedValue( - values: ThemedValue[], - activeTheme?: Theme, -): string | number | boolean | undefined { - if (activeTheme && Object.keys(activeTheme).length > 0) { - const match = values.find((v) => { - if (!v.theme) return false - return Object.entries(activeTheme).every( - ([key, expected]) => v.theme?.[key] === expected, - ) - }) - if (match) return match.value - } - return values[0]?.value -} - -/** - * Resolve a single `$variableName` reference to its concrete value. - * Returns `undefined` if the variable does not exist or has an incompatible type. - */ -export function resolveVariableRef( - ref: string, - variables: Vars, - activeTheme?: Theme, -): string | number | boolean | undefined { - if (!ref.startsWith('$')) return undefined - const name = ref.slice(1) - const def = variables[name] - if (!def) return undefined - - const val = def.value - if (Array.isArray(val)) { - const resolved = resolveThemedValue(val, activeTheme) - // Circular guard: if resolved value is also a $ref, stop - if (typeof resolved === 'string' && resolved.startsWith('$')) return undefined - return resolved - } - // Circular guard - if (typeof val === 'string' && val.startsWith('$')) return undefined - return val -} - -/** - * Resolve a color string that may be a `$variable` reference. - * Returns the original string if it's not a ref, or the resolved color. - */ -export function resolveColorRef( - color: string | undefined, - variables: Vars, - activeTheme?: Theme, -): string | undefined { - if (color === undefined) return undefined - if (!isVariableRef(color)) return color - const resolved = resolveVariableRef(color, variables, activeTheme) - return typeof resolved === 'string' ? resolved : undefined -} - -/** - * Resolve a numeric value that may be a `$variable` reference. - * Returns the original number if it's not a ref. - */ -export function resolveNumericRef( - value: unknown, - variables: Vars, - activeTheme?: Theme, -): number | undefined { - if (typeof value === 'number') return value - if (typeof value === 'string') { - if (isVariableRef(value)) { - const resolved = resolveVariableRef(value, variables, activeTheme) - return typeof resolved === 'number' ? resolved : undefined - } - const num = parseFloat(value) - return isNaN(num) ? undefined : num - } - return undefined -} - -// --------------------------------------------------------------------------- -// Fill / stroke / effect resolution -// --------------------------------------------------------------------------- - -function resolveFillsForCanvas( - fills: PenFill[] | undefined, - vars: Vars, - theme?: Theme, -): PenFill[] | undefined { - if (!fills) return fills - return fills.map((fill) => { - if (fill.type === 'solid') { - const color = resolveColorRef(fill.color, vars, theme) - return color !== fill.color ? { ...fill, color: color ?? '#000000' } : fill - } - if (fill.type === 'linear_gradient' || fill.type === 'radial_gradient') { - const newStops = fill.stops.map((stop) => { - const color = resolveColorRef(stop.color, vars, theme) - return color !== stop.color ? { ...stop, color: color ?? '#000000' } : stop - }) - return newStops !== fill.stops ? { ...fill, stops: newStops } : fill - } - return fill - }) -} - -function resolveStrokeForCanvas( - stroke: PenStroke | undefined, - vars: Vars, - theme?: Theme, -): PenStroke | undefined { - if (!stroke) return stroke - let changed = false - const out: Record = { ...stroke } - - // Resolve thickness - if (typeof stroke.thickness === 'string' && isVariableRef(stroke.thickness)) { - out.thickness = resolveNumericRef(stroke.thickness, vars, theme) ?? 1 - changed = true - } - - // Resolve stroke fill colors - if (stroke.fill) { - const resolved = resolveFillsForCanvas(stroke.fill, vars, theme) - if (resolved !== stroke.fill) { - out.fill = resolved - changed = true - } - } - - return changed ? (out as unknown as PenStroke) : stroke -} - -function resolveEffectsForCanvas( - effects: PenEffect[] | undefined, - vars: Vars, - theme?: Theme, -): PenEffect[] | undefined { - if (!effects) return effects - return effects.map((effect) => { - if (effect.type !== 'shadow') return effect - let changed = false - const out: Record = { ...effect } - - if (typeof effect.color === 'string' && isVariableRef(effect.color)) { - out.color = resolveColorRef(effect.color, vars, theme) ?? '#000000' - changed = true - } - for (const key of ['blur', 'offsetX', 'offsetY', 'spread'] as const) { - const val = effect[key] - if (typeof val === 'string' && isVariableRef(val)) { - out[key] = resolveNumericRef(val, vars, theme) ?? 0 - changed = true - } - } - - return changed ? (out as unknown as PenEffect) : effect - }) -} - -// --------------------------------------------------------------------------- -// Full node resolution for canvas rendering -// --------------------------------------------------------------------------- - -/** - * Resolve all `$variable` references in a PenNode, returning a new node - * with concrete values suitable for Fabric.js rendering. - * - * Returns the same object reference when no variables are present. - */ -export function resolveNodeForCanvas( - node: PenNode, - variables: Vars, - activeTheme?: Theme, -): PenNode { - if (!variables || Object.keys(variables).length === 0) return node - - let changed = false - const out: Record = { ...node } - - // Opacity - if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) { - out.opacity = resolveNumericRef(node.opacity, variables, activeTheme) ?? 1 - changed = true - } - - // Gap - if ('gap' in node && typeof (node as unknown as Record).gap === 'string') { - const gap = (node as unknown as Record).gap as string - if (isVariableRef(gap)) { - out.gap = resolveNumericRef(gap, variables, activeTheme) ?? 0 - changed = true - } - } - - // Padding - if ('padding' in node) { - const padding = (node as unknown as Record).padding - if (typeof padding === 'string' && isVariableRef(padding)) { - out.padding = resolveNumericRef(padding, variables, activeTheme) ?? 0 - changed = true - } - } - - // Fill - if ('fill' in node && (node as unknown as Record).fill) { - const fills = (node as unknown as Record).fill as PenFill[] - const resolved = resolveFillsForCanvas(fills, variables, activeTheme) - if (resolved !== fills) { - out.fill = resolved - changed = true - } - } - - // Stroke - if ('stroke' in node && (node as unknown as Record).stroke) { - const stroke = (node as unknown as Record).stroke as PenStroke - const resolved = resolveStrokeForCanvas(stroke, variables, activeTheme) - if (resolved !== stroke) { - out.stroke = resolved - changed = true - } - } - - // Effects - if ('effects' in node && (node as unknown as Record).effects) { - const effects = (node as unknown as Record).effects as PenEffect[] - const resolved = resolveEffectsForCanvas(effects, variables, activeTheme) - if (resolved !== effects) { - out.effects = resolved - changed = true - } - } - - // Text content - if (node.type === 'text' && typeof node.content === 'string' && isVariableRef(node.content)) { - const resolved = resolveVariableRef(node.content, variables, activeTheme) - if (typeof resolved === 'string') { - out.content = resolved - changed = true - } - } - - return changed ? (out as unknown as PenNode) : node -}