* perf(canvas): bitmap cache during zoom/pan, fix save for large files

- Add canvas-zoom-cache: captures pixel snapshot on first viewport change,
  draws cached bitmap with transform delta (~0.1ms vs ~15ms per frame).
  Refreshes snapshot every 200ms during continuous interaction.
- Integrate zoom cache into wheel zoom and space+drag panning.
- Set renderOnAddRemove: false on Fabric canvas to avoid per-object renders.
- Remove depth/size LOD from viewport culling (zoom cache handles perf).
- Rewrite syncCanvasPositionsToStore: single tree walk + single store set
  instead of per-object updateNode (was O(n²) with 200+ history pushes).
- Unify save/save-as: .op files save in-place, non-.op triggers save-as.
- Add Electron filePath storage for reliable native IPC saves.
- Add error handling with fallback for all save paths.

* feat(canvas): integrate CanvasKit for enhanced rendering and layout

- Added support for CanvasKit WASM to improve rendering performance and capabilities.
- Introduced a new SkiaCanvas component for rendering using CanvasKit.
- Implemented spatial indexing with RBush for efficient hit testing in the Skia engine.
- Enhanced text measurement and layout handling using Canvas 2D for accurate word wrapping.
- Updated layout engine to accommodate badge and overlay nodes without affecting layout flow.
- Bumped version from 0.3.3 to 0.3.4 to reflect these significant changes.

* fix(electron): add graceful-fs to devDependencies for Node.js v25 compat (#38)

* feat(figma): enhance instance override handling and derived data processing

- Added support for symbol tree context in instance conversion.
- Improved the application of instance overrides by filtering derived data to exclude nested instances.
- Implemented a two-strategy approach for resolving overrides and derived data, accommodating both direct index mapping and expanded DFS for nested instances.
- Updated FigmaSymbolOverride interface to extend FigmaNodeChange, enhancing the handling of overridable node properties.

* refactor(canvas): remove loading state and enhance error handling in SkiaCanvas

- Eliminated the loading state management from SkiaCanvas, simplifying the component.
- Updated error handling to directly display error messages without loading indicators.
- Adjusted the EditorLayout to directly render SkiaCanvas without suspense, improving performance.
- Introduced drag-and-drop file handling in the canvas store for better user experience.
- Enhanced Figma import dialog to auto-process pending files from drag-and-drop.

* fix(ai): improve error messages for API authentication and connection issues

- Updated error hints in chat.ts to provide clearer instructions for API authentication, including running "claude login" or setting the ANTHROPIC_API_KEY in settings.json.
- Enhanced friendly error messages in connect-agent.ts to guide users on authentication steps when encountering connection errors.
- Refactored resolve-claude-agent-env.ts to improve environment variable normalization, allowing for better handling of ANTHROPIC_CUSTOM_HEADERS and ensuring proper serialization of object values.

* refactor(env): streamline environment variable reading and enhance settings handling

- Renamed and refactored the function for reading Claude settings to improve clarity and maintainability.
- Introduced a new function to read settings from both `settings.json` and `settings.local.json`, ensuring local settings take precedence.
- Updated test setup to use the system's temporary directory for security tests, enhancing compatibility across environments.

* feat(ai): add fallback models for third-party API proxies in connect-agent

- Introduced FALLBACK_CLAUDE_MODELS to provide default model options when supportedModels() fails, enhancing connectivity with third-party API proxies.
- Updated error handling in connectClaudeCode to return fallback models on specific connection errors, ensuring users can still connect and select a model.

* chore: bump version from 0.3.4 to 0.4.0 in package.json

* feat(ai): enhance prompt handling for basic-tier models

- Introduced logic to inline system prompts into user messages for basic-tier models (e.g., MiniMax, GLM) to ensure proper instruction visibility.
- Updated design modification and orchestrator functions to accommodate this change, improving interaction with third-party routers.
- Added a utility function to strip non-standard XML-like tags from AI responses, enhancing JSON extraction reliability.

* fix(server): add ESM-compatible __dirname polyfill (#42)

- Add fileURLToPath/dirname polyfill to mcp-install.ts
- Add fileURLToPath/dirname polyfill to mcp-server-manager.ts

Fixes browser version crash with "__dirname is not defined"
Fixes MCP over HTTP transport failure since v3.3

Closes #37

* fix(server): make MCP HTTP server survive parent process lifecycle (#43)

## Problem
The MCP HTTP server process died when:
1. User closed the Settings/Agent dialog in the UI
2. User interacted with the editor canvas
3. Nitro server hot-reloaded or restarted
4. Electron app sent SIGTERM to Nitro on window close

This made the MCP server unusable for HTTP transport, requiring users
to keep the settings dialog open constantly.

## Root Cause Analysis
The MCP server was spawned as a regular child process without detached
mode. This created several fatal dependencies:

1. **Signal propagation**: Lines 29-34 registered handlers for SIGINT,
   SIGTERM, SIGHUP that killed the MCP process when the parent received
   these signals (e.g., Electron closing Nitro)

2. **Parent-child lifecycle binding**: Without detached mode, the child
   process is tied to the parent's event loop. Any parent instability
   (hot reload, dialog closure causing microtasks, etc.) could affect
   the child

3. **In-memory process tracking**: The mcpProcess variable was stored
   in module memory, so Nitro restarts/hot-reloads would lose track of
   the running server

## Solution
1. **Detached spawn mode**: Use `detached: true` + `unref()` to make
   the MCP process completely independent of the parent

2. **PID file persistence**: Track the process via files in /tmp
   instead of in-memory variables, surviving parent restarts

3. **Remove signal handlers**: Delete the SIGINT/SIGTERM/SIGHUP
   handlers that were killing the MCP process

4. **Cross-platform kill**: Use process.kill() instead of execSync
   with taskkill for safer process termination

## Testing
### Test Case 1: Settings Dialog Close
- Open OpenPencil → Settings → Start MCP HTTP Server
- Close settings dialog
- Reopen settings → MCP server still running 

### Test Case 2: Editor Interaction
- Start MCP HTTP Server
- Draw on canvas, create shapes, use all tools
- Check MCP server status → Still running 

### Test Case 3: Browser Version
- Run `npx vite --port 3000`
- Start MCP from browser UI
- Close tab, reopen → Server still running 

### Test Case 4: Electron App
- Start MCP from installed app
- Close app window
- Reopen app → Server still running (tracked via PID file) 

### Test Case 5: Stop/Restart Cycle
- Start → Stop → Start again
- Works correctly 

## Files Changed
- server/utils/mcp-server-manager.ts - Complete rewrite with detached mode
- server/api/ai/mcp-install.ts - Add ESM __dirname polyfill

Fixes #37

Co-authored-by: Kayshen Xu <kayshen.xu@gmail.com>

* fix(canvas): respect node-level theme overrides during variable resolution (#41)

Previously, resolveNodeForCanvas was called with a single activeTheme
for all nodes, ignoring any per-node theme property. This meant nodes
with theme: {"Mode": "Light"} would still render using Dark mode colors.

This fix merges each node's theme property with the default activeTheme,
allowing individual nodes to override theme axes for proper themed
variable resolution.

Fixes design documents where some panels need different theme variants
than the default (e.g., Light mode components in a Dark mode document).

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Kayshen Xu <kayshen.xu@gmail.com>

* feat(ai): enhance text processing by stripping tool call blocks

- Added a new function to strip fake tool call blocks emitted by basic-tier models, ensuring cleaner JSON extraction.
- Updated the existing text processing flow to incorporate this new function, improving the handling of non-standard model artifacts.
- Modified orchestrator prompts to disallow function calls and tool call syntax in JSON outputs, enhancing response consistency.

* fix(server): resolve merge conflict and clean up ESM __dirname polyfill

- Removed merge conflict markers from mcp-install.ts and mcp-server-manager.ts.
- Ensured consistent implementation of ESM-compatible __dirname polyfill across both files.

* feat(ai): add node availability check and HTTP fallback for MCP server installation

- Implemented a function to check for the availability of Node.js on the system, enhancing compatibility for environments without Node.js.
- Added a fallback mechanism to install the MCP server using an HTTP URL when Node.js is not found, ensuring functionality remains intact.
- Updated the installation response to include a flag indicating if the HTTP fallback was used, allowing the UI to reflect the server status accurately.
- Enhanced the agent settings dialog to synchronize the MCP server status when the HTTP server is auto-started.

* refactor(canvas): replace Fabric.js with CanvasKit/Skia for enhanced rendering performance

- Removed Fabric.js dependencies and related code, transitioning to CanvasKit/Skia as the primary canvas engine.
- Updated documentation and README files to reflect the new canvas technology.
- Adjusted data flow and component interactions to accommodate the new rendering engine.
- Ensured backward compatibility by retaining legacy Fabric.js files for specific utilities until full removal is feasible.

* refactor(canvas): update layout engine and remove Fabric.js dependencies

- Enhanced the canvas layout engine by integrating Skia and CanvasKit, replacing Fabric.js references.
- Improved type safety by refining type assertions and imports across various components.
- Added a new function to retrieve canvas dimensions, defaulting to 800x600 if no engine is mounted.
- Removed obsolete rendering logic related to Fabric.js, streamlining the layout indicator functionality.
- Updated multiple components to utilize the new canvas size retrieval method, ensuring consistent behavior across the application.

* fix(figma): resolve multiple .fig import rendering issues

- Fix layout properties in preserve mode: only apply auto-layout (gap,
  padding, justify, align) for frames with stackMode, use absolute
  positioning for non-auto-layout frames
- Reverse children order for auto-layout frames in preserve mode to
  match Figma's layout flow order (tree builder sorts descending for
  z-stacking but layout needs ascending)
- Fix instance inlining: always inline symbol content when instance has
  no local children, regardless of override/derived data presence
- Fix derived data mapping: use direct GUID matching (Strategy 0) when
  derived guidPath GUIDs are actual symbol node GUIDs, preventing the
  off-by-one size/transform misassignment from index-based mapping
- Add styleIdForFill/styleIdForStrokeFill resolution: resolve fill style
  references to inline paints before tree building, fixing missing fills
  on 448+ nodes including active menu items and styled elements
- Fix collectImageBlobs to detect JPEG/GIF/WebP in addition to PNG
- Fix instance override application: apply overrides even when derived
  data is absent

* fix(figma): skip opacity=0 nodes during import to fix breadcrumb visibility

Nodes with opacity <= 0 are now skipped in convertChildren, along with
all their descendants.  This fixes the breadcrumb "Page / Page / Page"
text showing through despite the parent items having opacity=0, since
the Skia renderer does not propagate parent opacity to child nodes.

* fix(figma): resolve styleIdForFill in instance override entries

Style references (styleIdForFill, styleIdForStrokeFill) inside
symbolOverrides were not being resolved to inline paints.  This caused
text nodes in overridden instances (e.g. "All Products" card on blue
background) to retain the symbol's default colors instead of the
instance-specific white fills.

The resolveStyleReferences pre-processing step now iterates into each
node's symbolData.symbolOverrides array and resolves style references
there as well.

* fix(figma): apply icon colors and fix arc rendering in .fig import

- Fix donut chart missing segment: swap start/end angles when
  endingAngle < startingAngle instead of adding 2π, producing correct
  clockwise arc equivalents for counter-clockwise Figma arcs

- Fix icon stroke thickness: scale strokeWeight proportionally in
  scaleTreeChildren, applyInstanceOverrides derived sizing, and
  convertVector for Lucide icons rendered smaller than 24×24

- Fix nested instance color overrides: build nestedOverrideMap from
  multi-guid override paths (e.g. instanceGuid/childGuid) and inject
  child-scoped overrides into nested INSTANCE symbolOverrides, enabling
  Dashboard Summary Card icons to receive white/blue stroke colors

- Fix override-only instances: when derivedSymbolData is empty but
  symbolOverrides exist (e.g. sidebar icon instances with stroke color
  overrides but no size changes), fall through to direct GUID matching
  so stroke paints are correctly applied

- Include strokePaints in hasVisualOverrides check so instances with
  stroke-only overrides are properly inlined with their symbol children

* feat(canvas): add vector text rendering with bundled fonts for Figma import

Replace bitmap text fallback with CanvasKit Paragraph API for true vector
text rendering. Bundle 11 popular font families locally via @fontsource
packages to ensure reliable rendering in regions where Google Fonts CDN
is unavailable. Key changes:

- SkiaFontManager: local-first font loading with Google Fonts fallback
- Paragraph API rendering with caching, styled segments, and alignment
- Fixed-width text tolerance to prevent unwanted line wrapping from font
  metric differences between Figma and CanvasKit
- Auto-width text manual alignment offset (center/right) to avoid
  infinite layout width issues
- Figma text mapper: support RAW lineHeight units
- Figma import dialog: pre-load fonts after conversion

* fix(canvas): align text centering with CanvasKit and improve font fallback

- Replace Fabric.js FONT_SIZE_MULT (1.13) with actual paragraph height
  (fontSize * lineHeight) for cross-axis text centering, fixing icon
  vertical misalignment in horizontal layouts
- Remove Fabric-specific optical correction (getTextOpticalCenterYOffset)
  since CanvasKit halfLeading handles this correctly
- Add font fallback chain with separate Inter Ext family for latin-ext
  glyph coverage (₦ U+20A6 etc.)
- Cache failed font loads to prevent repeated fetch attempts per frame
- Skip Google Fonts requests for known system/proprietary fonts
  (PingFang SC, Microsoft YaHei, D-DIN-PRO, etc.)

* fix(figma): recursive nested instance expansion and CJK font support

- Fix 3 bugs in applyInstanceOverrides() that broke deeply nested Figma
  component instances (6+ levels): propagate multi-guid derivedSymbolData
  to nested instances, resolve virtual GUIDs via pkToNodeGuid map, and
  build nestedDerivedMap alongside nestedOverrideMap for recursive expansion
- Bundle Noto Sans SC (chinese-simplified + latin subsets, 400/700 weights)
  for offline CJK vector text rendering without Google Fonts dependency
- Add Noto Sans SC to font fallback chain for CJK glyph coverage
- Add China CDN mirror (fonts.font.im) as fallback when Google Fonts is
  inaccessible, with 4s timeout on primary CDN
- Increase .fig file size limits to 150MB compressed / 300MB decompressed

* fix(canvas): enhance CJK font support and pre-load Noto Sans SC

- Update SkiaEngine to pre-load Noto Sans SC alongside Inter for improved CJK glyph coverage in the font fallback chain, preventing rendering issues with system fonts.
- Modify FigmaImportDialog to ensure Noto Sans SC is included when primary fonts are system fonts, ensuring proper rendering of CJK text.

* refactor(figma): optimize instance override handling with full DFS strategy

- Replace hybrid skip-INSTANCE and expanded DFS with a unified full DFS approach for handling all node types, including INSTANCE nodes.
- Update the mapping logic to ensure correct localID assignments and improve the handling of overflow entries during instance expansion.
- Enhance the overall structure and readability of the applyInstanceOverrides function by consolidating the traversal methods.

* refactor(figma): improve layout mapping and instance override logic

- Enhance the mapFigmaLayout function to conditionally set gap based on stackSpacing and justifyContent, ensuring compatibility with Figma's layout behavior.
- Simplify the applyInstanceOverrides function by removing redundant logic and focusing on a unified full DFS approach for node mapping, improving clarity and performance.
- Ensure derivedSymbolData is correctly replaced with nested data from outer instances, preventing incorrect merging of entries.

* refactor(figma): enhance instance override logic with root-inclusive DFS

- Introduce a root-inclusive depth-first search (DFS) to accurately detect and skip overrides targeting the symbol root during instance processing.
- Update the applyInstanceOverrides function to improve clarity and performance by refining the handling of derived data and overrides.
- Ensure that overrides targeting the root are excluded from child nodes, preventing incorrect application of overrides in nested instances.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Related8919 <191752213+Related8919@users.noreply.github.com>
Co-authored-by: Hrijul Dey <44521405+hr1juldey@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Kayshen Xu 2026-03-15 10:01:35 +08:00 committed by GitHub
parent c1587b1a55
commit 90bbcb16fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
164 changed files with 6989 additions and 7653 deletions

179
CLAUDE.md
View file

@ -19,7 +19,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
OpenPencil is an open-source vector design tool (alternative to Pencil.dev) with a Design-as-Code philosophy. Built as a **TanStack Start** full-stack React application with Bun runtime. Server API powered by **Nitro**. Also ships as an **Electron** desktop app for macOS, Windows, and Linux.
**Key technologies:** React 19, Fabric.js v7 (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), TypeScript (strict mode).
**Key technologies:** React 19, CanvasKit/Skia WASM (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), TypeScript (strict mode).
### Data Flow
@ -35,14 +35,15 @@ React Components (Toolbar, LayerPanel, PropertyPanel)
└────────┬────────┘ └────────┬──────────┘
│ │
▼ ▼
Fabric.js Canvas canvas-sync-lock
(imperative render) (prevents circular sync)
CanvasKit/Skia canvas-sync-lock
(GPU-accelerated (prevents circular sync)
WASM renderer)
```
- **document-store** is the single source of truth. Fabric.js only renders.
- User edits on canvas → Fabric events → update document-store (with sync lock)
- 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
- **document-store** is the single source of truth. CanvasKit only renders.
- User edits on canvas → SkiaEngine events → update document-store
- User edits in panels → update document-store → SkiaEngine `syncFromDocument()` re-renders
- `canvas-sync-lock.ts` prevents circular updates when canvas events write to the store
### Multi-Page Architecture
@ -74,7 +75,7 @@ PenDocument (source of truth)
- **`$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
- `resolveNodeForCanvas()` resolves `$refs` on-the-fly before CanvasKit 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[]`)
@ -118,41 +119,45 @@ design_refine(rootId) → Full-tree validation + auto-fixes
### Key Modules
- **`src/canvas/`** — Fabric.js integration (30 files):
- `fabric-canvas.tsx` — Canvas component initialization
- `use-fabric-canvas.ts` — Canvas initialization hook
- `canvas-object-factory.ts` — Creates Fabric objects from PenNodes (rect, ellipse, line, polygon, path, text, image, frame, group)
- `canvas-object-sync.ts` — Syncs individual object properties between Fabric and store
- `canvas-sync-lock.ts` — Prevents circular sync loops
- `canvas-sync-utils.ts``forcePageResync()` utility for page-aware canvas re-sync
- `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
- `canvas-node-creator.ts``createNodeForTool`, `isDrawingTool`, `toScene` helpers extracted from use-canvas-events
- `canvas-object-modified.ts``syncObjToStore`, `syncSelectionToStore`, `handleObjectModified` — Fabric `object:modified` handler logic
- `use-canvas-sync.ts` — Bidirectional PenDocument ↔ Fabric.js sync, node flattening with parent offsets, variable resolution via `resolveNodeForCanvas()`
- `canvas-layout-engine.ts` — Auto-layout computation: `resolvePadding`, `getNodeWidth/Height`, `computeLayoutPositions`, `Padding` interface
- `canvas-text-measure.ts` — Text width/height estimation, CJK detection, `parseSizing`, `getTextOpticalCenterYOffset`
- `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-hover.ts` — Hover state management for objects
- `use-canvas-guides.ts` — Smart alignment guides with snapping
- `guide-utils.ts` — Guide calculation and rendering
- `pen-tool.ts` — Bezier pen tool: anchor points, control handles, path closure, preview rendering
- `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
- `use-entered-frame-overlay.ts` — Visual overlay when entering a frame for editing
- `use-layout-indicator.ts` — Layout indicator rendering during drag operations
- `insertion-indicator.ts` — Insertion point indicator for layout drop targets
- `drag-into-layout.ts` — Drag-and-drop into auto-layout frames with insertion detection
- `drag-reparent.ts` — Reparenting nodes during drag operations
- `layout-reorder.ts` — Reorder children within layout frames during drag
- `selection-context.ts` — Selection context management for multi-select operations
- **`src/canvas/`** — Canvas engine (33 files + `skia/` subdir with 11 files):
- **`skia/`** — CanvasKit/Skia WASM renderer (primary canvas engine):
- `skia-canvas.tsx` — Main canvas React component (SkiaCanvas), mouse/keyboard event handling, drawing tools, select/drag/resize/rotate, marquee, pen tool, text editing overlay
- `skia-engine.ts` — Core rendering engine: `SkiaEngine` class, `syncFromDocument()`, viewport transform, node flattening with layout resolution, `SpatialIndex` integration, zoom/pan, dirty-flag rendering loop
- `skia-renderer.ts` — GPU-accelerated draw calls: rectangles, ellipses, text, paths, images, frames, groups, selection handles, guides, agent indicators
- `skia-init.ts` — CanvasKit WASM loader with CDN fallback
- `skia-hit-test.ts``SpatialIndex` for spatial queries: `hitTest()`, `searchRect()`, R-tree backed
- `skia-viewport.ts` — Viewport math: `screenToScene`, `sceneToScreen`, `viewportMatrix`, `zoomToPoint`
- `skia-paint-utils.ts` — Color parsing, gradient creation, text line wrapping for CanvasKit
- `skia-path-utils.ts` — SVG path `d` string to CanvasKit Path conversion
- `skia-image-loader.ts` — Async image loading and caching for CanvasKit
- `skia-overlays.ts` — Selection overlays, hover highlights, dimension labels
- `skia-pen-tool.ts` — Pen tool implementation for Skia: anchor points, control handles, path building
- **Legacy Fabric.js files** (retained for `zoomToFitContent` utility, pending removal):
- `fabric-canvas.tsx`, `use-fabric-canvas.ts`, `canvas-object-factory.ts`, `canvas-object-sync.ts`, `canvas-object-modified.ts`, `canvas-controls.ts`
- **Shared modules** (used by both Skia and legacy code):
- `canvas-sync-lock.ts` — Prevents circular sync loops
- `canvas-sync-utils.ts``forcePageResync()` utility for page-aware canvas re-sync
- `canvas-constants.ts` — Default colors, zoom limits, stroke widths
- `canvas-node-creator.ts``createNodeForTool`, `isDrawingTool` helpers
- `canvas-layout-engine.ts` — Auto-layout computation: `resolvePadding`, `getNodeWidth/Height`, `computeLayoutPositions`, `Padding` interface
- `canvas-text-measure.ts` — Text width/height estimation, CJK detection, `parseSizing`
- `canvas-zoom-cache.ts` — Bitmap caching during zoom/pan for performance
- `use-canvas-sync.ts` — PenDocument ↔ canvas sync, node flattening, variable resolution via `resolveNodeForCanvas()`
- `use-canvas-events.ts` — Drawing events, shape creation
- `use-canvas-viewport.ts` — Wheel zoom, space+drag panning, tool cursor switching
- `use-canvas-selection.ts` — Selection sync between canvas and store
- `use-canvas-hover.ts` — Hover state management
- `use-canvas-guides.ts` / `guide-utils.ts` — Smart alignment guides with snapping
- `pen-tool.ts` — Bezier pen tool (Fabric-based, Skia version in `skia/skia-pen-tool.ts`)
- `parent-child-transform.ts` — Propagates parent transforms to children proportionally
- `use-dimension-label.ts` / `use-frame-labels.ts` / `use-entered-frame-overlay.ts` / `use-layout-indicator.ts` — Visual overlays
- `insertion-indicator.ts` / `drag-into-layout.ts` / `drag-reparent.ts` / `layout-reorder.ts` — Drag-and-drop support
- `selection-context.ts` — Multi-select context management
- `agent-indicator.ts` / `use-agent-indicators.ts` — Agent visual indicators on canvas during concurrent AI generation
- **`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 (8 files):
- **`src/stores/`** — Zustand stores (9 files):
- `canvas-store.ts` — UI/tool/selection/viewport/clipboard/interaction state, `variablesPanelOpen` toggle, `activePageId`, `figmaImportDialogOpen`
- `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)
- `document-store-pages.ts` — Page actions extracted from document-store: `addPage`, `removePage`, `renamePage`, `reorderPage`, `duplicatePage`
@ -161,7 +166,8 @@ design_refine(rootId) → Full-tree validation + auto-fixes
- `ai-store.ts` — Chat messages, streaming state, generated code, model selection, `pendingAttachments` for image uploads
- `agent-settings-store.ts` — AI provider config (Anthropic/OpenAI/OpenCode/Copilot), MCP CLI integrations (Claude Code, Codex CLI, Gemini CLI, OpenCode CLI, Kiro CLI, Copilot CLI), localStorage persistence
- `uikit-store.ts` — UIKit management: imported kits, component browser state (search, category filters), localStorage persistence
- **`src/types/`** — Type system (8 files):
- `theme-preset-store.ts` — Theme preset management and persistence
- **`src/types/`** — Type system (9 files):
- `pen.ts` — PenDocument/PenNode (frame, group, rectangle, ellipse, line, polygon, path, text, image, ref), ContainerProps, `PenPage`; `PenDocument.variables`, `PenDocument.themes`, `PenDocument.pages`
- `canvas.ts` — ToolType (select, frame, rectangle, ellipse, line, polygon, path, text, hand), ViewportState, SelectionState, CanvasInteraction
- `styles.ts` — PenFill (solid, linear_gradient, radial_gradient), PenStroke, PenEffect (shadow, blur), BlendMode, StyledTextSegment
@ -169,9 +175,10 @@ design_refine(rootId) → Full-tree validation + auto-fixes
- `uikit.ts` — UIKit, KitComponent, ComponentCategory types for reusable component organization and browsing
- `agent-settings.ts` — AI provider config types (`AIProviderType`: anthropic/openai/opencode/copilot, `AIProviderConfig`, `MCPCliIntegration`, `GroupedModel`)
- `electron.d.ts` — Electron IPC bridge types (file dialogs, save operations, updater: `UpdaterState`/`UpdaterStatus`, `getState`/`checkForUpdates`/`quitAndInstall`/`onStateChange`)
- `theme-preset.ts` — Type definitions for theme presets
- `opencode-sdk.d.ts` — Type declarations for @opencode-ai/sdk
- **`src/components/editor/`** — Editor UI (9 files): editor-layout, toolbar (with variables panel toggle), boolean-toolbar (contextual floating toolbar for union/subtract/intersect, shown when 2+ compatible shapes selected), tool-button, shape-tool-dropdown (rectangle/ellipse/line/path + icon picker + image import), top-bar (with `AgentStatusButton`), status-bar, page-tabs (multi-page navigation with context menu), update-ready-banner (Electron auto-updater notification)
- **`src/components/panels/`** — Panels (26 files):
- **`src/components/panels/`** — Panels (29 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
@ -189,24 +196,39 @@ design_refine(rootId) → Full-tree validation + auto-fixes
- `ai-chat-panel.tsx` / `chat-message.tsx` — AI chat with markdown, design block collapse, apply design, image attachment upload (paperclip button, preview strip, 5MB/4-image limit)
- `ai-chat-handlers.ts``useChatHandlers` hook, `isDesignRequest`, `buildContextString` helpers extracted from ai-chat-panel
- `ai-chat-checklist.tsx``FixedChecklist` component for AI generation progress display
- `code-panel.tsx` — Code generation output (React/Tailwind, HTML/CSS, CSS Variables)
- `code-panel.tsx` — Code generation output (React/Tailwind, HTML/CSS, CSS Variables, Vue, Svelte, Flutter, SwiftUI, Compose, React Native)
- `image-section.tsx` — Image property panel section
- `node-preview-svg.tsx` — Node SVG preview component
- `right-panel.tsx` — Right panel container
- `component-browser-panel.tsx` / `component-browser-grid.tsx` / `component-browser-card.tsx` — Resizable floating panel for browsing, importing, and inserting UIKit components with category tabs and search
- `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, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog, VariablePicker, FigmaImportDialog
- **`src/components/shared/`** — Reusable UI (10 files): ColorPicker, NumberInput, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog, VariablePicker, FigmaImportDialog, LanguageSelector
- **`src/components/icons/`** — Provider/brand logos: ClaudeLogo, OpenAILogo, OpenCodeLogo, CopilotLogo, FigmaLogo
- **`src/components/ui/`** — shadcn/ui primitives: Button, Select, Separator, Slider, Switch, Toggle, Tooltip
- **`src/services/ai/`** — AI services (20 files):
- **`src/services/ai/`** — AI services (31 files + `role-definitions/` subdir with 9 files):
- `ai-service.ts` — Main AI chat API wrapper, model negotiation, provider selection
- `ai-prompts.ts` — System prompts for design generation, context building
- `ai-types.ts``ChatMessage` (with `attachments?: ChatAttachment[]`), `ChatAttachment` (id, name, mediaType, data, size), `AIDesignRequest`, `OrchestratorPlan`, streaming response types
- `ai-runtime-config.ts` — Configuration constants for AI timeouts, thinking modes, effort levels, prompt length limits
- `model-profiles.ts` — Model capability profiles: adapts thinking mode, effort, timeouts, prompt complexity per model tier (full/standard/basic)
- `agent-identity.ts` — Agent identity assignment (color, name) for concurrent multi-agent generation
- `design-generator.ts` — Top-level `generateDesign`/`generateDesignModification` with orchestrator fallback, re-exports from design-parser and design-canvas-ops
- `design-parser.ts` — Pure JSON/JSONL parsing: `extractJsonFromResponse`, `extractStreamingNodes`, `parseJsonlToTree`, node validation and scoring
- `design-canvas-ops.ts` — Canvas mutation operations: `insertStreamingNode`, `applyNodesToCanvas`, `upsertNodesToCanvas`, `animateNodesToCanvas`, generation state management, sanitization and heuristics
- `design-node-sanitization.ts` — Node cloning and merging utilities: `deepCloneNode`, `setNodeChildren`, `mergeNodeForProgressiveUpsert` (extracted from design-canvas-ops)
- `design-animation.ts` — Fade-in animation coordination for generated design nodes
- `design-validation.ts` — Post-generation screenshot validation using vision API to detect and auto-fix visual issues
- `design-validation-fixes.ts` — Auto-fix strategies for validation-detected issues
- `design-pre-validation.ts` — Pre-validation heuristics before vision API call
- `design-screenshot.ts` — Canvas screenshot capture for validation pipeline
- `design-type-presets.ts` — Design type detection and section presets (landing page, dashboard, mobile, etc.)
- `design-code-generator.ts` — AI-powered code generation from design nodes
- `design-code-prompts.ts` — Prompts for design-to-code generation
- `design-system-generator.ts` — Design system token extraction from generated designs
- `design-system-prompts.ts` — Prompts for design system generation
- `html-renderer.ts` — PenNode tree to HTML rendering for validation screenshots
- `visual-ref-orchestrator.ts` — Visual reference-based orchestration for image-guided generation
- `generation-utils.ts` — Pure utilities for text measurement, size/padding parsing, phone placeholder generation, color extraction
- `icon-resolver.ts` — Auto-resolves AI-generated icon path nodes by name to verified Lucide SVG paths
- `role-resolver.ts` — Registry-based system for applying role-specific defaults (button padding, card gaps) and tree post-pass fixes
@ -217,7 +239,7 @@ design_refine(rootId) → Full-tree validation + auto-fixes
- `orchestrator-prompts.ts` — Ultra-lightweight orchestrator prompt for spatial decomposition
- `orchestrator-prompt-optimizer.ts` — Prompt preparation, compression, timeout calculation, fallback plan generation
- `context-optimizer.ts` — Chat history trimming, sliding window to prevent unbounded context growth
- **`src/services/figma/`** — Figma `.fig` file import pipeline (11 files):
- **`src/services/figma/`** — Figma `.fig` file import pipeline (14 files):
- `fig-parser.ts` — Binary `.fig` file parser
- `figma-types.ts` — Figma internal type definitions
- `figma-node-mapper.ts` — Maps Figma nodes to PenNodes
@ -229,10 +251,25 @@ design_refine(rootId) → Full-tree validation + auto-fixes
- `figma-vector-decoder.ts` — Decodes Figma vector geometry
- `figma-color-utils.ts` — Color space conversion utilities
- `figma-image-resolver.ts` — Resolves image blob references
- **`src/services/codegen/`** — React+Tailwind and HTML+CSS code generators (output `var(--name)` for `$variable` refs), CSS variables generator
- **`src/hooks/`** — Hooks (2 files):
- `figma-clipboard.ts` — Figma clipboard paste handling
- `figma-node-converters.ts` — Figma node conversion utilities
- `figma-tree-builder.ts` — Figma document tree building
- **`src/services/codegen/`** — Multi-platform code generators (9 files, output `var(--name)` for `$variable` refs):
- `react-generator.ts` — React + Tailwind CSS
- `html-generator.ts` — HTML + CSS
- `css-variables-generator.ts` — CSS Variables from design tokens
- `vue-generator.ts` — Vue 3 + CSS
- `svelte-generator.ts` — Svelte + CSS
- `flutter-generator.ts` — Flutter/Dart
- `swiftui-generator.ts` — SwiftUI
- `compose-generator.ts` — Android Jetpack Compose
- `react-native-generator.ts` — React Native
- **`src/hooks/`** — Hooks (5 files):
- `use-keyboard-shortcuts.ts` — Global keyboard event handling: tools, clipboard, undo/redo, save, select all, delete, arrow nudge, z-order, boolean operations (Cmd+Alt+U/S/I)
- `use-electron-menu.ts` — Electron native menu IPC listener: dispatches menu actions (new, open, save, save-as, undo, redo, etc.) to Zustand stores; also handles `onOpenFile` for `.op` file association
- `use-figma-paste.ts` — Handle Figma clipboard paste into canvas
- `use-file-drop.ts` — Handle file drag-and-drop onto canvas
- `use-mcp-sync.ts` — MCP live canvas synchronization hook
- **`src/lib/`** — Utility functions (`utils.ts` with `cn()` for class merging)
- **`src/uikit/`** — UI kit system (3 files + `kits/` subdir):
- `built-in-registry.ts` — Default built-in UIKit with standard UI components
@ -243,37 +280,37 @@ design_refine(rootId) → Full-tree validation + auto-fixes
- `server.ts` — MCP server entry point, tool registration (stdio + HTTP modes)
- `document-manager.ts` — MCP utility for reading, writing, and caching PenDocuments from disk; live canvas sync via Nitro API
- `tools/` — Individual MCP tool implementations:
- Core: `open-document.ts`, `batch-get.ts`, `batch-design.ts` (DSL operations), `node-crud.ts` (insert/update/delete/move/copy/replace)
- Core: `open-document.ts`, `batch-get.ts`, `get-selection.ts`, `batch-design.ts` (DSL operations), `node-crud.ts` (insert/update/delete/move/copy/replace)
- Layout: `snapshot-layout.ts`, `find-empty-space.ts`, `import-svg.ts`
- Variables: `variables.ts`, `theme-presets.ts`
- Pages: `pages.ts` (add/remove/rename/reorder/duplicate)
- Layered design: `design-prompt.ts` (segmented retrieval), `design-skeleton.ts`, `design-content.ts`, `design-refine.ts`, `layered-design-defs.ts`
- `utils/` — Shared utilities: `id.ts`, `node-operations.ts` (page-aware `getDocChildren`/`setDocChildren`), `sanitize.ts`
- **`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, boolean operations (union/subtract/intersect via Paper.js)
- **`server/api/ai/`** — Nitro server API (7 files): `chat.ts` (streaming SSE with thinking state, multimodal image attachments per provider), `generate.ts` (non-streaming generation), `connect-agent.ts` (Claude Code/Codex CLI/OpenCode/Copilot connection), `models.ts` (model definitions), `validate.ts` (vision-based post-generation validation), `mcp-install.ts` (MCP server install/uninstall into CLI tool configs), `icon.ts` (icon name → SVG path resolution via local Iconify sets). Supports Anthropic API key or Claude Agent SDK (local OAuth) as dual providers
- **`server/utils/`** — Server utilities (5 files):
- `utils/` — Shared utilities: `id.ts`, `node-operations.ts` (page-aware `getDocChildren`/`setDocChildren`), `sanitize.ts`, `svg-node-parser.ts`
- **`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, boolean operations (union/subtract/intersect via Paper.js), `app-storage.ts` (application storage/persistence), `arc-path.ts` (SVG arc utilities), `theme-preset-io.ts` (theme preset file I/O)
- **`src/constants/`** — Application constants: `app.ts` (MCP default port, app-level config)
- **`src/i18n/`** — Internationalization setup and locale files
- **`server/api/ai/`** — Nitro server API (8 files): `chat.ts` (streaming SSE with thinking state, multimodal image attachments per provider), `generate.ts` (non-streaming generation), `connect-agent.ts` (Claude Code/Codex CLI/OpenCode/Copilot connection), `models.ts` (model definitions), `validate.ts` (vision-based post-generation validation), `mcp-install.ts` (MCP server install/uninstall into CLI tool configs; auto-detects `node` availability — if missing, falls back to HTTP URL config `{ type: "http", url }` and auto-starts the MCP HTTP server), `install-agent.ts` (agent installation endpoint), `icon.ts` (icon name → SVG path resolution via local Iconify sets). Supports Anthropic API key or Claude Agent SDK (local OAuth) as dual providers
- **`server/utils/`** — Server utilities (7 files):
- `resolve-claude-cli.ts` — Resolves standalone `claude` binary path (handles Nitro bundling issues with SDK's `import.meta.url`)
- `resolve-claude-agent-env.ts` — Builds Claude Agent SDK environment: merges `~/.claude/settings.json` env, validates `ANTHROPIC_CUSTOM_HEADERS`, handles auth token compat
- `opencode-client.ts` — Shared OpenCode client manager, reuses server on port 4096 with random port fallback
- `codex-client.ts` — Codex CLI client wrapper with JSON streaming, thinking mode support, timeout handling, optional `imageFiles` for vision queries
- `copilot-client.ts` — Resolves standalone `copilot` binary path to avoid Bun's `node:sqlite` incompatibility with bundled CLI
- `mcp-server-manager.ts` — MCP HTTP server lifecycle management: spawn/track/stop detached process, PID file persistence
- `mcp-sync-state.ts` — In-memory MCP state: current document/selection, SSE broadcast to connected clients
### Fabric.js v7 Gotchas
### CanvasKit/Skia Architecture
- **Default origin is `center`/`center`** — always set `originX: 'left'`, `originY: 'top'` on objects so `left`/`top` means top-left corner
- **Pointer capture** — Fabric captures pointers on `upperCanvasEl`; attach pointer listeners there, not on `document`
- **Coordinate conversion** — use `canvas.getScenePoint(e)` with `canvas.calcOffset()` for accurate pointer-to-scene mapping
- **Default strokeWidth is 1** — explicitly set `strokeWidth: 0` when no stroke is desired
- **Tool isolation** — when a drawing tool is active, set both `canvas.selection = false` and `canvas.skipTargetFind = true` to prevent Fabric from selecting existing objects during draw. Restore both when switching back to select tool.
- **Parent-child transforms** — nodes are flattened to absolute coordinates for Fabric; `nodeRenderInfo` stores parent offsets for converting back to relative coordinates. `parent-child-transform.ts` handles propagating transforms to descendants during drag/scale/rotate.
### Canvas Tool State Management
When switching tools, **two subscribers** manage canvas state:
- `use-canvas-events.ts` — sets `selection`/`skipTargetFind` based on drawing vs select tool
- `use-canvas-viewport.ts` — also manages `selection`/`skipTargetFind` for tool switches and space-key panning
Both must stay consistent: only `select` tool (without space pressed) should have `selection = true` and `skipTargetFind = false`.
- **GPU-accelerated WASM rendering** — CanvasKit (Skia compiled to WASM) renders all canvas content via WebGL surface
- **SkiaEngine class** (`skia-engine.ts`) is the core: owns the render loop, viewport transforms, node flattening, and `SpatialIndex` for hit testing
- **Dirty-flag rendering**`markDirty()` schedules a `requestAnimationFrame` redraw; no continuous rendering loop
- **Node flattening**`syncFromDocument()` walks the PenDocument tree, resolves auto-layout positions via `canvas-layout-engine.ts`, and produces flat `RenderNode[]` with absolute coordinates
- **SpatialIndex** (`skia-hit-test.ts`) — R-tree backed spatial queries for `hitTest()` (click) and `searchRect()` (marquee selection)
- **Coordinate conversion**`screenToScene()` / `sceneToScreen()` in `skia-viewport.ts` handle viewport ↔ scene transforms
- **Bitmap caching**`canvas-zoom-cache.ts` caches rendered frames during zoom/pan for smoother interaction
- **Event handling** — all mouse/keyboard events are handled directly in `skia-canvas.tsx` (no Fabric abstractions)
- **Parent-child transforms** — nodes are flattened to absolute coordinates; `parent-child-transform.ts` propagates transforms to descendants during drag/scale/rotate
- **Legacy Fabric.js**`fabric` package remains as a dependency for `zoomToFitContent` utility; canvas rendering no longer uses Fabric
### Routing
@ -292,8 +329,12 @@ Tailwind CSS v4 imported via `src/styles.css`. UI primitives from shadcn/ui (`sr
### Electron Desktop App
- **`electron/main.ts`** — Main process: window creation, Nitro server fork, IPC for native file dialogs, native application menu, auto-updater, macOS traffic-light padding (auto-hidden in fullscreen), `.op` file association handling (`open-file` event on macOS, CLI args + single-instance lock on Windows/Linux)
- **`electron/main.ts`** — Main process: window creation, Nitro server fork, IPC for native file dialogs, `.op` file association handling (`open-file` event on macOS, CLI args + single-instance lock on Windows/Linux)
- **`electron/preload.ts`** — Context bridge for renderer ↔ main IPC (file dialogs, menu actions, updater state, `onOpenFile`/`readFile` for file association)
- **`electron/app-menu.ts`** — Native application menu configuration (File, Edit, View, Help)
- **`electron/auto-updater.ts`** — Auto-updater implementation: checks GitHub Releases on startup and periodically
- **`electron/constants.ts`** — Electron-specific constants
- **`electron/logger.ts`** — Main process logging
- **`electron-builder.yml`** — Packaging config: macOS (dmg/zip), Windows (nsis/portable), Linux (AppImage/deb), `.op` file association (`fileAssociations`)
- **`scripts/electron-dev.ts`** — Dev workflow: starts Vite → waits for port 3000 → compiles electron/ with esbuild → launches Electron
- Build flow: `BUILD_TARGET=electron bun run build``bun run electron:compile``npx electron-builder`

View file

@ -123,15 +123,15 @@ bun run electron:dev
**MCP-Server**
- Eingebauter MCP-Server — Ein-Klick-Installation in Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
- Automatische Node.js-Erkennung — falls nicht installiert, automatischer Fallback auf HTTP-Transport und automatischer Start des MCP-HTTP-Servers
- Design-Automatisierung vom Terminal aus: `.op`-Dateien über jeden MCP-kompatiblen Agenten lesen, erstellen und bearbeiten
- **Mehrstufiger Design-Workflow**`design_skeleton``design_content``design_refine` für hochwertigere mehrteilige Designs
- **Segmentierter Prompt-Abruf** — laden Sie nur das benötigte Design-Wissen (Schema, Layout, Rollen, Icons, Planung usw.)
- Mehrseitige Unterstützung — Seiten erstellen, umbenennen, neu ordnen und duplizieren über MCP-Tools
**Codegenerierung**
- React + Tailwind CSS
- HTML + CSS
- CSS Variables aus Design-Tokens
- React + Tailwind CSS, HTML + CSS, CSS Variables
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
## Funktionen
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Canvas** | Fabric.js v7 |
| **Canvas** | CanvasKit/Skia (WASM, GPU-beschleunigt) |
| **Zustand** | Zustand v5 |
| **Server** | Nitro |
| **Desktop** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Fabric.js-Engine — Zeichnen, Synchronisierung, Layout, Hilfslinien, Stiftwerkzeug
canvas/ CanvasKit/Skia-Engine — Zeichnen, Synchronisierung, Layout, Hilfslinien, Stiftwerkzeug
components/ React-UI — Editor, Panels, gemeinsame Dialoge, Icons
services/ai/ KI-Chat, Orchestrierer, Designgenerierung, Streaming
services/figma/ Figma-.fig-Binär-Importpipeline

View file

@ -123,15 +123,15 @@ bun run electron:dev
**Servidor MCP**
- Servidor MCP integrado — instalación con un clic en Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
- Detección automática de Node.js — si no está instalado, recurre automáticamente al transporte HTTP e inicia el servidor MCP HTTP
- Automatización de diseño desde la terminal: leer, crear y modificar archivos `.op` a través de cualquier agente compatible con MCP
- **Flujo de diseño por capas**`design_skeleton``design_content``design_refine` para diseños multisección de mayor fidelidad
- **Recuperación segmentada de prompts** — carga solo el conocimiento de diseño que necesitas (schema, layout, roles, icons, planning, etc.)
- Soporte multipágina — crear, renombrar, reordenar y duplicar páginas mediante herramientas MCP
**Generación de Código**
- React + Tailwind CSS
- HTML + CSS
- CSS Variables a partir de tokens de diseño
- React + Tailwind CSS, HTML + CSS, CSS Variables
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
## Características
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Lienzo** | Fabric.js v7 |
| **Lienzo** | CanvasKit/Skia (WASM, acelerado por GPU) |
| **Estado** | Zustand v5 |
| **Servidor** | Nitro |
| **Escritorio** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Motor Fabric.js — dibujo, sincronización, diseño, guías, herramienta pluma
canvas/ Motor CanvasKit/Skia — dibujo, sincronización, diseño, guías, herramienta pluma
components/ Interfaz React — editor, paneles, diálogos compartidos, iconos
services/ai/ Chat de IA, orquestador, generación de diseño, transmisión
services/figma/ Pipeline de importación binaria de Figma .fig

View file

@ -123,15 +123,15 @@ bun run electron:dev
**Serveur MCP**
- Serveur MCP intégré — installation en un clic dans les CLI Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot
- Détection automatique de Node.js — si non installé, bascule automatiquement vers le transport HTTP et démarre le serveur MCP HTTP
- Automatisation du design depuis le terminal : lire, créer et modifier des fichiers `.op` via tout agent compatible MCP
- **Workflow de design en couches**`design_skeleton``design_content``design_refine` pour des designs multi-sections de plus haute fidélité
- **Récupération segmentée des prompts** — chargez uniquement les connaissances de design nécessaires (schéma, layout, rôles, icônes, planification, etc.)
- Support multi-pages — créer, renommer, réordonner et dupliquer des pages via les outils MCP
**Génération de code**
- React + Tailwind CSS
- HTML + CSS
- CSS Variables à partir des tokens de design
- React + Tailwind CSS, HTML + CSS, CSS Variables
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
## Fonctionnalités
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Canevas** | Fabric.js v7 |
| **Canevas** | CanvasKit/Skia (WASM, accélération GPU) |
| **État** | Zustand v5 |
| **Serveur** | Nitro |
| **Bureau** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Moteur Fabric.js — dessin, sync, mise en page, guides, outil plume
canvas/ Moteur CanvasKit/Skia — dessin, sync, mise en page, guides, outil plume
components/ Interface React — éditeur, panneaux, boîtes de dialogue partagées, icônes
services/ai/ Chat IA, orchestrateur, génération de design, streaming
services/figma/ Pipeline d'import binaire Figma .fig

View file

@ -123,15 +123,15 @@ bun run electron:dev
**MCP सर्वर**
- बिल्ट-इन MCP सर्वर — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs में वन-क्लिक इंस्टॉल
- Node.js स्वचालित पहचान — यदि इंस्टॉल नहीं है तो HTTP ट्रांसपोर्ट पर स्वचालित फ़ॉलबैक और MCP HTTP सर्वर ऑटो-स्टार्ट
- टर्मिनल से डिज़ाइन ऑटोमेशन: किसी भी MCP-संगत एजेंट के ज़रिए `.op` फ़ाइलें पढ़ें, बनाएँ और संपादित करें
- **लेयर्ड डिज़ाइन वर्कफ़्लो** — उच्च-फ़िडेलिटी मल्टी-सेक्शन डिज़ाइन के लिए `design_skeleton``design_content``design_refine`
- **सेगमेंटेड प्रॉम्प्ट रिट्रीवल** — केवल आवश्यक डिज़ाइन ज्ञान लोड करें (schema, layout, roles, icons, planning, आदि)
- मल्टी-पेज सपोर्ट — MCP टूल के ज़रिए पेज बनाएँ, नाम बदलें, क्रम बदलें और डुप्लिकेट करें
**कोड जनरेशन**
- React + Tailwind CSS
- HTML + CSS
- डिज़ाइन टोकन से CSS Variables
- React + Tailwind CSS, HTML + CSS, CSS Variables
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
## विशेषताएँ
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **फ्रंटएंड** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **कैनवास** | Fabric.js v7 |
| **कैनवास** | CanvasKit/Skia (WASM, GPU-एक्सेलेरेटेड) |
| **स्टेट** | Zustand v5 |
| **सर्वर** | Nitro |
| **डेस्कटॉप** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Fabric.js इंजन — ड्रॉइंग, सिंक, लेआउट, गाइड, पेन टूल
canvas/ CanvasKit/Skia इंजन — ड्रॉइंग, सिंक, लेआउट, गाइड, पेन टूल
components/ React UI — एडिटर, पैनल, शेयर्ड डायलॉग, आइकन
services/ai/ AI चैट, ऑर्केस्ट्रेटर, डिज़ाइन जनरेशन, स्ट्रीमिंग
services/figma/ Figma .fig बाइनरी इम्पोर्ट पाइपलाइन

View file

@ -123,15 +123,15 @@ bun run electron:dev
**Server MCP**
- Server MCP bawaan — instal satu klik ke Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
- Deteksi otomatis Node.js — jika tidak terinstal, otomatis beralih ke transport HTTP dan memulai server MCP HTTP
- Otomasi desain dari terminal: baca, buat, dan modifikasi file `.op` melalui agen yang kompatibel dengan MCP
- **Alur kerja desain berlapis**`design_skeleton``design_content``design_refine` untuk desain multi-bagian dengan fidelitas lebih tinggi
- **Pengambilan prompt tersegmentasi** — muat hanya pengetahuan desain yang Anda butuhkan (schema, layout, roles, icons, planning, dll.)
- Dukungan multi-halaman — buat, ganti nama, urutkan ulang, dan duplikasi halaman melalui alat MCP
**Pembuatan Kode**
- React + Tailwind CSS
- HTML + CSS
- CSS Variables dari token desain
- React + Tailwind CSS, HTML + CSS, CSS Variables
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
## Fitur
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Kanvas** | Fabric.js v7 |
| **Kanvas** | CanvasKit/Skia (WASM, akselerasi GPU) |
| **State** | Zustand v5 |
| **Server** | Nitro |
| **Desktop** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Mesin Fabric.js — menggambar, sinkronisasi, tata letak, panduan, alat pen
canvas/ Mesin CanvasKit/Skia — menggambar, sinkronisasi, tata letak, panduan, alat pen
components/ UI React — editor, panel, dialog bersama, ikon
services/ai/ Chat AI, orkestrator, pembuatan desain, streaming
services/figma/ Pipeline impor biner Figma .fig

View file

@ -123,15 +123,15 @@ bun run electron:dev
**MCP サーバー**
- 内蔵 MCP サーバー — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI にワンクリックでインストール
- Node.js を自動検出 — 未インストールの場合は HTTP トランスポートに自動フォールバックし、MCP HTTP サーバーを自動起動
- ターミナルからのデザイン自動化MCP 対応エージェントを通じて `.op` ファイルの読み取り、作成、編集が可能
- **レイヤードデザインワークフロー**`design_skeleton``design_content``design_refine` による高忠実度マルチセクションデザイン
- **セグメント化プロンプト取得** — 必要なデザイン知識のみをロードschema、layout、roles、icons、planning など)
- マルチページサポート — MCP ツールを通じてページの作成、名前変更、並べ替え、複製が可能
**コード生成**
- React + Tailwind CSS
- HTML + CSS
- デザイントークンから CSS Variables を生成
- React + Tailwind CSS、HTML + CSS、CSS Variables
- Vue、Svelte、Flutter、SwiftUI、Jetpack Compose、React Native
## 機能
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **フロントエンド** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **キャンバス** | Fabric.js v7 |
| **キャンバス** | CanvasKit/SkiaWASM、GPU アクセラレーション) |
| **状態管理** | Zustand v5 |
| **サーバー** | Nitro |
| **デスクトップ** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Fabric.js エンジン — 描画、同期、レイアウト、ガイド、ペンツール
canvas/ CanvasKit/Skia エンジン — 描画、同期、レイアウト、ガイド、ペンツール
components/ React UI — エディター、パネル、共有ダイアログ、アイコン
services/ai/ AI チャット、オーケストレーター、デザイン生成、ストリーミング
services/figma/ Figma .fig バイナリインポートパイプライン

View file

@ -123,15 +123,15 @@ bun run electron:dev
**MCP 서버**
- 내장 MCP 서버 — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI에 원클릭 설치
- Node.js 자동 감지 — 설치되지 않은 경우 HTTP 전송 모드로 자동 대체하고 MCP HTTP 서버를 자동 시작
- 터미널에서 디자인 자동화: MCP 호환 에이전트를 통해 `.op` 파일 읽기, 생성, 편집 가능
- **계층적 디자인 워크플로**`design_skeleton``design_content``design_refine`으로 더 높은 충실도의 멀티 섹션 디자인
- **세그먼트 프롬프트 검색** — 필요한 디자인 지식만 로드 (스키마, 레이아웃, 역할, 아이콘, 계획 등)
- 멀티 페이지 지원 — MCP 도구를 통해 페이지 생성, 이름 변경, 순서 변경, 복제
**코드 생성**
- React + Tailwind CSS
- HTML + CSS
- 디자인 토큰에서 CSS Variables 생성
- React + Tailwind CSS, HTML + CSS, CSS Variables
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
## 기능
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **프론트엔드** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **캔버스** | Fabric.js v7 |
| **캔버스** | CanvasKit/Skia (WASM, GPU 가속) |
| **상태 관리** | Zustand v5 |
| **서버** | Nitro |
| **데스크톱** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Fabric.js 엔진 — 드로잉, 동기화, 레이아웃, 가이드, 펜 툴
canvas/ CanvasKit/Skia 엔진 — 드로잉, 동기화, 레이아웃, 가이드, 펜 툴
components/ React UI — 에디터, 패널, 공유 다이얼로그, 아이콘
services/ai/ AI 채팅, 오케스트레이터, 디자인 생성, 스트리밍
services/figma/ Figma .fig 바이너리 가져오기 파이프라인

View file

@ -123,15 +123,15 @@ bun run electron:dev
**MCP Server**
- Built-in MCP server — one-click install into Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
- Auto-detects Node.js — if not installed, falls back to HTTP transport and auto-starts the MCP HTTP server
- Design automation from terminal: read, create, and modify `.op` files via any MCP-compatible agent
- **Layered design workflow**`design_skeleton``design_content``design_refine` for higher-fidelity multi-section designs
- **Segmented prompt retrieval** — load only the design knowledge you need (schema, layout, roles, icons, planning, etc.)
- Multi-page support — create, rename, reorder, and duplicate pages via MCP tools
**Code Generation**
- React + Tailwind CSS
- HTML + CSS
- CSS Variables from design tokens
- React + Tailwind CSS, HTML + CSS, CSS Variables
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
## Features
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Canvas** | Fabric.js v7 |
| **Canvas** | CanvasKit/Skia (WASM, GPU-accelerated) |
| **State** | Zustand v5 |
| **Server** | Nitro |
| **Desktop** | Electron 35 |
@ -175,11 +175,11 @@ bun run electron:dev
```text
src/
canvas/ Fabric.js engine — drawing, sync, layout, guides, pen tool
canvas/ CanvasKit/Skia engine — drawing, sync, layout, guides, pen tool
components/ React UI — editor, panels, shared dialogs, icons
services/ai/ AI chat, orchestrator, design generation, streaming
services/figma/ Figma .fig binary import pipeline
services/codegen React+Tailwind and HTML+CSS code generators
services/codegen Multi-platform code generators (React, HTML, Vue, Svelte, Flutter, SwiftUI, Compose, React Native)
stores/ Zustand — canvas, document, pages, history, AI, settings
variables/ Design token resolution and reference management
mcp/ MCP server tools for external CLI integration

View file

@ -123,15 +123,15 @@ bun run electron:dev
**Servidor MCP**
- Servidor MCP integrado — instalação com um clique no Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
- Detecção automática de Node.js — se não instalado, recurso automático para transporte HTTP e início automático do servidor MCP HTTP
- Automação de design pelo terminal: leia, crie e modifique arquivos `.op` via qualquer agente compatível com MCP
- **Fluxo de design em camadas**`design_skeleton``design_content``design_refine` para designs multi-seção de maior fidelidade
- **Recuperação segmentada de prompts** — carregue apenas o conhecimento de design necessário (schema, layout, roles, icons, planning, etc.)
- Suporte a múltiplas páginas — crie, renomeie, reordene e duplique páginas via ferramentas MCP
**Geração de Código**
- React + Tailwind CSS
- HTML + CSS
- CSS Variables a partir de tokens de design
- React + Tailwind CSS, HTML + CSS, CSS Variables
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
## Funcionalidades
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Canvas** | Fabric.js v7 |
| **Canvas** | CanvasKit/Skia (WASM, acelerado por GPU) |
| **Estado** | Zustand v5 |
| **Servidor** | Nitro |
| **Desktop** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Motor Fabric.js — desenho, sincronização, layout, guias, ferramenta caneta
canvas/ Motor CanvasKit/Skia — desenho, sincronização, layout, guias, ferramenta caneta
components/ UI React — editor, painéis, diálogos compartilhados, ícones
services/ai/ Chat IA, orquestrador, geração de design, streaming
services/figma/ Pipeline de importação binária do Figma .fig

View file

@ -123,15 +123,15 @@ bun run electron:dev
**MCP-сервер**
- Встроенный MCP-сервер — установка в один клик в Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
- Автоопределение Node.js — если не установлен, автоматический переход на HTTP-транспорт и автозапуск MCP HTTP-сервера
- Автоматизация дизайна из терминала: чтение, создание и изменение файлов `.op` через любой MCP-совместимый агент
- **Послойный рабочий процесс**`design_skeleton``design_content``design_refine` для дизайнов высокого качества с несколькими секциями
- **Сегментированное получение промптов** — загружайте только нужные знания о дизайне (schema, layout, roles, icons, planning и т.д.)
- Поддержка нескольких страниц — создание, переименование, переупорядочивание и дублирование страниц через инструменты MCP
**Генерация кода**
- React + Tailwind CSS
- HTML + CSS
- CSS Variables из дизайн-токенов
- React + Tailwind CSS, HTML + CSS, CSS Variables
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
## Возможности
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **Фронтенд** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Холст** | Fabric.js v7 |
| **Холст** | CanvasKit/Skia (WASM, GPU-ускорение) |
| **Состояние** | Zustand v5 |
| **Сервер** | Nitro |
| **Десктоп** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Движок Fabric.js — рисование, синхронизация, раскладка, направляющие, инструмент пера
canvas/ Движок CanvasKit/Skia — рисование, синхронизация, раскладка, направляющие, инструмент пера
components/ React UI — редактор, панели, общие диалоги, иконки
services/ai/ AI-чат, оркестратор, генерация дизайна, стриминг
services/figma/ Пайплайн бинарного импорта Figma .fig

View file

@ -123,15 +123,15 @@ bun run electron:dev
**MCP Server**
- MCP Server ในตัว — ติดตั้งได้ด้วยคลิกเดียวใน Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
- ตรวจจับ Node.js อัตโนมัติ — หากไม่ได้ติดตั้ง จะสำรองไปใช้ HTTP transport โดยอัตโนมัติและเริ่ม MCP HTTP เซิร์ฟเวอร์
- การทำ Design Automation จาก Terminal: อ่าน สร้าง และแก้ไขไฟล์ `.op` ผ่าน agent ที่รองรับ MCP
- **Layered design workflow**`design_skeleton``design_content``design_refine` สำหรับดีไซน์หลายส่วนที่มีความละเอียดสูงขึ้น
- **Segmented prompt retrieval** — โหลดเฉพาะความรู้ด้านดีไซน์ที่ต้องการ (schema, layout, roles, icons, planning ฯลฯ)
- รองรับหลายหน้า — สร้าง เปลี่ยนชื่อ เรียงลำดับ และทำซ้ำหน้าผ่าน MCP tools
**การสร้างโค้ด**
- React + Tailwind CSS
- HTML + CSS
- CSS Variables จาก design tokens
- React + Tailwind CSS, HTML + CSS, CSS Variables
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
## ฟีเจอร์
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Canvas** | Fabric.js v7 |
| **Canvas** | CanvasKit/Skia (WASM, GPU-accelerated) |
| **State** | Zustand v5 |
| **Server** | Nitro |
| **Desktop** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Fabric.js engine — การวาด, sync, layout, guides, pen tool
canvas/ CanvasKit/Skia engine — การวาด, sync, layout, guides, pen tool
components/ React UI — editor, panels, shared dialogs, icons
services/ai/ AI chat, orchestrator, การสร้างดีไซน์, streaming
services/figma/ Figma .fig binary import pipeline

View file

@ -123,15 +123,15 @@ bun run electron:dev
**MCP Sunucusu**
- Yerleşik MCP sunucusu — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI'larına tek tıkla kurulum
- Otomatik Node.js algılama — kurulu değilse otomatik olarak HTTP aktarımına geçer ve MCP HTTP sunucusunu otomatik başlatır
- Terminalden tasarım otomasyonu: herhangi bir MCP uyumlu ajan aracılığıyla `.op` dosyalarını okuyun, oluşturun ve düzenleyin
- **Katmanlı tasarım iş akışı** — daha yüksek kaliteli çok bölümlü tasarımlar için `design_skeleton``design_content``design_refine`
- **Bölümlenmiş prompt alımı** — yalnızca ihtiyacınız olan tasarım bilgisini yükleyin (şema, düzen, roller, simgeler, planlama vb.)
- Çok sayfa desteği — MCP araçları ile sayfaları oluşturun, yeniden adlandırın, sıralayın ve çoğaltın
**Kod Üretimi**
- React + Tailwind CSS
- HTML + CSS
- Tasarım tokenlarından CSS Variables
- React + Tailwind CSS, HTML + CSS, CSS Variables
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
## Özellikler
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **Ön Uç** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Kanvas** | Fabric.js v7 |
| **Kanvas** | CanvasKit/Skia (WASM, GPU hizlandirmali) |
| **Durum Yönetimi** | Zustand v5 |
| **Sunucu** | Nitro |
| **Masaüstü** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Fabric.js motoru — çizim, senkronizasyon, düzen, kılavuzlar, kalem aracı
canvas/ CanvasKit/Skia motoru — çizim, senkronizasyon, düzen, kılavuzlar, kalem aracı
components/ React UI — editör, paneller, paylaşılan iletişim kutuları, simgeler
services/ai/ AI sohbet, orkestratör, tasarım üretimi, akış
services/figma/ Figma .fig ikili içe aktarma ardışık düzeni

View file

@ -123,15 +123,15 @@ bun run electron:dev
**Máy chủ MCP**
- Máy chủ MCP tích hợp sẵn — cài đặt một cú nhấp vào Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
- Tự động phát hiện Node.js — nếu chưa cài đặt, tự động chuyển sang HTTP transport và khởi động MCP HTTP server
- Tự động hóa thiết kế từ terminal: đọc, tạo và chỉnh sửa các tệp `.op` qua bất kỳ tác nhân tương thích MCP nào
- **Quy trình thiết kế phân lớp**`design_skeleton``design_content``design_refine` cho thiết kế đa phần có độ trung thực cao hơn
- **Truy xuất prompt phân đoạn** — chỉ tải kiến thức thiết kế cần thiết (schema, layout, roles, icons, planning, v.v.)
- Hỗ trợ nhiều trang — tạo, đổi tên, sắp xếp lại và nhân bản trang qua các công cụ MCP
**Tạo mã nguồn**
- React + Tailwind CSS
- HTML + CSS
- CSS Variables từ các token thiết kế
- React + Tailwind CSS, HTML + CSS, CSS Variables
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
## Tính năng
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **Canvas** | Fabric.js v7 |
| **Canvas** | CanvasKit/Skia (WASM, tăng tốc GPU) |
| **Trạng thái** | Zustand v5 |
| **Máy chủ** | Nitro |
| **Desktop** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Fabric.js engine — vẽ, đồng bộ, layout, hướng dẫn, công cụ bút
canvas/ CanvasKit/Skia engine — vẽ, đồng bộ, layout, hướng dẫn, công cụ bút
components/ React UI — editor, panels, hộp thoại dùng chung, icons
services/ai/ AI chat, orchestrator, tạo thiết kế, streaming
services/figma/ Pipeline nhập binary Figma .fig

View file

@ -123,15 +123,15 @@ bun run electron:dev
**MCP 伺服器**
- 內建 MCP 伺服器 — 一鍵安裝至 Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
- 自動偵測 Node.js — 若未安裝則自動回退到 HTTP 傳輸模式並啟動 MCP HTTP 伺服器
- 從終端機進行設計自動化:透過任意 MCP 相容的智能體讀取、建立和修改 `.op` 檔案
- **分層設計工作流**`design_skeleton``design_content``design_refine`,適用於高保真多區塊設計
- **分段提示詞擷取** — 僅載入所需的設計知識schema、layout、roles、icons、planning 等)
- 多頁面支援 — 透過 MCP 工具建立、重新命名、重新排序和複製頁面
**程式碼生成**
- React + Tailwind CSS
- HTML + CSS
- 從設計令牌生成 CSS Variables
- React + Tailwind CSS、HTML + CSS、CSS Variables
- Vue、Svelte、Flutter、SwiftUI、Jetpack Compose、React Native
## 功能特色
@ -163,7 +163,7 @@ bun run electron:dev
| | |
| --- | --- |
| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **畫布** | Fabric.js v7 |
| **畫布** | CanvasKit/SkiaWASM、GPU 加速) |
| **狀態管理** | Zustand v5 |
| **伺服器** | Nitro |
| **桌面端** | Electron 35 |
@ -175,7 +175,7 @@ bun run electron:dev
```text
src/
canvas/ Fabric.js 引擎 — 繪圖、同步、版面配置、參考線、鋼筆工具
canvas/ CanvasKit/Skia 引擎 — 繪圖、同步、版面配置、參考線、鋼筆工具
components/ React UI — 編輯器、面板、共用對話框、圖示
services/ai/ AI 聊天、編排器、設計生成、串流處理
services/figma/ Figma .fig 二進位檔案匯入管線

View file

@ -123,14 +123,15 @@ bun run electron:dev
**MCP 服务器**
- 内置 MCP 服务器 — 一键安装到 Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
- 自动检测 Node.js — 若未安装则自动回退到 HTTP 传输模式并启动 MCP HTTP 服务器
- 从终端进行设计自动化:通过任意 MCP 兼容的智能体读取、创建和修改 `.op` 文件
- **分层设计工作流**`design_skeleton``design_content``design_refine`,实现更高保真度的多区块设计
- **分段提示词检索** — 按需加载所需的设计知识schema、layout、roles、icons、planning 等)
- 多页面支持 — 通过 MCP 工具创建、重命名、重新排序和复制页面
**代码生成**
- React + Tailwind CSS
- HTML + CSS
- React + Tailwind CSS、HTML + CSS、CSS Variables
- Vue、Svelte、Flutter、SwiftUI、Jetpack Compose、React Native
- 从设计令牌生成 CSS Variables
## 功能特性
@ -163,7 +164,7 @@ bun run electron:dev
| | |
| --- | --- |
| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
| **画布** | Fabric.js v7 |
| **画布** | CanvasKit/SkiaWASM, GPU 加速) |
| **状态管理** | Zustand v5 |
| **服务器** | Nitro |
| **桌面端** | Electron 35 |
@ -175,11 +176,11 @@ bun run electron:dev
```text
src/
canvas/ Fabric.js 引擎 — 绘图、同步、布局、参考线、钢笔工具
canvas/ CanvasKit/Skia 引擎 — 绘图、同步、布局、参考线、钢笔工具
components/ React UI — 编辑器、面板、共享对话框、图标
services/ai/ AI 聊天、编排器、设计生成、流式处理
services/figma/ Figma .fig 二进制文件导入流水线
services/codegen React+Tailwind 和 HTML+CSS 代码生成器
services/codegen 多平台代码生成器React、HTML、Vue、Svelte、Flutter、SwiftUI、Compose、React Native
stores/ Zustand — 画布、文档、页面、历史、AI、设置
variables/ 设计令牌解析与引用管理
mcp/ 供外部 CLI 集成使用的 MCP 服务器工具

View file

@ -7,6 +7,18 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
"@anthropic-ai/sdk": "^0.77.0",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@fontsource/lato": "^5.2.7",
"@fontsource/montserrat": "^5.2.8",
"@fontsource/nunito": "^5.2.7",
"@fontsource/open-sans": "^5.2.7",
"@fontsource/playfair-display": "^5.2.8",
"@fontsource/poppins": "^5.2.7",
"@fontsource/raleway": "^5.2.8",
"@fontsource/roboto": "^5.2.10",
"@fontsource/source-sans-3": "^5.2.9",
"@github/copilot-sdk": "^0.1.30",
"@iconify-json/feather": "^1.2.1",
"@iconify-json/lucide": "^1.2.93",
@ -26,10 +38,10 @@
"@tanstack/react-router-ssr-query": "^1.131.7",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"canvaskit-wasm": "^0.40.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-updater": "^6.6.2",
"fabric": "^7.1.0",
"fzstd": "^0.1.1",
"html2canvas": "^1.4.1",
"i18next": "^25.8.14",
@ -39,6 +51,7 @@
"nanoid": "^5.1.6",
"nitro": "npm:nitro-nightly@latest",
"paper": "^0.12.18",
"rbush": "^4.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.4",
@ -53,6 +66,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.10.2",
"@types/rbush": "^4.0.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/uzip": "^0.20201231.2",
@ -60,6 +74,7 @@
"electron": "^35.0.0",
"electron-builder": "^26.0.0",
"esbuild": "^0.25.0",
"graceful-fs": "^4.2.11",
"jsdom": "^27.0.0",
"typescript": "^5.7.2",
"vite": "^7.1.7",
@ -224,6 +239,30 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "https://registry.npmmirror.com/@fontsource-variable/inter/-/inter-5.2.8.tgz", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
"@fontsource/dm-sans": ["@fontsource/dm-sans@5.2.8", "https://registry.npmmirror.com/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz", {}, "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA=="],
"@fontsource/inter": ["@fontsource/inter@5.2.8", "https://registry.npmmirror.com/@fontsource/inter/-/inter-5.2.8.tgz", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@fontsource/lato": ["@fontsource/lato@5.2.7", "https://registry.npmmirror.com/@fontsource/lato/-/lato-5.2.7.tgz", {}, "sha512-k5mum1ADbDW5cTw1Ett1eQVWeoZ6gq0ct6SFBibEhB4LRxhniChJZTBgd6ph5yBxLkN1fcnsnmicBNA4S/3nbw=="],
"@fontsource/montserrat": ["@fontsource/montserrat@5.2.8", "https://registry.npmmirror.com/@fontsource/montserrat/-/montserrat-5.2.8.tgz", {}, "sha512-xTjLxSbSfCycDB0pwmNsfNvdfWPaDaRQ2LC6yt/ZI7SdvXG52zHnzNYC/09mzuAuWNJyShkteutfCoDgym56hQ=="],
"@fontsource/nunito": ["@fontsource/nunito@5.2.7", "https://registry.npmmirror.com/@fontsource/nunito/-/nunito-5.2.7.tgz", {}, "sha512-pmtBq0H9ex9nk+RtJYEJOD9pag393iHETnl/PVKleF4i06cd0ttngK5ZCTgYb5eOqR3Xdlrjtev8m7bmgYprew=="],
"@fontsource/open-sans": ["@fontsource/open-sans@5.2.7", "https://registry.npmmirror.com/@fontsource/open-sans/-/open-sans-5.2.7.tgz", {}, "sha512-8yfgDYjE5O0vmTPdrcjV35y4yMnctsokmi9gN49Gcsr0sjzkMkR97AnKDe6OqZh2SFkYlR28fxOvi21bYEgMSw=="],
"@fontsource/playfair-display": ["@fontsource/playfair-display@5.2.8", "https://registry.npmmirror.com/@fontsource/playfair-display/-/playfair-display-5.2.8.tgz", {}, "sha512-fUEhib70SszNhQVsGbUMSsWJhr7Je0rdeuZdtGpDNu0GKF1xJM8QhpI/y0pckU25GcChXm9TLOmeZupkvvZo2g=="],
"@fontsource/poppins": ["@fontsource/poppins@5.2.7", "https://registry.npmmirror.com/@fontsource/poppins/-/poppins-5.2.7.tgz", {}, "sha512-6uQyPmseo4FgI97WIhA4yWRlNaoLk4vSDK/PyRwdqqZb5zAEuc+Kunt8JTMcsHYUEGYBtN15SNkMajMdqUSUmg=="],
"@fontsource/raleway": ["@fontsource/raleway@5.2.8", "https://registry.npmmirror.com/@fontsource/raleway/-/raleway-5.2.8.tgz", {}, "sha512-OATNM+J4XniKQRlI1e67v3euOnCUiXwpqgMofomKc5JNYOq0JPE6E5uTXMnFaZejKN1Z7s05JrHpmYB7/tg1Fw=="],
"@fontsource/roboto": ["@fontsource/roboto@5.2.10", "https://registry.npmmirror.com/@fontsource/roboto/-/roboto-5.2.10.tgz", {}, "sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg=="],
"@fontsource/source-sans-3": ["@fontsource/source-sans-3@5.2.9", "https://registry.npmmirror.com/@fontsource/source-sans-3/-/source-sans-3-5.2.9.tgz", {}, "sha512-u3ymIq4rfmCCyB9MEw/sFR5lPVJ1yTNXmIMbUz+9kVCFIHvNtfzXOEBuvkg3Tk0zhmioPeJ28ZK5smZ7TurezQ=="],
"@github/copilot": ["@github/copilot@0.0.420", "https://registry.npmmirror.com/@github/copilot/-/copilot-0.0.420.tgz", { "optionalDependencies": { "@github/copilot-darwin-arm64": "0.0.420", "@github/copilot-darwin-x64": "0.0.420", "@github/copilot-linux-arm64": "0.0.420", "@github/copilot-linux-x64": "0.0.420", "@github/copilot-win32-arm64": "0.0.420", "@github/copilot-win32-x64": "0.0.420" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-UpPuSjxUxQ+j02WjZEFffWf0scLb23LvuGHzMFtaSsweR+P/BdbtDUI5ZDIA6T0tVyyt6+X1/vgfsJiRqd6jig=="],
"@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@0.0.420", "https://registry.npmmirror.com/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.420.tgz", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-sj8Oxcf3oKDbeUotm2gtq5YU1lwCt3QIzbMZioFD/PMLOeqSX/wrecI+c0DDYXKofFhALb0+DxxnWgbEs0mnkQ=="],
@ -608,6 +647,8 @@
"@types/plist": ["@types/plist@3.0.5", "https://registry.npmmirror.com/@types/plist/-/plist-3.0.5.tgz", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
"@types/rbush": ["@types/rbush@4.0.0", "https://registry.npmmirror.com/@types/rbush/-/rbush-4.0.0.tgz", {}, "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ=="],
"@types/react": ["@types/react@19.2.14", "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@ -636,6 +677,8 @@
"@vitest/utils": ["@vitest/utils@3.2.4", "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@webgpu/types": ["@webgpu/types@0.1.21", "https://registry.npmmirror.com/@webgpu/types/-/types-0.1.21.tgz", {}, "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"abbrev": ["abbrev@3.0.1", "https://registry.npmmirror.com/abbrev/-/abbrev-3.0.1.tgz", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
@ -742,6 +785,8 @@
"canvas": ["canvas@3.2.1", "https://registry.npmmirror.com/canvas/-/canvas-3.2.1.tgz", { "dependencies": { "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.3" } }, "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg=="],
"canvaskit-wasm": ["canvaskit-wasm@0.40.0", "https://registry.npmmirror.com/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", { "dependencies": { "@webgpu/types": "0.1.21" } }, "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw=="],
"chai": ["chai@5.3.3", "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
@ -968,8 +1013,6 @@
"extsprintf": ["extsprintf@1.4.1", "https://registry.npmmirror.com/extsprintf/-/extsprintf-1.4.1.tgz", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
"fabric": ["fabric@7.1.0", "https://registry.npmmirror.com/fabric/-/fabric-7.1.0.tgz", { "optionalDependencies": { "canvas": "^3.2.0", "jsdom": "^26.1.0" } }, "sha512-061QsoSw6xn7UoRXYq816qMyvObP4gRNVph0jAFWtG5E2kBlfdjrYBiLPRuaAHSmVQUz9RjbPpePB/hljiYJIw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@ -1282,8 +1325,6 @@
"nth-check": ["nth-check@2.1.1", "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"nwsapi": ["nwsapi@2.2.23", "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
"object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
@ -1372,10 +1413,14 @@
"quick-lru": ["quick-lru@5.1.1", "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
"quickselect": ["quickselect@3.0.0", "https://registry.npmmirror.com/quickselect/-/quickselect-3.0.0.tgz", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="],
"range-parser": ["range-parser@1.2.1", "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"rbush": ["rbush@4.0.1", "https://registry.npmmirror.com/rbush/-/rbush-4.0.1.tgz", { "dependencies": { "quickselect": "^3.0.0" } }, "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ=="],
"rc": ["rc@1.2.8", "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.2.4", "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
@ -1430,8 +1475,6 @@
"router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"rrweb-cssom": ["rrweb-cssom@0.8.0", "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
"safe-buffer": ["safe-buffer@5.2.1", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
@ -1810,8 +1853,6 @@
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "https://registry.npmmirror.com/fs-extra/-/fs-extra-7.0.1.tgz", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
"fabric/jsdom": ["jsdom@26.1.0", "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="],
"filelist/minimatch": ["minimatch@5.1.6", "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@ -1924,20 +1965,6 @@
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"fabric/jsdom/cssstyle": ["cssstyle@4.6.0", "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
"fabric/jsdom/data-urls": ["data-urls@5.0.0", "https://registry.npmmirror.com/data-urls/-/data-urls-5.0.0.tgz", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
"fabric/jsdom/html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"fabric/jsdom/parse5": ["parse5@7.3.0", "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"fabric/jsdom/tough-cookie": ["tough-cookie@5.1.2", "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
"fabric/jsdom/webidl-conversions": ["webidl-conversions@7.0.0", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
"fabric/jsdom/whatwg-url": ["whatwg-url@14.2.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
"filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
@ -2072,30 +2099,10 @@
"dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"fabric/jsdom/cssstyle/@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
"fabric/jsdom/tough-cookie/tldts": ["tldts@6.1.86", "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
"fabric/jsdom/whatwg-url/tr46": ["tr46@5.1.1", "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
"filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"cacache/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"fabric/jsdom/cssstyle/@asamuzakjp/css-color/@csstools/css-calc": ["@csstools/css-calc@2.1.4", "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
"fabric/jsdom/cssstyle/@asamuzakjp/css-color/@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="],
"fabric/jsdom/cssstyle/@asamuzakjp/css-color/@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="],
"fabric/jsdom/cssstyle/@asamuzakjp/css-color/@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
"fabric/jsdom/cssstyle/@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"fabric/jsdom/tough-cookie/tldts/tldts-core": ["tldts-core@6.1.86", "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
"fabric/jsdom/cssstyle/@asamuzakjp/css-color/@csstools/css-color-parser/@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
}
}

View file

@ -1,6 +1,6 @@
{
"name": "openpencil",
"version": "0.3.3",
"version": "0.4.0",
"description": "The world's first open-source AI-native vector design tool and the first to feature concurrent Agent Teams. Design-as-Code. Turn prompts into UI directly on the live canvas. A modern alternative to Pencil.",
"author": {
"name": "ZSeven-W",
@ -30,6 +30,18 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
"@anthropic-ai/sdk": "^0.77.0",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@fontsource/lato": "^5.2.7",
"@fontsource/montserrat": "^5.2.8",
"@fontsource/nunito": "^5.2.7",
"@fontsource/open-sans": "^5.2.7",
"@fontsource/playfair-display": "^5.2.8",
"@fontsource/poppins": "^5.2.7",
"@fontsource/raleway": "^5.2.8",
"@fontsource/roboto": "^5.2.10",
"@fontsource/source-sans-3": "^5.2.9",
"@github/copilot-sdk": "^0.1.30",
"@iconify-json/feather": "^1.2.1",
"@iconify-json/lucide": "^1.2.93",
@ -49,10 +61,10 @@
"@tanstack/react-router-ssr-query": "^1.131.7",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"canvaskit-wasm": "^0.40.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-updater": "^6.6.2",
"fabric": "^7.1.0",
"fzstd": "^0.1.1",
"html2canvas": "^1.4.1",
"i18next": "^25.8.14",
@ -62,6 +74,7 @@
"nanoid": "^5.1.6",
"nitro": "npm:nitro-nightly@latest",
"paper": "^0.12.18",
"rbush": "^4.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.4",
@ -76,6 +89,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.10.2",
"@types/rbush": "^4.0.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/uzip": "^0.20201231.2",
@ -83,9 +97,10 @@
"electron": "^35.0.0",
"electron-builder": "^26.0.0",
"esbuild": "^0.25.0",
"graceful-fs": "^4.2.11",
"jsdom": "^27.0.0",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vitest": "^3.0.5"
}
}
}

BIN
public/canvaskit/canvaskit.wasm Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/fonts/lato-400.woff2 Normal file

Binary file not shown.

BIN
public/fonts/lato-700.woff2 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -61,7 +61,11 @@ function buildClaudeExitHint(rawError: string, debugTail?: string[]): string | u
hints.push('Upstream API connection failed (check proxy/DNS/network reachability to your ANTHROPIC_BASE_URL).')
}
if (/ANTHROPIC_CUSTOM_HEADERS present: false, has Authorization header: false/i.test(text)) {
hints.push('No API auth header detected by Claude runtime; verify token/header env mapping.')
hints.push(
'No API auth header detected. Run "claude login" to authenticate, ' +
'or set ANTHROPIC_API_KEY in ~/.claude/settings.json ' +
'(env: { "ANTHROPIC_API_KEY": "sk-..." }).',
)
}
if (hints.length === 0) return undefined

View file

@ -48,6 +48,21 @@ export default defineEventHandler(async (event) => {
return { connected: false, models: [], error: `Unknown agent: ${body.agent}` } satisfies ConnectResult
})
/**
* Fallback models when supportedModels() fails.
* Used with third-party API proxies (e.g. Claude Router) that don't support
* the model-listing endpoint. Covers common model IDs routers typically expose.
*/
const FALLBACK_CLAUDE_MODELS: GroupedModel[] = [
{ value: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6', description: '', provider: 'anthropic' },
{ value: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', description: '', provider: 'anthropic' },
{ value: 'claude-sonnet-4-5-20250514', displayName: 'Claude Sonnet 4.5', description: '', provider: 'anthropic' },
{ value: 'claude-haiku-4-5-20251001', displayName: 'Claude Haiku 4.5', description: '', provider: 'anthropic' },
{ value: 'claude-3-7-sonnet-20250219', displayName: 'Claude 3.7 Sonnet', description: '', provider: 'anthropic' },
{ value: 'claude-3-5-sonnet-20241022', displayName: 'Claude 3.5 Sonnet', description: '', provider: 'anthropic' },
{ value: 'claude-3-5-haiku-20241022', displayName: 'Claude 3.5 Haiku', description: '', provider: 'anthropic' },
]
/** Connect to Claude Code via Agent SDK and fetch real supported models */
async function connectClaudeCode(): Promise<ConnectResult> {
const claudePath = resolveClaudeCli()
@ -86,15 +101,21 @@ async function connectClaudeCode(): Promise<ConnectResult> {
return { connected: true, models }
} catch (error) {
const raw = error instanceof Error ? error.message : 'Failed to connect'
return { connected: false, models: [], error: friendlyClaudeError(raw) }
const msg = error instanceof Error ? error.message : 'Failed to connect'
// Third-party API proxies often don't support the supportedModels() call,
// causing "query closed before response". Fall back to a default model list
// so users can still connect and choose a model.
if (/closed before|closed early|query closed/i.test(msg)) {
return { connected: true, models: FALLBACK_CLAUDE_MODELS }
}
return { connected: false, models: [], error: friendlyClaudeError(msg) }
}
}
/** Map raw Agent SDK errors to user-friendly messages */
function friendlyClaudeError(raw: string): string {
if (/process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)) {
return 'Claude Code exited with code 1. Check your model mapping (e.g. ANTHROPIC_MODEL / default Sonnet model) and run "claude login" if needed.'
return 'Claude Code exited with code 1. Run "claude login" to authenticate, or set ANTHROPIC_API_KEY in ~/.claude/settings.json.'
}
if (/exited with code/i.test(raw)) {
return 'Unable to connect. Claude Code process exited unexpectedly.'

View file

@ -1,8 +1,14 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { join, resolve, dirname } from 'node:path'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { execSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
// ESM-compatible __dirname polyfill
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const MCP_DEFAULT_PORT = 3100
@ -17,6 +23,8 @@ interface InstallResult {
success: boolean
error?: string
configPath?: string
/** True when node was not found and HTTP URL fallback was used */
fallbackHttp?: boolean
}
const MCP_SERVER_NAME = 'openpencil'
@ -42,6 +50,49 @@ function resolveMcpServerPath(): string {
return projectDist // Return expected path even if not yet compiled
}
/**
* Detect if `node` is available on the system.
* Checks PATH first, then common install locations (the Nitro/Electron
* process may run with a stripped PATH that doesn't include the user's
* node installation).
* Caches the result for the lifetime of the process.
*/
let _nodeAvailable: boolean | null = null
function isNodeAvailable(): boolean {
if (_nodeAvailable !== null) return _nodeAvailable
// Try PATH first
try {
execSync('node --version', { stdio: 'ignore', timeout: 5000 })
_nodeAvailable = true
return true
} catch { /* not on PATH */ }
// Check common absolute paths (macOS/Linux + Windows)
const candidates = process.platform === 'win32'
? [
join(process.env.ProgramFiles ?? 'C:\\Program Files', 'nodejs', 'node.exe'),
join(process.env.LOCALAPPDATA ?? '', 'fnm_multishells', '**', 'node.exe'),
join(homedir(), '.nvm', 'current', 'bin', 'node.exe'),
]
: [
'/usr/local/bin/node',
'/usr/bin/node',
join(homedir(), '.nvm', 'versions', 'node'), // nvm directory
'/opt/homebrew/bin/node',
]
for (const p of candidates) {
if (existsSync(p)) {
_nodeAvailable = true
return true
}
}
_nodeAvailable = false
return false
}
function buildMcpServerEntry(
serverPath: string,
transportMode: 'stdio' | 'http' | 'both' = 'stdio',
@ -57,6 +108,11 @@ function buildMcpServerEntry(
}
}
/** Build an HTTP URL-based MCP server entry (no local node required). */
function buildMcpHttpUrlEntry(httpPort = MCP_DEFAULT_PORT): { type: 'http'; url: string } {
return { type: 'http', url: `http://127.0.0.1:${httpPort}/mcp` }
}
/** Config file locations and formats for each CLI tool. */
interface CliConfigDef {
configPath: () => string
@ -79,6 +135,20 @@ function installMcpServer(
}
}
/** Install MCP server using HTTP URL (for environments without node). */
function installMcpServerHttpUrl(
config: Record<string, any>,
httpPort?: number,
): Record<string, any> {
return {
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: buildMcpHttpUrlEntry(httpPort),
},
}
}
function uninstallMcpServer(config: Record<string, any>): Record<string, any> {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
@ -158,16 +228,34 @@ export default defineEventHandler(async (event) => {
try {
const configPath = cliConfig.configPath()
const config = await cliConfig.read(configPath)
const serverPath = resolveMcpServerPath()
const updated =
body.action === 'install'
? installMcpServer(config, serverPath, body.transportMode, body.httpPort)
: uninstallMcpServer(config)
let updated: Record<string, any>
let fallbackHttp = false
if (body.action === 'uninstall') {
updated = uninstallMcpServer(config)
} else if (!isNodeAvailable()) {
// No node on this machine — fall back to HTTP URL config
// and ensure the MCP HTTP server is running
const httpPort = body.httpPort ?? MCP_DEFAULT_PORT
updated = installMcpServerHttpUrl(config, httpPort)
fallbackHttp = true
// Auto-start the MCP HTTP server so the URL is reachable
try {
const { startMcpHttpServer } = await import('../../utils/mcp-server-manager')
startMcpHttpServer(httpPort)
} catch {
// Non-fatal: server may already be running or will be started manually
}
} else {
const serverPath = resolveMcpServerPath()
updated = installMcpServer(config, serverPath, body.transportMode, body.httpPort)
}
await cliConfig.write(configPath, updated)
return { success: true, configPath } satisfies InstallResult
return { success: true, configPath, ...(fallbackHttp ? { fallbackHttp } : {}) } satisfies InstallResult
} catch (err) {
return {
success: false,

View file

@ -1,37 +1,17 @@
import { spawn, execSync, type ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import { spawn } from 'node:child_process'
import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs'
import { networkInterfaces } from 'node:os'
import { join, resolve } from 'node:path'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { tmpdir } from 'node:os'
let mcpProcess: ChildProcess | null = null
let mcpPort: number | null = null
// ESM-compatible __dirname polyfill
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// Auto-cleanup MCP child process when the parent (Nitro) process exits.
// On Linux, child processes become orphaned when the parent is killed;
// this ensures the MCP server is stopped when the Nitro server exits.
function killMcpProcessSync(): void {
if (!mcpProcess || !mcpProcess.pid) return
try {
if (process.platform === 'win32') {
execSync(`taskkill /pid ${mcpProcess.pid} /T /F`, { stdio: 'ignore' })
} else {
process.kill(mcpProcess.pid, 'SIGKILL')
}
} catch { /* process may have already exited */ }
mcpProcess = null
mcpPort = null
}
// Synchronous cleanup on process exit (last resort)
process.on('exit', () => killMcpProcessSync())
// Graceful cleanup on signals (e.g. Electron killing Nitro with SIGTERM)
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
process.on(signal, () => {
killMcpProcessSync()
process.exit(0)
})
}
// PID/Port files for tracking the detached MCP server process across restarts
const MCP_PID_FILE = join(tmpdir(), 'openpencil-mcp-server.pid')
const MCP_PORT_FILE = join(tmpdir(), 'openpencil-mcp-server.port')
/** Resolve the MCP server script path across dev, web build, and Electron production. */
function resolveMcpServerScript(): string {
@ -41,7 +21,7 @@ function resolveMcpServerScript(): string {
const p = join(electronResources, 'mcp-server.cjs')
if (existsSync(p)) return p
}
// dev + web build
// dev + web build (from cwd)
const fromCwd = resolve(process.cwd(), 'dist', 'mcp-server.cjs')
if (existsSync(fromCwd)) return fromCwd
// Fallback: relative to this file (Nitro bundled output)
@ -63,48 +43,87 @@ export function getLocalIp(): string | null {
return null
}
export function getMcpServerStatus(): { running: boolean; port: number | null; localIp: string | null } {
const running = mcpProcess !== null && mcpProcess.exitCode === null
return {
running,
port: running ? mcpPort : null,
localIp: running ? getLocalIp() : null,
/** Check if a process with the given PID is running. */
function isProcessRunning(pid: number): boolean {
try {
// Signal 0 checks existence without actually sending a signal
process.kill(pid, 0)
return true
} catch {
return false
}
}
/** Read PID from file if it exists and process is still running. */
function getRunningPid(): { pid: number; port: number } | null {
try {
if (!existsSync(MCP_PID_FILE)) return null
const pid = parseInt(readFileSync(MCP_PID_FILE, 'utf-8').trim(), 10)
if (isNaN(pid) || !isProcessRunning(pid)) {
// Stale PID file - clean up
try { unlinkSync(MCP_PID_FILE) } catch { /* ignore */ }
try { unlinkSync(MCP_PORT_FILE) } catch { /* ignore */ }
return null
}
const port = existsSync(MCP_PORT_FILE)
? parseInt(readFileSync(MCP_PORT_FILE, 'utf-8').trim(), 10)
: 3100
return { pid, port: isNaN(port) ? 3100 : port }
} catch {
return null
}
}
export function getMcpServerStatus(): { running: boolean; port: number | null; localIp: string | null } {
const info = getRunningPid()
if (!info) {
return { running: false, port: null, localIp: null }
}
return { running: true, port: info.port, localIp: getLocalIp() }
}
export function startMcpHttpServer(port: number): { running: boolean; port: number; localIp: string | null; error?: string } {
if (mcpProcess && mcpProcess.exitCode === null) {
return { running: true, port: mcpPort!, localIp: getLocalIp() }
// Check if already running
const existing = getRunningPid()
if (existing) {
return { running: true, port: existing.port, localIp: getLocalIp() }
}
const serverScript = resolveMcpServerScript()
try {
// Use spawn instead of fork to avoid IPC channel issues on Windows.
// fork() creates an IPC channel that, if unused and disconnected, can
// cause the child process to exit unexpectedly on Windows.
mcpProcess = spawn(process.execPath, [serverScript, '--http', '--port', String(port)], {
stdio: ['ignore', 'pipe', 'pipe'],
// CRITICAL: Use detached mode with unref() so the MCP server survives
// independently of the parent Nitro process. This prevents the server
// from dying when:
// 1. The UI settings dialog is closed
// 2. The user interacts with the editor canvas
// 3. The Nitro server restarts or hot-reloads
// 4. The Electron app sends SIGTERM to Nitro on window close
//
// The process runs in its own session and writes its PID to a file
// for later tracking and graceful shutdown.
const child = spawn(process.execPath, [serverScript, '--http', '--port', String(port)], {
stdio: ['ignore', 'ignore', 'ignore'],
env: { ...process.env },
detached: true,
windowsHide: true,
})
mcpPort = port
// Allow parent to exit independently of child
child.unref()
mcpProcess.stdout?.on('data', (data: Buffer) => {
const msg = data.toString().trim()
if (msg) console.log(`[mcp-server] ${msg}`)
})
mcpProcess.stderr?.on('data', (data: Buffer) => {
console.error(`[mcp-server] ${data.toString().trim()}`)
})
mcpProcess.on('exit', (code) => {
console.error(`[mcp-server] exited with code ${code}`)
mcpProcess = null
mcpPort = null
})
// Write PID to file for later tracking (after brief delay to ensure startup)
const childPid = child.pid
if (childPid) {
setTimeout(() => {
try {
if (isProcessRunning(childPid)) {
writeFileSync(MCP_PID_FILE, String(childPid), 'utf-8')
writeFileSync(MCP_PORT_FILE, String(port), 'utf-8')
}
} catch { /* ignore write errors */ }
}, 100)
}
return { running: true, port, localIp: getLocalIp() }
} catch (err) {
@ -113,20 +132,15 @@ export function startMcpHttpServer(port: number): { running: boolean; port: numb
}
export function stopMcpHttpServer(): { running: false } {
if (mcpProcess) {
if (process.platform === 'win32') {
// SIGTERM is unreliable on Windows; use taskkill for proper cleanup
try {
const pid = mcpProcess.pid
if (pid) {
execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' })
}
} catch { /* ignore */ }
} else {
mcpProcess.kill('SIGTERM')
}
mcpProcess = null
mcpPort = null
const info = getRunningPid()
if (info) {
try {
// Use process.kill which is cross-platform and safe
process.kill(info.pid, 'SIGTERM')
} catch { /* process may have already exited */ }
// Clean up PID/Port files
try { unlinkSync(MCP_PID_FILE) } catch { /* ignore */ }
try { unlinkSync(MCP_PORT_FILE) } catch { /* ignore */ }
}
return { running: false }
}

View file

@ -1,5 +1,5 @@
import { mkdirSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
type EnvLike = Record<string, string | undefined>
@ -8,35 +8,36 @@ interface ClaudeSettings {
env?: Record<string, unknown>
}
function normalizeEnvValue(value: unknown): string | undefined {
function normalizeEnvValue(key: string, value: unknown): string | undefined {
if (value == null) return undefined
if (typeof value === 'string') {
// Filter out empty strings - they cause issues
if (value.trim() === '') return undefined
return value
}
// Skip non-string values (objects, arrays, numbers, booleans) to prevent
// invalid header errors like "Invalid header name: '{...}'"
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
// Skip object values - they would cause header errors
// ANTHROPIC_CUSTOM_HEADERS can be an object in settings.json — serialize it.
// Other object values are skipped to prevent "Invalid header name" errors.
if (typeof value === 'object') {
if (key === 'ANTHROPIC_CUSTOM_HEADERS') {
try { return JSON.stringify(value) } catch { return undefined }
}
return undefined
}
return undefined
}
function readClaudeSettingsEnv(): EnvLike {
function readSingleSettingsFile(filePath: string): EnvLike {
try {
const path = join(homedir(), '.claude', 'settings.json')
const raw = readFileSync(path, 'utf-8')
const raw = readFileSync(filePath, 'utf-8')
const parsed = JSON.parse(raw) as ClaudeSettings
if (!parsed.env || typeof parsed.env !== 'object') return {}
const env: EnvLike = {}
for (const [key, value] of Object.entries(parsed.env)) {
const normalized = normalizeEnvValue(value)
const normalized = normalizeEnvValue(key, value)
if (normalized !== undefined) {
env[key] = normalized
}
@ -47,6 +48,17 @@ function readClaudeSettingsEnv(): EnvLike {
}
}
/**
* Read env from ~/.claude/settings.json and ~/.claude/settings.local.json.
* Local settings take priority (same as Claude Code's own precedence).
*/
function readClaudeSettingsEnv(): EnvLike {
const claudeDir = join(homedir(), '.claude')
const base = readSingleSettingsFile(join(claudeDir, 'settings.json'))
const local = readSingleSettingsFile(join(claudeDir, 'settings.local.json'))
return { ...base, ...local }
}
/**
* Validate if a string is valid JSON (for ANTHROPIC_CUSTOM_HEADERS).
*/
@ -98,7 +110,7 @@ export function buildClaudeAgentEnv(): EnvLike {
*/
export function getClaudeAgentDebugFilePath(): string | undefined {
try {
const dir = join('/tmp', 'openpencil-claude-debug')
const dir = join(tmpdir(), 'openpencil-claude-debug')
mkdirSync(dir, { recursive: true })
return join(dir, 'claude-agent.log')
} catch {

View file

@ -1,82 +0,0 @@
import * as fabric from 'fabric'
function rotationCursorSvg(angleDeg: number): string {
// 270° clockwise arc (radius 4) with small arrowhead — Figma-style minimal.
// Uses single quotes so the SVG doesn't break the outer CSS url("...").
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><g transform='rotate(${angleDeg} 12 12)' fill='none' stroke='%23333' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><path d='M16 12 A4 4 0 1 1 12 8'/><polyline points='10 6.5 12 8 10 9.5'/></g></svg>`
return `url("data:image/svg+xml,${svg}") 12 12, crosshair`
}
const CURSORS = {
tl: rotationCursorSvg(-45),
tr: rotationCursorSvg(45),
br: rotationCursorSvg(135),
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 },
]
export function applyRotationControls(obj: fabric.FabricObject) {
for (const pos of ROTATION_POSITIONS) {
obj.controls[pos.key] = new fabric.Control({
x: pos.x,
y: pos.y,
offsetX: pos.ox,
offsetY: pos.oy,
sizeX: ROTATION_SIZE,
sizeY: ROTATION_SIZE,
actionName: 'rotate',
actionHandler: fabric.controlsUtils.rotationWithSnapping,
cursorStyleHandler: () => pos.cursor,
render: () => {},
})
}
}
/**
* Fabric.js `_setCursorFromEvent` only checks controls on the `target` found
* by `findTarget` (the object directly under the mouse). Rotation controls
* are offset outside the object boundary, so `findTarget` returns null there
* and the rotation cursor never shows.
*
* Fix: patch `_setCursorFromEvent` to also check the active object's controls
* before falling through to the default behavior.
*/
export function setupRotationCursorHandler(canvas: fabric.Canvas) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const c = canvas as any
const original = c._setCursorFromEvent
c._setCursorFromEvent = function (
e: MouseEvent,
target: fabric.FabricObject | undefined,
) {
// Check the active object's rotation controls first
const activeObject = this.getActiveObject()
if (activeObject) {
const pointer = this.getViewportPoint(e)
const found = activeObject.findControl(pointer)
if (found && found.control.actionName === 'rotate') {
this.setCursor(
found.control.cursorStyleHandler(
e,
found.control,
activeObject,
found.coord,
),
)
return
}
}
// No rotation control hit — fall through to default behavior
original.call(this, e, target)
}
}

View file

@ -1,4 +1,4 @@
import type { PenNode, ContainerProps } from '@/types/pen'
import type { PenNode, ContainerProps, SizingBehavior } from '@/types/pen'
import { isBadgeOverlayNode } from '@/services/ai/design-node-sanitization'
import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store'
import {
@ -7,15 +7,11 @@ import {
estimateTextWidthPrecise,
estimateTextHeight,
estimateLineWidth,
getTextOpticalCenterYOffset,
resolveTextContent,
countExplicitTextLines,
defaultLineHeight,
} from './canvas-text-measure'
// Fabric.js internal constant: single-line text height = fontSize * _fontSizeMult.
// The lineHeight property only adds spacing BETWEEN lines in multi-line text;
// for single-line text, Fabric always renders height as fontSize * 1.13.
const FABRIC_FONT_SIZE_MULT = 1.13
// ---------------------------------------------------------------------------
// Padding
@ -110,7 +106,7 @@ export function inferLayout(node: PenNode): 'horizontal' | undefined {
if ('children' in node && node.children?.length) {
for (const child of node.children) {
if ('width' in child && child.width === 'fill_container') return 'horizontal'
if ('height' in child && (child as any).height === 'fill_container') return 'horizontal'
if ('height' in child && child.height === 'fill_container') return 'horizontal'
}
}
return undefined
@ -123,45 +119,53 @@ export function inferLayout(node: PenNode): 'horizontal' | undefined {
/** Compute fit-content width from children. */
export function fitContentWidth(node: PenNode, parentAvail?: number): number {
if (!('children' in node) || !node.children?.length) return 0
const visibleChildren = node.children.filter((child) => isNodeVisible(child))
// Exclude badge/overlay nodes — they use absolute positioning and
// should not inflate the container's fit-content dimensions.
const visibleChildren = node.children.filter(
(child) => isNodeVisible(child) && !isBadgeOverlayNode(child),
)
if (visibleChildren.length === 0) return 0
const c = node as PenNode & ContainerProps
const layout = c.layout || inferLayout(node)
const pad = resolvePadding('padding' in node ? (node as any).padding : undefined)
const gap = 'gap' in node && typeof (node as any).gap === 'number' ? (node as any).gap : 0
const pad = resolvePadding(c.padding)
const nodeGap = typeof c.gap === 'number' ? c.gap : 0
if (layout === 'horizontal') {
const gapTotal = gap * Math.max(0, visibleChildren.length - 1)
const gapTotal = nodeGap * Math.max(0, visibleChildren.length - 1)
const childAvail = parentAvail !== undefined
? Math.max(0, parentAvail - pad.left - pad.right - gapTotal)
: undefined
const childTotal = visibleChildren.reduce((sum, c) => sum + getNodeWidth(c, childAvail), 0)
const childTotal = visibleChildren.reduce((sum, ch) => sum + getNodeWidth(ch, childAvail), 0)
return childTotal + gapTotal + pad.left + pad.right
}
const childAvail = parentAvail !== undefined
? Math.max(0, parentAvail - pad.left - pad.right)
: undefined
const maxChildW = visibleChildren.reduce((max, c) => Math.max(max, getNodeWidth(c, childAvail)), 0)
const maxChildW = visibleChildren.reduce((max, ch) => Math.max(max, getNodeWidth(ch, childAvail)), 0)
return maxChildW + pad.left + pad.right
}
/** Compute fit-content height from children. */
export function fitContentHeight(node: PenNode, parentAvailW?: number): number {
if (!('children' in node) || !node.children?.length) return 0
const visibleChildren = node.children.filter((child) => isNodeVisible(child))
// Exclude badge/overlay nodes — they use absolute positioning and
// should not inflate the container's fit-content dimensions.
const visibleChildren = node.children.filter(
(child) => isNodeVisible(child) && !isBadgeOverlayNode(child),
)
if (visibleChildren.length === 0) return 0
const c = node as PenNode & ContainerProps
const layout = c.layout || inferLayout(node)
const pad = resolvePadding('padding' in node ? (node as any).padding : undefined)
const gap = 'gap' in node && typeof (node as any).gap === 'number' ? (node as any).gap : 0
const pad = resolvePadding(c.padding)
const nodeGap = typeof c.gap === 'number' ? c.gap : 0
// Compute available width for children (used by text height estimation)
const nodeW = getNodeWidth(node, parentAvailW)
const childAvailW = nodeW > 0 ? Math.max(0, nodeW - pad.left - pad.right) : parentAvailW
if (layout === 'vertical') {
const childTotal = visibleChildren.reduce((sum, c) => sum + getNodeHeight(c, undefined, childAvailW), 0)
const gapTotal = gap * Math.max(0, visibleChildren.length - 1)
const childTotal = visibleChildren.reduce((sum, ch) => sum + getNodeHeight(ch, undefined, childAvailW), 0)
const gapTotal = nodeGap * Math.max(0, visibleChildren.length - 1)
return childTotal + gapTotal + pad.top + pad.bottom
}
const maxChildH = visibleChildren.reduce((max, c) => Math.max(max, getNodeHeight(c, undefined, childAvailW)), 0)
const maxChildH = visibleChildren.reduce((max, ch) => Math.max(max, getNodeHeight(ch, undefined, childAvailW)), 0)
return maxChildH + pad.top + pad.bottom
}
@ -292,7 +296,7 @@ export function computeLayoutPositions(
const mainSizing = layoutChildren.map((ch) => {
const prop = isVertical ? 'height' : 'width'
if (prop in ch) {
const s = parseSizing((ch as any)[prop])
const s = parseSizing((ch as PenNode & { width?: SizingBehavior; height?: SizingBehavior })[prop])
if (s === 'fill') return 'fill' as const
}
return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW)
@ -307,19 +311,26 @@ export function computeLayoutPositions(
const sizes = layoutChildren.map((ch, i) => {
let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number)
// For single-line text in vertical layouts, use Fabric's actual rendered
// height (fontSize * 1.13) instead of fontSize * lineHeight. This ensures
// justify:center/end position the text correctly on the main axis.
// For single-line text in vertical layouts, use the actual CanvasKit
// Paragraph height (fontSize * lineHeight) for correct main-axis positioning.
if (isVertical && ch.type === 'text' && mainSizing[i] !== 'fill') {
const content = resolveTextContent(ch)
if (countExplicitTextLines(content) <= 1) {
const fontSize = (ch as any).fontSize ?? 16
mainSize = fontSize * FABRIC_FONT_SIZE_MULT
const fontSize = ch.fontSize ?? 16
const lineHeight = ch.lineHeight ?? defaultLineHeight(fontSize)
const singleLineH = fontSize * lineHeight
const estH = estimateTextHeight(ch, availW)
if (estH <= singleLineH + 1) {
mainSize = singleLineH
}
}
}
return {
w: isVertical ? getNodeWidth(ch, availW) : mainSize,
h: isVertical ? mainSize : getNodeHeight(ch, availH, availW),
// For horizontal layouts, use the child's resolved width (mainSize) for
// height estimation. This ensures text wrapping is calculated at the
// actual width the child will occupy, not the parent's full available width.
h: isVertical ? mainSize : getNodeHeight(ch, availH, isVertical ? availW : mainSize),
}
})
@ -366,28 +377,23 @@ export function computeLayoutPositions(
let crossPos = 0
// For single-line text centered in horizontal layouts, use the actual
// Fabric-rendered height (fontSize * 1.13) instead of fontSize * lineHeight.
// Fabric.js strips lineHeight from the last (only) line, so single-line text
// height is always fontSize * _fontSizeMult regardless of lineHeight.
// Using fontSize * lineHeight overestimates the height, shifting text upward.
// CanvasKit Paragraph height (fontSize * lineHeight) for centering.
// CanvasKit renders with halfLeading:true, distributing leading evenly
// above and below, so no optical correction is needed.
let effectiveChildCross = childCross
if (align === 'center' && !isVertical && child.type === 'text') {
const fontSize = child.fontSize ?? 16
const lineHeight = child.lineHeight ?? defaultLineHeight(fontSize)
const content = resolveTextContent(child)
const isSingleLine = countExplicitTextLines(content) <= 1
if (isSingleLine) {
effectiveChildCross = fontSize * FABRIC_FONT_SIZE_MULT
effectiveChildCross = fontSize * lineHeight
}
}
switch (align) {
case 'center':
crossPos = (crossAvail - effectiveChildCross) / 2
// Optical correction: centered text in horizontal layouts tends to
// look slightly too high; nudge it down a bit for visual centering.
if (!isVertical && child.type === 'text') {
crossPos += getTextOpticalCenterYOffset(child)
}
break
case 'end':
crossPos = crossAvail - childCross
@ -396,7 +402,7 @@ export function computeLayoutPositions(
break
}
// Keep child within cross-axis bounds after optical correction.
// Keep child within cross-axis bounds.
const clampCrossSize =
(!isVertical && align === 'center' && child.type === 'text')
? effectiveChildCross
@ -435,6 +441,7 @@ export function computeLayoutPositions(
}
}
return out as unknown as PenNode
})

View file

@ -1,4 +1,3 @@
import * as fabric from 'fabric'
import { generateId } from '@/stores/document-store'
import type { PenNode } from '@/types/pen'
import type { ToolType } from '@/types/canvas'
@ -97,15 +96,3 @@ export function isDrawingTool(tool: ToolType): boolean {
return tool !== 'select' && tool !== 'hand'
}
/**
* Convert a pointer event to scene coordinates using Fabric's own method
* which correctly handles DPR / retina scaling and viewport transform.
*/
export function toScene(
canvas: fabric.Canvas,
e: PointerEvent,
): { x: number; y: number } {
canvas.calcOffset()
const point = canvas.getScenePoint(e)
return { x: point.x, y: point.y }
}

View file

@ -1,743 +0,0 @@
import * as fabric from 'fabric'
import type { PenNode, ImageFitMode } from '@/types/pen'
import { buildEllipseArcPath, isArcEllipse } from '@/utils/arc-path'
import type {
PenFill,
PenStroke,
PenEffect,
LinearGradientFill,
RadialGradientFill,
ShadowEffect,
} from '@/types/styles'
import {
DEFAULT_FILL,
DEFAULT_STROKE,
DEFAULT_STROKE_WIDTH,
SELECTION_BLUE,
} from './canvas-constants'
import { defaultLineHeight } from './canvas-text-measure'
import { applyRotationControls } from './canvas-controls'
import { lookupIconByName, tryAsyncIconFontResolution } from '@/services/ai/icon-resolver'
function angleToCoords(
angleDeg: number,
width: number,
height: number,
): { x1: number; y1: number; x2: number; y2: number } {
const rad = ((angleDeg - 90) * Math.PI) / 180
const cos = Math.cos(rad)
const sin = Math.sin(rad)
return {
x1: width / 2 - (cos * width) / 2,
y1: height / 2 - (sin * height) / 2,
x2: width / 2 + (cos * width) / 2,
y2: height / 2 + (sin * height) / 2,
}
}
function sanitizeColorStops(
stops: { offset: number; color: string }[],
): { offset: number; color: string }[] {
return stops.map((s) => ({
offset: Number.isFinite(s.offset)
? Math.max(0, Math.min(1, s.offset))
: 0,
color: s.color,
}))
}
function createLinearGradient(
fill: LinearGradientFill,
width: number,
height: number,
): fabric.Gradient<'linear'> {
const coords = angleToCoords(fill.angle ?? 0, width, height)
return new fabric.Gradient({
type: 'linear',
coords,
colorStops: sanitizeColorStops(fill.stops),
})
}
function createRadialGradient(
fill: RadialGradientFill,
width: number,
height: number,
): fabric.Gradient<'radial'> {
const cx = (fill.cx ?? 0.5) * width
const cy = (fill.cy ?? 0.5) * height
const r = (fill.radius ?? 0.5) * Math.max(width, height)
return new fabric.Gradient({
type: 'radial',
coords: { x1: cx, y1: cy, r1: 0, x2: cx, y2: cy, r2: r },
colorStops: sanitizeColorStops(fill.stops),
})
}
export function resolveFill(
fills: PenFill[] | string | undefined,
width: number,
height: number,
): string | fabric.Gradient<'linear'> | fabric.Gradient<'radial'> {
// Pencil format may use a plain color string instead of PenFill[]
if (typeof fills === 'string') return fills
if (!fills || fills.length === 0) return DEFAULT_FILL
const first = fills[0]
if (first.type === 'solid') return first.color
if (first.type === 'linear_gradient') {
if (!first.stops || first.stops.length === 0) return DEFAULT_FILL
return createLinearGradient(first, width, height)
}
if (first.type === 'radial_gradient') {
if (!first.stops || first.stops.length === 0) return DEFAULT_FILL
return createRadialGradient(first, width, height)
}
return DEFAULT_FILL
}
export function resolveFillColor(fills?: PenFill[] | string): string {
if (typeof fills === 'string') return fills
if (!fills || fills.length === 0) return DEFAULT_FILL
const first = fills[0]
if (first.type === 'solid') return first.color
if (
first.type === 'linear_gradient' ||
first.type === 'radial_gradient'
) {
return first.stops[0]?.color ?? DEFAULT_FILL
}
return DEFAULT_FILL
}
export function resolveShadow(
effects?: PenEffect[],
): fabric.Shadow | undefined {
if (!effects) return undefined
const shadow = effects.find(
(e): e is ShadowEffect => e.type === 'shadow',
)
if (!shadow) return undefined
return new fabric.Shadow({
color: shadow.color,
blur: shadow.blur,
offsetX: shadow.offsetX,
offsetY: shadow.offsetY,
})
}
export function resolveStrokeColor(stroke?: PenStroke): string | undefined {
if (!stroke) return undefined
if (typeof stroke.fill === 'string') return stroke.fill
if (stroke.fill && stroke.fill.length > 0) {
return resolveFillColor(stroke.fill)
}
// No explicit fill color → stroke should be invisible (not default black).
// Pencil uses fill-less strokes for internal layout spacing.
return undefined
}
export function resolveStrokeWidth(stroke?: PenStroke): number {
if (!stroke) return 0
if (typeof stroke.thickness === 'number') return stroke.thickness
// Directional strokes (e.g. { top: 1 } or { bottom: 1 }) should NOT
// render as a full border. Return 0 so the main rect has no stroke;
// directional borders are rendered as separate synthetic nodes in canvas-sync.
if (typeof stroke.thickness === 'object' && !Array.isArray(stroke.thickness)) {
return 0
}
return stroke.thickness?.[0] ?? DEFAULT_STROKE_WIDTH
}
/** Check if a stroke is directional (top/right/bottom/left specific). */
export function isDirectionalStroke(stroke?: PenStroke): boolean {
if (!stroke) return false
return (
typeof stroke.thickness === 'object'
&& !Array.isArray(stroke.thickness)
&& ('top' in stroke.thickness || 'right' in stroke.thickness
|| 'bottom' in stroke.thickness || 'left' in stroke.thickness)
)
}
/** Get directional stroke thicknesses. */
export function getDirectionalStrokeThicknesses(stroke: PenStroke): {
top: number; right: number; bottom: number; left: number
} {
const t = stroke.thickness as unknown as Record<string, number>
return {
top: t.top ?? 0,
right: t.right ?? 0,
bottom: t.bottom ?? 0,
left: t.left ?? 0,
}
}
function resolveTextContent(
content: string | { text: string }[],
): string {
if (typeof content === 'string') return content
return content.map((s) => s.text).join('')
}
function shouldSplitByGrapheme(text: string): boolean {
// Mixed CJK content (especially with a long CJK run) wraps poorly with word-based splitting.
// Enable grapheme splitting so Textbox can break between CJK characters naturally.
const hasCjk = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(text)
const hasLongCjkRun = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]{4,}/.test(text)
return hasCjk && hasLongCjkRun
}
function isFixedWidthText(node: PenNode): boolean {
if (node.type !== 'text') return false
if (node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height') return true
// When textAlign is not 'left', the layout engine injected centering.
// IText ignores width and computes its own, making textAlign ineffective
// for single-line text. Use Textbox so the width is respected and
// textAlign:'center' actually centers the text within the box.
if (node.textAlign && node.textAlign !== 'left') return true
return false
}
function sizeToNumber(
val: number | string | undefined,
fallback: number,
): number {
if (typeof val === 'number') return val
if (typeof val === 'string') {
// Handle "fit_content(N)" / "fill_container(N)"
const m = val.match(/\((\d+(?:\.\d+)?)\)/)
if (m) return parseFloat(m[1])
const n = parseFloat(val)
if (!isNaN(n)) return n
}
return fallback
}
function cornerRadiusValue(
cr: number | [number, number, number, number] | undefined,
): number {
if (cr === undefined) return 0
if (typeof cr === 'number') return cr
return cr[0]
}
/**
* Compute image scale, crop, and clip for a given fit mode.
*
* Fill/Crop uses FabricImage's native cropX/cropY to trim source pixels,
* avoiding a self-clipPath that would conflict with parent frame clipping.
* Corner radius is handled via a separate clipPath when needed.
*/
export function computeImageTransform(
nw: number,
nh: number,
w: number,
h: number,
mode: ImageFitMode = 'fill',
cornerRadius = 0,
): {
scaleX: number
scaleY: number
cropX: number
cropY: number
cropWidth: number
cropHeight: number
clipPath?: fabric.Rect
} {
switch (mode) {
case 'fill':
case 'crop': {
const ratio = Math.max(w / nw, h / nh)
// Crop dimensions in source pixels (center crop)
const cropW = w / ratio
const cropH = h / ratio
const cropX = (nw - cropW) / 2
const cropY = (nh - cropH) / 2
const clipR = cornerRadius > 0 ? cornerRadius / ratio : 0
return {
scaleX: ratio,
scaleY: ratio,
cropX,
cropY,
cropWidth: cropW,
cropHeight: cropH,
clipPath: clipR > 0
? new fabric.Rect({
width: cropW,
height: cropH,
rx: clipR,
ry: clipR,
originX: 'center',
originY: 'center',
})
: undefined,
}
}
case 'fit': {
const ratio = Math.min(w / nw, h / nh)
const clipR = cornerRadius > 0 ? cornerRadius / ratio : 0
return {
scaleX: ratio,
scaleY: ratio,
cropX: 0,
cropY: 0,
cropWidth: nw,
cropHeight: nh,
clipPath: clipR > 0
? new fabric.Rect({
width: nw,
height: nh,
rx: clipR,
ry: clipR,
originX: 'center',
originY: 'center',
})
: undefined,
}
}
default:
// 'tile' is handled at call site — fall through to stretch
return { scaleX: w / nw, scaleY: h / nh, cropX: 0, cropY: 0, cropWidth: nw, cropHeight: nh }
}
}
export interface FabricObjectWithPenId extends fabric.FabricObject {
penNodeId?: string
}
export function createFabricObject(
node: PenNode,
): FabricObjectWithPenId | null {
let obj: FabricObjectWithPenId | null = null
const baseProps = {
left: node.x ?? 0,
top: node.y ?? 0,
originX: 'left' as const,
originY: 'top' as const,
angle: node.rotation ?? 0,
opacity: typeof node.opacity === 'number' ? node.opacity : 1,
}
// Resolve effects (shadow) — handle both `effects` (array) and `effect` (single object)
let effects = 'effects' in node ? node.effects : undefined
if (!effects && 'effect' in node && (node as any).effect) {
effects = [(node as any).effect]
}
const shadow = resolveShadow(effects)
// Resolve visibility and lock
const visible = ('visible' in node ? node.visible : undefined) !== false
const locked = ('locked' in node ? node.locked : undefined) === true
switch (node.type) {
case 'frame': {
// Frames without explicit fill are transparent containers
const w = sizeToNumber(node.width, 100)
const h = sizeToNumber(node.height, 100)
const r = Math.min(cornerRadiusValue(node.cornerRadius), h / 2)
const fillVal = node.fill as PenFill[] | string | undefined
const hasFill = typeof fillVal === 'string' ? fillVal.length > 0 : (fillVal && fillVal.length > 0)
obj = new fabric.Rect({
...baseProps,
width: w,
height: h,
rx: r,
ry: r,
fill: hasFill ? resolveFill(fillVal, w, h) : 'transparent',
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
}) as FabricObjectWithPenId
break
}
case 'rectangle': {
const w = sizeToNumber(node.width, 100)
const h = sizeToNumber(node.height, 100)
const r = Math.min(cornerRadiusValue(node.cornerRadius), h / 2)
const rectFillVal = node.fill as PenFill[] | string | undefined
const hasFill = typeof rectFillVal === 'string' ? rectFillVal.length > 0 : (rectFillVal && rectFillVal.length > 0)
const hasStroke = resolveStrokeWidth(node.stroke) > 0
obj = new fabric.Rect({
...baseProps,
width: w,
height: h,
rx: r,
ry: r,
// Stroke-only rectangles (no fill + has stroke) should be transparent
fill: hasFill ? resolveFill(rectFillVal, w, h) : (hasStroke ? 'transparent' : DEFAULT_FILL),
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
}) as FabricObjectWithPenId
break
}
case 'ellipse': {
const w = sizeToNumber(node.width, 100)
const h = sizeToNumber(node.height, 100)
if (isArcEllipse(node.startAngle, node.sweepAngle, node.innerRadius)) {
const arcD = buildEllipseArcPath(w, h, node.startAngle ?? 0, node.sweepAngle ?? 360, node.innerRadius ?? 0)
obj = new fabric.Path(arcD, {
...baseProps,
fill: resolveFill(node.fill, w, h),
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
strokeUniform: true,
fillRule: 'evenodd',
}) as FabricObjectWithPenId
// The arc path is drawn within a 0,0 → w,h coordinate space.
// Override Fabric's auto-computed bounding box to avoid distortion.
;(obj as any).__sourceD = arcD
;(obj as any).__nativeWidth = w
;(obj as any).__nativeHeight = h
;(obj as any).pathOffset = new fabric.Point(w / 2, h / 2)
obj.set({ width: w, height: h, scaleX: 1, scaleY: 1 })
} else {
obj = new fabric.Ellipse({
...baseProps,
rx: w / 2,
ry: h / 2,
fill: resolveFill(node.fill, w, h),
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
}) as FabricObjectWithPenId
}
break
}
case 'line': {
obj = new fabric.Line(
[
node.x ?? 0,
node.y ?? 0,
node.x2 ?? (node.x ?? 0) + 100,
node.y2 ?? (node.y ?? 0),
],
{
...baseProps,
stroke: resolveStrokeColor(node.stroke) ?? DEFAULT_STROKE,
strokeWidth: resolveStrokeWidth(node.stroke) || DEFAULT_STROKE_WIDTH,
fill: '',
},
) as FabricObjectWithPenId
break
}
case 'polygon': {
const w = sizeToNumber(node.width, 100)
const h = sizeToNumber(node.height, 100)
const count = node.polygonCount || 6
const points = Array.from({ length: count }, (_, i) => {
const angle = (i * 2 * Math.PI) / count - Math.PI / 2
return {
x: (w / 2) * Math.cos(angle) + w / 2,
y: (h / 2) * Math.sin(angle) + h / 2,
}
})
obj = new fabric.Polygon(points, {
...baseProps,
fill: resolveFill(node.fill, w, h),
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
}) as FabricObjectWithPenId
// Cache native dimensions before scaling (Polygon width/height is derived from points)
;(obj as any).__nativeWidth = obj.width
;(obj as any).__nativeHeight = obj.height
if (w > 0 && h > 0 && obj.width && obj.height) {
obj.set({ scaleX: w / obj.width, scaleY: h / obj.height })
}
break
}
case 'path': {
const pw = sizeToNumber(node.width, 0)
const ph = sizeToNumber(node.height, 0)
const safePathData =
typeof node.d === 'string' && node.d.trim().length > 0
? node.d
: 'M0 0 L0 0'
const hasExplicitFill = node.fill && node.fill.length > 0
const strokeColor = resolveStrokeColor(node.stroke)
const strokeWidth = resolveStrokeWidth(node.stroke)
const hasVisibleStroke = strokeWidth > 0 && !!strokeColor
// Stroke-only icons (e.g. Lucide-style) must not get a default fill.
// Use 'transparent' (not 'none' — Fabric.js ignores 'none' and falls back to black).
const pathFill = hasExplicitFill
? resolveFill(node.fill, pw || 100, ph || 100)
: hasVisibleStroke
? 'transparent'
: DEFAULT_FILL
obj = new fabric.Path(safePathData, {
...baseProps,
fill: pathFill,
stroke: hasVisibleStroke ? strokeColor : undefined,
strokeWidth: hasVisibleStroke ? strokeWidth : 0,
strokeUniform: true,
fillRule: 'evenodd', // Compound paths: inner sub-paths become transparent cutouts
}) as FabricObjectWithPenId
;(obj as any).__sourceD = safePathData
// Cache native dimensions before scaling (Path width/height is derived from d)
;(obj as any).__nativeWidth = obj.width
;(obj as any).__nativeHeight = obj.height
if (pw > 0 && ph > 0 && obj.width && obj.height) {
// Uniform scale — preserve aspect ratio so icons don't get squished
const uniformScale = Math.min(pw / obj.width, ph / obj.height)
// Keep native path width/height. Overriding width/height can shift pathOffset
// and make icons appear visually off-center in logos.
obj.set({ scaleX: uniformScale, scaleY: uniformScale })
}
break
}
case 'icon_font': {
const iconName = node.iconFontName ?? node.name ?? ''
const iconMatch = lookupIconByName(iconName)
const iconD = iconMatch?.d ?? 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0'
const iconStyle = iconMatch?.style ?? 'stroke'
// Queue async resolution when local lookup fails — result cached for future lookups
if (!iconMatch && iconName) {
tryAsyncIconFontResolution(node.id, iconName)
}
const pw = sizeToNumber(node.width, 20)
const ph = sizeToNumber(node.height, 20)
// Resolve fill color: runtime icon_font.fill may be a string "#hex" or PenFill[]
const rawFill = (node as unknown as Record<string, unknown>).fill
const iconFillColor = typeof rawFill === 'string'
? rawFill
: Array.isArray(node.fill) && node.fill.length > 0
? resolveFillColor(node.fill)
: '#64748B'
const iconPathFill = iconStyle === 'stroke' ? 'transparent' : iconFillColor
const iconStrokeColor = iconStyle === 'stroke' ? iconFillColor : undefined
const iconStrokeWidth = iconStyle === 'stroke' ? 2 : 0
obj = new fabric.Path(iconD, {
...baseProps,
fill: iconPathFill,
stroke: iconStrokeColor,
strokeWidth: iconStrokeWidth,
strokeUniform: true,
strokeLineCap: 'round',
strokeLineJoin: 'round',
fillRule: 'evenodd',
}) as FabricObjectWithPenId
;(obj as any).__nativeWidth = obj.width
;(obj as any).__nativeHeight = obj.height
;(obj as any).__iconFontName = iconName
;(obj as any).__iconStyle = iconStyle
if (pw > 0 && ph > 0 && obj.width && obj.height) {
const uniformScale = Math.min(pw / obj.width, ph / obj.height)
obj.set({ scaleX: uniformScale, scaleY: uniformScale })
}
break
}
case 'text': {
const textContent = resolveTextContent(node.content)
const w = sizeToNumber(node.width, 0)
const splitByGrapheme = shouldSplitByGrapheme(textContent)
const textProps = {
...baseProps,
fontFamily: node.fontFamily ?? 'Inter, sans-serif',
fontSize: node.fontSize ?? 16,
fontWeight: (node.fontWeight as string) ?? 'normal',
fontStyle: node.fontStyle ?? 'normal',
fill: resolveFillColor(node.fill),
textAlign: node.textAlign ?? 'left',
underline: node.underline ?? false,
linethrough: node.strikethrough ?? false,
lineHeight: node.lineHeight ?? defaultLineHeight(node.fontSize ?? 16),
charSpacing: node.letterSpacing
? (node.letterSpacing / (node.fontSize || 16)) * 1000
: 0,
}
// Use Textbox for fixed-width / fixed-size modes (word wrapping).
// Use IText for auto-width mode (no wrapping, expands horizontally).
const useTextbox = isFixedWidthText(node)
if (useTextbox) {
obj = new fabric.Textbox(textContent, {
...textProps,
width: w > 0 ? w : 200,
splitByGrapheme,
}) as FabricObjectWithPenId
} else {
obj = new fabric.IText(textContent, textProps) as FabricObjectWithPenId
}
break
}
case 'image': {
const w = sizeToNumber(node.width, 200)
const h = sizeToNumber(node.height, 200)
const r = Math.min(cornerRadiusValue(node.cornerRadius), h / 2)
const fitMode = node.objectFit ?? 'fill'
const imgEl = new Image()
imgEl.src = node.src
// Tile mode: use a Rect with a Pattern fill instead of FabricImage
if (fitMode === 'tile') {
const tileRect = new fabric.Rect({
...baseProps,
width: w,
height: h,
rx: r,
ry: r,
fill: '#e5e7eb',
strokeWidth: 0,
}) as FabricObjectWithPenId
;(tileRect as any).__objectFit = 'tile'
const applyTilePattern = () => {
const canvas = tileRect.canvas
tileRect.set({
fill: new fabric.Pattern({
source: imgEl,
repeat: 'repeat',
}),
dirty: true,
})
canvas?.requestRenderAll()
}
if (imgEl.complete) {
applyTilePattern()
} else {
tileRect.penNodeId = node.id
imgEl.onload = applyTilePattern
}
obj = tileRect
break
}
if (imgEl.complete) {
const nw = imgEl.naturalWidth || w
const nh = imgEl.naturalHeight || h
const transform = computeImageTransform(nw, nh, w, h, fitMode, r)
obj = new fabric.FabricImage(imgEl, {
...baseProps,
cropX: transform.cropX,
cropY: transform.cropY,
width: transform.cropWidth,
height: transform.cropHeight,
scaleX: transform.scaleX,
scaleY: transform.scaleY,
clipPath: transform.clipPath ?? undefined,
objectCaching: !transform.clipPath,
}) as unknown as FabricObjectWithPenId
;(obj as any).__objectFit = fitMode
;(obj as any).__nativeWidth = nw
;(obj as any).__nativeHeight = nh
} else {
// Placeholder while image loads
const placeholder = new fabric.Rect({
...baseProps,
width: w,
height: h,
rx: r,
ry: r,
fill: '#e5e7eb',
strokeWidth: 0,
}) as FabricObjectWithPenId
placeholder.penNodeId = node.id
;(placeholder as any).__objectFit = fitMode
imgEl.onload = () => {
const canvas = placeholder.canvas
if (!canvas) return
const nw = imgEl.naturalWidth
const nh = imgEl.naturalHeight
const transform = computeImageTransform(nw, nh, w, h, fitMode, r)
const fabricImg = new fabric.FabricImage(imgEl, {
...baseProps,
left: placeholder.left,
top: placeholder.top,
cropX: transform.cropX,
cropY: transform.cropY,
width: transform.cropWidth,
height: transform.cropHeight,
scaleX: transform.scaleX,
scaleY: transform.scaleY,
}) as unknown as FabricObjectWithPenId
fabricImg.penNodeId = node.id
;(fabricImg as any).__objectFit = fitMode
;(fabricImg as any).__nativeWidth = nw
;(fabricImg as any).__nativeHeight = nh
fabricImg.set({
borderColor: SELECTION_BLUE,
borderScaleFactor: 2,
cornerColor: SELECTION_BLUE,
cornerStrokeColor: '#ffffff',
cornerStyle: 'rect',
cornerSize: 8,
transparentCorners: false,
borderOpacityWhenMoving: 1,
padding: 0,
hoverCursor: 'default',
})
fabricImg.setControlVisible('mtr', false)
applyRotationControls(fabricImg)
if (shadow) fabricImg.shadow = shadow
fabricImg.visible = visible
fabricImg.selectable = !locked
fabricImg.evented = !locked
// Apply clipPath from transform (corner radius only)
if (transform.clipPath) {
fabricImg.clipPath = transform.clipPath
fabricImg.objectCaching = false
}
// Preserve clipPath from placeholder so clipped-frame children stay clipped
if (!transform.clipPath && placeholder.clipPath) {
fabricImg.clipPath = placeholder.clipPath
fabricImg.dirty = true
}
// Preserve z-order: insert at placeholder's index instead of
// appending to end (which would put the image on top of everything)
const idx = canvas.getObjects().indexOf(placeholder)
canvas.remove(placeholder)
if (idx >= 0) {
canvas.insertAt(idx, fabricImg)
} else {
canvas.add(fabricImg)
}
canvas.requestRenderAll()
}
obj = placeholder
}
break
}
case 'group': {
const w = sizeToNumber(node.width, 100)
const h = sizeToNumber(node.height, 100)
obj = new fabric.Rect({
...baseProps,
width: w,
height: h,
fill: resolveFill(node.fill, w, h),
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
selectable: true,
}) as FabricObjectWithPenId
break
}
case 'ref': {
// RefNodes need to be resolved before rendering
return null
}
}
if (obj) {
obj.penNodeId = node.id
// Selection styling (from HEAD)
obj.set({
borderColor: SELECTION_BLUE,
borderScaleFactor: 2,
cornerColor: SELECTION_BLUE,
cornerStrokeColor: '#ffffff',
cornerStyle: 'rect',
cornerSize: 8,
transparentCorners: false,
borderOpacityWhenMoving: 1,
padding: 0,
hoverCursor: 'default',
})
obj.setControlVisible('mtr', false)
applyRotationControls(obj)
// Shadow, visibility, lock (from theirs)
if (shadow) obj.shadow = shadow
obj.visible = visible
obj.selectable = !locked
obj.evented = !locked
}
return obj
}

View file

@ -1,362 +0,0 @@
import * as fabric from 'fabric'
import type { PenDocument, PenNode } from '@/types/pen'
import { useDocumentStore } from '@/stores/document-store'
import { forcePageResync } from './canvas-sync-utils'
import { useHistoryStore } from '@/stores/history-store'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { setFabricSyncLock } from './canvas-sync-lock'
import { nodeRenderInfo, rebuildNodeRenderInfo } from './use-canvas-sync'
import { clearGuides } from './guide-utils'
import {
getActiveDragSession,
finalizeParentTransform,
finalizeParentRotation,
} from './parent-child-transform'
import {
endLayoutDrag,
cancelLayoutDrag,
isLayoutDragActive,
} from './layout-reorder'
import {
checkDragReparent,
checkDragReparentByBounds,
checkReparentIntoFrame,
} from './drag-reparent'
import {
commitDragInto,
cancelDragInto,
isDragIntoActive,
commitDragIntoMulti,
} from './drag-into-layout'
/** Absolute bounds for each child computed during syncSelectionToStore. */
export interface ChildAbsBounds {
nodeId: string
x: number
y: number
w: number
h: number
}
/** Sync a single Fabric object's transform back to the document store. */
export function syncObjToStore(obj: FabricObjectWithPenId): void {
if (!obj?.penNodeId) return
const info = nodeRenderInfo.get(obj.penNodeId)
const scaleX = obj.scaleX ?? 1
const scaleY = obj.scaleY ?? 1
// Convert Fabric absolute position -> document-tree relative position
const offsetX = info?.parentOffsetX ?? 0
const offsetY = info?.parentOffsetY ?? 0
const updates: Partial<PenNode> = {
x: (obj.left ?? 0) - offsetX,
y: (obj.top ?? 0) - offsetY,
rotation: obj.angle ?? 0,
}
if (obj.width !== undefined) {
;(updates as Record<string, unknown>).width = obj.width * scaleX
}
if (obj.height !== undefined) {
;(updates as Record<string, unknown>).height = obj.height * scaleY
}
setFabricSyncLock(true)
useDocumentStore.getState().updateNode(obj.penNodeId, updates)
setFabricSyncLock(false)
}
/**
* Resolve the absolute position of each child inside an ActiveSelection
* and sync every child to the document store.
* Returns an array of absolute bounds (for subsequent reparent checks).
*/
export function syncSelectionToStore(
target: fabric.FabricObject,
): ChildAbsBounds[] {
const boundsOut: ChildAbsBounds[] = []
if (!('getObjects' in target)) return boundsOut
const group = target as fabric.ActiveSelection
const groupMatrix = group.calcTransformMatrix()
setFabricSyncLock(true)
for (const child of group.getObjects()) {
const obj = child as FabricObjectWithPenId
if (!obj.penNodeId) continue
// Transform the child's local origin into absolute scene coords
const childMatrix = child.calcOwnMatrix()
const combined = fabric.util.multiplyTransformMatrices(
groupMatrix,
childMatrix,
)
// The origin point in local space is (0,0) when originX/Y = 'left'/'top'.
// For originX:'left', originY:'top' the top-left is at (-width/2, -height/2)
// relative to the object's own center.
const halfW =
((child.width ?? 0) * (child.scaleX ?? 1)) / 2
const halfH =
((child.height ?? 0) * (child.scaleY ?? 1)) / 2
const absCenter = fabric.util.transformPoint(
new fabric.Point(0, 0),
combined,
)
const absLeft = absCenter.x - halfW
const absTop = absCenter.y - halfH
const info = nodeRenderInfo.get(obj.penNodeId)
const offsetX = info?.parentOffsetX ?? 0
const offsetY = info?.parentOffsetY ?? 0
const scaleX = child.scaleX ?? 1
const scaleY = child.scaleY ?? 1
const absW = (child.width ?? 0) * scaleX
const absH = (child.height ?? 0) * scaleY
const updates: Partial<PenNode> = {
x: absLeft - offsetX,
y: absTop - offsetY,
rotation: child.angle ?? 0,
}
if (child.width !== undefined) {
;(updates as Record<string, unknown>).width = absW
}
if (child.height !== undefined) {
;(updates as Record<string, unknown>).height = absH
}
useDocumentStore.getState().updateNode(obj.penNodeId, updates)
boundsOut.push({
nodeId: obj.penNodeId,
x: absLeft,
y: absTop,
w: absW,
h: absH,
})
}
setFabricSyncLock(false)
return boundsOut
}
// Re-entry guard for handleObjectModified (module-level state)
let inModifiedHandler = false
/**
* Handle the object:modified Fabric event.
* Extracted from use-canvas-events.ts to keep it under 800 lines.
*
* @param opt - The Fabric event options containing the modified target
* @param canvas - The Fabric canvas instance
* @param getPreModDoc - Getter for the pre-modification document snapshot
* @param clearPreModDoc - Callback to clear the pre-modification snapshot after use
* @param closeTransformBatch - Callback to close the history batch
*/
export function handleObjectModified(
opt: { target: fabric.FabricObject },
canvas: fabric.Canvas,
getPreModDoc: () => PenDocument | null,
clearPreModDoc: () => void,
closeTransformBatch: () => void,
): void {
// Guard against re-entry: discardActiveObject() fires
// _finalizeCurrentTransform -> object:modified recursively.
if (inModifiedHandler) return
inModifiedHandler = true
try {
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 = getPreModDoc() ?? useDocumentStore.getState().document
clearPreModDoc()
// 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)
}
let isSelectionModification = false
let selectionReparented = false
try {
// Single object -- bake scale and sync
const asPen = target as FabricObjectWithPenId
if (asPen.penNodeId) {
// Layout reorder: skip normal sync, reorder instead
// BUT first check if the node was dragged outside its root frame
if (isLayoutDragActive()) {
if (checkDragReparent(asPen)) {
// Dragged outside parent -- cancel layout reorder and detach
cancelLayoutDrag()
rebuildNodeRenderInfo()
forcePageResync()
closeTransformBatch()
return
}
endLayoutDrag(asPen, canvas)
rebuildNodeRenderInfo()
closeTransformBatch()
return
}
// Drag-into layout container: reparent into target container
if (isDragIntoActive()) {
commitDragInto(asPen, canvas)
rebuildNodeRenderInfo()
closeTransformBatch()
return
}
const scaleX = target.scaleX ?? 1
const scaleY = target.scaleY ?? 1
// Path/Polygon dimensions are derived from their geometry data, and
// Image dimensions represent the source element's natural size.
// Baking scale into width/height would corrupt their internal state.
// Keep scaleX/scaleY on the Fabric object and let syncObjToStore
// compute the stored dimensions (width * scaleX).
const skipScaleBake =
target.type === 'path' ||
target.type === 'polygon' ||
target.type === 'image'
if (!skipScaleBake) {
if (target.width !== undefined) {
target.set({ width: target.width * scaleX, scaleX: 1 })
}
if (target.height !== undefined) {
target.set({ height: target.height * scaleY, scaleY: 1 })
}
}
target.setCoords()
syncObjToStore(asPen)
// Finalize children: bake their scale + update store positions
if (getActiveDragSession()) {
if (scaleX !== 1 || scaleY !== 1) {
finalizeParentTransform(asPen, canvas, scaleX, scaleY)
}
finalizeParentRotation(asPen)
}
// Check if the node was dragged out of / into a root frame
const didReparentOut = checkDragReparent(asPen)
// Fallback: check if a root-level node should be reparented INTO a frame
const didReparentIn = !didReparentOut && asPen.penNodeId
? (() => {
setFabricSyncLock(true)
const result = checkReparentIntoFrame(asPen.penNodeId!, {
x: asPen.left ?? 0,
y: asPen.top ?? 0,
w: (asPen.width ?? 0) * (asPen.scaleX ?? 1),
h: (asPen.height ?? 0) * (asPen.scaleY ?? 1),
})
setFabricSyncLock(false)
return result
})()
: false
if (didReparentOut || didReparentIn) {
// Force re-sync since tree structure changed
rebuildNodeRenderInfo()
forcePageResync()
}
} else if ('getObjects' in target) {
isSelectionModification = true
// ActiveSelection -- bake scale per child, then sync all
const group = target as fabric.ActiveSelection
for (const child of group.getObjects()) {
const sx = child.scaleX ?? 1
const sy = child.scaleY ?? 1
const childSkipScaleBake =
child.type === 'path' ||
child.type === 'polygon' ||
child.type === 'image'
if (!childSkipScaleBake) {
if (child.width !== undefined) {
child.set({ width: child.width * sx, scaleX: 1 })
}
if (child.height !== undefined) {
child.set({ height: child.height * sy, scaleY: 1 })
}
}
child.setCoords()
}
// Drag-into container: reparent all selected objects
if (isDragIntoActive()) {
canvas.discardActiveObject()
commitDragIntoMulti(canvas)
rebuildNodeRenderInfo()
closeTransformBatch()
return
}
const childBounds = syncSelectionToStore(target)
// Check reparenting for each child based on final positions:
// 1. Objects with a parent that are dragged completely outside -> detach
// 2. Root-level objects whose center is inside a frame -> reparent into it
let anyReparented = false
const selNodeIdSet = new Set(childBounds.map((b) => b.nodeId))
setFabricSyncLock(true)
for (const b of childBounds) {
const bounds = { x: b.x, y: b.y, w: b.w, h: b.h }
if (checkDragReparentByBounds(b.nodeId, bounds)) {
anyReparented = true
} else if (
checkReparentIntoFrame(b.nodeId, bounds, selNodeIdSet)
) {
anyReparented = true
}
}
setFabricSyncLock(false)
if (anyReparented) {
// Tree structure changed -- force full re-sync after the
// ActiveSelection is disbanded so clip paths and positions
// are recomputed correctly.
isSelectionModification = false
selectionReparented = true
}
}
// Safety cleanup: clear any leftover drag-into session that wasn't
// 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.
// Skip the forced setState for ActiveSelection modifications -- the
// positions were already written by syncSelectionToStore(), and
// re-syncing absolute coords onto group-relative objects would undo
// the move.
rebuildNodeRenderInfo()
if (selectionReparented) {
// Disband the ActiveSelection so objects become individual canvas
// items. Then defer the full re-sync to the next frame -- Fabric
// needs a render cycle to fully restore the objects' transforms
// and properties from the disbanded group. Without this delay,
// the subscriber re-syncs while objects are in a transitional
// state and visual properties (fill, clip paths) get lost.
canvas.discardActiveObject()
canvas.requestRenderAll()
requestAnimationFrame(() => {
rebuildNodeRenderInfo()
forcePageResync()
})
} else if (!isSelectionModification) {
forcePageResync()
}
closeTransformBatch()
} finally {
inModifiedHandler = false
}
}

View file

@ -1,284 +0,0 @@
import * as fabric from 'fabric'
import type { PenNode } from '@/types/pen'
import { buildEllipseArcPath, isArcEllipse } from '@/utils/arc-path'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import {
resolveFill,
resolveFillColor,
resolveShadow,
resolveStrokeColor,
resolveStrokeWidth,
computeImageTransform,
} from './canvas-object-factory'
function sizeToNumber(
val: number | string | undefined,
fallback: number,
): number {
if (typeof val === 'number') return val
if (typeof val === 'string') {
const m = val.match(/\((\d+(?:\.\d+)?)\)/)
if (m) return parseFloat(m[1])
const n = parseFloat(val)
if (!isNaN(n)) return n
}
return fallback
}
function cornerRadiusValue(
cr: number | [number, number, number, number] | undefined,
): number {
if (cr === undefined) return 0
if (typeof cr === 'number') return cr
return cr[0]
}
function shouldSplitByGrapheme(text: string): boolean {
const hasCjk = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(text)
const hasLongCjkRun = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]{4,}/.test(text)
return hasCjk && hasLongCjkRun
}
function isFixedWidthText(node: PenNode): boolean {
if (node.type !== 'text') return false
return node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height'
}
export function syncFabricObject(
obj: FabricObjectWithPenId,
node: PenNode,
) {
const visible = ('visible' in node ? node.visible : undefined) !== false
const locked = ('locked' in node ? node.locked : undefined) === true
const effects = 'effects' in node ? node.effects : undefined
const shadow = resolveShadow(effects)
obj.set({
left: node.x ?? obj.left,
top: node.y ?? obj.top,
angle: node.rotation ?? 0,
opacity: typeof node.opacity === 'number' ? node.opacity : 1,
visible,
selectable: !locked,
evented: !locked,
})
obj.shadow = shadow ?? null
switch (node.type) {
case 'frame': {
// Frames without explicit fill are transparent containers
const w = sizeToNumber(node.width, 100)
const h = sizeToNumber(node.height, 100)
const hasFill = node.fill && node.fill.length > 0
obj.set({
width: w,
height: h,
fill: hasFill ? resolveFill(node.fill, w, h) : 'transparent',
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
})
if ('rx' in obj) {
const r = Math.min(cornerRadiusValue(node.cornerRadius), h / 2)
obj.set({ rx: r, ry: r })
}
break
}
case 'rectangle':
case 'group': {
const w = sizeToNumber(node.width, 100)
const h = sizeToNumber(node.height, 100)
obj.set({
width: w,
height: h,
fill: resolveFill(node.fill, w, h),
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
})
if ('rx' in obj) {
const r = Math.min(cornerRadiusValue(node.cornerRadius), h / 2)
obj.set({ rx: r, ry: r })
}
break
}
case 'ellipse': {
const w = sizeToNumber(node.width, 100)
const h = sizeToNumber(node.height, 100)
if (isArcEllipse(node.startAngle, node.sweepAngle, node.innerRadius)) {
// Arc ellipse rendered as Fabric.Path — update path data
const arcD = buildEllipseArcPath(w, h, node.startAngle ?? 0, node.sweepAngle ?? 360, node.innerRadius ?? 0)
if (obj instanceof fabric.Path) {
const trackedD = typeof (obj as any).__sourceD === 'string' ? (obj as any).__sourceD.trim() : ''
if (arcD !== trackedD) {
const tmp = new fabric.Path(arcD)
;(obj as any).path = (tmp as any).path
;(obj as any).__sourceD = arcD
;(obj as any).__nativeWidth = w
;(obj as any).__nativeHeight = h
}
}
// Override Fabric's auto-computed bounding box — the arc path is
// drawn within a 0,0 → w,h coordinate space.
;(obj as any).pathOffset = new fabric.Point(w / 2, h / 2)
obj.set({
width: w,
height: h,
scaleX: 1,
scaleY: 1,
fill: resolveFill(node.fill, w, h),
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
})
} else {
obj.set({
rx: w / 2,
ry: h / 2,
fill: resolveFill(node.fill, w, h),
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
})
}
break
}
case 'line': {
obj.set({
x1: node.x ?? 0,
y1: node.y ?? 0,
x2: node.x2 ?? 100,
y2: node.y2 ?? 0,
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
})
break
}
case 'text': {
const content =
typeof node.content === 'string'
? node.content
: node.content.map((s) => s.text).join('')
const w = sizeToNumber(node.width, 0)
const fixedWidthText = isFixedWidthText(node)
const fontSize = node.fontSize ?? 16
const splitByGrapheme = shouldSplitByGrapheme(content)
obj.set({
text: content,
fontFamily: node.fontFamily ?? 'Inter, sans-serif',
fontSize,
fontWeight: (node.fontWeight as string) ?? 'normal',
fontStyle: node.fontStyle ?? 'normal',
fill: resolveFillColor(node.fill),
textAlign: node.textAlign ?? 'left',
lineHeight: node.lineHeight ?? 1.2,
charSpacing: node.letterSpacing
? (node.letterSpacing / fontSize) * 1000
: 0,
})
if (obj instanceof fabric.Textbox) {
obj.set({ splitByGrapheme } as Partial<fabric.Textbox>)
if (fixedWidthText && w > 0) obj.set({ width: w })
}
break
}
case 'image': {
const w = sizeToNumber(node.width, 200)
const h = sizeToNumber(node.height, 200)
const r = Math.min(cornerRadiusValue(node.cornerRadius), h / 2)
const fitMode = node.objectFit ?? 'fill'
// Detect mode changes that require object recreation (e.g. tile ↔ non-tile)
const prevMode = (obj as any).__objectFit ?? 'fill'
if (prevMode !== fitMode) {
;(obj as any).__needsRecreation = true
return
}
// Tile mode: update pattern fill on the Rect
if (fitMode === 'tile') {
obj.set({ width: w, height: h, rx: r, ry: r, dirty: true })
break
}
// Fill/Fit/Crop: use computeImageTransform with native (source) dimensions
const nw = (obj as any).__nativeWidth || obj.width || w
const nh = (obj as any).__nativeHeight || obj.height || h
const transform = computeImageTransform(nw, nh, w, h, fitMode, r)
obj.set({
cropX: transform.cropX,
cropY: transform.cropY,
width: transform.cropWidth,
height: transform.cropHeight,
scaleX: transform.scaleX,
scaleY: transform.scaleY,
})
// clipPath only for corner radius (fill/crop overflow handled by cropX/cropY)
if (transform.clipPath) {
obj.set({
clipPath: transform.clipPath,
objectCaching: false,
dirty: true,
})
} else {
obj.set({
clipPath: undefined,
objectCaching: true,
dirty: true,
})
}
break
}
case 'polygon':
case 'path': {
// Update path data in-place when `d` changes — avoids object recreation
// and preserves selection, position, and Fabric object identity.
if (obj instanceof fabric.Path && node.type === 'path') {
const nextD = typeof node.d === 'string' ? node.d.trim() : ''
const trackedD = typeof (obj as any).__sourceD === 'string' ? (obj as any).__sourceD.trim() : ''
if (nextD && nextD !== trackedD) {
// Parse into a temporary Path to get the new internal representation
const tmp = new fabric.Path(nextD)
;(obj as any).path = (tmp as any).path
obj.width = tmp.width
obj.height = tmp.height
;(obj as any).pathOffset = (tmp as any).pathOffset
;(obj as any).__sourceD = nextD
;(obj as any).__nativeWidth = tmp.width
;(obj as any).__nativeHeight = tmp.height
}
}
const w = sizeToNumber('width' in node ? node.width : undefined, 100)
const h = sizeToNumber('height' in node ? node.height : undefined, 100)
const hasExplicitFill = node.type === 'path' && 'fill' in node && node.fill && node.fill.length > 0
const strokeColor = resolveStrokeColor('stroke' in node ? node.stroke : undefined)
const strokeWidth = resolveStrokeWidth('stroke' in node ? node.stroke : undefined)
const hasVisibleStroke = node.type === 'path' && strokeWidth > 0 && !!strokeColor
// For path nodes: stroke-only icons must not get a default fill
const fill = node.type === 'path' && !hasExplicitFill && hasVisibleStroke
? 'transparent'
: resolveFill('fill' in node ? node.fill : undefined, w, h)
obj.set({
fill,
stroke: hasVisibleStroke ? strokeColor : undefined,
strokeWidth: hasVisibleStroke ? strokeWidth : 0,
...(node.type === 'path' ? { strokeUniform: true, fillRule: 'evenodd' } : {}),
})
// Use cached native dimensions (from path/points data) to compute correct
// scale, even if obj.width was previously corrupted by scale baking.
const nw = (obj as any).__nativeWidth || obj.width
const nh = (obj as any).__nativeHeight || obj.height
if (w > 0 && h > 0 && nw && nh) {
if (node.type === 'path') {
// Uniform scale — preserve aspect ratio so icons don't get squished
const uniformScale = Math.min(w / nw, h / nh)
// Keep native width/height to avoid pathOffset drift that can visually
// offset icons inside logo containers.
obj.set({ width: nw, height: nh, scaleX: uniformScale, scaleY: uniformScale })
} else {
obj.set({ width: nw, height: nh, scaleX: w / nw, scaleY: h / nh })
}
}
break
}
}
obj.setCoords()
}

View file

@ -171,6 +171,95 @@ export function getTextOpticalCenterYOffset(node: PenNode): number {
return Math.max(0, Math.min(Math.round(fontSize * 0.05), Math.round(offset)))
}
// ---------------------------------------------------------------------------
// Canvas 2D measurement context (lazy singleton, browser-only)
// ---------------------------------------------------------------------------
let _textMeasureCtx: CanvasRenderingContext2D | null = null
function getTextMeasureCtx(): CanvasRenderingContext2D | null {
if (typeof document === 'undefined') return null
if (!_textMeasureCtx) {
const c = document.createElement('canvas')
_textMeasureCtx = c.getContext('2d')
}
return _textMeasureCtx
}
/**
* Count wrapped lines using Canvas 2D measureText for accurate word-wrap
* prediction. Falls back to character-width estimation if Canvas 2D is
* unavailable (e.g. SSR).
*/
function countWrappedLinesCanvas2D(
rawLines: string[],
wrapWidth: number,
fontSize: number,
fontWeight: string | number | undefined,
fontFamily: string,
letterSpacing: number,
): number {
const ctx = getTextMeasureCtx()
if (!ctx) {
// Fallback: character-width estimation
return rawLines.reduce((sum, line) => {
const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight) * widthSafetyFactor(line)
return sum + Math.max(1, Math.ceil(lineWidth / wrapWidth))
}, 0)
}
const fw = typeof fontWeight === 'number' ? String(fontWeight) : (fontWeight ?? '400')
ctx.font = `${fw} ${fontSize}px ${fontFamily}`
let total = 0
for (const rawLine of rawLines) {
if (!rawLine) { total += 1; continue }
// Word-wrap using Canvas 2D measureText — same logic as the renderer's wrapLine
if (ctx.measureText(rawLine).width <= wrapWidth) { total += 1; continue }
let lineCount = 0
let current = ''
let i = 0
while (i < rawLine.length) {
const ch = rawLine[i]
if (isCjkCodePoint(ch.codePointAt(0) ?? 0)) {
const test = current + ch
if (ctx.measureText(test).width > wrapWidth && current) {
lineCount++
current = ch
} else {
current = test
}
i++
} else if (ch === ' ') {
const test = current + ch
if (ctx.measureText(test).width > wrapWidth && current) {
lineCount++
current = ''
} else {
current = test
}
i++
} else {
// Collect word
let word = ''
while (i < rawLine.length && rawLine[i] !== ' ' && !isCjkCodePoint(rawLine[i].codePointAt(0) ?? 0)) {
word += rawLine[i]
i++
}
const test = current + word
if (ctx.measureText(test).width > wrapWidth && current) {
lineCount++
current = word
} else {
current = test
}
}
}
if (current) lineCount++
total += Math.max(1, lineCount)
}
return total
}
// ---------------------------------------------------------------------------
// Text height estimation (multi-line wrapping aware)
// ---------------------------------------------------------------------------
@ -181,7 +270,11 @@ export function estimateTextHeight(node: PenNode, availableWidth?: number): numb
const n = node as unknown as Record<string, unknown>
const fontSize = (typeof n.fontSize === 'number' ? n.fontSize : 16)
const lineHeight = (typeof n.lineHeight === 'number' ? n.lineHeight : defaultLineHeight(fontSize))
const singleLineH = fontSize * lineHeight
// Fabric.js uses _fontSizeMult = 1.13 for the glyph height of a single line.
// lineHeight spacing applies *between* lines, not below the last line.
const FABRIC_FONT_MULT = 1.13
const glyphH = fontSize * FABRIC_FONT_MULT
const lineStep = fontSize * lineHeight
// Get text content
const rawContent = n.content
@ -190,7 +283,7 @@ export function estimateTextHeight(node: PenNode, availableWidth?: number): numb
: Array.isArray(rawContent)
? rawContent.map((s: { text: string }) => s.text).join('')
: ''
if (!content) return singleLineH
if (!content) return glyphH
// Determine the effective text width for wrapping estimation
let textWidth = 0
@ -203,18 +296,22 @@ export function estimateTextHeight(node: PenNode, availableWidth?: number): numb
// If no width constraint is known, still count explicit newlines
if (textWidth <= 0) {
const explicitLines = content.split(/\r?\n/).length
return Math.round(Math.max(1, explicitLines) * singleLineH)
const n2 = Math.max(1, explicitLines)
return Math.round(n2 <= 1 ? glyphH : (n2 - 1) * lineStep + glyphH)
}
// Estimate wrapped lines per paragraph line, then sum.
// This preserves explicit newlines and avoids under-estimating CJK widths.
const letterSpacing = (typeof n.letterSpacing === 'number' ? n.letterSpacing : 0)
// Use Canvas 2D measureText for accurate wrapping prediction (matches renderer).
// Falls back to character-width estimation in non-browser environments.
const fontWeight = n.fontWeight as string | number | undefined
const fontFamily = (typeof n.fontFamily === 'string' ? n.fontFamily : '') || 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
const letterSpacing = (typeof n.letterSpacing === 'number' ? n.letterSpacing : 0)
const rawLines = content.split(/\r?\n/)
const wrappedLineCount = rawLines.reduce((sum, line) => {
const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight) * widthSafetyFactor(line)
return sum + Math.max(1, Math.ceil(lineWidth / textWidth))
}, 0)
// Add tolerance matching the renderer's wrapLine (w + fontSize * 0.2)
const wrapWidth = textWidth + fontSize * 0.2
const wrappedLineCount = countWrappedLinesCanvas2D(
rawLines, wrapWidth, fontSize, fontWeight, fontFamily, letterSpacing,
)
return Math.round(Math.max(1, wrappedLineCount) * singleLineH)
const totalLines = Math.max(1, wrappedLineCount)
return Math.round(totalLines <= 1 ? glyphH : (totalLines - 1) * lineStep + glyphH)
}

View file

@ -1,582 +0,0 @@
import type * as fabric from 'fabric'
import { useDocumentStore } from '@/stores/document-store'
import { forcePageResync } from './canvas-sync-utils'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { setFabricSyncLock } from './canvas-sync-lock'
import { layoutContainerBounds, rootFrameBounds } from './use-canvas-sync'
import type { LayoutContainerInfo } from './use-canvas-sync'
import { setInsertionIndicator, setContainerHighlight } from './insertion-indicator'
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
interface DragIntoSession {
nodeId: string
nodeIds?: string[]
targetContainerId: string
insertionIndex: number
/** Whether the target is a layout container (vertical/horizontal) vs a plain frame */
isLayoutTarget: boolean
}
let activeSession: DragIntoSession | null = null
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildFabObjectMap(
canvas: fabric.Canvas,
): Map<string, FabricObjectWithPenId> {
const map = new Map<string, FabricObjectWithPenId>()
for (const obj of canvas.getObjects() as FabricObjectWithPenId[]) {
if (obj.penNodeId) map.set(obj.penNodeId, obj)
}
return map
}
/**
* Compute the insertion index inside a layout container based on the
* dragged object's main-axis center relative to each child's midpoint.
*/
function calcInsertionIndex(
objMainCenter: number,
containerInfo: LayoutContainerInfo,
childIds: string[],
fabObjectMap: Map<string, FabricObjectWithPenId>,
): number {
const isVertical = containerInfo.layout === 'vertical'
let insertIndex = childIds.length
for (let i = 0; i < childIds.length; i++) {
const sibObj = fabObjectMap.get(childIds[i])
if (!sibObj) continue
const sibMid = isVertical
? (sibObj.top ?? 0) + ((sibObj.height ?? 0) * (sibObj.scaleY ?? 1)) / 2
: (sibObj.left ?? 0) + ((sibObj.width ?? 0) * (sibObj.scaleX ?? 1)) / 2
if (objMainCenter < sibMid) {
insertIndex = i
break
}
}
return insertIndex
}
/**
* Compute the insertion indicator position for a given container and index.
*/
function computeIndicator(
containerInfo: LayoutContainerInfo,
childIds: string[],
insertIndex: number,
fabObjectMap: Map<string, FabricObjectWithPenId>,
) {
const { x, y, w, h, layout, padding: pad, gap } = containerInfo
const isVertical = layout === 'vertical'
if (isVertical) {
let indicatorY: number
if (childIds.length === 0) {
indicatorY = y + pad.top
} else if (insertIndex === 0) {
const firstSib = fabObjectMap.get(childIds[0])
indicatorY = firstSib
? (firstSib.top ?? 0) - gap / 2
: y + pad.top
} else if (insertIndex >= childIds.length) {
const lastSib = fabObjectMap.get(childIds[childIds.length - 1])
indicatorY = lastSib
? (lastSib.top ?? 0) +
(lastSib.height ?? 0) * (lastSib.scaleY ?? 1) +
gap / 2
: y + h - pad.bottom
} else {
const prev = fabObjectMap.get(childIds[insertIndex - 1])
const next = fabObjectMap.get(childIds[insertIndex])
const prevBottom = prev
? (prev.top ?? 0) + (prev.height ?? 0) * (prev.scaleY ?? 1)
: 0
const nextTop = next ? (next.top ?? 0) : 0
indicatorY = (prevBottom + nextTop) / 2
}
setInsertionIndicator({
x: x + pad.left,
y: indicatorY,
length: w - pad.left - pad.right,
orientation: 'horizontal',
})
} else {
let indicatorX: number
if (childIds.length === 0) {
indicatorX = x + pad.left
} else if (insertIndex === 0) {
const firstSib = fabObjectMap.get(childIds[0])
indicatorX = firstSib
? (firstSib.left ?? 0) - gap / 2
: x + pad.left
} else if (insertIndex >= childIds.length) {
const lastSib = fabObjectMap.get(childIds[childIds.length - 1])
indicatorX = lastSib
? (lastSib.left ?? 0) +
(lastSib.width ?? 0) * (lastSib.scaleX ?? 1) +
gap / 2
: x + w - pad.right
} else {
const prev = fabObjectMap.get(childIds[insertIndex - 1])
const next = fabObjectMap.get(childIds[insertIndex])
const prevRight = prev
? (prev.left ?? 0) + (prev.width ?? 0) * (prev.scaleX ?? 1)
: 0
const nextLeft = next ? (next.left ?? 0) : 0
indicatorX = (prevRight + nextLeft) / 2
}
setInsertionIndicator({
x: indicatorX,
y: y + pad.top,
length: h - pad.top - pad.bottom,
orientation: 'vertical',
})
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Called during object:moving detect if the dragged object is over a
* layout container and show insertion indicator + container highlight.
*/
export function checkDragIntoTarget(
obj: FabricObjectWithPenId,
canvas: fabric.Canvas,
): void {
const nodeId = obj.penNodeId
if (!nodeId) return
const store = useDocumentStore.getState()
// Object center point for hit detection
const objCenterX =
(obj.left ?? 0) + ((obj.width ?? 0) * (obj.scaleX ?? 1)) / 2
const objCenterY =
(obj.top ?? 0) + ((obj.height ?? 0) * (obj.scaleY ?? 1)) / 2
// Find the best (innermost) container containing the center.
// Check layout containers first (they support insertion indicators),
// then non-layout root frames (just container highlight).
const parent = store.getParentOf(nodeId)
let bestContainerId: string | null = null
let bestInfo: LayoutContainerInfo | null = null
let bestFrameBounds: { x: number; y: number; w: number; h: number } | null = null
let bestArea = Infinity
let bestIsLayout = false
// 1. Check layout containers (preferred — support insertion indicators)
for (const [containerId, info] of layoutContainerBounds) {
if (parent?.id === containerId) continue
if (store.isDescendantOf(containerId, nodeId)) continue
if (containerId === nodeId) continue
if (
objCenterX >= info.x &&
objCenterX <= info.x + info.w &&
objCenterY >= info.y &&
objCenterY <= info.y + info.h
) {
const area = info.w * info.h
if (area < bestArea) {
bestArea = area
bestContainerId = containerId
bestInfo = info
bestIsLayout = true
}
}
}
// 2. Check non-layout root frames (just container highlight, no indicator)
for (const [frameId, bounds] of rootFrameBounds) {
if (layoutContainerBounds.has(frameId)) continue // already checked above
if (parent?.id === frameId) continue
if (store.isDescendantOf(frameId, nodeId)) continue
if (frameId === nodeId) continue
if (
objCenterX >= bounds.x &&
objCenterX <= bounds.x + bounds.w &&
objCenterY >= bounds.y &&
objCenterY <= bounds.y + bounds.h
) {
const area = bounds.w * bounds.h
if (area < bestArea) {
bestArea = area
bestContainerId = frameId
bestInfo = null
bestFrameBounds = bounds
bestIsLayout = false
}
}
}
if (bestContainerId && bestIsLayout && bestInfo) {
// Layout container: show insertion indicator + container highlight
const fabObjectMap = buildFabObjectMap(canvas)
const container = store.getNodeById(bestContainerId)
const childIds =
container && 'children' in container && container.children
? container.children.map((c) => c.id)
: []
const isVertical = bestInfo.layout === 'vertical'
const mainCenter = isVertical ? objCenterY : objCenterX
const insertIndex = calcInsertionIndex(
mainCenter,
bestInfo,
childIds,
fabObjectMap,
)
activeSession = {
nodeId,
targetContainerId: bestContainerId,
insertionIndex: insertIndex,
isLayoutTarget: true,
}
computeIndicator(bestInfo, childIds, insertIndex, fabObjectMap)
setContainerHighlight({
x: bestInfo.x,
y: bestInfo.y,
w: bestInfo.w,
h: bestInfo.h,
})
canvas.requestRenderAll()
} else if (bestContainerId && !bestIsLayout) {
// Non-layout frame: show container highlight only, append at end
const bounds = bestFrameBounds ?? rootFrameBounds.get(bestContainerId)!
const container = store.getNodeById(bestContainerId)
const childCount =
container && 'children' in container && container.children
? container.children.length
: 0
activeSession = {
nodeId,
targetContainerId: bestContainerId,
insertionIndex: childCount,
isLayoutTarget: false,
}
setInsertionIndicator(null)
setContainerHighlight({
x: bounds.x,
y: bounds.y,
w: bounds.w,
h: bounds.h,
})
canvas.requestRenderAll()
} else {
// Not over any container — clear
if (activeSession) {
activeSession = null
setInsertionIndicator(null)
setContainerHighlight(null)
canvas.requestRenderAll()
}
}
}
/**
* Commit the drag-into: reparent the node into the target container.
* Returns true if a reparent was performed.
*/
export function commitDragInto(
obj: FabricObjectWithPenId,
canvas: fabric.Canvas,
): boolean {
if (!activeSession) return false
const { nodeId, targetContainerId, insertionIndex, isLayoutTarget } = activeSession
setFabricSyncLock(true)
if (isLayoutTarget) {
// Clear manual position so layout engine takes over
useDocumentStore.getState().updateNode(nodeId, {
x: undefined,
y: undefined,
} as Partial<import('@/types/pen').PenNode>)
} else {
// Non-layout frame: convert absolute position to relative
const targetBounds = rootFrameBounds.get(targetContainerId)
if (targetBounds) {
useDocumentStore.getState().updateNode(nodeId, {
x: (obj.left ?? 0) - targetBounds.x,
y: (obj.top ?? 0) - targetBounds.y,
})
}
}
// Reparent into the target container
useDocumentStore.getState().moveNode(nodeId, targetContainerId, insertionIndex)
setFabricSyncLock(false)
// Force re-sync: must re-read state after mutations (getState() above
// returned snapshots that are now stale).
forcePageResync()
// Clean up
activeSession = null
setInsertionIndicator(null)
setContainerHighlight(null)
canvas.requestRenderAll()
return true
}
/**
* Multi-selection version of checkDragIntoTarget.
* Uses a given center point and list of node IDs.
*/
export function checkDragIntoTargetMulti(
centerX: number,
centerY: number,
nodeIds: string[],
canvas: fabric.Canvas,
): void {
if (nodeIds.length === 0) return
const store = useDocumentStore.getState()
const nodeIdSet = new Set(nodeIds)
let bestContainerId: string | null = null
let bestInfo: LayoutContainerInfo | null = null
let bestFrameBounds: { x: number; y: number; w: number; h: number } | null = null
let bestArea = Infinity
let bestIsLayout = false
// 1. Check layout containers (preferred — support insertion indicators)
for (const [containerId, info] of layoutContainerBounds) {
if (nodeIdSet.has(containerId)) continue
if (nodeIds.some((nid) => store.isDescendantOf(containerId, nid))) continue
if (
centerX >= info.x &&
centerX <= info.x + info.w &&
centerY >= info.y &&
centerY <= info.y + info.h
) {
const area = info.w * info.h
if (area < bestArea) {
bestArea = area
bestContainerId = containerId
bestInfo = info
bestIsLayout = true
}
}
}
// 2. Check non-layout root frames
for (const [frameId, bounds] of rootFrameBounds) {
if (layoutContainerBounds.has(frameId)) continue
if (nodeIdSet.has(frameId)) continue
if (nodeIds.some((nid) => store.isDescendantOf(frameId, nid))) continue
if (
centerX >= bounds.x &&
centerX <= bounds.x + bounds.w &&
centerY >= bounds.y &&
centerY <= bounds.y + bounds.h
) {
const area = bounds.w * bounds.h
if (area < bestArea) {
bestArea = area
bestContainerId = frameId
bestInfo = null
bestFrameBounds = bounds
bestIsLayout = false
}
}
}
if (bestContainerId && bestIsLayout && bestInfo) {
// Layout container: show insertion indicator + container highlight
const fabObjectMap = buildFabObjectMap(canvas)
const container = store.getNodeById(bestContainerId)
const childIds =
container && 'children' in container && container.children
? container.children.map((c) => c.id).filter((id) => !nodeIdSet.has(id))
: []
const isVertical = bestInfo.layout === 'vertical'
const mainCenter = isVertical ? centerY : centerX
const insertIndex = calcInsertionIndex(
mainCenter,
bestInfo,
childIds,
fabObjectMap,
)
activeSession = {
nodeId: nodeIds[0],
nodeIds,
targetContainerId: bestContainerId,
insertionIndex: insertIndex,
isLayoutTarget: true,
}
computeIndicator(bestInfo, childIds, insertIndex, fabObjectMap)
setContainerHighlight({
x: bestInfo.x,
y: bestInfo.y,
w: bestInfo.w,
h: bestInfo.h,
})
canvas.requestRenderAll()
} else if (bestContainerId && !bestIsLayout) {
// Non-layout frame: show container highlight only, append at end
const bounds = bestFrameBounds ?? rootFrameBounds.get(bestContainerId)!
const container = store.getNodeById(bestContainerId)
const childIds =
container && 'children' in container && container.children
? container.children.map((c) => c.id).filter((id) => !nodeIdSet.has(id))
: []
activeSession = {
nodeId: nodeIds[0],
nodeIds,
targetContainerId: bestContainerId,
insertionIndex: childIds.length,
isLayoutTarget: false,
}
setInsertionIndicator(null)
setContainerHighlight({
x: bounds.x,
y: bounds.y,
w: bounds.w,
h: bounds.h,
})
canvas.requestRenderAll()
} else {
if (activeSession) {
activeSession = null
setInsertionIndicator(null)
setContainerHighlight(null)
canvas.requestRenderAll()
}
}
}
/**
* Commit drag-into for multi-selection: reparent or reorder nodes.
* Handles both cross-container reparent and same-container reorder.
* Returns true if a reparent/reorder was performed.
*/
export function commitDragIntoMulti(
canvas: fabric.Canvas,
): boolean {
if (!activeSession || !activeSession.nodeIds) return false
const { nodeIds, targetContainerId, insertionIndex, isLayoutTarget } = activeSession
const store = useDocumentStore.getState()
// Check if this is a same-container reorder
const firstParent = store.getParentOf(nodeIds[0])
const isSameContainer = firstParent?.id === targetContainerId
setFabricSyncLock(true)
if (isSameContainer) {
// Same container: reorder by manipulating children array directly.
// This avoids shifting-index issues that arise when calling moveNode
// in a loop (each removal shifts subsequent indices).
const container = store.getNodeById(targetContainerId)
if (container && 'children' in container && container.children) {
const nodeIdSet = new Set(nodeIds)
const dragged: typeof container.children = []
const remaining: typeof container.children = []
for (const child of container.children) {
if (nodeIdSet.has(child.id)) {
dragged.push(child)
} else {
remaining.push(child)
}
}
// insertionIndex was computed for the filtered (remaining) list
const newChildren = [
...remaining.slice(0, insertionIndex),
...dragged,
...remaining.slice(insertionIndex),
]
store.updateNode(targetContainerId, {
children: newChildren,
} as Partial<import('@/types/pen').PenNode>)
}
} else {
// Cross container: reparent
const fabObjectMap = buildFabObjectMap(canvas)
const targetBounds = rootFrameBounds.get(targetContainerId)
for (let i = 0; i < nodeIds.length; i++) {
if (isLayoutTarget) {
// Clear manual position so layout engine takes over
useDocumentStore.getState().updateNode(nodeIds[i], {
x: undefined,
y: undefined,
} as Partial<import('@/types/pen').PenNode>)
} else if (targetBounds) {
// Non-layout frame: convert absolute position to relative
const fabObj = fabObjectMap.get(nodeIds[i])
if (fabObj) {
useDocumentStore.getState().updateNode(nodeIds[i], {
x: (fabObj.left ?? 0) - targetBounds.x,
y: (fabObj.top ?? 0) - targetBounds.y,
})
}
}
// Reparent into the target container at successive indices
useDocumentStore.getState().moveNode(nodeIds[i], targetContainerId, insertionIndex + i)
}
}
setFabricSyncLock(false)
// Force re-sync
forcePageResync()
// Clean up
activeSession = null
setInsertionIndicator(null)
setContainerHighlight(null)
canvas.requestRenderAll()
return true
}
/** Cancel the drag-into session (safety cleanup). */
export function cancelDragInto(): void {
if (!activeSession) return
activeSession = null
setInsertionIndicator(null)
setContainerHighlight(null)
}
/** Check if a drag-into session is currently active. */
export function isDragIntoActive(): boolean {
return activeSession !== null
}

View file

@ -1,292 +0,0 @@
import { useDocumentStore } from '@/stores/document-store'
import { setFabricSyncLock } from './canvas-sync-lock'
import { nodeRenderInfo, rootFrameBounds, layoutContainerBounds } from './use-canvas-sync'
import type { FabricObjectWithPenId } from './canvas-object-factory'
interface Bounds {
x: number
y: number
w: number
h: number
}
function isCompletelyOutside(obj: Bounds, parent: Bounds): boolean {
return (
obj.x + obj.w <= parent.x ||
obj.x >= parent.x + parent.w ||
obj.y + obj.h <= parent.y ||
obj.y >= parent.y + parent.h
)
}
function overlapArea(a: Bounds, b: Bounds): number {
const overlapX = Math.max(
0,
Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x),
)
const overlapY = Math.max(
0,
Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y),
)
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 node (identified by absolute bounds) was dragged outside its
* parent container. If so, reparent it to the best overlapping root frame
* or to root level.
*
* Unlike `checkDragReparent` (which reads Fabric coords), this version
* accepts pre-computed absolute bounds needed for objects inside an
* ActiveSelection whose Fabric left/top are group-relative.
*
* The caller is responsible for holding the fabric sync lock.
* Returns true if reparenting occurred.
*/
export function checkDragReparentByBounds(
nodeId: string,
objBounds: Bounds,
): boolean {
const store = useDocumentStore.getState()
const parent = store.getParentOf(nodeId)
if (!parent) return false // Already root-level
const parentBounds = getParentBounds(parent.id, parent)
if (!parentBounds) return false
if (!isCompletelyOutside(objBounds, parentBounds)) return false
// Find the root frame with the most overlap
let bestFrameId: string | null = null
let bestOverlap = 0
for (const [frameId, frameBounds] of rootFrameBounds) {
// Don't reparent into the frame we just left
if (frameId === parent.id) continue
const area = overlapArea(objBounds, frameBounds)
if (area > bestOverlap) {
bestOverlap = area
bestFrameId = frameId
}
}
if (bestFrameId) {
// Reparent into the overlapping frame — convert absolute to relative position
const targetBounds = rootFrameBounds.get(bestFrameId)!
store.updateNode(nodeId, {
x: objBounds.x - targetBounds.x,
y: objBounds.y - targetBounds.y,
})
// Insert at index 0 = top of layer panel = frontmost
store.moveNode(nodeId, bestFrameId, 0)
} else {
// No overlapping frame — make it a root-level node
store.updateNode(nodeId, {
x: objBounds.x,
y: objBounds.y,
})
// Insert at index 0 = top of layer panel = frontmost
store.moveNode(nodeId, null, 0)
}
return true
}
/**
* Check if a root-level node (identified by absolute bounds) should be
* reparented INTO a root frame based on center-point containment.
*
* This handles the case where objects are dragged from root level into
* a frame the reverse of checkDragReparentByBounds (which handles
* objects being dragged OUT of a frame).
*
* The caller is responsible for holding the fabric sync lock.
* Returns true if reparenting occurred.
*/
export function checkReparentIntoFrame(
nodeId: string,
objBounds: Bounds,
skipFrameIds?: Set<string>,
): boolean {
const store = useDocumentStore.getState()
const parent = store.getParentOf(nodeId)
if (parent) return false // Not root-level — use checkDragReparentByBounds instead
const centerX = objBounds.x + objBounds.w / 2
const centerY = objBounds.y + objBounds.h / 2
// Find the smallest root frame containing the object's center
let bestFrameId: string | null = null
let bestArea = Infinity
for (const [frameId, frameBounds] of rootFrameBounds) {
if (frameId === nodeId) continue
if (skipFrameIds?.has(frameId)) continue
if (store.isDescendantOf(frameId, nodeId)) continue
if (
centerX >= frameBounds.x &&
centerX <= frameBounds.x + frameBounds.w &&
centerY >= frameBounds.y &&
centerY <= frameBounds.y + frameBounds.h
) {
const area = frameBounds.w * frameBounds.h
if (area < bestArea) {
bestArea = area
bestFrameId = frameId
}
}
}
// Also check layout containers (nested layout frames that aren't root frames)
for (const [containerId, info] of layoutContainerBounds) {
if (containerId === nodeId) continue
if (skipFrameIds?.has(containerId)) continue
if (rootFrameBounds.has(containerId)) continue // already checked above
if (store.isDescendantOf(containerId, nodeId)) continue
if (
centerX >= info.x &&
centerX <= info.x + info.w &&
centerY >= info.y &&
centerY <= info.y + info.h
) {
const area = info.w * info.h
if (area < bestArea) {
bestArea = area
bestFrameId = containerId
}
}
}
if (!bestFrameId) return false
// Determine if target is a layout container (clear x/y) or plain frame (relative position)
const layoutInfo = layoutContainerBounds.get(bestFrameId)
if (layoutInfo) {
// Layout container: clear position, layout engine will place it
store.updateNode(nodeId, {
x: undefined,
y: undefined,
} as any)
} else {
// Non-layout frame: convert absolute to relative position
const targetBounds = rootFrameBounds.get(bestFrameId)
if (targetBounds) {
store.updateNode(nodeId, {
x: objBounds.x - targetBounds.x,
y: objBounds.y - targetBounds.y,
})
}
}
// Insert at index 0 = top of layer panel = frontmost
store.moveNode(nodeId, bestFrameId, 0)
return true
}
/**
* 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.
*
* Works for layout and non-layout containers.
* Returns true if reparenting occurred.
*/
export function checkDragReparent(obj: FabricObjectWithPenId): boolean {
const nodeId = obj.penNodeId
if (!nodeId) return false
const store = useDocumentStore.getState()
const parent = store.getParentOf(nodeId)
if (!parent) return false // Already root-level
const parentBounds = getParentBounds(parent.id, parent)
if (!parentBounds) return false
// Compute object's absolute bounds from Fabric
const objBounds: Bounds = {
x: obj.left ?? 0,
y: obj.top ?? 0,
w: (obj.width ?? 0) * (obj.scaleX ?? 1),
h: (obj.height ?? 0) * (obj.scaleY ?? 1),
}
// Only trigger if completely outside parent
if (!isCompletelyOutside(objBounds, parentBounds)) return false
// Find the root frame with the most overlap
let bestFrameId: string | null = null
let bestOverlap = 0
for (const [frameId, frameBounds] of rootFrameBounds) {
const area = overlapArea(objBounds, frameBounds)
if (area > bestOverlap) {
bestOverlap = area
bestFrameId = frameId
}
}
setFabricSyncLock(true)
try {
if (bestFrameId) {
// Reparent into the overlapping frame — convert absolute to relative position
const targetBounds = rootFrameBounds.get(bestFrameId)!
store.updateNode(nodeId, {
x: objBounds.x - targetBounds.x,
y: objBounds.y - targetBounds.y,
})
// Insert at index 0 = top of layer panel = frontmost
store.moveNode(nodeId, bestFrameId, 0)
} else {
// No overlapping frame — make it a root-level node
store.updateNode(nodeId, {
x: objBounds.x,
y: objBounds.y,
})
// Insert at index 0 = top of layer panel = frontmost
store.moveNode(nodeId, null, 0)
}
} finally {
setFabricSyncLock(false)
}
return true
}

View file

@ -1,39 +0,0 @@
import { useRef } from 'react'
import { useFabricCanvas } from './use-fabric-canvas'
import { useCanvasGuides } from './use-canvas-guides'
import { useCanvasEvents } from './use-canvas-events'
import { useCanvasViewport } from './use-canvas-viewport'
import { useCanvasSelection } from './use-canvas-selection'
import { useCanvasSync } from './use-canvas-sync'
import { useFrameLabels } from './use-frame-labels'
import { useLayoutIndicator } from './use-layout-indicator'
import { useCanvasHover } from './use-canvas-hover'
import { useEnteredFrameOverlay } from './use-entered-frame-overlay'
import { useAgentIndicators } from './use-agent-indicators'
export default function FabricCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useFabricCanvas(canvasRef, containerRef)
useCanvasGuides()
useCanvasEvents()
useCanvasViewport()
useCanvasSelection()
useCanvasSync()
useCanvasHover()
useEnteredFrameOverlay()
useFrameLabels()
useLayoutIndicator()
useAgentIndicators()
return (
<div
ref={containerRef}
className="flex-1 relative overflow-hidden bg-muted"
>
<canvas ref={canvasRef} />
</div>
)
}

View file

@ -1,232 +0,0 @@
import type * as fabric from 'fabric'
import { SNAP_THRESHOLD } from './canvas-constants'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface GuideLine {
orientation: 'horizontal' | 'vertical'
/** x for vertical guides, y for horizontal guides (scene coords) */
position: number
/** start of line extent (scene coords) */
start: number
/** end of line extent (scene coords) */
end: number
}
interface Edges {
left: number
right: number
top: number
bottom: number
centerX: number
centerY: number
}
interface SnapCandidate {
position: number
delta: number
absDelta: number
}
// ---------------------------------------------------------------------------
// Module-level state — shared between event handler and render hook
// ---------------------------------------------------------------------------
export let activeGuides: GuideLine[] = []
export function clearGuides() {
activeGuides = []
}
// ---------------------------------------------------------------------------
// Edge extraction
// ---------------------------------------------------------------------------
function getEdges(obj: fabric.FabricObject): Edges {
let left = obj.left ?? 0
let top = obj.top ?? 0
const w = (obj.width ?? 0) * (obj.scaleX ?? 1)
const h = (obj.height ?? 0) * (obj.scaleY ?? 1)
// ActiveSelection / Group uses center origin — adjust to top-left
if (obj.originX === 'center') left -= w / 2
if (obj.originY === 'center') top -= h / 2
return {
left,
right: left + w,
top,
bottom: top + h,
centerX: left + w / 2,
centerY: top + h / 2,
}
}
// ---------------------------------------------------------------------------
// Main calculation — returns guide lines + snap deltas
// ---------------------------------------------------------------------------
function calculateGuides(
target: fabric.FabricObject,
canvas: fabric.Canvas,
threshold: number,
): { guides: GuideLine[]; deltaX: number; deltaY: number } {
const guides: GuideLine[] = []
const te = getEdges(target)
// Collect other visible objects that aren't the target or part of an ActiveSelection
const activeSelection = canvas.getActiveObject()
const selectedSet = new Set<fabric.FabricObject>()
if (activeSelection?.isType?.('activeSelection')) {
for (const child of (activeSelection as fabric.ActiveSelection).getObjects()) {
selectedSet.add(child)
}
}
selectedSet.add(target)
const others = canvas.getObjects().filter(
(obj) => !selectedSet.has(obj) && obj.visible !== false,
)
if (others.length === 0) return { guides, deltaX: 0, deltaY: 0 }
let bestX: SnapCandidate | null = null
let bestY: SnapCandidate | null = null
// Accumulate guide extents per snap position
const xExtents = new Map<number, { start: number; end: number }>()
const yExtents = new Map<number, { start: number; end: number }>()
for (const obj of others) {
const oe = getEdges(obj)
// X-axis alignment: target edges vs. object edges → vertical guide lines
const xPairs: [number, number][] = [
[te.left, oe.left],
[te.left, oe.right],
[te.left, oe.centerX],
[te.right, oe.left],
[te.right, oe.right],
[te.right, oe.centerX],
[te.centerX, oe.left],
[te.centerX, oe.right],
[te.centerX, oe.centerX],
]
for (const [tX, oX] of xPairs) {
const delta = oX - tX
const absDelta = Math.abs(delta)
if (absDelta >= threshold) continue
if (!bestX || absDelta < bestX.absDelta) {
bestX = { position: oX, delta, absDelta }
}
// Track the vertical extent of this guide
const key = Math.round(oX * 10) / 10
const yMin = Math.min(te.top, oe.top)
const yMax = Math.max(te.bottom, oe.bottom)
const existing = xExtents.get(key)
if (existing) {
existing.start = Math.min(existing.start, yMin)
existing.end = Math.max(existing.end, yMax)
} else {
xExtents.set(key, { start: yMin, end: yMax })
}
}
// Y-axis alignment: target edges vs. object edges → horizontal guide lines
const yPairs: [number, number][] = [
[te.top, oe.top],
[te.top, oe.bottom],
[te.top, oe.centerY],
[te.bottom, oe.top],
[te.bottom, oe.bottom],
[te.bottom, oe.centerY],
[te.centerY, oe.top],
[te.centerY, oe.bottom],
[te.centerY, oe.centerY],
]
for (const [tY, oY] of yPairs) {
const delta = oY - tY
const absDelta = Math.abs(delta)
if (absDelta >= threshold) continue
if (!bestY || absDelta < bestY.absDelta) {
bestY = { position: oY, delta, absDelta }
}
const key = Math.round(oY * 10) / 10
const xMin = Math.min(te.left, oe.left)
const xMax = Math.max(te.right, oe.right)
const existing = yExtents.get(key)
if (existing) {
existing.start = Math.min(existing.start, xMin)
existing.end = Math.max(existing.end, xMax)
} else {
yExtents.set(key, { start: xMin, end: xMax })
}
}
}
// Build result guides from best snap positions only
if (bestX) {
const key = Math.round(bestX.position * 10) / 10
const ext = xExtents.get(key)
if (ext) {
guides.push({
orientation: 'vertical',
position: bestX.position,
start: ext.start,
end: ext.end,
})
}
}
if (bestY) {
const key = Math.round(bestY.position * 10) / 10
const ext = yExtents.get(key)
if (ext) {
guides.push({
orientation: 'horizontal',
position: bestY.position,
start: ext.start,
end: ext.end,
})
}
}
return {
guides,
deltaX: bestX?.delta ?? 0,
deltaY: bestY?.delta ?? 0,
}
}
// ---------------------------------------------------------------------------
// Public API — called from use-canvas-events during object:moving
// ---------------------------------------------------------------------------
/** Calculate guides, snap target position, and update activeGuides state. */
export function calculateAndSnap(
target: fabric.FabricObject,
canvas: fabric.Canvas,
): void {
const result = calculateGuides(target, canvas, SNAP_THRESHOLD)
// Apply snapping
if (result.deltaX !== 0) {
target.set('left', (target.left ?? 0) + result.deltaX)
}
if (result.deltaY !== 0) {
target.set('top', (target.top ?? 0) + result.deltaY)
}
if (result.deltaX !== 0 || result.deltaY !== 0) {
target.setCoords()
}
activeGuides = result.guides
}

View file

@ -1,290 +0,0 @@
import type * as fabric from 'fabric'
import { useDocumentStore } from '@/stores/document-store'
import { forcePageResync } from './canvas-sync-utils'
import type { PenNode, ContainerProps } from '@/types/pen'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { setFabricSyncLock } from './canvas-sync-lock'
import { nodeRenderInfo } from './use-canvas-sync'
import { setInsertionIndicator } from './insertion-indicator'
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
interface LayoutDragSession {
nodeId: string
parentId: string
parentLayout: 'vertical' | 'horizontal'
siblingIds: string[]
originalIndex: number
}
let activeSession: LayoutDragSession | null = null
// ---------------------------------------------------------------------------
// Padding helper (duplicated from use-canvas-sync to avoid circular deps)
// ---------------------------------------------------------------------------
interface Padding {
top: number
right: number
bottom: number
left: number
}
function resolvePadding(
padding:
| number
| [number, number]
| [number, number, number, number]
| string
| undefined,
): Padding {
if (!padding || typeof padding === 'string')
return { top: 0, right: 0, bottom: 0, left: 0 }
if (typeof padding === 'number')
return { top: padding, right: padding, bottom: padding, left: padding }
if (padding.length === 2)
return {
top: padding[0],
right: padding[1],
bottom: padding[0],
left: padding[1],
}
return {
top: padding[0],
right: padding[1],
bottom: padding[2],
left: padding[3],
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildFabObjectMap(
canvas: fabric.Canvas,
): Map<string, FabricObjectWithPenId> {
const map = new Map<string, FabricObjectWithPenId>()
for (const obj of canvas.getObjects() as FabricObjectWithPenId[]) {
if (obj.penNodeId) map.set(obj.penNodeId, obj)
}
return map
}
/**
* Calculate insertion index based on the dragged object's main-axis center
* relative to each sibling's midpoint. The returned index is in "after
* removal" space (siblingIds already excludes the dragged node) and maps
* directly to the `index` parameter of `moveNode`.
*/
function calcInsertionIndex(
obj: FabricObjectWithPenId,
fabObjectMap: Map<string, FabricObjectWithPenId>,
): number {
if (!activeSession) return 0
const { siblingIds, parentLayout } = activeSession
const isVertical = parentLayout === 'vertical'
const objMainCenter = isVertical
? (obj.top ?? 0) + ((obj.height ?? 0) * (obj.scaleY ?? 1)) / 2
: (obj.left ?? 0) + ((obj.width ?? 0) * (obj.scaleX ?? 1)) / 2
let insertIndex = siblingIds.length
for (let i = 0; i < siblingIds.length; i++) {
const sibObj = fabObjectMap.get(siblingIds[i])
if (!sibObj) continue
const sibMid = isVertical
? (sibObj.top ?? 0) + ((sibObj.height ?? 0) * (sibObj.scaleY ?? 1)) / 2
: (sibObj.left ?? 0) +
((sibObj.width ?? 0) * (sibObj.scaleX ?? 1)) / 2
if (objMainCenter < sibMid) {
insertIndex = i
break
}
}
return insertIndex
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
/**
* Try to begin a layout reorder drag.
* Returns true if the node is a layout child and the session was started.
*/
export function beginLayoutDrag(nodeId: string): boolean {
const info = nodeRenderInfo.get(nodeId)
if (!info?.isLayoutChild) return false
const { getParentOf } = useDocumentStore.getState()
const parent = getParentOf(nodeId)
if (!parent) return false
const container = parent as PenNode & ContainerProps
const layout = container.layout
if (!layout || layout === 'none') return false
const children = 'children' in parent ? parent.children ?? [] : []
const originalIndex = children.findIndex((c) => c.id === nodeId)
if (originalIndex === -1) return false
activeSession = {
nodeId,
parentId: parent.id,
parentLayout: layout as 'vertical' | 'horizontal',
siblingIds: children.filter((c) => c.id !== nodeId).map((c) => c.id),
originalIndex,
}
return true
}
/**
* Update insertion indicator position during drag.
*/
export function updateLayoutDrag(
obj: FabricObjectWithPenId,
canvas: fabric.Canvas,
): void {
if (!activeSession) return
const { siblingIds, parentId, parentLayout } = activeSession
const isVertical = parentLayout === 'vertical'
const fabObjectMap = buildFabObjectMap(canvas)
const insertIndex = calcInsertionIndex(obj, fabObjectMap)
const { getNodeById } = useDocumentStore.getState()
const parent = getNodeById(parentId) as
| (PenNode & ContainerProps)
| undefined
if (!parent) return
const pad = resolvePadding(parent.padding)
const gap = typeof parent.gap === 'number' ? parent.gap : 0
const parentInfo = nodeRenderInfo.get(parentId)
const parentAbsX = (parentInfo?.parentOffsetX ?? 0) + (parent.x ?? 0)
const parentAbsY = (parentInfo?.parentOffsetY ?? 0) + (parent.y ?? 0)
const parentW = typeof parent.width === 'number' ? parent.width : 0
const parentH = typeof parent.height === 'number' ? parent.height : 0
if (isVertical) {
let indicatorY: number
if (siblingIds.length === 0) {
indicatorY = parentAbsY + pad.top
} else if (insertIndex === 0) {
const firstSib = fabObjectMap.get(siblingIds[0])
indicatorY = firstSib
? (firstSib.top ?? 0) - gap / 2
: parentAbsY + pad.top
} else if (insertIndex >= siblingIds.length) {
const lastSib = fabObjectMap.get(siblingIds[siblingIds.length - 1])
indicatorY = lastSib
? (lastSib.top ?? 0) +
(lastSib.height ?? 0) * (lastSib.scaleY ?? 1) +
gap / 2
: parentAbsY + parentH - pad.bottom
} else {
const prev = fabObjectMap.get(siblingIds[insertIndex - 1])
const next = fabObjectMap.get(siblingIds[insertIndex])
const prevBottom = prev
? (prev.top ?? 0) + (prev.height ?? 0) * (prev.scaleY ?? 1)
: 0
const nextTop = next ? (next.top ?? 0) : 0
indicatorY = (prevBottom + nextTop) / 2
}
setInsertionIndicator({
x: parentAbsX + pad.left,
y: indicatorY,
length: parentW - pad.left - pad.right,
orientation: 'horizontal',
})
} else {
let indicatorX: number
if (siblingIds.length === 0) {
indicatorX = parentAbsX + pad.left
} else if (insertIndex === 0) {
const firstSib = fabObjectMap.get(siblingIds[0])
indicatorX = firstSib
? (firstSib.left ?? 0) - gap / 2
: parentAbsX + pad.left
} else if (insertIndex >= siblingIds.length) {
const lastSib = fabObjectMap.get(siblingIds[siblingIds.length - 1])
indicatorX = lastSib
? (lastSib.left ?? 0) +
(lastSib.width ?? 0) * (lastSib.scaleX ?? 1) +
gap / 2
: parentAbsX + parentW - pad.right
} else {
const prev = fabObjectMap.get(siblingIds[insertIndex - 1])
const next = fabObjectMap.get(siblingIds[insertIndex])
const prevRight = prev
? (prev.left ?? 0) + (prev.width ?? 0) * (prev.scaleX ?? 1)
: 0
const nextLeft = next ? (next.left ?? 0) : 0
indicatorX = (prevRight + nextLeft) / 2
}
setInsertionIndicator({
x: indicatorX,
y: parentAbsY + pad.top,
length: parentH - pad.top - pad.bottom,
orientation: 'vertical',
})
}
canvas.requestRenderAll()
}
/**
* End the layout drag: clear manual x/y, reorder the node, force re-sync.
*/
export function endLayoutDrag(
obj: FabricObjectWithPenId,
canvas: fabric.Canvas,
): void {
if (!activeSession) return
const { nodeId, parentId, originalIndex } = activeSession
const fabObjectMap = buildFabObjectMap(canvas)
const newIndex = calcInsertionIndex(obj, fabObjectMap)
setFabricSyncLock(true)
// Clear manual position so layout engine takes over
useDocumentStore.getState().updateNode(nodeId, {
x: undefined,
y: undefined,
} as Partial<PenNode>)
// Reorder if the position actually changed
if (newIndex !== originalIndex) {
useDocumentStore.getState().moveNode(nodeId, parentId, newIndex)
}
setFabricSyncLock(false)
// Force re-sync: create new children reference so the subscription fires
forcePageResync()
activeSession = null
setInsertionIndicator(null)
canvas.requestRenderAll()
}
/** Cancel the layout drag session (safety cleanup). */
export function cancelLayoutDrag(): void {
activeSession = null
setInsertionIndicator(null)
}
/** Check if a layout drag session is currently active. */
export function isLayoutDragActive(): boolean {
return activeSession !== null
}

View file

@ -1,354 +0,0 @@
import * as fabric from 'fabric'
import { useDocumentStore } from '@/stores/document-store'
import type { PenNode } from '@/types/pen'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { setFabricSyncLock } from './canvas-sync-lock'
// ---------------------------------------------------------------------------
// Drag session state
// ---------------------------------------------------------------------------
interface InitialPosition {
left: number
top: number
width: number
height: number
angle: number
}
interface DragSession {
parentId: string
/** All recursive descendant node IDs */
descendantIds: Set<string>
/** Initial absolute Fabric positions of parent + all descendants */
initialPositions: Map<string, InitialPosition>
/** Cached Fabric object references for fast lookup during drag */
descendantObjects: Map<string, FabricObjectWithPenId>
}
let activeDragSession: DragSession | null = null
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Recursively collect all descendant node IDs from the document tree. */
export function collectDescendantIds(nodeId: string): Set<string> {
const result = new Set<string>()
const node = useDocumentStore.getState().getNodeById(nodeId)
if (!node) return result
const recurse = (children: PenNode[] | undefined) => {
if (!children) return
for (const child of children) {
result.add(child.id)
if ('children' in child && child.children) {
recurse(child.children)
}
}
}
if ('children' in node && node.children) {
recurse(node.children)
}
return result
}
/** Check if a node is a container with at least one child. */
function hasChildren(nodeId: string): boolean {
const node = useDocumentStore.getState().getNodeById(nodeId)
if (!node) return false
return (
'children' in node &&
Array.isArray(node.children) &&
node.children.length > 0
)
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
/**
* Called on mouse:down when the target might be a container.
* Collects descendant IDs and caches initial Fabric positions.
* Returns null if the target has no children.
*/
export function beginParentDrag(
parentId: string,
canvas: fabric.Canvas,
): DragSession | null {
if (!hasChildren(parentId)) {
activeDragSession = null
return null
}
const descendantIds = collectDescendantIds(parentId)
if (descendantIds.size === 0) {
activeDragSession = null
return null
}
// Filter out descendants that are part of an ActiveSelection
// (Fabric already moves those; we'd double-move them otherwise)
const activeObj = canvas.getActiveObject()
if (activeObj && 'getObjects' in activeObj) {
const selectionIds = new Set(
(activeObj as fabric.ActiveSelection)
.getObjects()
.map((o) => (o as FabricObjectWithPenId).penNodeId)
.filter(Boolean) as string[],
)
for (const id of selectionIds) {
descendantIds.delete(id)
}
}
if (descendantIds.size === 0) {
activeDragSession = null
return null
}
// Cache initial positions and Fabric object references
const initialPositions = new Map<string, InitialPosition>()
const descendantObjects = new Map<string, FabricObjectWithPenId>()
const objects = canvas.getObjects() as FabricObjectWithPenId[]
for (const obj of objects) {
if (!obj.penNodeId) continue
if (obj.penNodeId === parentId || descendantIds.has(obj.penNodeId)) {
initialPositions.set(obj.penNodeId, {
left: obj.left ?? 0,
top: obj.top ?? 0,
width: (obj.width ?? 0) * (obj.scaleX ?? 1),
height: (obj.height ?? 0) * (obj.scaleY ?? 1),
angle: obj.angle ?? 0,
})
}
if (descendantIds.has(obj.penNodeId)) {
descendantObjects.set(obj.penNodeId, obj)
}
}
activeDragSession = {
parentId,
descendantIds,
initialPositions,
descendantObjects,
}
return activeDragSession
}
/** Clear the active session. */
export function endParentDrag(): void {
activeDragSession = null
}
/** Returns the active session, if any. */
export function getActiveDragSession(): DragSession | null {
return activeDragSession
}
// ---------------------------------------------------------------------------
// Move propagation
// ---------------------------------------------------------------------------
/**
* Move all descendant Fabric objects by the same delta as the parent.
* Called on each `object:moving` frame for visual feedback.
* No store sync needed children's relative positions don't change.
*/
export function moveDescendants(
parentObj: FabricObjectWithPenId,
_canvas: fabric.Canvas,
): void {
if (!activeDragSession) return
const initParent = activeDragSession.initialPositions.get(
activeDragSession.parentId,
)
if (!initParent) return
const deltaX = (parentObj.left ?? 0) - initParent.left
const deltaY = (parentObj.top ?? 0) - initParent.top
for (const [nodeId, obj] of activeDragSession.descendantObjects) {
const initPos = activeDragSession.initialPositions.get(nodeId)
if (!initPos) continue
obj.set({
left: initPos.left + deltaX,
top: initPos.top + deltaY,
})
obj.setCoords()
}
}
// ---------------------------------------------------------------------------
// Scale propagation
// ---------------------------------------------------------------------------
/**
* Reposition and scale all descendant Fabric objects proportionally
* relative to the parent's scale. Called on each `object:scaling` frame.
*/
export function scaleDescendants(
parentObj: FabricObjectWithPenId,
_canvas: fabric.Canvas,
): void {
if (!activeDragSession) return
const initParent = activeDragSession.initialPositions.get(
activeDragSession.parentId,
)
if (!initParent) return
const parentScaleX = parentObj.scaleX ?? 1
const parentScaleY = parentObj.scaleY ?? 1
const parentLeft = parentObj.left ?? 0
const parentTop = parentObj.top ?? 0
for (const [nodeId, obj] of activeDragSession.descendantObjects) {
const initPos = activeDragSession.initialPositions.get(nodeId)
if (!initPos) continue
// Child's initial offset from parent
const relativeX = initPos.left - initParent.left
const relativeY = initPos.top - initParent.top
obj.set({
left: parentLeft + relativeX * parentScaleX,
top: parentTop + relativeY * parentScaleY,
scaleX: parentScaleX,
scaleY: parentScaleY,
})
obj.setCoords()
}
}
// ---------------------------------------------------------------------------
// Rotation propagation
// ---------------------------------------------------------------------------
/**
* Rotate all descendant Fabric objects around the parent's center.
* Called on each `object:rotating` frame for visual feedback.
*
* In Fabric.js with originX:'left', originY:'top', the center of an object
* is always at (left + width/2, top + height/2) regardless of angle.
* Rotation happens visually around this center, and left/top stay unchanged
* when only the angle changes.
*/
export function rotateDescendants(
parentObj: FabricObjectWithPenId,
_canvas: fabric.Canvas,
): void {
if (!activeDragSession) return
const initParent = activeDragSession.initialPositions.get(
activeDragSession.parentId,
)
if (!initParent) return
const deltaAngle = (parentObj.angle ?? 0) - initParent.angle
const deltaRad = (deltaAngle * Math.PI) / 180
const cos = Math.cos(deltaRad)
const sin = Math.sin(deltaRad)
// Parent center (stays fixed during Fabric rotation)
const pcx = initParent.left + initParent.width / 2
const pcy = initParent.top + initParent.height / 2
for (const [nodeId, obj] of activeDragSession.descendantObjects) {
const initPos = activeDragSession.initialPositions.get(nodeId)
if (!initPos) continue
// Rotate child center around parent center
const ccx = initPos.left + initPos.width / 2
const ccy = initPos.top + initPos.height / 2
const dx = ccx - pcx
const dy = ccy - pcy
const newCx = pcx + dx * cos - dy * sin
const newCy = pcy + dx * sin + dy * cos
// Convert center back to left/top
const halfW = (obj.width ?? 0) * (obj.scaleX ?? 1) / 2
const halfH = (obj.height ?? 0) * (obj.scaleY ?? 1) / 2
obj.set({
left: newCx - halfW,
top: newCy - halfH,
angle: initPos.angle + deltaAngle,
})
obj.setCoords()
}
}
/**
* After parent rotation: update descendants' relative positions and angles
* in the document store so they persist correctly.
*/
export function finalizeParentRotation(
parentObj: FabricObjectWithPenId,
): void {
if (!activeDragSession) return
const initParent = activeDragSession.initialPositions.get(
activeDragSession.parentId,
)
if (!initParent) return
const deltaAngle = (parentObj.angle ?? 0) - initParent.angle
if (deltaAngle === 0) return
setFabricSyncLock(true)
useDocumentStore
.getState()
.rotateDescendantsInStore(activeDragSession.parentId, deltaAngle)
setFabricSyncLock(false)
}
// ---------------------------------------------------------------------------
// Finalize after object:modified
// ---------------------------------------------------------------------------
/**
* After parent scale is baked into width/height:
* 1. Bake scale into all descendant Fabric objects
* 2. Update descendants' relative positions and sizes in the document store
*/
export function finalizeParentTransform(
_parentObj: FabricObjectWithPenId,
_canvas: fabric.Canvas,
effectiveScaleX: number,
effectiveScaleY: number,
): void {
if (!activeDragSession) return
if (effectiveScaleX === 1 && effectiveScaleY === 1) return
// Bake scale into descendant Fabric objects
for (const [, obj] of activeDragSession.descendantObjects) {
const sx = obj.scaleX ?? 1
const sy = obj.scaleY ?? 1
if (obj.width !== undefined) {
obj.set({ width: obj.width * sx, scaleX: 1 })
}
if (obj.height !== undefined) {
obj.set({ height: obj.height * sy, scaleY: 1 })
}
obj.setCoords()
}
// Update document store: scale all descendants' relative positions and sizes
setFabricSyncLock(true)
useDocumentStore
.getState()
.scaleDescendantsInStore(
activeDragSession.parentId,
effectiveScaleX,
effectiveScaleY,
)
setFabricSyncLock(false)
}

View file

@ -1,504 +0,0 @@
import * as fabric from 'fabric'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore, generateId } from '@/stores/document-store'
import {
DEFAULT_STROKE,
DEFAULT_STROKE_WIDTH,
SELECTION_BLUE,
PEN_ANCHOR_FILL,
PEN_ANCHOR_RADIUS,
PEN_ANCHOR_FIRST_RADIUS,
PEN_HANDLE_DOT_RADIUS,
PEN_HANDLE_LINE_STROKE,
PEN_RUBBER_BAND_STROKE,
PEN_RUBBER_BAND_DASH,
PEN_CLOSE_HIT_THRESHOLD,
} from './canvas-constants'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface PenAnchorPoint {
x: number
y: number
/** Incoming control handle (offset relative to anchor). null = straight. */
handleIn: { x: number; y: number } | null
/** Outgoing control handle (offset relative to anchor). null = straight. */
handleOut: { x: number; y: number } | null
}
interface PenToolState {
isActive: boolean
points: PenAnchorPoint[]
isDraggingHandle: boolean
cursorPos: { x: number; y: number } | null
}
// ---------------------------------------------------------------------------
// Module-level state
// ---------------------------------------------------------------------------
let state: PenToolState = {
isActive: false,
points: [],
isDraggingHandle: false,
cursorPos: null,
}
// Temporary Fabric objects for visual feedback
let previewPath: fabric.FabricObject | null = null
let rubberBandLine: fabric.FabricObject | null = null
let anchorCircles: fabric.FabricObject[] = []
let handleLines: fabric.FabricObject[] = []
let handleDots: fabric.FabricObject[] = []
// ---------------------------------------------------------------------------
// Visual constants
// ---------------------------------------------------------------------------
const PREVIEW_STROKE = SELECTION_BLUE
const PREVIEW_STROKE_WIDTH = 1.5
const ANCHOR_STROKE = SELECTION_BLUE
// ---------------------------------------------------------------------------
// Exported query
// ---------------------------------------------------------------------------
export function isPenToolActive(): boolean {
return state.isActive
}
// ---------------------------------------------------------------------------
// Exported event handlers
// ---------------------------------------------------------------------------
export function penToolPointerDown(
canvas: fabric.Canvas,
scenePoint: { x: number; y: number },
): void {
if (!state.isActive) {
// First click — start a new path
state.isActive = true
state.points = [
{ x: scenePoint.x, y: scenePoint.y, handleIn: null, handleOut: null },
]
state.isDraggingHandle = true
state.cursorPos = scenePoint
renderPreview(canvas)
return
}
// Check if clicking near the first point to close the path
if (state.points.length >= 3) {
const first = state.points[0]
const zoom = useCanvasStore.getState().viewport.zoom || 1
const threshold = PEN_CLOSE_HIT_THRESHOLD / zoom
const dist = Math.hypot(
scenePoint.x - first.x,
scenePoint.y - first.y,
)
if (dist < threshold) {
finalizePath(canvas, true)
return
}
}
// Add a new anchor point
state.points.push({
x: scenePoint.x,
y: scenePoint.y,
handleIn: null,
handleOut: null,
})
state.isDraggingHandle = true
renderPreview(canvas)
}
export function penToolPointerMove(
canvas: fabric.Canvas,
scenePoint: { x: number; y: number },
): void {
if (!state.isActive) return
// Ignore if panning
const { isPanning } = useCanvasStore.getState().interaction
if (isPanning) return
if (state.isDraggingHandle && state.points.length > 0) {
// Update handle for the current (last) point
const pt = state.points[state.points.length - 1]
const dx = scenePoint.x - pt.x
const dy = scenePoint.y - pt.y
// Only set handles if the drag is significant (> 2px)
if (Math.hypot(dx, dy) > 2) {
pt.handleOut = { x: dx, y: dy }
pt.handleIn = { x: -dx, y: -dy }
} else {
pt.handleOut = null
pt.handleIn = null
}
}
state.cursorPos = scenePoint
renderPreview(canvas)
}
export function penToolPointerUp(canvas: fabric.Canvas): void {
if (!state.isActive) return
state.isDraggingHandle = false
renderPreview(canvas)
}
export function penToolDoubleClick(canvas: fabric.Canvas): void {
if (!state.isActive) return
// The double-click adds an extra point from the second click of the pair.
// Remove it so we don't get a duplicate degenerate segment.
if (state.points.length > 1) {
state.points.pop()
}
finalizePath(canvas, false)
}
/**
* Handle keyboard events during pen drawing.
* Returns true if the event was consumed.
*/
export function penToolKeyDown(
canvas: fabric.Canvas,
key: string,
): boolean {
if (!state.isActive) return false
switch (key) {
case 'Enter':
finalizePath(canvas, false)
return true
case 'Escape':
cancelPenTool(canvas)
return true
case 'Backspace': {
if (state.points.length > 1) {
state.points.pop()
renderPreview(canvas)
} else {
// Only one point left — cancel entirely
cancelPenTool(canvas)
}
return true
}
default:
return false
}
}
export function cancelPenTool(canvas: fabric.Canvas): void {
clearPreviewObjects(canvas)
state = {
isActive: false,
points: [],
isDraggingHandle: false,
cursorPos: null,
}
useCanvasStore.getState().setActiveTool('select')
}
// ---------------------------------------------------------------------------
// Internal: path data construction
// ---------------------------------------------------------------------------
function buildPathData(
points: PenAnchorPoint[],
closed: boolean,
): string {
if (points.length === 0) return ''
const parts: string[] = []
const first = points[0]
parts.push(`M ${first.x} ${first.y}`)
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1]
const curr = points[i]
appendSegment(parts, prev, curr)
}
if (closed && points.length > 1) {
// Close segment from last point back to first
const last = points[points.length - 1]
appendSegment(parts, last, first)
parts.push('Z')
}
return parts.join(' ')
}
function appendSegment(
parts: string[],
from: PenAnchorPoint,
to: PenAnchorPoint,
): void {
const hasHandleOut = from.handleOut !== null
const hasHandleIn = to.handleIn !== null
if (!hasHandleOut && !hasHandleIn) {
// Straight line
parts.push(`L ${to.x} ${to.y}`)
} else {
// Cubic bezier
const cx1 = from.x + (from.handleOut?.x ?? 0)
const cy1 = from.y + (from.handleOut?.y ?? 0)
const cx2 = to.x + (to.handleIn?.x ?? 0)
const cy2 = to.y + (to.handleIn?.y ?? 0)
parts.push(`C ${cx1} ${cy1} ${cx2} ${cy2} ${to.x} ${to.y}`)
}
}
// ---------------------------------------------------------------------------
// Internal: bounding box via browser SVG engine
// ---------------------------------------------------------------------------
function getPathBBox(d: string): {
x: number
y: number
w: number
h: number
} {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
const pathEl = document.createElementNS(
'http://www.w3.org/2000/svg',
'path',
)
pathEl.setAttribute('d', d)
svg.appendChild(pathEl)
document.body.appendChild(svg)
const bbox = pathEl.getBBox()
document.body.removeChild(svg)
return { x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height }
}
// ---------------------------------------------------------------------------
// Internal: finalize path into a PenNode
// ---------------------------------------------------------------------------
function finalizePath(canvas: fabric.Canvas, closed: boolean): void {
clearPreviewObjects(canvas)
// Need at least 2 points for a valid path
if (state.points.length < 2) {
state = {
isActive: false,
points: [],
isDraggingHandle: false,
cursorPos: null,
}
useCanvasStore.getState().setActiveTool('select')
return
}
// Build path data in absolute scene coordinates
const absD = buildPathData(state.points, closed)
const bbox = getPathBBox(absD)
// Guard against degenerate paths
if (bbox.w < 1 && bbox.h < 1) {
state = {
isActive: false,
points: [],
isDraggingHandle: false,
cursorPos: null,
}
useCanvasStore.getState().setActiveTool('select')
return
}
// Normalize: translate all points so the path origin is at (0,0)
const normalized = state.points.map((pt) => ({
...pt,
x: pt.x - bbox.x,
y: pt.y - bbox.y,
}))
const d = buildPathData(normalized, closed)
useDocumentStore.getState().addNode(null, {
id: generateId(),
type: 'path',
name: 'Path',
x: bbox.x,
y: bbox.y,
d,
width: Math.round(bbox.w),
height: Math.round(bbox.h),
fill: [{ type: 'solid', color: 'transparent' }],
stroke: {
thickness: DEFAULT_STROKE_WIDTH,
fill: [{ type: 'solid', color: DEFAULT_STROKE }],
},
})
state = {
isActive: false,
points: [],
isDraggingHandle: false,
cursorPos: null,
}
useCanvasStore.getState().setActiveTool('select')
}
// ---------------------------------------------------------------------------
// Internal: visual preview rendering
// ---------------------------------------------------------------------------
function clearPreviewObjects(canvas: fabric.Canvas): void {
const objs = [
previewPath,
rubberBandLine,
...anchorCircles,
...handleLines,
...handleDots,
].filter(Boolean) as fabric.FabricObject[]
for (const obj of objs) {
canvas.remove(obj)
}
previewPath = null
rubberBandLine = null
anchorCircles = []
handleLines = []
handleDots = []
}
function renderPreview(canvas: fabric.Canvas): void {
clearPreviewObjects(canvas)
const { points, cursorPos } = state
if (points.length === 0) return
const baseProps = {
selectable: false,
evented: false,
objectCaching: false,
originX: 'left' as const,
originY: 'top' as const,
excludeFromExport: true,
}
// --- Preview path (constructed segments so far) ---
if (points.length > 1) {
const d = buildPathData(points, false)
try {
previewPath = new fabric.Path(d, {
...baseProps,
fill: 'transparent',
stroke: PREVIEW_STROKE,
strokeWidth: PREVIEW_STROKE_WIDTH,
strokeUniform: true,
})
canvas.add(previewPath)
} catch {
// Invalid path data — skip preview
}
}
// --- Rubber-band line from last point to cursor ---
const last = points[points.length - 1]
if (cursorPos && !state.isDraggingHandle) {
rubberBandLine = new fabric.Line(
[last.x, last.y, cursorPos.x, cursorPos.y],
{
...baseProps,
fill: '',
stroke: PEN_RUBBER_BAND_STROKE,
strokeWidth: 1,
strokeDashArray: PEN_RUBBER_BAND_DASH,
strokeUniform: true,
},
)
canvas.add(rubberBandLine)
}
// --- Anchor circles ---
for (let i = 0; i < points.length; i++) {
const pt = points[i]
const isFirst = i === 0
const r = isFirst ? PEN_ANCHOR_FIRST_RADIUS : PEN_ANCHOR_RADIUS
const circle = new fabric.Circle({
...baseProps,
left: pt.x - r,
top: pt.y - r,
radius: r,
fill: PEN_ANCHOR_FILL,
stroke: ANCHOR_STROKE,
strokeWidth: 1.5,
strokeUniform: true,
})
anchorCircles.push(circle)
canvas.add(circle)
}
// --- Handle lines and dots ---
for (const pt of points) {
if (pt.handleOut) {
const hx = pt.x + pt.handleOut.x
const hy = pt.y + pt.handleOut.y
const line = new fabric.Line([pt.x, pt.y, hx, hy], {
...baseProps,
fill: '',
stroke: PEN_HANDLE_LINE_STROKE,
strokeWidth: 1,
strokeUniform: true,
})
handleLines.push(line)
canvas.add(line)
const dot = new fabric.Circle({
...baseProps,
left: hx - PEN_HANDLE_DOT_RADIUS,
top: hy - PEN_HANDLE_DOT_RADIUS,
radius: PEN_HANDLE_DOT_RADIUS,
fill: SELECTION_BLUE,
stroke: '#ffffff',
strokeWidth: 1,
strokeUniform: true,
})
handleDots.push(dot)
canvas.add(dot)
}
if (pt.handleIn) {
const hx = pt.x + pt.handleIn.x
const hy = pt.y + pt.handleIn.y
const line = new fabric.Line([pt.x, pt.y, hx, hy], {
...baseProps,
fill: '',
stroke: PEN_HANDLE_LINE_STROKE,
strokeWidth: 1,
strokeUniform: true,
})
handleLines.push(line)
canvas.add(line)
const dot = new fabric.Circle({
...baseProps,
left: hx - PEN_HANDLE_DOT_RADIUS,
top: hy - PEN_HANDLE_DOT_RADIUS,
radius: PEN_HANDLE_DOT_RADIUS,
fill: SELECTION_BLUE,
stroke: '#ffffff',
strokeWidth: 1,
strokeUniform: true,
})
handleDots.push(dot)
canvas.add(dot)
}
}
canvas.renderAll()
}

View file

@ -0,0 +1,58 @@
/**
* Module-level singleton reference to the active SkiaEngine instance.
* Set by SkiaCanvas on mount, cleared on unmount.
* Allows external code (keyboard shortcuts, AI orchestrator, etc.) to call
* engine methods like zoomToFitContent() without prop-drilling.
*/
import type { SkiaEngine } from './skia/skia-engine'
let _engine: SkiaEngine | null = null
export function setSkiaEngineRef(engine: SkiaEngine | null) {
_engine = engine
}
export function getSkiaEngineRef(): SkiaEngine | null {
return _engine
}
/**
* Zoom and pan so all document content fits in the visible canvas area.
* Delegates to the active SkiaEngine instance.
*/
export function zoomToFitContent() {
_engine?.zoomToFitContent()
}
/**
* Returns the canvas element dimensions in CSS pixels.
* Falls back to 800x600 if no engine is mounted.
*/
export function getCanvasSize(): { width: number; height: number } {
return _engine?.getCanvasSize() ?? { width: 800, height: 600 }
}
/**
* No-op with the Skia engine, document-store is always in sync.
* Previously needed for Fabric.js where canvas objects held authoritative positions.
*/
export function syncCanvasPositionsToStore() {
// Skia engine writes positions directly to document-store during interactions.
// No sync needed before save.
}
/**
* Flag to skip depth-resolution on the next selection event.
* Used by layer panel to programmatically select children without
* auto-resolving them to their parent group.
*/
let _skipNextDepthResolve = false
export function setSkipNextDepthResolve() {
_skipNextDepthResolve = true
}
export function consumeSkipNextDepthResolve(): boolean {
const v = _skipNextDepthResolve
_skipNextDepthResolve = false
return v
}

View file

@ -0,0 +1,941 @@
import { useRef, useEffect, useState } from 'react'
import { loadCanvasKit } from './skia-init'
import { SkiaEngine, screenToScene } from './skia-engine'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { createNodeForTool, isDrawingTool } from '../canvas-node-creator'
import { inferLayout } from '../canvas-layout-engine'
import { SkiaPenTool } from './skia-pen-tool'
import { setSkiaEngineRef } from '../skia-engine-ref'
import type { ToolType } from '@/types/canvas'
import type { PenNode, ContainerProps, TextNode } from '@/types/pen'
interface TextEditState {
nodeId: string
x: number; y: number; w: number; h: number
content: string
fontSize: number
fontFamily: string
fontWeight: string
textAlign: string
color: string
lineHeight: number
}
function toolToCursor(tool: ToolType): string {
switch (tool) {
case 'hand': return 'grab'
case 'text': return 'text'
case 'select': return 'default'
default: return 'crosshair'
}
}
export default function SkiaCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const engineRef = useRef<SkiaEngine | null>(null)
const [error, setError] = useState<string | null>(null)
const [editingText, setEditingText] = useState<TextEditState | null>(null)
// Initialize CanvasKit + engine
useEffect(() => {
let disposed = false
async function init() {
try {
const ck = await loadCanvasKit()
if (disposed) return
const canvasEl = canvasRef.current
if (!canvasEl) return
const engine = new SkiaEngine(ck)
engine.init(canvasEl)
engineRef.current = engine
setSkiaEngineRef(engine)
// Initial sync
engine.syncFromDocument()
requestAnimationFrame(() => engine.zoomToFitContent())
} catch (err) {
console.error('SkiaCanvas init failed:', err)
setError(String(err))
}
}
init()
return () => {
disposed = true
setSkiaEngineRef(null)
engineRef.current?.dispose()
engineRef.current = null
}
}, [])
// Resize observer
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver((entries) => {
const engine = engineRef.current
if (!engine) return
for (const entry of entries) {
const { width, height } = entry.contentRect
engine.resize(width, height)
}
})
observer.observe(container)
return () => observer.disconnect()
}, [])
// Document sync: re-render when document changes
useEffect(() => {
const unsub = useDocumentStore.subscribe(() => {
engineRef.current?.syncFromDocument()
})
return unsub
}, [])
// Page sync: re-render when active page changes
useEffect(() => {
let prevPageId = useCanvasStore.getState().activePageId
const unsub = useCanvasStore.subscribe((state) => {
if (state.activePageId !== prevPageId) {
prevPageId = state.activePageId
engineRef.current?.syncFromDocument()
}
})
return unsub
}, [])
// Selection sync: re-render when selection changes
useEffect(() => {
let prevIds = useCanvasStore.getState().selection.selectedIds
const unsub = useCanvasStore.subscribe((state) => {
if (state.selection.selectedIds !== prevIds) {
prevIds = state.selection.selectedIds
engineRef.current?.markDirty()
}
})
return unsub
}, [])
// ---- Event handlers ----
// Wheel: zoom + pan
useEffect(() => {
const canvasEl = canvasRef.current
if (!canvasEl) return
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
e.stopPropagation()
const engine = engineRef.current
if (!engine) return
if (e.ctrlKey || e.metaKey) {
let delta = -e.deltaY
if (e.deltaMode === 1) delta *= 40
const factor = Math.pow(1.005, delta)
const newZoom = engine.zoom * factor
engine.zoomToPoint(e.clientX, e.clientY, newZoom)
} else {
let dx = -e.deltaX
let dy = -e.deltaY
if (e.deltaMode === 1) { dx *= 40; dy *= 40 }
engine.pan(dx, dy)
}
}
canvasEl.addEventListener('wheel', handleWheel, { passive: false })
return () => canvasEl.removeEventListener('wheel', handleWheel)
}, [])
// Mouse interactions: select, move, resize, draw, hover
useEffect(() => {
const canvasEl = canvasRef.current
if (!canvasEl) return
// --- Shared state ---
let isPanning = false
let spacePressed = false
let lastX = 0
let lastY = 0
// --- Select tool state ---
let isDragging = false
let dragMoved = false
let isMarquee = false
let dragNodeIds: string[] = []
let dragStartSceneX = 0
let dragStartSceneY = 0
let dragOrigPositions: { id: string; x: number; y: number }[] = []
let dragPrevDx = 0
let dragPrevDy = 0
/** Set of node IDs being dragged (including descendants). */
let dragAllIds: Set<string> | null = null
const DRAG_THRESHOLD = 3
// --- Resize handle state ---
type HandleDir = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
let isResizing = false
let resizeHandle: HandleDir | null = null
let resizeNodeId: string | null = null
let resizeOrigX = 0
let resizeOrigY = 0
let resizeOrigW = 0
let resizeOrigH = 0
let resizeStartSceneX = 0
let resizeStartSceneY = 0
const HANDLE_HIT_RADIUS = 8 // larger hit area than visual
const ROTATE_OUTER_RADIUS = 16 // rotation zone is outside corner handles
const handleCursors: Record<HandleDir, string> = {
nw: 'nwse-resize', n: 'ns-resize', ne: 'nesw-resize', e: 'ew-resize',
se: 'nwse-resize', s: 'ns-resize', sw: 'nesw-resize', w: 'ew-resize',
}
// --- Rotation state ---
let isRotating = false
let rotateNodeId: string | null = null
let rotateOrigAngle = 0
let rotateCenterX = 0
let rotateCenterY = 0
let rotateStartAngle = 0
// --- Drawing tool state ---
let isDrawing = false
let drawTool: ToolType = 'select'
let drawStartX = 0
let drawStartY = 0
// --- Pen tool ---
const penTool = new SkiaPenTool(() => engineRef.current)
const getEngine = () => engineRef.current
const getTool = () => useCanvasStore.getState().activeTool
const getScene = (e: MouseEvent) => {
const engine = getEngine()
if (!engine) return null
const rect = engine.getCanvasRect()
if (!rect) return null
return screenToScene(e.clientX, e.clientY, rect, {
zoom: engine.zoom, panX: engine.panX, panY: engine.panY,
})
}
/** Get the selected node's render info (single selection only). */
const getSelectedRN = () => {
const engine = getEngine()
if (!engine) return null
const { selectedIds } = useCanvasStore.getState().selection
if (selectedIds.length !== 1) return null
return engine.spatialIndex.get(selectedIds[0]) ?? null
}
/** Check if a scene point hits a resize handle of the selected node. */
const hitTestHandle = (sceneX: number, sceneY: number): { dir: HandleDir; nodeId: string } | null => {
const engine = getEngine()
if (!engine) return null
const rn = getSelectedRN()
if (!rn) return null
const hitR = HANDLE_HIT_RADIUS / engine.zoom
const { absX: x, absY: y, absW: w, absH: h } = rn
const handles: [HandleDir, number, number][] = [
['nw', x, y], ['n', x + w / 2, y], ['ne', x + w, y],
['w', x, y + h / 2], ['e', x + w, y + h / 2],
['sw', x, y + h], ['s', x + w / 2, y + h], ['se', x + w, y + h],
]
for (const [dir, hx, hy] of handles) {
if (Math.abs(sceneX - hx) <= hitR && Math.abs(sceneY - hy) <= hitR) {
return { dir, nodeId: rn.node.id }
}
}
return null
}
/** Check if a scene point is in the rotation zone (just outside corner handles). */
const hitTestRotation = (sceneX: number, sceneY: number): { nodeId: string } | null => {
const engine = getEngine()
if (!engine) return null
const rn = getSelectedRN()
if (!rn) return null
const innerR = HANDLE_HIT_RADIUS / engine.zoom
const outerR = ROTATE_OUTER_RADIUS / engine.zoom
const { absX: x, absY: y, absW: w, absH: h } = rn
const corners = [[x, y], [x + w, y], [x, y + h], [x + w, y + h]]
for (const [cx, cy] of corners) {
const dist = Math.hypot(sceneX - cx, sceneY - cy)
if (dist > innerR && dist <= outerR) {
return { nodeId: rn.node.id }
}
}
return null
}
// --- Keyboard: space for panning ---
const onKeyDown = (e: KeyboardEvent) => {
// Pen tool keyboard shortcuts
if (penTool.onKeyDown(e.key)) {
e.preventDefault()
return
}
if (e.code === 'Space' && !e.repeat) {
spacePressed = true
canvasEl.style.cursor = 'grab'
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') {
spacePressed = false
isPanning = false
canvasEl.style.cursor = toolToCursor(getTool())
}
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
// Tool change → cursor + cancel pen if switching away
const unsubTool = useCanvasStore.subscribe((state) => {
if (!spacePressed && !isResizing) canvasEl.style.cursor = toolToCursor(state.activeTool)
penTool.onToolChange(state.activeTool)
})
// =====================================================================
// MOUSE DOWN
// =====================================================================
const onMouseDown = (e: MouseEvent) => {
const engine = getEngine()
if (!engine) return
// Ignore right-click — only handle left (0) and middle (1)
if (e.button === 2) return
// --- Pan: space+click, hand tool, or middle mouse ---
if (spacePressed || getTool() === 'hand' || e.button === 1) {
isPanning = true
lastX = e.clientX
lastY = e.clientY
canvasEl.style.cursor = 'grabbing'
return
}
const tool = getTool()
const scene = getScene(e)
if (!scene) return
// --- Text tool: click to create immediately ---
if (tool === 'text') {
const node = createNodeForTool('text', scene.x, scene.y, 0, 0)
if (node) {
useDocumentStore.getState().addNode(null, node)
useCanvasStore.getState().setSelection([node.id], node.id)
}
useCanvasStore.getState().setActiveTool('select')
return
}
// --- Pen tool ---
if (tool === 'path') {
penTool.onMouseDown(scene, engine.zoom || 1)
return
}
// --- Drawing tools: start rubber-band ---
if (isDrawingTool(tool)) {
isDrawing = true
drawTool = tool
drawStartX = scene.x
drawStartY = scene.y
engine.previewShape = {
type: tool as 'rectangle' | 'ellipse' | 'frame' | 'line',
x: scene.x, y: scene.y, w: 0, h: 0,
}
engine.markDirty()
return
}
// --- Select tool ---
if (tool === 'select') {
// Check resize handle first (only for single selection)
const handleHit = hitTestHandle(scene.x, scene.y)
if (handleHit) {
isResizing = true
resizeHandle = handleHit.dir
resizeNodeId = handleHit.nodeId
resizeStartSceneX = scene.x
resizeStartSceneY = scene.y
const docNode = useDocumentStore.getState().getNodeById(handleHit.nodeId)
resizeOrigX = docNode?.x ?? 0
resizeOrigY = docNode?.y ?? 0
// Use resolved dimensions from spatial index — document store may have
// string values like 'fill_container' that break arithmetic.
const resizeRN = engine.spatialIndex.get(handleHit.nodeId)
const docNodeAny = docNode as (PenNode & ContainerProps) | undefined
resizeOrigW = resizeRN?.absW ?? (typeof docNodeAny?.width === 'number' ? docNodeAny.width : 100)
resizeOrigH = resizeRN?.absH ?? (typeof docNodeAny?.height === 'number' ? docNodeAny.height : 100)
canvasEl.style.cursor = handleCursors[handleHit.dir]
return
}
// Check rotation zone (just outside corner handles)
const rotHit = hitTestRotation(scene.x, scene.y)
if (rotHit) {
isRotating = true
rotateNodeId = rotHit.nodeId
const docNode = useDocumentStore.getState().getNodeById(rotHit.nodeId)
rotateOrigAngle = docNode?.rotation ?? 0
const rn = getSelectedRN()!
rotateCenterX = rn.absX + rn.absW / 2
rotateCenterY = rn.absY + rn.absH / 2
rotateStartAngle = Math.atan2(scene.y - rotateCenterY, scene.x - rotateCenterX) * 180 / Math.PI
canvasEl.style.cursor = 'grabbing'
return
}
const hits = engine.spatialIndex.hitTest(scene.x, scene.y)
if (hits.length > 0) {
const topHit = hits[0]
let nodeId = topHit.node.id
const currentSelection = useCanvasStore.getState().selection.selectedIds
const docStore = useDocumentStore.getState()
// If the hit node is a descendant of an already-selected node,
// keep the current selection so the whole group moves together.
const isChildOfSelected = currentSelection.some(
(selId) => selId !== nodeId && docStore.isDescendantOf(nodeId, selId),
)
if (isChildOfSelected) {
// Don't change selection — proceed to drag the selected parent
} else if (!currentSelection.includes(nodeId)) {
// Walk up the tree to find the top-level parent (group behavior):
// clicking a child inside a group selects the group, not the child.
const parent = docStore.getParentOf(nodeId)
if (parent && (parent.type === 'frame' || parent.type === 'group')) {
// Check if parent is a top-level node (its parent is the page root)
const grandparent = docStore.getParentOf(parent.id)
if (!grandparent || grandparent.type === 'frame') {
nodeId = parent.id
}
}
if (e.shiftKey) {
if (currentSelection.includes(nodeId)) {
const next = currentSelection.filter((id) => id !== nodeId)
useCanvasStore.getState().setSelection(next, next[0] ?? null)
} else {
useCanvasStore.getState().setSelection([...currentSelection, nodeId], nodeId)
}
} else {
useCanvasStore.getState().setSelection([nodeId], nodeId)
}
}
// Start drag — move all selected nodes
const selectedIds = useCanvasStore.getState().selection.selectedIds
isDragging = true
dragMoved = false
dragNodeIds = selectedIds
dragStartSceneX = scene.x
dragStartSceneY = scene.y
dragOrigPositions = selectedIds.map((id) => {
const n = useDocumentStore.getState().getNodeById(id)
return { id, x: n?.x ?? 0, y: n?.y ?? 0 }
})
} else {
// Empty space → start marquee or clear selection
if (!e.shiftKey) {
useCanvasStore.getState().clearSelection()
}
isMarquee = true
lastX = scene.x
lastY = scene.y
engine.marquee = { x1: scene.x, y1: scene.y, x2: scene.x, y2: scene.y }
}
}
}
// =====================================================================
// MOUSE MOVE
// =====================================================================
const onMouseMove = (e: MouseEvent) => {
const engine = getEngine()
if (!engine) return
if (isPanning) {
const dx = e.clientX - lastX
const dy = e.clientY - lastY
lastX = e.clientX
lastY = e.clientY
engine.pan(dx, dy)
return
}
const scene = getScene(e)
if (!scene) return
// --- Pen tool move ---
if (penTool.onMouseMove(scene)) return
// --- Resize handle drag ---
if (isResizing && resizeHandle && resizeNodeId) {
const dx = scene.x - resizeStartSceneX
const dy = scene.y - resizeStartSceneY
let newX = resizeOrigX
let newY = resizeOrigY
let newW = resizeOrigW
let newH = resizeOrigH
// Compute new bounds based on which handle is dragged
const dir = resizeHandle
if (dir.includes('w')) { newX = resizeOrigX + dx; newW = resizeOrigW - dx }
if (dir.includes('e')) { newW = resizeOrigW + dx }
if (dir.includes('n')) { newY = resizeOrigY + dy; newH = resizeOrigH - dy }
if (dir.includes('s')) { newH = resizeOrigH + dy }
// Enforce minimum size
const MIN = 2
if (newW < MIN) { if (dir.includes('w')) newX = resizeOrigX + resizeOrigW - MIN; newW = MIN }
if (newH < MIN) { if (dir.includes('n')) newY = resizeOrigY + resizeOrigH - MIN; newH = MIN }
// For text nodes, switching to fixed-width enables auto-wrap
const resizedNode = useDocumentStore.getState().getNodeById(resizeNodeId)
const updates: Record<string, unknown> = { x: newX, y: newY, width: newW, height: newH }
if (resizedNode?.type === 'text' && !(resizedNode as TextNode).textGrowth) {
updates.textGrowth = 'fixed-width'
}
useDocumentStore.getState().updateNode(resizeNodeId, updates as Partial<PenNode>)
// Scale children proportionally when resizing a container with children
if (
resizedNode
&& 'children' in resizedNode
&& resizedNode.children?.length
) {
// Use resolved dimensions — document store may have string values
const resizeRN2 = engine.spatialIndex.get(resizeNodeId)
const resizedContainer = resizedNode as PenNode & ContainerProps
const oldW = resizeRN2?.absW ?? (typeof resizedContainer.width === 'number' ? resizedContainer.width : 0)
const oldH = resizeRN2?.absH ?? (typeof resizedContainer.height === 'number' ? resizedContainer.height : 0)
if (oldW > 0 && oldH > 0) {
const scaleX = newW / oldW
const scaleY = newH / oldH
useDocumentStore.getState().scaleDescendantsInStore(resizeNodeId, scaleX, scaleY)
}
}
return
}
// --- Rotation drag ---
if (isRotating && rotateNodeId) {
const currentAngle = Math.atan2(scene.y - rotateCenterY, scene.x - rotateCenterX) * 180 / Math.PI
let newAngle = rotateOrigAngle + (currentAngle - rotateStartAngle)
// Snap to 15° increments when holding shift
if (e.shiftKey) {
newAngle = Math.round(newAngle / 15) * 15
}
useDocumentStore.getState().updateNode(rotateNodeId, { rotation: newAngle } as Partial<PenNode>)
return
}
// --- Drawing tool preview ---
if (isDrawing && engine.previewShape) {
const dx = scene.x - drawStartX
const dy = scene.y - drawStartY
if (drawTool === 'line') {
engine.previewShape = {
type: 'line',
x: drawStartX, y: drawStartY,
w: dx, h: dy,
}
} else {
// Rectangle / ellipse / frame: handle negative drag direction
engine.previewShape = {
type: drawTool as 'rectangle' | 'ellipse' | 'frame' | 'line',
x: dx < 0 ? scene.x : drawStartX,
y: dy < 0 ? scene.y : drawStartY,
w: Math.abs(dx),
h: Math.abs(dy),
}
}
engine.markDirty()
return
}
// --- Select tool: drag move (all selected nodes) ---
if (isDragging && dragNodeIds.length > 0) {
const dx = scene.x - dragStartSceneX
const dy = scene.y - dragStartSceneY
if (!dragMoved) {
const screenDist = Math.hypot(dx * engine.zoom, dy * engine.zoom)
if (screenDist < DRAG_THRESHOLD) return
dragMoved = true
// Suppress store→sync loop so layout engine doesn't override positions
engine.dragSyncSuppressed = true
dragPrevDx = 0
dragPrevDy = 0
// Collect all node IDs being moved (selected + their descendants)
dragAllIds = new Set(dragNodeIds)
for (const id of dragNodeIds) {
const collectDescs = (nodeId: string) => {
const n = useDocumentStore.getState().getNodeById(nodeId)
if (n && 'children' in n && n.children) {
for (const child of n.children) {
dragAllIds!.add(child.id)
collectDescs(child.id)
}
}
}
collectDescs(id)
}
}
// Apply incremental delta directly to render nodes for immediate feedback
const incrDx = dx - dragPrevDx
const incrDy = dy - dragPrevDy
dragPrevDx = dx
dragPrevDy = dy
for (const rn of engine.renderNodes) {
if (dragAllIds!.has(rn.node.id)) {
rn.absX += incrDx
rn.absY += incrDy
rn.node = { ...rn.node, x: rn.absX, y: rn.absY }
}
}
engine.spatialIndex.rebuild(engine.renderNodes)
engine.markDirty()
return
}
// --- Marquee ---
if (isMarquee && engine.marquee) {
engine.marquee.x2 = scene.x
engine.marquee.y2 = scene.y
engine.markDirty()
const marqueeHits = engine.spatialIndex.searchRect(
engine.marquee.x1, engine.marquee.y1,
engine.marquee.x2, engine.marquee.y2,
)
const ids = marqueeHits.map((rn) => rn.node.id)
useCanvasStore.getState().setSelection(ids, ids[0] ?? null)
return
}
// --- Hover + handle cursor (select tool only) ---
if (getTool() === 'select' && !spacePressed) {
// Check handle hover for cursor
const handleHit = hitTestHandle(scene.x, scene.y)
if (handleHit) {
canvasEl.style.cursor = handleCursors[handleHit.dir]
} else if (hitTestRotation(scene.x, scene.y)) {
// Rotation cursor — rotate icon via CSS
canvasEl.style.cursor = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'24\' height=\'24\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'black\' stroke-width=\'2\'%3E%3Cpath d=\'M21 2v6h-6\'/%3E%3Cpath d=\'M21 13a9 9 0 1 1-3-7.7L21 8\'/%3E%3C/svg%3E") 12 12, crosshair'
} else {
const hoverHits = engine.spatialIndex.hitTest(scene.x, scene.y)
const newHoveredId = hoverHits.length > 0 ? hoverHits[0].node.id : null
canvasEl.style.cursor = newHoveredId ? 'move' : 'default'
if (newHoveredId !== engine.hoveredNodeId) {
engine.hoveredNodeId = newHoveredId
useCanvasStore.getState().setHoveredId(newHoveredId)
engine.markDirty()
}
}
}
}
// =====================================================================
// MOUSE UP
// =====================================================================
const onMouseUp = () => {
const engine = getEngine()
// --- Pen tool: end handle drag ---
if (penTool.onMouseUp()) return
// --- Pan end ---
if (isPanning) {
isPanning = false
canvasEl.style.cursor = spacePressed ? 'grab' : toolToCursor(getTool())
}
// --- Resize end ---
if (isResizing) {
isResizing = false
resizeHandle = null
resizeNodeId = null
canvasEl.style.cursor = toolToCursor(getTool())
}
// --- Rotation end ---
if (isRotating) {
isRotating = false
rotateNodeId = null
canvasEl.style.cursor = toolToCursor(getTool())
}
// --- Drawing tool: create node ---
if (isDrawing && engine?.previewShape) {
const { type, x, y, w, h } = engine.previewShape
engine.previewShape = null
engine.markDirty()
isDrawing = false
// Ignore tiny accidental clicks (< 2px)
const minSize = type === 'line'
? Math.hypot(w, h) >= 2
: w >= 2 && h >= 2
if (minSize) {
const node = createNodeForTool(drawTool, x, y, w, h)
if (node) {
useDocumentStore.getState().addNode(null, node)
useCanvasStore.getState().setSelection([node.id], node.id)
}
}
useCanvasStore.getState().setActiveTool('select')
return
}
isDrawing = false
// --- Select tool: end drag / marquee ---
if (isDragging && dragMoved && dragOrigPositions.length > 0 && engine) {
const dx = dragPrevDx
const dy = dragPrevDy
const docStore = useDocumentStore.getState()
for (const orig of dragOrigPositions) {
const parent = docStore.getParentOf(orig.id)
const draggedRN = engine.renderNodes.find((rn) => rn.node.id === orig.id)
const objBounds = draggedRN
? { x: draggedRN.absX, y: draggedRN.absY, w: draggedRN.absW, h: draggedRN.absH }
: { x: orig.x + dx, y: orig.y + dy, w: 100, h: 100 }
// Check if dragged completely outside parent → reparent
if (parent) {
const parentRN = engine.renderNodes.find((rn) => rn.node.id === parent.id)
if (parentRN) {
const pBounds = { x: parentRN.absX, y: parentRN.absY, w: parentRN.absW, h: parentRN.absH }
const outside =
objBounds.x + objBounds.w <= pBounds.x ||
objBounds.x >= pBounds.x + pBounds.w ||
objBounds.y + objBounds.h <= pBounds.y ||
objBounds.y >= pBounds.y + pBounds.h
if (outside) {
// Reparent to root level with absolute position
docStore.updateNode(orig.id, { x: objBounds.x, y: objBounds.y } as Partial<PenNode>)
docStore.moveNode(orig.id, null, 0)
continue
}
}
}
// Check if node is inside a layout container
const parentLayout = parent
? ((parent as PenNode & ContainerProps).layout || inferLayout(parent))
: undefined
if (parentLayout && parentLayout !== 'none' && parent) {
// Layout child: reorder based on drop position
const siblings = ('children' in parent ? parent.children ?? [] : [])
.filter((c) => c.id !== orig.id)
const isVertical = parentLayout === 'vertical'
let newIndex = siblings.length
for (let i = 0; i < siblings.length; i++) {
const sibRN = engine.renderNodes.find((rn) => rn.node.id === siblings[i].id)
const sibMid = sibRN
? (isVertical ? sibRN.absY + sibRN.absH / 2 : sibRN.absX + sibRN.absW / 2)
: 0
const dragMid = isVertical
? objBounds.y + objBounds.h / 2
: objBounds.x + objBounds.w / 2
if (dragMid < sibMid) {
newIndex = i
break
}
}
docStore.moveNode(orig.id, parent.id, newIndex)
} else {
// Non-layout node: freely set position
docStore.updateNode(orig.id, {
x: orig.x + dx,
y: orig.y + dy,
} as Partial<PenNode>)
}
}
// Re-enable sync and do a full rebuild
engine.dragSyncSuppressed = false
engine.syncFromDocument()
} else if (engine) {
engine.dragSyncSuppressed = false
}
isDragging = false
dragNodeIds = []
dragOrigPositions = []
dragAllIds = null
if (isMarquee && engine) {
engine.marquee = null
engine.markDirty()
}
isMarquee = false
}
// =====================================================================
// DOUBLE CLICK — text editing
// =====================================================================
const onDblClick = (e: MouseEvent) => {
const engine = getEngine()
if (!engine) return
// Pen tool: double-click finalizes the path (open)
if (penTool.onDblClick()) return
if (getTool() !== 'select') return
const scene = getScene(e)
if (!scene) return
const hits = engine.spatialIndex.hitTest(scene.x, scene.y)
if (hits.length === 0) return
const topHit = hits[0]
const currentSelection = useCanvasStore.getState().selection.selectedIds
// Double-click on a selected group/frame → enter it and select the child
if (currentSelection.length === 1) {
const selectedNode = useDocumentStore.getState().getNodeById(currentSelection[0])
if (
selectedNode
&& (selectedNode.type === 'frame' || selectedNode.type === 'group')
&& 'children' in selectedNode && selectedNode.children?.length
) {
// Find the direct child under the cursor
const childId = topHit.node.id
if (childId !== currentSelection[0]) {
useCanvasStore.getState().setSelection([childId], childId)
return
}
}
}
if (topHit.node.type !== 'text') return
const tNode = topHit.node as TextNode
const fills = tNode.fill
const firstFill = Array.isArray(fills) ? fills[0] : undefined
const color = firstFill?.type === 'solid' ? firstFill.color : '#000000'
setEditingText({
nodeId: topHit.node.id,
x: topHit.absX * engine.zoom + engine.panX,
y: topHit.absY * engine.zoom + engine.panY,
w: topHit.absW * engine.zoom,
h: topHit.absH * engine.zoom,
content: typeof tNode.content === 'string'
? tNode.content
: Array.isArray(tNode.content)
? tNode.content.map((s) => s.text ?? '').join('')
: '',
fontSize: (tNode.fontSize ?? 16) * engine.zoom,
fontFamily: tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif',
fontWeight: String(tNode.fontWeight ?? '400'),
textAlign: tNode.textAlign ?? 'left',
color,
lineHeight: tNode.lineHeight ?? 1.4,
})
}
const onContextMenu = (e: MouseEvent) => e.preventDefault()
canvasEl.addEventListener('mousedown', onMouseDown)
canvasEl.addEventListener('dblclick', onDblClick)
canvasEl.addEventListener('contextmenu', onContextMenu)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
canvasEl.removeEventListener('mousedown', onMouseDown)
canvasEl.removeEventListener('dblclick', onDblClick)
canvasEl.removeEventListener('contextmenu', onContextMenu)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
unsubTool()
}
}, [])
return (
<div
ref={containerRef}
className="flex-1 relative overflow-hidden bg-muted"
>
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full"
/>
{editingText && (
<textarea
autoFocus
defaultValue={editingText.content}
style={{
position: 'absolute',
left: editingText.x,
top: editingText.y,
width: Math.max(editingText.w, 40),
minHeight: Math.max(editingText.h, 24),
fontSize: editingText.fontSize,
fontFamily: editingText.fontFamily,
fontWeight: editingText.fontWeight,
textAlign: editingText.textAlign as CanvasTextAlign,
color: editingText.color,
lineHeight: editingText.lineHeight,
background: 'rgba(255,255,255,0.9)',
border: '2px solid #0d99ff',
borderRadius: 2,
outline: 'none',
resize: 'none',
padding: '0 1px',
margin: 0,
overflow: 'hidden',
zIndex: 10,
boxSizing: 'border-box',
}}
onBlur={(e) => {
const newContent = e.target.value
if (newContent !== editingText.content) {
useDocumentStore.getState().updateNode(editingText.nodeId, { content: newContent } as Partial<PenNode>)
}
setEditingText(null)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setEditingText(null)
}
e.stopPropagation()
}}
/>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center text-destructive">
Failed to load CanvasKit: {error}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,742 @@
import type { CanvasKit, Surface } from 'canvaskit-wasm'
import type { PenNode, ContainerProps } from '@/types/pen'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore, getActivePageChildren, getAllChildren } from '@/stores/document-store'
import { resolveNodeForCanvas, getDefaultTheme } from '@/variables/resolve-variables'
import { getCanvasBackground, MIN_ZOOM, MAX_ZOOM } from '../canvas-constants'
import {
resolvePadding,
isNodeVisible,
getNodeWidth,
getNodeHeight,
computeLayoutPositions,
inferLayout,
} from '../canvas-layout-engine'
import { parseSizing, defaultLineHeight } from '../canvas-text-measure'
import { SkiaRenderer, type RenderNode } from './skia-renderer'
import { SpatialIndex } from './skia-hit-test'
import { parseColor, wrapLine } from './skia-paint-utils'
import {
viewportMatrix,
zoomToPoint as vpZoomToPoint,
} from './skia-viewport'
import {
getActiveAgentIndicators,
getActiveAgentFrames,
isPreviewNode,
} from '../agent-indicator'
import { isNodeBorderReady, getNodeRevealTime } from '@/services/ai/design-animation'
// Re-export for use by canvas component
export { screenToScene } from './skia-viewport'
export { SpatialIndex } from './skia-hit-test'
// ---------------------------------------------------------------------------
// Pre-measure text widths using Canvas 2D (browser fonts)
// ---------------------------------------------------------------------------
let _measureCtx: CanvasRenderingContext2D | null = null
function getMeasureCtx(): CanvasRenderingContext2D {
if (!_measureCtx) {
const c = document.createElement('canvas')
_measureCtx = c.getContext('2d')!
}
return _measureCtx
}
/**
* Walk the node tree and fix text HEIGHTS using actual Canvas 2D wrapping.
*
* Only targets fixed-width text with auto height these are the cases where
* estimateTextHeight may underestimate because its width estimation differs
* from Canvas 2D's actual text measurement, leading to incorrect wrap counts.
*
* IMPORTANT: This function never touches WIDTH or container-relative sizing
* strings (fill_container / fit_content). Changing widths breaks layout
* resolution in computeLayoutPositions.
*/
function premeasureTextHeights(nodes: PenNode[]): PenNode[] {
return nodes.map((node) => {
let result = node
if (node.type === 'text') {
const tNode = node as import('@/types/pen').TextNode
const hasFixedWidth = typeof tNode.width === 'number' && tNode.width > 0
const isContainerHeight = typeof tNode.height === 'string'
&& (tNode.height === 'fill_container' || tNode.height === 'fit_content')
const textGrowth = tNode.textGrowth
const content = typeof tNode.content === 'string'
? tNode.content
: Array.isArray(tNode.content)
? tNode.content.map((s) => s.text ?? '').join('')
: ''
// Match Fabric.js wrapping: only premeasure when text actually wraps.
// textGrowth='auto' means auto-width (no wrapping) regardless of textAlign.
// textGrowth=undefined with non-left textAlign uses fixed-width for alignment.
const textAlign = tNode.textAlign
const isFixedWidthText = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
|| (textGrowth !== 'auto' && textAlign != null && textAlign !== 'left')
if (content && hasFixedWidth && isFixedWidthText && !isContainerHeight) {
const fontSize = tNode.fontSize ?? 16
const fontWeight = tNode.fontWeight ?? '400'
const fontFamily = tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
const ctx = getMeasureCtx()
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`
// Fixed-width text with auto height: wrap and measure actual height
const wrapWidth = (tNode.width as number) + fontSize * 0.2
const rawLines = content.split('\n')
const wrappedLines: string[] = []
for (const raw of rawLines) {
if (!raw) { wrappedLines.push(''); continue }
wrapLine(ctx, raw, wrapWidth, wrappedLines)
}
const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize)
const lineHeight = lineHeightMul * fontSize
const glyphH = fontSize * 1.13
const measuredHeight = Math.ceil(
wrappedLines.length <= 1
? glyphH + 2
: (wrappedLines.length - 1) * lineHeight + glyphH + 2,
)
const currentHeight = typeof tNode.height === 'number' ? tNode.height : 0
const explicitLineCount = rawLines.length
const needsHeight = currentHeight <= 0 || wrappedLines.length > explicitLineCount
if (needsHeight && measuredHeight > currentHeight) {
result = { ...node, height: measuredHeight } as unknown as PenNode
}
}
}
// Recurse into children
if ('children' in result && result.children) {
const children = result.children
const measured = premeasureTextHeights(children)
if (measured !== children) {
result = { ...result, children: measured } as unknown as PenNode
}
}
return result
})
}
// ---------------------------------------------------------------------------
// Flatten document tree → absolute-positioned RenderNode list
// ---------------------------------------------------------------------------
interface ClipInfo {
x: number; y: number; w: number; h: number; rx: number
}
function sizeToNumber(val: number | string | undefined, fallback: number): number {
if (typeof val === 'number') return val
if (typeof val === 'string') {
const m = val.match(/\((\d+(?:\.\d+)?)\)/)
if (m) return parseFloat(m[1])
const n = parseFloat(val)
if (!isNaN(n)) return n
}
return fallback
}
function cornerRadiusVal(cr: number | [number, number, number, number] | undefined): number {
if (cr === undefined) return 0
if (typeof cr === 'number') return cr
return cr[0]
}
/** Resolve RefNodes inline (same logic as use-canvas-sync.ts). */
function resolveRefs(
nodes: PenNode[],
rootNodes: PenNode[],
findInTree: (nodes: PenNode[], id: string) => PenNode | null,
visited = new Set<string>(),
): PenNode[] {
return nodes.flatMap((node) => {
if (node.type !== 'ref') {
if ('children' in node && node.children) {
return [{ ...node, children: resolveRefs(node.children, rootNodes, findInTree, visited) } as PenNode]
}
return [node]
}
if (visited.has(node.ref)) return []
const component = findInTree(rootNodes, node.ref)
if (!component) return []
visited.add(node.ref)
const resolved: Record<string, unknown> = { ...component }
for (const [key, val] of Object.entries(node)) {
if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children') continue
if (val !== undefined) resolved[key] = val
}
resolved.type = component.type
if (!resolved.name) resolved.name = component.name
delete resolved.reusable
const resolvedNode = resolved as unknown as PenNode
if ('children' in component && component.children) {
const refNode = node as import('@/types/pen').RefNode
;(resolvedNode as PenNode & ContainerProps).children = remapIds(component.children, node.id, refNode.descendants)
}
visited.delete(node.ref)
return [resolvedNode]
})
}
function remapIds(children: PenNode[], refId: string, overrides?: Record<string, Partial<PenNode>>): PenNode[] {
return children.map((child) => {
const virtualId = `${refId}__${child.id}`
const ov = overrides?.[child.id] ?? {}
const mapped = { ...child, ...ov, id: virtualId } as PenNode
if ('children' in mapped && mapped.children) {
(mapped as PenNode & ContainerProps).children = remapIds(mapped.children, refId, overrides)
}
return mapped
})
}
export function flattenToRenderNodes(
nodes: PenNode[],
offsetX = 0,
offsetY = 0,
parentAvailW?: number,
parentAvailH?: number,
clipCtx?: ClipInfo,
depth = 0,
): RenderNode[] {
const result: RenderNode[] = []
// Reverse order: children[0] = top layer = rendered last (frontmost)
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i]
if (!isNodeVisible(node)) continue
// Resolve fill_container / fit_content
let resolved = node
if (parentAvailW !== undefined || parentAvailH !== undefined) {
let changed = false
const r: Record<string, unknown> = { ...node }
if ('width' in node && typeof node.width !== 'number') {
const s = parseSizing(node.width)
if (s === 'fill' && parentAvailW) { r.width = parentAvailW; changed = true }
else if (s === 'fit') { r.width = getNodeWidth(node, parentAvailW); changed = true }
}
if ('height' in node && typeof node.height !== 'number') {
const s = parseSizing(node.height)
if (s === 'fill' && parentAvailH) { r.height = parentAvailH; changed = true }
else if (s === 'fit') { r.height = getNodeHeight(node, parentAvailH, parentAvailW); changed = true }
}
if (changed) resolved = r as unknown as PenNode
}
// Compute height for frames without explicit numeric height
if (
node.type === 'frame'
&& 'children' in node && node.children?.length
&& (!('height' in resolved) || typeof resolved.height !== 'number')
) {
const computedH = getNodeHeight(resolved, parentAvailH, parentAvailW)
if (computedH > 0) resolved = { ...resolved, height: computedH } as unknown as PenNode
}
const absX = (resolved.x ?? 0) + offsetX
const absY = (resolved.y ?? 0) + offsetY
const absW = 'width' in resolved ? sizeToNumber(resolved.width, 100) : 100
const absH = 'height' in resolved ? sizeToNumber(resolved.height, 100) : 100
result.push({
node: { ...resolved, x: absX, y: absY } as PenNode,
absX, absY, absW, absH,
clipRect: clipCtx,
})
// Recurse into children
const children = 'children' in node ? node.children : undefined
if (children && children.length > 0) {
const nodeW = getNodeWidth(resolved, parentAvailW)
const nodeH = getNodeHeight(resolved, parentAvailH, parentAvailW)
const pad = resolvePadding('padding' in resolved ? (resolved as PenNode & ContainerProps).padding : undefined)
const childAvailW = Math.max(0, nodeW - pad.left - pad.right)
const childAvailH = Math.max(0, nodeH - pad.top - pad.bottom)
const layout = ('layout' in node ? (node as ContainerProps).layout : undefined) || inferLayout(node)
const positioned = layout && layout !== 'none'
? computeLayoutPositions(resolved, children)
: children
// Clipping — only clip for root frames (artboard behavior).
// Nested frames do NOT clip children, matching Fabric.js behavior.
// Fabric.js doesn't implement frame-level clipping, so children always overflow.
// TODO: add proper clipContent support once Fabric.js is fully replaced.
let childClip = clipCtx
const isRootFrame = node.type === 'frame' && depth === 0
if (isRootFrame) {
const crRaw = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0
const cr = Math.min(crRaw, nodeH / 2)
childClip = { x: absX, y: absY, w: nodeW, h: nodeH, rx: cr }
}
const childRNs = flattenToRenderNodes(positioned, absX, absY, childAvailW, childAvailH, childClip, depth + 1)
// Propagate parent flip to children: mirror positions within parent bounds
// and toggle child flipX/flipY. Must run BEFORE rotation propagation.
const parentFlipX = node.flipX === true
const parentFlipY = node.flipY === true
if (parentFlipX || parentFlipY) {
const pcx = absX + nodeW / 2
const pcy = absY + nodeH / 2
for (const crn of childRNs) {
const updates: Record<string, unknown> = {}
if (parentFlipX) {
const ccx = crn.absX + crn.absW / 2
crn.absX = 2 * pcx - ccx - crn.absW / 2
const childFlip = crn.node.flipX === true
updates.flipX = !childFlip || undefined
}
if (parentFlipY) {
const ccy = crn.absY + crn.absH / 2
crn.absY = 2 * pcy - ccy - crn.absH / 2
const childFlip = crn.node.flipY === true
updates.flipY = !childFlip || undefined
}
crn.node = { ...crn.node, x: crn.absX, y: crn.absY, ...updates } as PenNode
}
}
// Propagate parent rotation to children: rotate their positions around
// the parent's center and accumulate the rotation angle.
// Children are in the parent's LOCAL (unrotated) coordinate space, so we
// need to apply the parent's rotation to get correct absolute positions.
const parentRot = node.rotation ?? 0
if (parentRot !== 0) {
const cx = absX + nodeW / 2
const cy = absY + nodeH / 2
const rad = parentRot * Math.PI / 180
const cosA = Math.cos(rad)
const sinA = Math.sin(rad)
for (const crn of childRNs) {
// Rotate child CENTER around parent center
const ccx = crn.absX + crn.absW / 2
const ccy = crn.absY + crn.absH / 2
const dx = ccx - cx
const dy = ccy - cy
const newCx = cx + dx * cosA - dy * sinA
const newCy = cy + dx * sinA + dy * cosA
crn.absX = newCx - crn.absW / 2
crn.absY = newCy - crn.absH / 2
// Accumulate rotation and update node position
const childRot = crn.node.rotation ?? 0
crn.node = { ...crn.node, x: crn.absX, y: crn.absY, rotation: childRot + parentRot } as PenNode
}
}
result.push(...childRNs)
}
}
return result
}
// ---------------------------------------------------------------------------
// Component / instance ID collection (from raw tree, before ref resolution)
// ---------------------------------------------------------------------------
function collectReusableIds(nodes: PenNode[], result: Set<string>) {
for (const node of nodes) {
if (node.type === 'frame' && node.reusable === true) {
result.add(node.id)
}
if ('children' in node && node.children) {
collectReusableIds(node.children, result)
}
}
}
function collectInstanceIds(nodes: PenNode[], result: Set<string>) {
for (const node of nodes) {
if (node.type === 'ref') {
result.add(node.id)
}
if ('children' in node && node.children) {
collectInstanceIds(node.children, result)
}
}
}
// ---------------------------------------------------------------------------
// SkiaEngine — ties rendering, viewport, hit testing together
// ---------------------------------------------------------------------------
export class SkiaEngine {
ck: CanvasKit
surface: Surface | null = null
renderer: SkiaRenderer
spatialIndex = new SpatialIndex()
renderNodes: RenderNode[] = []
// Component/instance IDs for colored frame labels
private reusableIds = new Set<string>()
private instanceIds = new Set<string>()
// Agent animation: track start time so glow only pulses ~2 times
private agentAnimStart = 0
private canvasEl: HTMLCanvasElement | null = null
private animFrameId = 0
private dirty = true
// Viewport
zoom = 1
panX = 0
panY = 0
// Drag suppression — prevents syncFromDocument during drag
// so the layout engine doesn't override visual positions
dragSyncSuppressed = false
// Interaction state
hoveredNodeId: string | null = null
marquee: { x1: number; y1: number; x2: number; y2: number } | null = null
previewShape: {
type: 'rectangle' | 'ellipse' | 'frame' | 'line'
x: number; y: number; w: number; h: number
} | null = null
penPreview: import('./skia-overlays').PenPreviewData | null = null
constructor(ck: CanvasKit) {
this.ck = ck
this.renderer = new SkiaRenderer(ck)
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
init(canvasEl: HTMLCanvasElement) {
this.canvasEl = canvasEl
const dpr = window.devicePixelRatio || 1
canvasEl.width = canvasEl.clientWidth * dpr
canvasEl.height = canvasEl.clientHeight * dpr
this.surface = this.ck.MakeWebGLCanvasSurface(canvasEl)
if (!this.surface) {
// Fallback to software
this.surface = this.ck.MakeSWCanvasSurface(canvasEl)
}
if (!this.surface) {
console.error('SkiaEngine: Failed to create surface')
return
}
this.renderer.init()
this.renderer.setRedrawCallback(() => this.markDirty())
// Re-render when async font loading completes
;(this.renderer as any)._onFontLoaded = () => this.markDirty()
// Pre-load default fonts for vector text rendering.
// Noto Sans SC is loaded alongside Inter so CJK glyphs are always available
// in the fallback chain — system CJK fonts (PingFang SC, Microsoft YaHei, etc.)
// are skipped from Google Fonts, and without Noto Sans SC the fallback chain
// would only contain Inter which has no CJK coverage, causing tofu.
this.renderer.fontManager.ensureFont('Inter').then(() => this.markDirty())
this.renderer.fontManager.ensureFont('Noto Sans SC').then(() => this.markDirty())
this.startRenderLoop()
}
dispose() {
if (this.animFrameId) cancelAnimationFrame(this.animFrameId)
this.renderer.dispose()
this.surface?.delete()
this.surface = null
}
resize(width: number, height: number) {
if (!this.canvasEl) return
const dpr = window.devicePixelRatio || 1
this.canvasEl.width = width * dpr
this.canvasEl.height = height * dpr
// Recreate surface
this.surface?.delete()
this.surface = this.ck.MakeWebGLCanvasSurface(this.canvasEl)
if (!this.surface) {
this.surface = this.ck.MakeSWCanvasSurface(this.canvasEl)
}
this.markDirty()
}
// ---------------------------------------------------------------------------
// Document sync
// ---------------------------------------------------------------------------
syncFromDocument() {
if (this.dragSyncSuppressed) return
const docState = useDocumentStore.getState()
const activePageId = useCanvasStore.getState().activePageId
const pageChildren = getActivePageChildren(docState.document, activePageId)
const allNodes = getAllChildren(docState.document)
// Simple findNodeInTree
const findInTree = (nodes: PenNode[], id: string): PenNode | null => {
for (const n of nodes) {
if (n.id === id) return n
if ('children' in n && n.children) {
const found = findInTree(n.children, id)
if (found) return found
}
}
return null
}
// Collect reusable/instance IDs from raw tree (before ref resolution strips them)
this.reusableIds.clear()
this.instanceIds.clear()
collectReusableIds(pageChildren, this.reusableIds)
collectInstanceIds(pageChildren, this.instanceIds)
// Resolve refs, variables, then flatten
const resolved = resolveRefs(pageChildren, allNodes, findInTree)
// Resolve design variables
const variables = docState.document.variables ?? {}
const themes = docState.document.themes
const defaultTheme = getDefaultTheme(themes)
const variableResolved = resolved.map((n) =>
resolveNodeForCanvas(n, variables, defaultTheme),
)
// Only premeasure text HEIGHTS for fixed-width text (where wrapping
// estimation may differ from Canvas 2D). Never touch widths or
// container-relative sizing to maintain layout consistency with Fabric.js.
const measured = premeasureTextHeights(variableResolved)
this.renderNodes = flattenToRenderNodes(measured)
this.spatialIndex.rebuild(this.renderNodes)
this.markDirty()
}
// ---------------------------------------------------------------------------
// Render loop
// ---------------------------------------------------------------------------
markDirty() {
this.dirty = true
}
private startRenderLoop() {
const loop = () => {
this.animFrameId = requestAnimationFrame(loop)
if (!this.dirty || !this.surface) return
this.dirty = false
this.render()
}
this.animFrameId = requestAnimationFrame(loop)
}
private render() {
if (!this.surface || !this.canvasEl) return
const canvas = this.surface.getCanvas()
const ck = this.ck
const dpr = window.devicePixelRatio || 1
const selectedIds = new Set(useCanvasStore.getState().selection.selectedIds)
// Clear
const bgColor = getCanvasBackground()
canvas.clear(parseColor(ck, bgColor))
// Apply viewport transform
canvas.save()
canvas.scale(dpr, dpr)
canvas.concat(viewportMatrix({ zoom: this.zoom, panX: this.panX, panY: this.panY }))
// Pass current zoom to renderer for zoom-aware text rasterization
this.renderer.zoom = this.zoom
// Draw all render nodes
for (const rn of this.renderNodes) {
this.renderer.drawNode(canvas, rn, selectedIds)
}
// Draw frame labels (root frames + reusable components + instances at any depth)
for (const rn of this.renderNodes) {
if (!rn.node.name) continue
const isRootFrame = rn.node.type === 'frame' && !rn.clipRect
const isReusable = this.reusableIds.has(rn.node.id)
const isInstance = this.instanceIds.has(rn.node.id)
if (!isRootFrame && !isReusable && !isInstance) continue
this.renderer.drawFrameLabelColored(
canvas, rn.node.name, rn.absX, rn.absY,
isReusable, isInstance, this.zoom,
)
}
// Draw agent indicators (glow, badges, node borders, preview fills)
const agentIndicators = getActiveAgentIndicators()
const agentFrames = getActiveAgentFrames()
const hasAgentOverlays = agentIndicators.size > 0 || agentFrames.size > 0
if (!hasAgentOverlays) {
this.agentAnimStart = 0
}
if (hasAgentOverlays) {
const now = Date.now()
if (this.agentAnimStart === 0) this.agentAnimStart = now
const elapsed = now - this.agentAnimStart
// Frame glow: smooth fade-in → fade-out (single bell, ~1.2s)
const GLOW_DURATION = 1200
const glowT = Math.min(1, elapsed / GLOW_DURATION)
const breath = Math.sin(glowT * Math.PI) // 0 → 1 → 0
// Agent node borders and preview fills (per-element fade-in → fade-out)
const NODE_FADE_DURATION = 1000
for (const rn of this.renderNodes) {
const indicator = agentIndicators.get(rn.node.id)
if (!indicator) continue
if (!isNodeBorderReady(rn.node.id)) continue
const revealAt = getNodeRevealTime(rn.node.id)
if (revealAt === undefined) continue
const nodeElapsed = now - revealAt
if (nodeElapsed > NODE_FADE_DURATION) continue
// Smooth bell curve: fade in then fade out
const nodeT = Math.min(1, nodeElapsed / NODE_FADE_DURATION)
const nodeBreath = Math.sin(nodeT * Math.PI)
if (isPreviewNode(rn.node.id)) {
this.renderer.drawAgentPreviewFill(
canvas, rn.absX, rn.absY, rn.absW, rn.absH,
indicator.color, now,
)
}
this.renderer.drawAgentNodeBorder(
canvas, rn.absX, rn.absY, rn.absW, rn.absH,
indicator.color, nodeBreath, this.zoom,
)
}
// Agent frame glow and badges
for (const rn of this.renderNodes) {
const frame = agentFrames.get(rn.node.id)
if (!frame) continue
this.renderer.drawAgentGlow(
canvas, rn.absX, rn.absY, rn.absW, rn.absH,
frame.color, breath, this.zoom,
)
this.renderer.drawAgentBadge(
canvas, frame.name,
rn.absX, rn.absY, rn.absW,
frame.color, this.zoom, now,
)
}
}
// Hover outline
if (this.hoveredNodeId && !selectedIds.has(this.hoveredNodeId)) {
const hovered = this.spatialIndex.get(this.hoveredNodeId)
if (hovered) {
this.renderer.drawHoverOutline(canvas, hovered.absX, hovered.absY, hovered.absW, hovered.absH)
}
}
// Drawing preview shape
if (this.previewShape) {
this.renderer.drawPreview(canvas, this.previewShape)
}
// Pen tool preview
if (this.penPreview) {
this.renderer.drawPenPreview(canvas, this.penPreview, this.zoom)
}
// Selection marquee
if (this.marquee) {
this.renderer.drawSelectionMarquee(
canvas,
this.marquee.x1, this.marquee.y1,
this.marquee.x2, this.marquee.y2,
)
}
canvas.restore()
this.surface.flush()
// Keep animating while agent overlays are active (spinning dot + node flashes)
if (hasAgentOverlays) {
this.markDirty()
}
}
// ---------------------------------------------------------------------------
// Viewport control
// ---------------------------------------------------------------------------
setViewport(zoom: number, panX: number, panY: number) {
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
this.panX = panX
this.panY = panY
useCanvasStore.getState().setZoom(this.zoom)
useCanvasStore.getState().setPan(this.panX, this.panY)
this.markDirty()
}
zoomToPoint(screenX: number, screenY: number, newZoom: number) {
if (!this.canvasEl) return
const rect = this.canvasEl.getBoundingClientRect()
const vp = vpZoomToPoint(
{ zoom: this.zoom, panX: this.panX, panY: this.panY },
screenX, screenY, rect, newZoom,
)
this.setViewport(vp.zoom, vp.panX, vp.panY)
}
pan(dx: number, dy: number) {
this.setViewport(this.zoom, this.panX + dx, this.panY + dy)
}
getCanvasRect(): DOMRect | null {
return this.canvasEl?.getBoundingClientRect() ?? null
}
getCanvasSize(): { width: number; height: number } {
return {
width: this.canvasEl?.clientWidth ?? 800,
height: this.canvasEl?.clientHeight ?? 600,
}
}
zoomToFitContent() {
if (!this.canvasEl || this.renderNodes.length === 0) return
const FIT_PADDING = 64
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const rn of this.renderNodes) {
if (rn.clipRect) continue // skip children, only root bounds
minX = Math.min(minX, rn.absX)
minY = Math.min(minY, rn.absY)
maxX = Math.max(maxX, rn.absX + rn.absW)
maxY = Math.max(maxY, rn.absY + rn.absH)
}
if (!isFinite(minX)) return
const contentW = maxX - minX
const contentH = maxY - minY
const cw = this.canvasEl.clientWidth
const ch = this.canvasEl.clientHeight
const scaleX = (cw - FIT_PADDING * 2) / contentW
const scaleY = (ch - FIT_PADDING * 2) / contentH
let zoom = Math.min(scaleX, scaleY, 1)
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
const centerX = (minX + maxX) / 2
const centerY = (minY + maxY) / 2
this.setViewport(
zoom,
cw / 2 - centerX * zoom,
ch / 2 - centerY * zoom,
)
}
}

View file

@ -0,0 +1,362 @@
import type { TypefaceFontProvider, CanvasKit } from 'canvaskit-wasm'
/**
* Bundled font files served from /fonts/ (no external CDN dependency).
* Key = lowercase family name, values = local URLs.
*/
const BUNDLED_FONTS: Record<string, string[]> = {
inter: [
'/fonts/inter-400.woff2',
'/fonts/inter-500.woff2',
'/fonts/inter-600.woff2',
'/fonts/inter-700.woff2',
'/fonts/inter-ext-400.woff2',
'/fonts/inter-ext-500.woff2',
'/fonts/inter-ext-600.woff2',
'/fonts/inter-ext-700.woff2',
],
poppins: [
'/fonts/poppins-400.woff2',
'/fonts/poppins-500.woff2',
'/fonts/poppins-600.woff2',
'/fonts/poppins-700.woff2',
],
roboto: [
'/fonts/roboto-400.woff2',
'/fonts/roboto-500.woff2',
'/fonts/roboto-700.woff2',
],
montserrat: [
'/fonts/montserrat-400.woff2',
'/fonts/montserrat-500.woff2',
'/fonts/montserrat-600.woff2',
'/fonts/montserrat-700.woff2',
],
'open sans': [
'/fonts/open-sans-400.woff2',
'/fonts/open-sans-600.woff2',
'/fonts/open-sans-700.woff2',
],
lato: [
'/fonts/lato-400.woff2',
'/fonts/lato-700.woff2',
],
raleway: [
'/fonts/raleway-400.woff2',
'/fonts/raleway-500.woff2',
'/fonts/raleway-600.woff2',
'/fonts/raleway-700.woff2',
],
'dm sans': [
'/fonts/dm-sans-400.woff2',
'/fonts/dm-sans-500.woff2',
'/fonts/dm-sans-700.woff2',
],
'playfair display': [
'/fonts/playfair-display-400.woff2',
'/fonts/playfair-display-700.woff2',
],
nunito: [
'/fonts/nunito-400.woff2',
'/fonts/nunito-600.woff2',
'/fonts/nunito-700.woff2',
],
'source sans 3': [
'/fonts/source-sans-3-400.woff2',
'/fonts/source-sans-3-600.woff2',
'/fonts/source-sans-3-700.woff2',
],
'source sans pro': [
'/fonts/source-sans-3-400.woff2',
'/fonts/source-sans-3-600.woff2',
'/fonts/source-sans-3-700.woff2',
],
'noto sans sc': [
'/fonts/noto-sans-sc-400.woff2',
'/fonts/noto-sans-sc-700.woff2',
'/fonts/noto-sans-sc-latin-400.woff2',
'/fonts/noto-sans-sc-latin-700.woff2',
],
}
/** List of all bundled font family names (for UI font picker) */
export const BUNDLED_FONT_FAMILIES = [
'Inter',
'Noto Sans SC',
'Poppins',
'Roboto',
'Montserrat',
'Open Sans',
'Lato',
'Raleway',
'DM Sans',
'Playfair Display',
'Nunito',
'Source Sans 3',
]
/**
* Manages font loading for CanvasKit's Paragraph API (vector text rendering).
*
* Fonts are loaded from bundled /fonts/ directory first, falling back to
* Google Fonts CDN. Once loaded, text is rendered as true vector glyphs.
*/
export class SkiaFontManager {
private provider: TypefaceFontProvider
/** Registered family names (lowercase) → true once loaded */
private loadedFamilies = new Set<string>()
/** Font families that failed to load — prevents repeated fetch attempts */
private failedFamilies = new Set<string>()
/** In-flight font fetch promises to avoid duplicate requests */
private pendingFetches = new Map<string, Promise<boolean>>()
constructor(ck: CanvasKit) {
this.provider = ck.TypefaceFontProvider.Make()
}
getProvider(): TypefaceFontProvider {
return this.provider
}
/** Check if a font family is ready for use */
isFontReady(family: string): boolean {
return this.loadedFamilies.has(family.toLowerCase())
}
/** Check if a font family is bundled (available offline) */
isBundled(family: string): boolean {
return family.toLowerCase() in BUNDLED_FONTS
}
/**
* Build a font fallback chain for the Paragraph API.
* Only includes fonts actually registered in the TypefaceFontProvider.
* Extended subsets (e.g. "Inter Ext") are added for per-glyph fallback
* so characters like (U+20A6) render correctly.
*/
getFallbackChain(primaryFamily: string): string[] {
const chain: string[] = []
const lower = primaryFamily.toLowerCase()
// Only add primary if it's actually registered
if (this.loadedFamilies.has(lower)) {
chain.push(primaryFamily)
}
// Add ext subset of primary family if available
if (this.loadedFamilies.has(lower + ' ext')) {
chain.push(primaryFamily + ' Ext')
}
// Add Noto Sans SC for CJK glyph fallback (bundled, works offline)
if (lower !== 'noto sans sc' && this.loadedFamilies.has('noto sans sc')) {
chain.push('Noto Sans SC')
}
// Add Inter + Inter Ext as final fallback for Latin glyphs
if (lower !== 'inter') {
if (this.loadedFamilies.has('inter')) chain.push('Inter')
if (this.loadedFamilies.has('inter ext')) chain.push('Inter Ext')
}
// Must have at least one font
if (chain.length === 0) chain.push('Inter')
return chain
}
/**
* Check if there's at least one loaded fallback font for the given primary family.
* Used to decide whether vector rendering can proceed when the primary font is unavailable.
*/
hasAnyFallback(primaryFamily: string): boolean {
const key = primaryFamily.toLowerCase()
if (key === 'inter' || key === 'noto sans sc') return false
return this.loadedFamilies.has('inter') || this.loadedFamilies.has('noto sans sc')
}
/** Register a font from raw ArrayBuffer data */
registerFont(data: ArrayBuffer, familyName: string): boolean {
try {
this.provider.registerFont(data, familyName)
this.loadedFamilies.add(familyName.toLowerCase())
console.log(`[FontManager] Registered "${familyName}" (${(data.byteLength / 1024).toFixed(1)}KB)`)
return true
} catch (e) {
console.warn(`[FontManager] Failed to register "${familyName}":`, e)
return false
}
}
/**
* Ensure a font family is loaded. Tries bundled fonts first, then Google Fonts.
*/
async ensureFont(family: string, weights: number[] = [400, 500, 600, 700]): Promise<boolean> {
const key = family.toLowerCase()
if (this.loadedFamilies.has(key)) return true
if (this.failedFamilies.has(key)) return false
const existing = this.pendingFetches.get(key)
if (existing) return existing
const promise = this._loadFont(family, weights)
this.pendingFetches.set(key, promise)
const result = await promise
this.pendingFetches.delete(key)
if (!result) {
this.failedFamilies.add(key)
console.warn(`[FontManager] Font "${family}" unavailable, will not retry`)
}
return result
}
/**
* Load multiple font families concurrently.
*/
async ensureFonts(families: string[]): Promise<Set<string>> {
const unique = [...new Set(families.map(f => f.trim()).filter(Boolean))]
const results = await Promise.allSettled(
unique.map(f => this.ensureFont(f))
)
const loaded = new Set<string>()
results.forEach((r, i) => {
if (r.status === 'fulfilled' && r.value) loaded.add(unique[i])
})
return loaded
}
private async _loadFont(family: string, weights: number[]): Promise<boolean> {
// 1. Try bundled fonts first (no network dependency)
const bundled = BUNDLED_FONTS[family.toLowerCase()]
if (bundled) {
const ok = await this._fetchLocalFonts(family, bundled)
if (ok) return true
}
// 2. Skip Google Fonts for system/proprietary fonts that won't exist there
if (isSystemFont(family)) {
console.log(`[FontManager] "${family}" is a system font, skipping Google Fonts`)
return false
}
// 3. Fall back to Google Fonts CDN
return this._fetchGoogleFont(family, weights)
}
private async _fetchLocalFonts(family: string, urls: string[]): Promise<boolean> {
try {
const buffers = await Promise.all(
urls.map(async (url) => {
const resp = await fetch(url)
if (!resp.ok) {
console.warn(`[FontManager] Failed to fetch ${url}: ${resp.status}`)
return null
}
return resp.arrayBuffer()
})
)
let registered = 0
for (let i = 0; i < buffers.length; i++) {
const buf = buffers[i]
if (!buf) continue
// Register extended subset files (e.g. inter-ext-400.woff2) under a separate
// family name so CanvasKit's Paragraph API can do per-glyph fallback.
// Base Inter doesn't have ₦ (U+20A6) but latin-ext does.
const regName = urls[i].includes('-ext-') ? family + ' Ext' : family
if (this.registerFont(buf, regName)) registered++
}
console.log(`[FontManager] Local fonts for "${family}": ${registered}/${urls.length} registered`)
return registered > 0
} catch (e) {
console.warn(`[FontManager] Local font fetch error for "${family}":`, e)
return false
}
}
/**
* Fetch a font from Google Fonts CDN with China mirror fallback.
* Tries Google Fonts first (3s timeout), then falls back to loli.net mirror
* which is accessible in China where Google services are blocked.
*/
private async _fetchGoogleFont(family: string, weights: number[]): Promise<boolean> {
const weightStr = weights.join(';')
const encodedFamily = encodeURIComponent(family)
const query = `family=${encodedFamily}:wght@${weightStr}&display=swap`
// Try Google Fonts first, then China-accessible mirrors
const cdnConfigs = [
{
cssBase: 'https://fonts.googleapis.com/css2',
fontUrlPattern: /url\((https:\/\/fonts\.gstatic\.com\/[^)]+\.woff2)\)/g,
},
{
cssBase: 'https://fonts.font.im/css2',
fontUrlPattern: /url\((https?:\/\/[^)]+\.woff2)\)/g,
},
]
for (const cdn of cdnConfigs) {
try {
const cssUrl = `${cdn.cssBase}?${query}`
const cssResp = await fetchWithTimeout(cssUrl, 4000)
if (!cssResp.ok) continue
const css = await cssResp.text()
const urls: string[] = []
let match: RegExpExecArray | null
while ((match = cdn.fontUrlPattern.exec(css)) !== null) {
urls.push(match[1])
}
if (urls.length === 0) continue
const fontBuffers = await Promise.all(
urls.map(async (url) => {
try {
const resp = await fetchWithTimeout(url, 8000)
return resp.ok ? resp.arrayBuffer() : null
} catch { return null }
})
)
let registered = 0
for (const buf of fontBuffers) {
if (buf && this.registerFont(buf, family)) registered++
}
if (registered > 0) return true
} catch {
// CDN failed, try next
}
}
return false
}
dispose() {
this.provider.delete()
this.loadedFamilies.clear()
this.failedFamilies.clear()
this.pendingFetches.clear()
}
}
/**
* Known system/proprietary fonts that are NOT on Google Fonts.
* Avoids pointless 400 requests and CORS errors.
*/
const SYSTEM_FONT_PATTERNS = [
// Apple
'pingfang', 'sf pro', 'sf mono', 'sf compact', 'helvetica neue', 'helvetica',
'apple sd gothic', 'hiragino',
// Microsoft
'microsoft yahei', 'segoe ui', 'consolas', 'arial',
// CJK
'noto sans cjk', 'youshebiaotihei', 'simhei', 'simsun', 'fangsong', 'kaiti',
// Proprietary / non-Google
'd-din', 'din pro', 'din-pro', 'avenir', 'futura', 'proxima nova', 'gotham',
'brandon grotesque', 'aktiv grotesk', 'circular',
]
function isSystemFont(family: string): boolean {
const lower = family.toLowerCase()
return SYSTEM_FONT_PATTERNS.some(p => lower.includes(p))
}
/** Fetch with timeout — rejects if response doesn't arrive within `ms`. */
function fetchWithTimeout(url: string, ms: number): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), ms)
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer))
}

View file

@ -0,0 +1,89 @@
import RBush from 'rbush'
import type { RenderNode } from './skia-renderer'
interface RTreeItem {
minX: number
minY: number
maxX: number
maxY: number
nodeId: string
renderNode: RenderNode
/** Position in the render array — higher = rendered later = visually on top */
zIndex: number
}
/**
* Spatial index for fast hit testing using R-tree.
* Nodes are indexed with their render order so hit results
* are sorted topmost-first (children before parents).
*/
export class SpatialIndex {
private tree = new RBush<RTreeItem>()
private items = new Map<string, RTreeItem>()
/**
* Rebuild the entire index from a list of render nodes.
*/
rebuild(nodes: RenderNode[]) {
this.tree.clear()
this.items.clear()
const items: RTreeItem[] = []
for (let i = 0; i < nodes.length; i++) {
const rn = nodes[i]
if (('visible' in rn.node ? rn.node.visible : undefined) === false) continue
if (('locked' in rn.node ? rn.node.locked : undefined) === true) continue
const item: RTreeItem = {
minX: rn.absX,
minY: rn.absY,
maxX: rn.absX + rn.absW,
maxY: rn.absY + rn.absH,
nodeId: rn.node.id,
renderNode: rn,
zIndex: i,
}
items.push(item)
this.items.set(rn.node.id, item)
}
this.tree.load(items)
}
/**
* Find all nodes that contain the given scene point.
* Returns nodes sorted by z-order: topmost (highest zIndex) first.
*/
hitTest(sceneX: number, sceneY: number): RenderNode[] {
const candidates = this.tree.search({
minX: sceneX,
minY: sceneY,
maxX: sceneX,
maxY: sceneY,
})
// Sort by zIndex descending — children (rendered later) come first
candidates.sort((a, b) => b.zIndex - a.zIndex)
return candidates.map((c) => c.renderNode)
}
/**
* Find all nodes that intersect with a rectangle (for marquee selection).
*/
searchRect(left: number, top: number, right: number, bottom: number): RenderNode[] {
const candidates = this.tree.search({
minX: Math.min(left, right),
minY: Math.min(top, bottom),
maxX: Math.max(left, right),
maxY: Math.max(top, bottom),
})
return candidates.map((c) => c.renderNode)
}
/**
* Get the render node for a specific node ID.
*/
get(nodeId: string): RenderNode | undefined {
return this.items.get(nodeId)?.renderNode
}
}

View file

@ -0,0 +1,93 @@
import type { CanvasKit, Image as SkImage } from 'canvaskit-wasm'
/**
* Async image loader for CanvasKit. Loads images via browser's native Image
* element (supports all browser-supported formats), rasterizes to Canvas 2D,
* then converts to CanvasKit Image for GPU rendering.
*/
export class SkiaImageLoader {
private ck: CanvasKit
private cache = new Map<string, SkImage | null>()
private loading = new Set<string>()
private onLoaded: (() => void) | null = null
constructor(ck: CanvasKit) {
this.ck = ck
}
/** Set callback to trigger re-render when an image finishes loading. */
setOnLoaded(cb: () => void) {
this.onLoaded = cb
}
/** Get a cached image, or null if not loaded / failed. Returns undefined if not yet requested. */
get(src: string): SkImage | null | undefined {
return this.cache.get(src)
}
/** Start loading an image if not already cached or in progress. */
request(src: string) {
if (this.cache.has(src) || this.loading.has(src)) return
this.loading.add(src)
this.loadAsync(src)
}
dispose() {
for (const img of this.cache.values()) {
img?.delete()
}
this.cache.clear()
this.loading.clear()
}
private async loadAsync(src: string) {
try {
// Use browser Image element — supports all browser-supported formats
const htmlImg = await this.loadHtmlImage(src)
const skImg = this.htmlImageToSkia(htmlImg)
this.cache.set(src, skImg)
this.loading.delete(src)
this.onLoaded?.()
} catch (e) {
console.warn('Failed to load image:', src.slice(0, 80), e)
this.cache.set(src, null)
this.loading.delete(src)
}
}
private loadHtmlImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = (e) => reject(new Error(`Image load failed: ${e}`))
img.src = src
})
}
/** Rasterize an HTML Image to Canvas 2D, then convert to CanvasKit Image. */
private htmlImageToSkia(htmlImg: HTMLImageElement): SkImage | null {
const w = htmlImg.naturalWidth || htmlImg.width
const h = htmlImg.naturalHeight || htmlImg.height
if (w <= 0 || h <= 0) return null
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')!
ctx.drawImage(htmlImg, 0, 0, w, h)
const imageData = ctx.getImageData(0, 0, w, h)
return this.ck.MakeImage(
{
width: w,
height: h,
alphaType: this.ck.AlphaType.Unpremul,
colorType: this.ck.ColorType.RGBA_8888,
colorSpace: this.ck.ColorSpace.SRGB,
},
imageData.data,
w * 4,
) ?? null
}
}

View file

@ -0,0 +1,35 @@
import type { CanvasKit } from 'canvaskit-wasm'
let ckInstance: CanvasKit | null = null
let ckPromise: Promise<CanvasKit> | null = null
/**
* Load CanvasKit WASM singleton. Returns the same instance on subsequent calls.
*/
export async function loadCanvasKit(): Promise<CanvasKit> {
if (ckInstance) return ckInstance
if (ckPromise) return ckPromise
ckPromise = (async () => {
// canvaskit-wasm is a CJS module (module.exports = CanvasKitInit).
// Depending on bundler interop, the init function may be on .default or the module itself.
const mod = await import('canvaskit-wasm')
const CanvasKitInit = typeof mod.default === 'function'
? mod.default
: (mod as unknown as (opts?: { locateFile?: (file: string) => string }) => Promise<CanvasKit>)
const ck = await CanvasKitInit({
locateFile: (file: string) => `/canvaskit/${file}`,
})
ckInstance = ck
return ck
})()
return ckPromise
}
/**
* Get the already-loaded CanvasKit instance. Returns null if not yet loaded.
*/
export function getCanvasKit(): CanvasKit | null {
return ckInstance
}

View file

@ -0,0 +1,561 @@
import type { CanvasKit, Canvas, Image as SkImage } from 'canvaskit-wasm'
import {
SELECTION_BLUE,
COMPONENT_COLOR,
INSTANCE_COLOR,
FRAME_LABEL_COLOR,
PEN_ANCHOR_FILL,
PEN_ANCHOR_RADIUS,
PEN_ANCHOR_FIRST_RADIUS,
PEN_HANDLE_DOT_RADIUS,
PEN_HANDLE_LINE_STROKE,
PEN_RUBBER_BAND_STROKE,
PEN_RUBBER_BAND_DASH,
} from '../canvas-constants'
import { parseColor } from './skia-paint-utils'
// ---------------------------------------------------------------------------
// Canvas 2D text rasterization (CanvasKit null typeface can't render text)
// ---------------------------------------------------------------------------
const OVERLAY_FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
const textImageCache = new Map<string, { img: SkImage; w: number; h: number }>()
const textCacheOrder: string[] = []
const TEXT_CACHE_MAX = 200
function evictTextCache() {
while (textCacheOrder.length > TEXT_CACHE_MAX) {
const key = textCacheOrder.shift()!
const entry = textImageCache.get(key)
entry?.img.delete()
textImageCache.delete(key)
}
}
/** Measure text width using Canvas 2D. */
function measureText(text: string, fontSize: number, fontWeight = '500'): number {
const c = document.createElement('canvas')
const ctx = c.getContext('2d')!
ctx.font = `${fontWeight} ${Math.ceil(fontSize)}px ${OVERLAY_FONT}`
return ctx.measureText(text).width
}
/** Draw text via Canvas 2D rasterization → CanvasKit image. Returns rendered width. */
function drawText2D(
ck: CanvasKit, canvas: Canvas,
text: string, x: number, y: number,
color: string, fontSize: number, fontWeight = '500',
alpha = 1,
): number {
const renderSize = Math.ceil(fontSize)
const cacheKey = `${text}|${color}|${renderSize}|${fontWeight}`
let entry = textImageCache.get(cacheKey)
if (!entry) {
const scale = 2
const mc = document.createElement('canvas')
const mCtx = mc.getContext('2d')!
mCtx.font = `${fontWeight} ${renderSize}px ${OVERLAY_FONT}`
const metrics = mCtx.measureText(text)
const tw = Math.ceil(metrics.width) + 4
const th = renderSize + 6
mc.width = tw * scale
mc.height = th * scale
const ctx = mc.getContext('2d')!
ctx.scale(scale, scale)
ctx.font = `${fontWeight} ${renderSize}px ${OVERLAY_FONT}`
ctx.fillStyle = color
ctx.textBaseline = 'top'
ctx.fillText(text, 1, 2)
const imageData = ctx.getImageData(0, 0, mc.width, mc.height)
const premul = new Uint8Array(imageData.data.length)
for (let p = 0; p < premul.length; p += 4) {
const a = imageData.data[p + 3]
if (a === 255) {
premul[p] = imageData.data[p]
premul[p + 1] = imageData.data[p + 1]
premul[p + 2] = imageData.data[p + 2]
premul[p + 3] = 255
} else if (a > 0) {
const f = a / 255
premul[p] = Math.round(imageData.data[p] * f)
premul[p + 1] = Math.round(imageData.data[p + 1] * f)
premul[p + 2] = Math.round(imageData.data[p + 2] * f)
premul[p + 3] = a
}
}
const img = ck.MakeImage(
{
width: mc.width, height: mc.height,
alphaType: ck.AlphaType.Premul,
colorType: ck.ColorType.RGBA_8888,
colorSpace: ck.ColorSpace.SRGB,
},
premul, mc.width * 4,
)
if (!img) return 0
entry = { img, w: tw, h: th }
textImageCache.set(cacheKey, entry)
textCacheOrder.push(cacheKey)
evictTextCache()
}
const paint = new ck.Paint()
paint.setAntiAlias(true)
if (alpha < 1) paint.setAlphaf(alpha)
// Draw at scene-space size matching fontSize
const drawW = entry.w * (fontSize / renderSize)
const drawH = entry.h * (fontSize / renderSize)
canvas.drawImageRect(
entry.img,
ck.LTRBRect(0, 0, entry.img.width(), entry.img.height()),
ck.LTRBRect(x, y, x + drawW, y + drawH),
paint,
)
paint.delete()
return drawW
}
// ---------------------------------------------------------------------------
// Pen tool types
// ---------------------------------------------------------------------------
export interface PenAnchor {
x: number
y: number
handleIn: { x: number; y: number } | null
handleOut: { x: number; y: number } | null
}
export interface PenPreviewData {
points: PenAnchor[]
cursorPos: { x: number; y: number } | null
isDraggingHandle: boolean
}
/**
* Draw selection border with corner handles around a rectangle.
*/
export function drawSelectionBorder(
ck: CanvasKit, canvas: Canvas,
x: number, y: number, w: number, h: number,
) {
const paint = new ck.Paint()
paint.setStyle(ck.PaintStyle.Stroke)
paint.setAntiAlias(true)
paint.setStrokeWidth(1.5)
paint.setColor(parseColor(ck, SELECTION_BLUE))
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint)
// Corner handles
const handleSize = 6
const halfHandle = handleSize / 2
const handlePaint = new ck.Paint()
handlePaint.setStyle(ck.PaintStyle.Fill)
handlePaint.setAntiAlias(true)
handlePaint.setColor(ck.WHITE)
const handleStrokePaint = new ck.Paint()
handleStrokePaint.setStyle(ck.PaintStyle.Stroke)
handleStrokePaint.setAntiAlias(true)
handleStrokePaint.setStrokeWidth(1)
handleStrokePaint.setColor(parseColor(ck, SELECTION_BLUE))
const corners = [
[x, y], [x + w, y], [x, y + h], [x + w, y + h],
[x + w / 2, y], [x + w / 2, y + h], [x, y + h / 2], [x + w, y + h / 2],
]
for (const [cx, cy] of corners) {
const rect = ck.LTRBRect(cx - halfHandle, cy - halfHandle, cx + halfHandle, cy + halfHandle)
canvas.drawRect(rect, handlePaint)
canvas.drawRect(rect, handleStrokePaint)
}
paint.delete()
handlePaint.delete()
handleStrokePaint.delete()
}
/**
* Draw a frame label above a frame.
*/
export function drawFrameLabel(
ck: CanvasKit, canvas: Canvas,
name: string, x: number, y: number,
) {
drawText2D(ck, canvas, name, x, y - 18, '#999999', 12, '500')
}
/**
* Draw a dashed hover outline around a node.
*/
export function drawHoverOutline(
ck: CanvasKit, canvas: Canvas,
x: number, y: number, w: number, h: number,
) {
const paint = new ck.Paint()
paint.setStyle(ck.PaintStyle.Stroke)
paint.setAntiAlias(true)
paint.setStrokeWidth(1.5)
paint.setColor(parseColor(ck, '#3b82f6'))
const effect = ck.PathEffect.MakeDash([4, 4], 0)
if (effect) paint.setPathEffect(effect)
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint)
paint.delete()
}
/**
* Draw a selection marquee rectangle.
*/
export function drawSelectionMarquee(
ck: CanvasKit, canvas: Canvas,
x1: number, y1: number, x2: number, y2: number,
) {
const left = Math.min(x1, x2)
const top = Math.min(y1, y2)
const right = Math.max(x1, x2)
const bottom = Math.max(y1, y2)
const fillPaint = new ck.Paint()
fillPaint.setStyle(ck.PaintStyle.Fill)
fillPaint.setColor(parseColor(ck, 'rgba(13, 153, 255, 0.06)'))
canvas.drawRect(ck.LTRBRect(left, top, right, bottom), fillPaint)
fillPaint.delete()
const strokePaint = new ck.Paint()
strokePaint.setStyle(ck.PaintStyle.Stroke)
strokePaint.setAntiAlias(true)
strokePaint.setStrokeWidth(1)
strokePaint.setColor(parseColor(ck, SELECTION_BLUE))
canvas.drawRect(ck.LTRBRect(left, top, right, bottom), strokePaint)
strokePaint.delete()
}
/**
* Draw a smart alignment guide line.
*/
export function drawGuide(
ck: CanvasKit, canvas: Canvas,
x1: number, y1: number, x2: number, y2: number,
zoom: number,
) {
const paint = new ck.Paint()
paint.setStyle(ck.PaintStyle.Stroke)
paint.setAntiAlias(true)
paint.setStrokeWidth(1 / zoom)
paint.setColor(parseColor(ck, '#FF6B35'))
const dashLen = 3 / zoom
const effect = ck.PathEffect.MakeDash([dashLen, dashLen], 0)
if (effect) paint.setPathEffect(effect)
canvas.drawLine(x1, y1, x2, y2, paint)
paint.delete()
}
// ---------------------------------------------------------------------------
// Pen tool preview
// ---------------------------------------------------------------------------
function buildPenPathSvg(points: PenAnchor[], closed: boolean): string {
if (points.length === 0) return ''
const parts: string[] = []
const first = points[0]
parts.push(`M ${first.x} ${first.y}`)
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1]
const curr = points[i]
appendSeg(parts, prev, curr)
}
if (closed && points.length > 1) {
appendSeg(parts, points[points.length - 1], first)
parts.push('Z')
}
return parts.join(' ')
}
function appendSeg(parts: string[], from: PenAnchor, to: PenAnchor) {
if (!from.handleOut && !to.handleIn) {
parts.push(`L ${to.x} ${to.y}`)
} else {
const cx1 = from.x + (from.handleOut?.x ?? 0)
const cy1 = from.y + (from.handleOut?.y ?? 0)
const cx2 = to.x + (to.handleIn?.x ?? 0)
const cy2 = to.y + (to.handleIn?.y ?? 0)
parts.push(`C ${cx1} ${cy1} ${cx2} ${cy2} ${to.x} ${to.y}`)
}
}
/**
* Draw pen tool preview: constructed path, anchor points, handles, rubber band.
*/
export function drawPenPreview(
ck: CanvasKit, canvas: Canvas,
data: PenPreviewData, zoom: number,
) {
const { points, cursorPos, isDraggingHandle } = data
if (points.length === 0) return
const invZ = 1 / zoom // scale visual elements inversely to zoom
// --- Preview path (segments constructed so far) ---
if (points.length > 1) {
const d = buildPenPathSvg(points, false)
const skPath = ck.Path.MakeFromSVGString(d)
if (skPath) {
const paint = new ck.Paint()
paint.setStyle(ck.PaintStyle.Stroke)
paint.setAntiAlias(true)
paint.setStrokeWidth(1.5 * invZ)
paint.setColor(parseColor(ck, SELECTION_BLUE))
canvas.drawPath(skPath, paint)
paint.delete()
skPath.delete()
}
}
// --- Rubber band line from last point to cursor ---
const last = points[points.length - 1]
if (cursorPos && !isDraggingHandle) {
const paint = new ck.Paint()
paint.setStyle(ck.PaintStyle.Stroke)
paint.setAntiAlias(true)
paint.setStrokeWidth(1 * invZ)
paint.setColor(parseColor(ck, PEN_RUBBER_BAND_STROKE))
const dashLen = PEN_RUBBER_BAND_DASH[0] * invZ
const effect = ck.PathEffect.MakeDash([dashLen, dashLen], 0)
if (effect) paint.setPathEffect(effect)
canvas.drawLine(last.x, last.y, cursorPos.x, cursorPos.y, paint)
paint.delete()
}
// --- Anchor circles ---
const anchorStrokePaint = new ck.Paint()
anchorStrokePaint.setStyle(ck.PaintStyle.Stroke)
anchorStrokePaint.setAntiAlias(true)
anchorStrokePaint.setStrokeWidth(1.5 * invZ)
anchorStrokePaint.setColor(parseColor(ck, SELECTION_BLUE))
const anchorFillPaint = new ck.Paint()
anchorFillPaint.setStyle(ck.PaintStyle.Fill)
anchorFillPaint.setAntiAlias(true)
anchorFillPaint.setColor(parseColor(ck, PEN_ANCHOR_FILL))
for (let i = 0; i < points.length; i++) {
const pt = points[i]
const r = (i === 0 ? PEN_ANCHOR_FIRST_RADIUS : PEN_ANCHOR_RADIUS) * invZ
canvas.drawCircle(pt.x, pt.y, r, anchorFillPaint)
canvas.drawCircle(pt.x, pt.y, r, anchorStrokePaint)
}
anchorStrokePaint.delete()
anchorFillPaint.delete()
// --- Handle lines and dots ---
const handleLinePaint = new ck.Paint()
handleLinePaint.setStyle(ck.PaintStyle.Stroke)
handleLinePaint.setAntiAlias(true)
handleLinePaint.setStrokeWidth(1 * invZ)
handleLinePaint.setColor(parseColor(ck, PEN_HANDLE_LINE_STROKE))
const handleDotFill = new ck.Paint()
handleDotFill.setStyle(ck.PaintStyle.Fill)
handleDotFill.setAntiAlias(true)
handleDotFill.setColor(parseColor(ck, SELECTION_BLUE))
const handleDotStroke = new ck.Paint()
handleDotStroke.setStyle(ck.PaintStyle.Stroke)
handleDotStroke.setAntiAlias(true)
handleDotStroke.setStrokeWidth(1 * invZ)
handleDotStroke.setColor(parseColor(ck, '#ffffff'))
const dotR = PEN_HANDLE_DOT_RADIUS * invZ
for (const pt of points) {
if (pt.handleOut) {
const hx = pt.x + pt.handleOut.x
const hy = pt.y + pt.handleOut.y
canvas.drawLine(pt.x, pt.y, hx, hy, handleLinePaint)
canvas.drawCircle(hx, hy, dotR, handleDotFill)
canvas.drawCircle(hx, hy, dotR, handleDotStroke)
}
if (pt.handleIn) {
const hx = pt.x + pt.handleIn.x
const hy = pt.y + pt.handleIn.y
canvas.drawLine(pt.x, pt.y, hx, hy, handleLinePaint)
canvas.drawCircle(hx, hy, dotR, handleDotFill)
canvas.drawCircle(hx, hy, dotR, handleDotStroke)
}
}
handleLinePaint.delete()
handleDotFill.delete()
handleDotStroke.delete()
}
// ---------------------------------------------------------------------------
// Enhanced frame label (with component/instance colors)
// ---------------------------------------------------------------------------
/**
* Draw a frame label with color based on node type (component, instance, or normal).
* Font size and offset scale inversely to zoom for constant screen size.
*/
export function drawFrameLabelColored(
ck: CanvasKit, canvas: Canvas,
name: string, x: number, y: number,
isReusable: boolean, isInstance: boolean,
zoom = 1,
) {
const color = isReusable ? COMPONENT_COLOR : isInstance ? INSTANCE_COLOR : FRAME_LABEL_COLOR
const fontSize = 12 / zoom
const labelH = (fontSize + 6) // approximate label height
drawText2D(ck, canvas, name, x, y - labelH, color, fontSize, '500')
}
// ---------------------------------------------------------------------------
// Agent indicators (breathing glow, badge, node borders)
// ---------------------------------------------------------------------------
/**
* Draw a breathing glow border around an agent-owned frame.
*/
export function drawAgentGlow(
ck: CanvasKit, canvas: Canvas,
x: number, y: number, w: number, h: number,
color: string, breath: number, zoom: number,
) {
const invZ = 1 / zoom
// Outer glow (wider, more transparent)
const outerPaint = new ck.Paint()
outerPaint.setStyle(ck.PaintStyle.Stroke)
outerPaint.setAntiAlias(true)
outerPaint.setStrokeWidth(3 * invZ)
outerPaint.setColor(parseColor(ck, color))
outerPaint.setAlphaf(breath * 0.4)
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), outerPaint)
outerPaint.delete()
// Inner crisp border
const innerPaint = new ck.Paint()
innerPaint.setStyle(ck.PaintStyle.Stroke)
innerPaint.setAntiAlias(true)
innerPaint.setStrokeWidth(1.5 * invZ)
innerPaint.setColor(parseColor(ck, color))
innerPaint.setAlphaf(breath)
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), innerPaint)
innerPaint.delete()
}
/**
* Draw an agent badge (pill with spinning dot + name) above a frame.
*/
export function drawAgentBadge(
ck: CanvasKit, canvas: Canvas,
name: string,
frameX: number, frameY: number, frameW: number,
color: string, zoom: number, time: number,
) {
const invZ = 1 / zoom
const fontSize = 11 * invZ
const padX = 6 * invZ
const padY = 3 * invZ
const radius = 4 * invZ
const dotR = 3 * invZ
const labelOffsetY = 6 * invZ
// Measure text width via Canvas 2D for accurate badge sizing
const textW = measureText(name, 11, '600') * invZ
const dotSpace = dotR * 2 + 4 * invZ
const badgeW = dotSpace + textW + padX * 2
const badgeH = fontSize + padY * 2
// Right-align badge to frame's right edge
const badgeX = frameX + frameW - badgeW
const badgeY = frameY - labelOffsetY - badgeH
// Badge background (pill shape)
const bgPaint = new ck.Paint()
bgPaint.setStyle(ck.PaintStyle.Fill)
bgPaint.setAntiAlias(true)
bgPaint.setColor(parseColor(ck, color))
bgPaint.setAlphaf(0.9)
const rrect = ck.RRectXY(
ck.LTRBRect(badgeX, badgeY, badgeX + badgeW, badgeY + badgeH),
radius, radius,
)
canvas.drawRRect(rrect, bgPaint)
bgPaint.delete()
// Spinning dot
const cx = badgeX + padX + dotR
const cy = badgeY + badgeH / 2
const angle = (time / 400) * Math.PI * 2
const dotPaint = new ck.Paint()
dotPaint.setStyle(ck.PaintStyle.Fill)
dotPaint.setAntiAlias(true)
dotPaint.setColor(ck.WHITE)
// Trail dots
for (let d = 0; d < 3; d++) {
const trailAngle = angle - d * 0.6
const dx = Math.cos(trailAngle) * dotR * 0.7
const dy = Math.sin(trailAngle) * dotR * 0.7
dotPaint.setAlphaf(0.4 - d * 0.12)
canvas.drawCircle(cx + dx, cy + dy, dotR * 0.8 * (1 - d * 0.2), dotPaint)
}
// Main dot
const mainDx = Math.cos(angle) * dotR * 0.7
const mainDy = Math.sin(angle) * dotR * 0.7
dotPaint.setAlphaf(0.95)
canvas.drawCircle(cx + mainDx, cy + mainDy, dotR * 0.6, dotPaint)
dotPaint.delete()
// Agent name text (via Canvas 2D rasterization)
drawText2D(ck, canvas, name,
badgeX + padX + dotSpace, badgeY + padY,
'#FFFFFF', fontSize, '600',
)
}
/**
* Draw a breathing dashed border on a node being generated.
*/
export function drawAgentNodeBorder(
ck: CanvasKit, canvas: Canvas,
x: number, y: number, w: number, h: number,
color: string, breath: number, zoom: number,
) {
const invZ = 1 / zoom
const paint = new ck.Paint()
paint.setStyle(ck.PaintStyle.Stroke)
paint.setAntiAlias(true)
paint.setStrokeWidth(1.5 * invZ)
paint.setColor(parseColor(ck, color))
paint.setAlphaf(breath * 0.7)
const dashLen = 4 * invZ
const effect = ck.PathEffect.MakeDash([dashLen, 3 * invZ], 0)
if (effect) paint.setPathEffect(effect)
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint)
paint.delete()
}
/**
* Draw a preview fill tint on a node that hasn't materialized yet.
*/
export function drawAgentPreviewFill(
ck: CanvasKit, canvas: Canvas,
x: number, y: number, w: number, h: number,
color: string, time: number,
) {
const alpha = 0.06 + Math.sin((time / 500) * Math.PI * 2) * 0.03
const paint = new ck.Paint()
paint.setStyle(ck.PaintStyle.Fill)
paint.setColor(parseColor(ck, color))
paint.setAlphaf(alpha)
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint)
paint.delete()
}

View file

@ -0,0 +1,146 @@
import type { CanvasKit } from 'canvaskit-wasm'
import type { PenFill, PenStroke } from '@/types/styles'
import { DEFAULT_FILL, DEFAULT_STROKE_WIDTH } from '../canvas-constants'
// ---------------------------------------------------------------------------
// Color parsing — ck.Color4f takes 0-1 floats for all channels (r, g, b, a)
// ---------------------------------------------------------------------------
export function parseColor(ck: CanvasKit, color: string): Float32Array {
if (color.startsWith('#')) {
const hex = color.slice(1)
if (hex.length === 8) {
const r = parseInt(hex.slice(0, 2), 16) / 255
const g = parseInt(hex.slice(2, 4), 16) / 255
const b = parseInt(hex.slice(4, 6), 16) / 255
const a = parseInt(hex.slice(6, 8), 16) / 255
return ck.Color4f(r, g, b, a)
}
if (hex.length === 6) {
const r = parseInt(hex.slice(0, 2), 16) / 255
const g = parseInt(hex.slice(2, 4), 16) / 255
const b = parseInt(hex.slice(4, 6), 16) / 255
return ck.Color4f(r, g, b, 1)
}
if (hex.length === 3) {
const r = parseInt(hex[0] + hex[0], 16) / 255
const g = parseInt(hex[1] + hex[1], 16) / 255
const b = parseInt(hex[2] + hex[2], 16) / 255
return ck.Color4f(r, g, b, 1)
}
}
if (color === 'transparent') return ck.Color4f(0, 0, 0, 0)
if (color === 'white') return ck.Color4f(1, 1, 1, 1)
if (color === 'black') return ck.Color4f(0, 0, 0, 1)
// rgba() parsing
const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
if (rgbaMatch) {
return ck.Color4f(
parseInt(rgbaMatch[1]) / 255,
parseInt(rgbaMatch[2]) / 255,
parseInt(rgbaMatch[3]) / 255,
rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1,
)
}
return ck.Color4f(0.82, 0.835, 0.858, 1) // fallback #d1d5db
}
// ---------------------------------------------------------------------------
// Corner radius helpers
// ---------------------------------------------------------------------------
export function cornerRadiusValue(cr: number | [number, number, number, number] | undefined): number {
if (cr === undefined) return 0
if (typeof cr === 'number') return cr
return cr[0]
}
export function cornerRadii(cr: number | [number, number, number, number] | undefined): [number, number, number, number] {
if (cr === undefined) return [0, 0, 0, 0]
if (typeof cr === 'number') return [cr, cr, cr, cr]
return cr
}
// ---------------------------------------------------------------------------
// Fill / stroke helpers
// ---------------------------------------------------------------------------
export function resolveFillColor(fills?: PenFill[] | string): string {
if (typeof fills === 'string') return fills
if (!fills || fills.length === 0) return DEFAULT_FILL
const first = fills[0]
if (first.type === 'solid') return first.color
if (first.type === 'linear_gradient' || first.type === 'radial_gradient') {
return first.stops[0]?.color ?? DEFAULT_FILL
}
return DEFAULT_FILL
}
export function resolveStrokeColor(stroke?: PenStroke): string | undefined {
if (!stroke) return undefined
if (typeof stroke.fill === 'string') return stroke.fill
if (stroke.fill && stroke.fill.length > 0) return resolveFillColor(stroke.fill)
return undefined
}
export function resolveStrokeWidth(stroke?: PenStroke): number {
if (!stroke) return 0
if (typeof stroke.thickness === 'number') return stroke.thickness
if (typeof stroke.thickness === 'object' && !Array.isArray(stroke.thickness)) return 0
return stroke.thickness?.[0] ?? DEFAULT_STROKE_WIDTH
}
// ---------------------------------------------------------------------------
// Text wrapping utilities
// ---------------------------------------------------------------------------
/** CJK character range check (for character-level line breaking). */
function isCJK(ch: string): boolean {
const c = ch.charCodeAt(0)
return (c >= 0x4E00 && c <= 0x9FFF) || (c >= 0x3400 && c <= 0x4DBF) ||
(c >= 0x3000 && c <= 0x303F) || (c >= 0xFF00 && c <= 0xFFEF) ||
(c >= 0x2E80 && c <= 0x2FDF)
}
/** Word-wrap a single line of text, appending wrapped lines to `out`. */
export function wrapLine(ctx: CanvasRenderingContext2D, text: string, maxW: number, out: string[]) {
if (ctx.measureText(text).width <= maxW) { out.push(text); return }
let current = ''
let i = 0
while (i < text.length) {
const ch = text[i]
if (isCJK(ch)) {
const test = current + ch
if (ctx.measureText(test).width > maxW && current) {
out.push(current)
current = ch
} else {
current = test
}
i++
} else if (ch === ' ') {
const test = current + ch
if (ctx.measureText(test).width > maxW && current) {
out.push(current)
current = ''
} else {
current = test
}
i++
} else {
let word = ''
while (i < text.length && text[i] !== ' ' && !isCJK(text[i])) {
word += text[i]; i++
}
const test = current + word
if (ctx.measureText(test).width > maxW && current) {
out.push(current)
current = word
} else {
current = test
}
}
}
if (current) out.push(current)
}

View file

@ -0,0 +1,228 @@
import type { CanvasKit, Path } from 'canvaskit-wasm'
/**
* Normalize SVG path data for CanvasKit's parser:
* - Add spaces between command letters and numbers
* - Handle negative-sign number separators (e.g. "10-5" "10 -5")
* - Normalize comma separators to spaces
* - Separate concatenated arc flags (e.g. "a2 2 0 012 2" "a2 2 0 0 1 2 2")
*/
export function sanitizeSvgPath(d: string): string {
let result = d
// Add space between command letter and following number/sign
.replace(/([MLCQZAHVSmlcqzahvsTt])([0-9.+-])/g, '$1 $2')
// Add space between digit and following negative sign (number separator)
.replace(/(\d)-/g, '$1 -')
// Replace commas with spaces
.replace(/,/g, ' ')
// Collapse multiple spaces
.replace(/\s+/g, ' ')
.trim()
// Separate concatenated arc flags: in SVG arc commands, the large-arc and
// sweep flags are single digits (0 or 1) that may be concatenated with each
// other and with the following number. e.g. "a2 2 0 012 2" → "a2 2 0 0 1 2 2"
result = result.replace(
/([aA])\s*([\d.e+-]+)\s+([\d.e+-]+)\s+([\d.e+-]+)\s+([01])([01])([\d.+-])/g,
'$1 $2 $3 $4 $5 $6 $7',
)
// Handle the case where all three (rotation + flags) are concatenated without spaces,
// e.g. "a4 4 0100-8" where 0100 = rotation=0, large-arc=1, sweep=0, then 0 is start of x
result = result.replace(
/([aA])\s*([\d.e+-]+)\s+([\d.e+-]+)\s+(\d)([01])([01])([\d.+-])/g,
'$1 $2 $3 $4 $5 $6 $7',
)
return result
}
/** Returns true if the path string contains NaN or Infinity values. */
export function hasInvalidNumbers(d: string): boolean {
return /NaN|Infinity/i.test(d)
}
/**
* Convert an SVG arc segment to cubic bezier curves and add them to the path.
* Based on the W3C SVG implementation note for arc-to-cubic conversion.
*/
function arcToCubics(
path: Path,
x1: number, y1: number,
rxIn: number, ryIn: number,
largeArc: boolean, sweep: boolean,
x2: number, y2: number,
): void {
// Degenerate: start == end
if (x1 === x2 && y1 === y2) return
let rx = Math.abs(rxIn)
let ry = Math.abs(ryIn)
const dx = (x1 - x2) / 2
const dy = (y1 - y2) / 2
// Simplified: ignore rotation (most icons use rotation=0)
const x1p = dx
const y1p = dy
// Correct radii
let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
if (lambda > 1) {
const s = Math.sqrt(lambda)
rx *= s
ry *= s
}
const rxSq = rx * rx
const rySq = ry * ry
const x1pSq = x1p * x1p
const y1pSq = y1p * y1p
let sq = (rxSq * rySq - rxSq * y1pSq - rySq * x1pSq) / (rxSq * y1pSq + rySq * x1pSq)
if (sq < 0) sq = 0
let root = Math.sqrt(sq)
if (largeArc === sweep) root = -root
const cxp = root * rx * y1p / ry
const cyp = -root * ry * x1p / rx
const cx = cxp + (x1 + x2) / 2
const cy = cyp + (y1 + y2) / 2
const angle = (ux: number, uy: number, vx: number, vy: number) => {
const n = Math.sqrt(ux * ux + uy * uy)
const d = Math.sqrt(vx * vx + vy * vy)
const c = (ux * vx + uy * vy) / (n * d)
const clamped = Math.max(-1, Math.min(1, c))
let a = Math.acos(clamped)
if (ux * vy - uy * vx < 0) a = -a
return a
}
const theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
let dTheta = angle(
(x1p - cxp) / rx, (y1p - cyp) / ry,
(-x1p - cxp) / rx, (-y1p - cyp) / ry,
)
if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI
if (sweep && dTheta < 0) dTheta += 2 * Math.PI
// Split into segments of at most PI/2
const segments = Math.ceil(Math.abs(dTheta) / (Math.PI / 2))
const segAngle = dTheta / segments
for (let i = 0; i < segments; i++) {
const t1 = theta1 + i * segAngle
const t2 = t1 + segAngle
const alpha = Math.sin(segAngle) * (Math.sqrt(4 + 3 * Math.pow(Math.tan(segAngle / 2), 2)) - 1) / 3
const cos1 = Math.cos(t1), sin1 = Math.sin(t1)
const cos2 = Math.cos(t2), sin2 = Math.sin(t2)
const p1x = cx + rx * cos1
const p1y = cy + ry * sin1
const p2x = cx + rx * cos2
const p2y = cy + ry * sin2
const cp1x = p1x - alpha * rx * sin1
const cp1y = p1y + alpha * ry * cos1
const cp2x = p2x + alpha * rx * sin2
const cp2y = p2y - alpha * ry * cos2
path.cubicTo(cp1x, cp1y, cp2x, cp2y, p2x, p2y)
}
}
/**
* Try building a CanvasKit path manually by tokenizing the SVG path string.
* Handles edge cases that MakeFromSVGString may reject (e.g. missing spaces,
* numbers with leading dots like ".5", relative commands, arcs).
*/
export function tryManualPathParse(ck: CanvasKit, d: string): Path | null {
try {
const path = new ck.Path()
// Replace NaN/Infinity with 0 so commands keep their parameter count.
// This produces degenerate but valid paths (e.g. a horizontal line at y=0)
// which is better than dropping the entire command.
const cleaned = d.replace(/-?NaN/g, '0').replace(/-?Infinity/g, '0')
// Tokenize: split on commands and extract numbers
const tokens = cleaned.match(/[MLCQZAHVSmlcqzahvs]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g)
if (!tokens || tokens.length === 0) return null
let i = 0
let lastCmd = ''
let cx = 0, cy = 0 // current point
while (i < tokens.length) {
let cmd = tokens[i]
if (/^[MLCQZAHVSmlcqzahvs]$/.test(cmd)) {
lastCmd = cmd
i++
} else if (lastCmd) {
// Implicit repeat of last command (M becomes L after first pair)
cmd = lastCmd === 'M' ? 'L' : lastCmd === 'm' ? 'l' : lastCmd
} else {
i++
continue
}
const nums = (count: number): number[] => {
const result: number[] = []
for (let j = 0; j < count && i < tokens.length; j++) {
const n = parseFloat(tokens[i])
if (isNaN(n)) break
result.push(n)
i++
}
return result
}
switch (cmd) {
case 'M': { const p = nums(2); if (p.length === 2) { path.moveTo(p[0], p[1]); cx = p[0]; cy = p[1]; lastCmd = 'L' } break }
case 'm': { const p = nums(2); if (p.length === 2) { path.moveTo(cx + p[0], cy + p[1]); cx += p[0]; cy += p[1]; lastCmd = 'l' } break }
case 'L': { const p = nums(2); if (p.length === 2) { path.lineTo(p[0], p[1]); cx = p[0]; cy = p[1] } break }
case 'l': { const p = nums(2); if (p.length === 2) { path.lineTo(cx + p[0], cy + p[1]); cx += p[0]; cy += p[1] } break }
case 'H': { const p = nums(1); if (p.length === 1) { path.lineTo(p[0], cy); cx = p[0] } break }
case 'h': { const p = nums(1); if (p.length === 1) { path.lineTo(cx + p[0], cy); cx += p[0] } break }
case 'V': { const p = nums(1); if (p.length === 1) { path.lineTo(cx, p[0]); cy = p[0] } break }
case 'v': { const p = nums(1); if (p.length === 1) { path.lineTo(cx, cy + p[0]); cy += p[0] } break }
case 'C': { const p = nums(6); if (p.length === 6) { path.cubicTo(p[0], p[1], p[2], p[3], p[4], p[5]); cx = p[4]; cy = p[5] } break }
case 'c': { const p = nums(6); if (p.length === 6) { path.cubicTo(cx+p[0], cy+p[1], cx+p[2], cy+p[3], cx+p[4], cy+p[5]); cx += p[4]; cy += p[5] } break }
case 'Q': { const p = nums(4); if (p.length === 4) { path.quadTo(p[0], p[1], p[2], p[3]); cx = p[2]; cy = p[3] } break }
case 'q': { const p = nums(4); if (p.length === 4) { path.quadTo(cx+p[0], cy+p[1], cx+p[2], cy+p[3]); cx += p[2]; cy += p[3] } break }
case 'S': { const p = nums(4); if (p.length === 4) { path.cubicTo(cx, cy, p[0], p[1], p[2], p[3]); cx = p[2]; cy = p[3] } break }
case 's': { const p = nums(4); if (p.length === 4) { path.cubicTo(cx, cy, cx+p[0], cy+p[1], cx+p[2], cy+p[3]); cx += p[2]; cy += p[3] } break }
case 'Z': case 'z': path.close(); break
case 'A': case 'a': {
// Arc: rx, ry, rotation, largeArc, sweep, x, y
// Convert SVG arc to cubic bezier approximation via CanvasKit's conic API
const p = nums(7)
if (p.length === 7) {
const [rx, ry, , largeArc, sweep, ex, ey] = p
const endX = cmd === 'a' ? cx + ex : ex
const endY = cmd === 'a' ? cy + ey : ey
if (rx > 0 && ry > 0) {
arcToCubics(path, cx, cy, rx, ry, largeArc !== 0, sweep !== 0, endX, endY)
} else {
path.lineTo(endX, endY)
}
cx = endX
cy = endY
}
break
}
default: i++
}
}
// Check if path has any geometry
const bounds = path.getBounds()
if (bounds[2] - bounds[0] < 0.001 && bounds[3] - bounds[1] < 0.001) {
path.delete()
return null
}
return path
} catch {
return null
}
}

View file

@ -0,0 +1,244 @@
import {
DEFAULT_STROKE,
DEFAULT_STROKE_WIDTH,
PEN_CLOSE_HIT_THRESHOLD,
} from '../canvas-constants'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore, generateId } from '@/stores/document-store'
import type { PenAnchor } from './skia-overlays'
import type { SkiaEngine } from './skia-engine'
export class SkiaPenTool {
private penActive = false
private penPoints: PenAnchor[] = []
private penDraggingHandle = false
private penCursorPos: { x: number; y: number } | null = null
private engineGetter: () => SkiaEngine | null
constructor(engineGetter: () => SkiaEngine | null) {
this.engineGetter = engineGetter
}
get isActive(): boolean {
return this.penActive
}
cancel(): boolean {
if (!this.penActive) return false
this.penActive = false
this.penPoints = []
this.penDraggingHandle = false
this.penCursorPos = null
const engine = this.engineGetter()
if (engine) { engine.penPreview = null; engine.markDirty() }
useCanvasStore.getState().setActiveTool('select')
return true
}
onToolChange(tool: string): void {
if (this.penActive && tool !== 'path') {
this.penActive = false
this.penPoints = []
this.penDraggingHandle = false
this.penCursorPos = null
const engine = this.engineGetter()
if (engine) { engine.penPreview = null; engine.markDirty() }
}
}
onMouseDown(scene: { x: number; y: number }, zoom: number): boolean {
if (!this.penActive) {
// First click — start a new path
this.penActive = true
this.penPoints = [{ x: scene.x, y: scene.y, handleIn: null, handleOut: null }]
this.penDraggingHandle = true
this.penCursorPos = scene
this.updatePenPreview()
return true
}
// Check if clicking near the first point to close the path
if (this.penPoints.length >= 3) {
const first = this.penPoints[0]
const threshold = PEN_CLOSE_HIT_THRESHOLD / zoom
if (Math.hypot(scene.x - first.x, scene.y - first.y) < threshold) {
this.finalizePen(true)
return true
}
}
// Add a new anchor point
this.penPoints.push({ x: scene.x, y: scene.y, handleIn: null, handleOut: null })
this.penDraggingHandle = true
this.updatePenPreview()
return true
}
onMouseMove(scene: { x: number; y: number }): boolean {
if (!this.penActive || this.penPoints.length === 0) return false
if (this.penDraggingHandle) {
const pt = this.penPoints[this.penPoints.length - 1]
const dx = scene.x - pt.x
const dy = scene.y - pt.y
if (Math.hypot(dx, dy) > 2) {
pt.handleOut = { x: dx, y: dy }
pt.handleIn = { x: -dx, y: -dy }
} else {
pt.handleOut = null
pt.handleIn = null
}
}
this.penCursorPos = scene
this.updatePenPreview()
return true
}
onMouseUp(): boolean {
if (!this.penActive) return false
this.penDraggingHandle = false
this.updatePenPreview()
return true
}
onDblClick(): boolean {
if (!this.penActive) return false
// Remove the extra point added by the second click of the double-click
if (this.penPoints.length > 1) this.penPoints.pop()
this.finalizePen(false)
return true
}
onKeyDown(key: string): boolean {
if (!this.penActive) return false
if (key === 'Enter') {
this.finalizePen(false)
return true
}
if (key === 'Escape') {
this.cancel()
return true
}
if (key === 'Backspace') {
if (this.penPoints.length > 1) {
this.penPoints.pop()
this.updatePenPreview()
} else {
this.cancel()
}
return true
}
return false
}
private updatePenPreview(): void {
const engine = this.engineGetter()
if (!engine) return
if (!this.penActive || this.penPoints.length === 0) {
engine.penPreview = null
} else {
engine.penPreview = {
points: this.penPoints.map((p) => ({ ...p })),
cursorPos: this.penCursorPos,
isDraggingHandle: this.penDraggingHandle,
}
}
engine.markDirty()
}
private finalizePen(closed: boolean): void {
const engine = this.engineGetter()
if (this.penPoints.length < 2) {
this.penActive = false
this.penPoints = []
this.penDraggingHandle = false
this.penCursorPos = null
if (engine) { engine.penPreview = null; engine.markDirty() }
useCanvasStore.getState().setActiveTool('select')
return
}
// Build absolute path
const buildD = (pts: PenAnchor[], close: boolean) => {
const parts: string[] = [`M ${pts[0].x} ${pts[0].y}`]
for (let i = 1; i < pts.length; i++) {
const prev = pts[i - 1], curr = pts[i]
if (!prev.handleOut && !curr.handleIn) {
parts.push(`L ${curr.x} ${curr.y}`)
} else {
const cx1 = prev.x + (prev.handleOut?.x ?? 0)
const cy1 = prev.y + (prev.handleOut?.y ?? 0)
const cx2 = curr.x + (curr.handleIn?.x ?? 0)
const cy2 = curr.y + (curr.handleIn?.y ?? 0)
parts.push(`C ${cx1} ${cy1} ${cx2} ${cy2} ${curr.x} ${curr.y}`)
}
}
if (close && pts.length > 1) {
const last = pts[pts.length - 1], first = pts[0]
if (!last.handleOut && !first.handleIn) {
parts.push(`L ${first.x} ${first.y}`)
} else {
const cx1 = last.x + (last.handleOut?.x ?? 0)
const cy1 = last.y + (last.handleOut?.y ?? 0)
const cx2 = first.x + (first.handleIn?.x ?? 0)
const cy2 = first.y + (first.handleIn?.y ?? 0)
parts.push(`C ${cx1} ${cy1} ${cx2} ${cy2} ${first.x} ${first.y}`)
}
parts.push('Z')
}
return parts.join(' ')
}
// Compute bbox via a temporary SVG element
const absD = buildD(this.penPoints, closed)
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path')
pathEl.setAttribute('d', absD)
svg.appendChild(pathEl)
document.body.appendChild(svg)
const bbox = pathEl.getBBox()
document.body.removeChild(svg)
if (bbox.width < 1 && bbox.height < 1) {
this.penActive = false
this.penPoints = []
this.penDraggingHandle = false
this.penCursorPos = null
if (engine) { engine.penPreview = null; engine.markDirty() }
useCanvasStore.getState().setActiveTool('select')
return
}
// Normalize to (0,0) origin
const normalized = this.penPoints.map((pt) => ({
...pt,
x: pt.x - bbox.x,
y: pt.y - bbox.y,
}))
const d = buildD(normalized, closed)
useDocumentStore.getState().addNode(null, {
id: generateId(),
type: 'path',
name: 'Path',
x: bbox.x,
y: bbox.y,
d,
width: Math.round(bbox.width),
height: Math.round(bbox.height),
fill: [{ type: 'solid', color: 'transparent' }],
stroke: {
thickness: DEFAULT_STROKE_WIDTH,
fill: [{ type: 'solid', color: DEFAULT_STROKE }],
},
})
this.penActive = false
this.penPoints = []
this.penDraggingHandle = false
this.penCursorPos = null
if (engine) { engine.penPreview = null; engine.markDirty() }
useCanvasStore.getState().setActiveTool('select')
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,105 @@
import { MIN_ZOOM, MAX_ZOOM } from '../canvas-constants'
export interface ViewportState {
zoom: number
panX: number
panY: number
}
/**
* Compute the 3x3 transform matrix for CanvasKit from viewport state.
* CanvasKit uses column-major [scaleX, skewX, transX, skewY, scaleY, transY, pers0, pers1, pers2]
*/
export function viewportMatrix(vp: ViewportState): number[] {
return [
vp.zoom, 0, vp.panX,
0, vp.zoom, vp.panY,
0, 0, 1,
]
}
/**
* Convert screen (client) coordinates to scene coordinates.
*/
export function screenToScene(
clientX: number, clientY: number,
canvasRect: DOMRect,
vp: ViewportState,
): { x: number; y: number } {
const sx = clientX - canvasRect.left
const sy = clientY - canvasRect.top
return {
x: (sx - vp.panX) / vp.zoom,
y: (sy - vp.panY) / vp.zoom,
}
}
/**
* Convert scene coordinates to screen coordinates.
*/
export function sceneToScreen(
sceneX: number, sceneY: number,
canvasRect: DOMRect,
vp: ViewportState,
): { x: number; y: number } {
return {
x: sceneX * vp.zoom + vp.panX + canvasRect.left,
y: sceneY * vp.zoom + vp.panY + canvasRect.top,
}
}
/**
* Zoom towards a point (in screen coordinates).
*/
export function zoomToPoint(
vp: ViewportState,
screenX: number, screenY: number,
canvasRect: DOMRect,
newZoom: number,
): ViewportState {
const clampedZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom))
const sx = screenX - canvasRect.left
const sy = screenY - canvasRect.top
// The scene point under the cursor should stay fixed
const sceneX = (sx - vp.panX) / vp.zoom
const sceneY = (sy - vp.panY) / vp.zoom
return {
zoom: clampedZoom,
panX: sx - sceneX * clampedZoom,
panY: sy - sceneY * clampedZoom,
}
}
/**
* Get viewport bounds in scene coordinates.
*/
export function getViewportBounds(
vp: ViewportState,
canvasWidth: number,
canvasHeight: number,
margin = 0,
) {
return {
left: (-vp.panX) / vp.zoom - margin,
top: (-vp.panY) / vp.zoom - margin,
right: (-vp.panX + canvasWidth) / vp.zoom + margin,
bottom: (-vp.panY + canvasHeight) / vp.zoom + margin,
}
}
/**
* Check if a rect is within the viewport bounds.
*/
export function isRectInViewport(
rect: { x: number; y: number; w: number; h: number },
vpBounds: ReturnType<typeof getViewportBounds>,
): boolean {
return !(
rect.x + rect.w < vpBounds.left
|| rect.x > vpBounds.right
|| rect.y + rect.h < vpBounds.top
|| rect.y > vpBounds.bottom
)
}

View file

@ -1,239 +0,0 @@
/**
* Canvas rendering hook for agent indicators during concurrent generation.
*
* Visual effects:
* 1. Soft colored border on nodes being generated (breathing opacity)
* 2. Breathing glow border around agent-owned frames (same color as badge)
* 3. Agent badge right-aligned above the frame with a spinning dot animation
* 4. Preview fill tint on nodes that haven't materialized yet
*/
import { useEffect } from 'react'
import { useCanvasStore } from '@/stores/canvas-store'
import {
getActiveAgentIndicators,
getActiveAgentFrames,
isPreviewNode,
} from './agent-indicator'
import { isNodeBorderReady } from '@/services/ai/design-animation'
import type { FabricObjectWithPenId } from './canvas-object-factory'
// Badge styling
const BADGE_FONT_SIZE = 11
const BADGE_PAD_X = 6
const BADGE_PAD_Y = 3
const BADGE_RADIUS = 4
const DOT_RADIUS = 3
export function useAgentIndicators() {
useEffect(() => {
let detach: (() => void) | null = null
let rafId: number | null = null
const interval = setInterval(() => {
const canvas = useCanvasStore.getState().fabricCanvas
if (!canvas) return
clearInterval(interval)
const onAfterRender = () => {
const indicators = getActiveAgentIndicators()
const agentFrames = getActiveAgentFrames()
if (indicators.size === 0 && agentFrames.size === 0) return
const el = canvas.lowerCanvasEl
if (!el) return
const ctx = el.getContext('2d')
if (!ctx) return
const vpt = canvas.viewportTransform
if (!vpt) return
const zoom = vpt[0]
if (!Number.isFinite(zoom) || zoom <= 0) return
if (!el.offsetWidth) return
const dpr = el.width / el.offsetWidth
const t = Date.now()
// Gentle breathing: range [0.4, 0.8]
const breath = 0.6 + Math.sin((t / 600) * Math.PI * 2) * 0.2
const objects = canvas.getObjects() as FabricObjectWithPenId[]
const objMap = new Map<string, FabricObjectWithPenId>()
for (const obj of objects) {
if (obj.penNodeId) objMap.set(obj.penNodeId, obj)
}
ctx.save()
ctx.setTransform(
vpt[0] * dpr, vpt[1] * dpr,
vpt[2] * dpr, vpt[3] * dpr,
vpt[4] * dpr, vpt[5] * dpr,
)
// ---- Draw node borders (only for nodes whose reveal time has arrived) ----
for (const entry of indicators.values()) {
// Sequential queue: only show border when this node's turn arrives
if (!isNodeBorderReady(entry.nodeId)) continue
const obj = objMap.get(entry.nodeId)
if (!obj) continue
const corners = obj.getCoords()
const xs = corners.map((p) => p.x)
const ys = corners.map((p) => p.y)
const x = Math.min(...xs)
const y = Math.min(...ys)
const w = Math.max(...xs) - x
const h = Math.max(...ys) - y
if (w < 1 || h < 1) continue
const preview = isPreviewNode(entry.nodeId)
// Dashed colored border
const borderWidth = 1.5 / zoom
ctx.strokeStyle = entry.color
ctx.globalAlpha = breath * 0.7
ctx.lineWidth = borderWidth
ctx.setLineDash([4 / zoom, 3 / zoom])
ctx.strokeRect(x, y, w, h)
ctx.setLineDash([])
// Preview fill tint (content not yet materialized)
if (preview) {
ctx.fillStyle = entry.color
ctx.globalAlpha = 0.06 + Math.sin((t / 500) * Math.PI * 2) * 0.03
ctx.fillRect(x, y, w, h)
}
}
// ---- Draw agent frame glow borders + badges ----
for (const frame of agentFrames.values()) {
const obj = objMap.get(frame.frameId)
if (!obj) continue
const corners = obj.getCoords()
const xs = corners.map((p) => p.x)
const ys = corners.map((p) => p.y)
const fx = Math.min(...xs)
const fy = Math.min(...ys)
const fw = Math.max(...xs) - fx
const fh = Math.max(...ys) - fy
if (fw < 1 || fh < 1) continue
// --- Breathing glow border around the frame ---
const glowWidth = 1.5 / zoom
ctx.save()
ctx.strokeStyle = frame.color
ctx.lineWidth = glowWidth
ctx.globalAlpha = breath * 0.8
// Outer glow (wider, more transparent)
ctx.shadowColor = frame.color
ctx.shadowBlur = 8 / zoom
ctx.strokeRect(fx, fy, fw, fh)
// Inner crisp border (no shadow)
ctx.shadowColor = 'transparent'
ctx.shadowBlur = 0
ctx.globalAlpha = breath
ctx.strokeRect(fx, fy, fw, fh)
ctx.restore()
// --- Badge: right-aligned above the frame ---
const fontSize = BADGE_FONT_SIZE / zoom
const padX = BADGE_PAD_X / zoom
const padY = BADGE_PAD_Y / zoom
const radius = BADGE_RADIUS / zoom
const dotR = DOT_RADIUS / zoom
const labelOffsetY = 6 / zoom
ctx.font = `600 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
const textWidth = ctx.measureText(frame.name).width
const dotSpace = dotR * 2 + 4 / zoom
const badgeW = dotSpace + textWidth + padX * 2
const badgeH = fontSize + padY * 2
// Right-align badge to frame's right edge
const badgeX = fx + fw - badgeW
const badgeY = fy - labelOffsetY - badgeH
// Badge background (pill shape)
ctx.globalAlpha = 0.9
ctx.fillStyle = frame.color
drawRoundedRect(ctx, badgeX, badgeY, badgeW, badgeH, radius)
ctx.fill()
// Spinning dot animation
const cx = badgeX + padX + dotR
const cy = badgeY + badgeH / 2
const angle = (t / 400) * Math.PI * 2
// Orbiting trail dots (3 dots trailing)
for (let d = 0; d < 3; d++) {
const trailAngle = angle - d * 0.6
const trailR = dotR * 0.8
const dx = Math.cos(trailAngle) * dotR * 0.7
const dy = Math.sin(trailAngle) * dotR * 0.7
ctx.globalAlpha = 0.4 - d * 0.12
ctx.fillStyle = '#FFFFFF'
ctx.beginPath()
ctx.arc(cx + dx, cy + dy, trailR * (1 - d * 0.2), 0, Math.PI * 2)
ctx.fill()
}
// Main spinning dot
const mainDx = Math.cos(angle) * dotR * 0.7
const mainDy = Math.sin(angle) * dotR * 0.7
ctx.globalAlpha = 0.95
ctx.fillStyle = '#FFFFFF'
ctx.beginPath()
ctx.arc(cx + mainDx, cy + mainDy, dotR * 0.6, 0, Math.PI * 2)
ctx.fill()
// Agent name text
ctx.globalAlpha = 1
ctx.fillStyle = '#FFFFFF'
ctx.textBaseline = 'top'
ctx.fillText(frame.name, badgeX + padX + dotSpace, badgeY + padY)
}
ctx.globalAlpha = 1
ctx.restore()
}
canvas.on('after:render', onAfterRender)
// RAF loop to drive breathing + spinning animation while indicators are active
const tick = () => {
if (getActiveAgentIndicators().size > 0 || getActiveAgentFrames().size > 0) {
canvas.requestRenderAll()
}
rafId = requestAnimationFrame(tick)
}
rafId = requestAnimationFrame(tick)
detach = () => {
canvas.off('after:render', onAfterRender)
if (rafId !== null) cancelAnimationFrame(rafId)
}
}, 100)
return () => {
clearInterval(interval)
detach?.()
}
}, [])
}
function drawRoundedRect(
ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number, r: number,
) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h - r)
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
}

View file

@ -1,702 +0,0 @@
import { useEffect } from 'react'
import * as fabric from 'fabric'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
import { useAIStore } from '@/stores/ai-store'
import type { PenDocument, PenNode } from '@/types/pen'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { setFabricSyncLock } from './canvas-sync-lock'
import { calculateAndSnap } from './guide-utils'
import {
beginParentDrag,
endParentDrag,
getActiveDragSession,
moveDescendants,
scaleDescendants,
rotateDescendants,
collectDescendantIds,
} from './parent-child-transform'
import {
isPenToolActive,
penToolPointerDown,
penToolPointerMove,
penToolPointerUp,
penToolDoubleClick,
cancelPenTool,
} from './pen-tool'
import {
beginLayoutDrag,
updateLayoutDrag,
cancelLayoutDrag,
isLayoutDragActive,
} from './layout-reorder'
import { isEnterableContainer, resolveTargetAtDepth } from './selection-context'
import {
checkDragIntoTarget,
checkDragIntoTargetMulti,
} from './drag-into-layout'
import { createNodeForTool, isDrawingTool, toScene } from './canvas-node-creator'
import { handleObjectModified } from './canvas-object-modified'
export function useCanvasEvents() {
useEffect(() => {
let tempObj: fabric.FabricObject | null = null
let startPoint: { x: number; y: number } | null = null
let drawing = false
const interval = setInterval(() => {
const canvas = useCanvasStore.getState().fabricCanvas
if (!canvas) return
clearInterval(interval)
const upperEl = canvas.upperCanvasEl
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
// Cancel pen tool if switching away mid-drawing
if (prevTool === 'path' && isPenToolActive() && state.fabricCanvas) {
cancelPenTool(state.fabricCanvas)
}
prevTool = state.activeTool
if (!state.fabricCanvas) return
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
if (isPanning) return
const pointer = toScene(canvas, e)
// Pen tool: delegate to state machine
if (tool === 'path') {
penToolPointerDown(canvas, pointer)
return
}
startPoint = { x: pointer.x, y: pointer.y }
drawing = true
// Text: create immediately
if (tool === 'text') {
const node = createNodeForTool(tool, pointer.x, pointer.y, 0, 0)
if (node) {
useDocumentStore.getState().addNode(null, node)
}
drawing = false
startPoint = null
useCanvasStore.getState().setActiveTool('select')
return
}
const baseProps = {
left: pointer.x,
top: pointer.y,
originX: 'left' as const,
originY: 'top' as const,
selectable: false,
evented: false,
objectCaching: false,
}
switch (tool) {
case 'rectangle':
case 'frame':
tempObj = new fabric.Rect({
...baseProps,
width: 0,
height: 0,
fill: 'rgba(59, 130, 246, 0.1)',
strokeWidth: 0,
})
break
case 'ellipse':
tempObj = new fabric.Ellipse({
...baseProps,
rx: 0,
ry: 0,
fill: 'rgba(59, 130, 246, 0.1)',
strokeWidth: 0,
})
break
case 'line':
tempObj = new fabric.Line(
[pointer.x, pointer.y, pointer.x, pointer.y],
{
...baseProps,
fill: '',
stroke: '#3b82f6',
strokeWidth: 1,
strokeUniform: true,
},
)
break
}
if (tempObj) {
canvas.add(tempObj)
canvas.renderAll()
}
}
const onPointerMove = (e: PointerEvent) => {
if (useAIStore.getState().isStreaming) return
// Pen tool has its own move handling
if (isPenToolActive()) {
const pointer = toScene(canvas, e)
penToolPointerMove(canvas, pointer)
return
}
if (!drawing || !tempObj || !startPoint) return
const tool = useCanvasStore.getState().activeTool
const pointer = toScene(canvas, e)
const dx = pointer.x - startPoint.x
const dy = pointer.y - startPoint.y
switch (tool) {
case 'rectangle':
case 'frame': {
tempObj.set({
left: dx < 0 ? pointer.x : startPoint.x,
top: dy < 0 ? pointer.y : startPoint.y,
width: Math.abs(dx),
height: Math.abs(dy),
})
break
}
case 'ellipse': {
tempObj.set({
left: dx < 0 ? pointer.x : startPoint.x,
top: dy < 0 ? pointer.y : startPoint.y,
rx: Math.abs(dx) / 2,
ry: Math.abs(dy) / 2,
})
break
}
case 'line': {
tempObj.set({ x2: pointer.x, y2: pointer.y })
break
}
}
tempObj.setCoords()
canvas.renderAll()
}
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)
return
}
if (!drawing || !tempObj || !startPoint) {
drawing = false
startPoint = null
return
}
const tool = useCanvasStore.getState().activeTool
const finalX = tempObj.left ?? 0
const finalY = tempObj.top ?? 0
let width = 0
let height = 0
if (tool === 'line') {
width = ((tempObj as fabric.Line).x2 ?? 0) - startPoint.x
height = ((tempObj as fabric.Line).y2 ?? 0) - startPoint.y
} else {
width = tempObj.width ?? 0
height = tempObj.height ?? 0
}
canvas.remove(tempObj)
tempObj = null
drawing = false
if (
Math.abs(width) > 2 ||
Math.abs(height) > 2 ||
tool === 'line'
) {
const node = createNodeForTool(tool, finalX, finalY, width, height)
if (node) {
useDocumentStore.getState().addNode(null, node)
}
}
startPoint = null
useCanvasStore.getState().setActiveTool('select')
}
const onDoubleClick = (e: MouseEvent) => {
if (useAIStore.getState().isStreaming) return
if (isPenToolActive()) {
e.preventDefault()
e.stopPropagation()
penToolDoubleClick(canvas)
return
}
const tool = useCanvasStore.getState().activeTool
if (tool !== 'select') return
const { activeId } = useCanvasStore.getState().selection
if (!activeId) return
if (isEnterableContainer(activeId)) {
canvas.discardActiveObject()
useCanvasStore.getState().enterFrame(activeId)
// Find and select the child under the cursor (Figma-style)
canvas.calcOffset()
const pointer = canvas.getScenePoint(e as unknown as PointerEvent)
const objects = canvas.getObjects() as FabricObjectWithPenId[]
// Iterate topmost-first to find the child under the cursor
for (let i = objects.length - 1; i >= 0; i--) {
const obj = objects[i]
if (!obj.penNodeId) continue
if (!obj.containsPoint(pointer)) continue
// Resolve to a selectable node at the new (entered) depth
const resolved = resolveTargetAtDepth(obj.penNodeId)
if (!resolved) continue
// Find the Fabric object for the resolved target
const resolvedObj = objects.find((o) => o.penNodeId === resolved)
if (resolvedObj) {
canvas.setActiveObject(resolvedObj)
useCanvasStore.getState().setSelection([resolved], resolved)
}
break
}
canvas.requestRenderAll()
}
}
// All listeners on upperEl because Fabric.js captures the pointer
// to this element, so pointermove/pointerup won't reach document.
upperEl.addEventListener('pointerdown', onPointerDown)
upperEl.addEventListener('pointermove', onPointerMove)
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
// --- ActiveSelection descendant tracking ---
// When dragging an ActiveSelection, children of selected objects are
// separate Fabric objects (due to tree flattening) and are NOT part of
// the selection group. We track them here so they follow the group drag.
interface SelectionDescInfo {
initGroupLeft: number
initGroupTop: number
descendants: Map<string, { obj: FabricObjectWithPenId; initLeft: number; initTop: number }>
}
let selectionDragInfo: SelectionDescInfo | 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 e = opt.e as MouseEvent | undefined
// Keep multi-selection active when clicking one of its selected objects
// so users can drag the whole set without needing Shift on drag start.
if (!e?.shiftKey) {
const { selectedIds } = useCanvasStore.getState().selection
const clicked = opt.target as FabricObjectWithPenId | null
const clickedResolved = clicked?.penNodeId
? resolveTargetAtDepth(clicked.penNodeId)
: null
const activeObj = canvas.getActiveObject()
const isActiveSelection = !!activeObj?.isType?.('activeSelection')
if (
!isActiveSelection &&
clickedResolved &&
selectedIds.length > 1 &&
selectedIds.includes(clickedResolved)
) {
const objects = canvas.getObjects() as FabricObjectWithPenId[]
const selectedSet = new Set(selectedIds)
const selectedObjs = objects.filter(
(o) => o.penNodeId && selectedSet.has(o.penNodeId),
)
if (selectedObjs.length > 1) {
const sel = new fabric.ActiveSelection(selectedObjs, { canvas })
canvas.setActiveObject(sel)
canvas.requestRenderAll()
}
}
}
const activeTarget = canvas.getActiveObject() ?? opt.target
if (!activeTarget) return
// Snapshot the document BEFORE any drag/resize/rotate begins.
// structuredClone ensures we have a deep copy unaffected by later mutations.
preModificationDoc = structuredClone(useDocumentStore.getState().document)
useHistoryStore
.getState()
.startBatch(useDocumentStore.getState().document)
transformBatchActive = true
// ActiveSelection move/scale/rotate: batch + final sync in object:modified.
// Layout/parent-child single-node logic does not apply here.
// However, we must track descendants of selected objects so they
// visually follow the group during drag.
if ('getObjects' in activeTarget) {
// Fix: if Fabric's _currentTransform targets a single object
// inside the selection (happens when handleSelection creates
// the ActiveSelection during selection:updated, after Fabric
// already set up the transform for the clicked single object),
// redirect the transform to the ActiveSelection so the whole
// group moves/scales/rotates together.
const ct = (canvas as unknown as { _currentTransform?: {
target: fabric.FabricObject
offsetX: number
offsetY: number
original?: Record<string, unknown>
} })._currentTransform
if (ct && ct.target !== activeTarget) {
const pointerEvt = opt.e as PointerEvent | undefined
if (pointerEvt) {
canvas.calcOffset()
const pointer = canvas.getScenePoint(pointerEvt)
ct.target = activeTarget
ct.offsetX = pointer.x - (activeTarget.left ?? 0)
ct.offsetY = pointer.y - (activeTarget.top ?? 0)
if (ct.original) {
ct.original = {
...ct.original,
left: activeTarget.left,
top: activeTarget.top,
scaleX: activeTarget.scaleX,
scaleY: activeTarget.scaleY,
}
}
}
}
cancelLayoutDrag()
const group = activeTarget as fabric.ActiveSelection
const selObjs = group.getObjects() as FabricObjectWithPenId[]
const selIds = new Set(
selObjs.map((o) => o.penNodeId).filter(Boolean) as string[],
)
// Collect descendants of all selected objects that are NOT in the selection
const allCanvasObjs = canvas.getObjects() as FabricObjectWithPenId[]
const canvasObjMap = new Map(
allCanvasObjs.filter((o) => o.penNodeId).map((o) => [o.penNodeId!, o]),
)
const descendants = new Map<
string,
{ obj: FabricObjectWithPenId; initLeft: number; initTop: number }
>()
for (const selObj of selObjs) {
if (!selObj.penNodeId) continue
for (const descId of collectDescendantIds(selObj.penNodeId)) {
if (selIds.has(descId) || descendants.has(descId)) continue
const descObj = canvasObjMap.get(descId)
if (descObj) {
descendants.set(descId, {
obj: descObj,
initLeft: descObj.left ?? 0,
initTop: descObj.top ?? 0,
})
}
}
}
selectionDragInfo =
descendants.size > 0
? {
initGroupLeft: group.left ?? 0,
initGroupTop: group.top ?? 0,
descendants,
}
: null
return
}
const target = activeTarget as FabricObjectWithPenId
if (!target.penNodeId) return
// 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)
})
canvas.on('mouse:up', () => {
cancelLayoutDrag()
// NOTE: do NOT cancelDragInto() here -- object:modified handles the
// commit and cleanup. In Fabric.js v7 mouse:up can fire before
// object:modified, which would clear the session prematurely.
endParentDrag()
selectionDragInfo = null
// 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 ---
// Real-time sync during drag / resize / rotate (locked to prevent circular sync)
let clipPathsCleared = false
canvas.on('object:moving', (opt) => {
// Clear frame-level clip paths (absolutePositioned: true) on first
// move so content isn't clipped by stale ancestor frame bounds
// during drag. Restored by post-drag re-sync.
// Preserve object-level clips (absolutePositioned: false) like
// image corner radius — these are self-contained and stay valid.
if (!clipPathsCleared) {
clipPathsCleared = true
const movingObj = opt.target as FabricObjectWithPenId
if (movingObj.clipPath?.absolutePositioned) movingObj.clipPath = undefined
// Clear clip paths on objects INSIDE the ActiveSelection
if ('getObjects' in opt.target) {
for (const child of (opt.target as fabric.ActiveSelection).getObjects()) {
if (child.clipPath?.absolutePositioned) child.clipPath = undefined
}
}
// Also clear descendants' clip paths
const session = getActiveDragSession()
if (session) {
for (const [, descObj] of session.descendantObjects) {
if (descObj.clipPath?.absolutePositioned) descObj.clipPath = undefined
}
}
// Clear clip paths on ActiveSelection descendants too
if (selectionDragInfo) {
for (const [, { obj }] of selectionDragInfo.descendants) {
if (obj.clipPath?.absolutePositioned) obj.clipPath = undefined
}
}
}
// ActiveSelection drag: snap + move descendants + drag-into detection
if ('getObjects' in opt.target) {
const group = opt.target as fabric.ActiveSelection
// Smart guides + snapping for the whole selection bounding box
calculateAndSnap(opt.target, canvas)
// Move descendants based on the (possibly snapped) group position
if (selectionDragInfo) {
const deltaX = (group.left ?? 0) - selectionDragInfo.initGroupLeft
const deltaY = (group.top ?? 0) - selectionDragInfo.initGroupTop
for (const [, { obj, initLeft, initTop }] of selectionDragInfo.descendants) {
obj.set({ left: initLeft + deltaX, top: initTop + deltaY })
obj.setCoords()
}
}
// Drag-into layout container detection (using selection center)
const selObjs = group.getObjects() as FabricObjectWithPenId[]
const selNodeIds = selObjs
.map((o) => o.penNodeId)
.filter(Boolean) as string[]
// ActiveSelection uses center origin -- left/top IS the center
const cx = group.originX === 'center'
? (group.left ?? 0)
: (group.left ?? 0) + ((group.width ?? 0) * (group.scaleX ?? 1)) / 2
const cy = group.originY === 'center'
? (group.top ?? 0)
: (group.top ?? 0) + ((group.height ?? 0) * (group.scaleY ?? 1)) / 2
checkDragIntoTargetMulti(cx, cy, selNodeIds, canvas)
return
}
if (isLayoutDragActive()) {
// Layout reorder mode: update insertion indicator, still propagate children
updateLayoutDrag(opt.target as FabricObjectWithPenId, canvas)
if (getActiveDragSession()) {
moveDescendants(opt.target as FabricObjectWithPenId, canvas)
}
return
}
// Check drag-into for non-layout-child nodes
checkDragIntoTarget(opt.target as FabricObjectWithPenId, canvas)
// Calculate guides + snap BEFORE syncing so the store gets the snapped position
calculateAndSnap(opt.target, canvas)
// Propagate move to descendants (visual only, no store sync needed)
if (getActiveDragSession()) {
moveDescendants(opt.target as FabricObjectWithPenId, canvas)
}
})
canvas.on('object:scaling', (opt) => {
// Propagate scale to descendants (visual only)
if (getActiveDragSession()) {
scaleDescendants(opt.target as FabricObjectWithPenId, canvas)
}
})
canvas.on('object:rotating', (opt) => {
// Propagate rotation to descendants (visual only)
if (getActiveDragSession()) {
rotateDescendants(opt.target as FabricObjectWithPenId, canvas)
}
})
// Final sync: reset scale to 1 and bake into width/height.
// History batching lives here (not in mouse:down/mouse:up) so that
// click-to-select without modification never creates a no-op undo
// entry. We use the pre-modification snapshot captured in mouse:down
// as the batch base to guarantee a correct undo point.
canvas.on('object:modified', (opt) => {
if (pendingBatchCloseRaf !== null) {
cancelAnimationFrame(pendingBatchCloseRaf)
pendingBatchCloseRaf = null
}
handleObjectModified(
opt,
canvas,
() => preModificationDoc,
() => { preModificationDoc = null },
closeTransformBatch,
)
})
// --- Text editing: sync edited content back to document store ---
canvas.on('text:editing:exited', (opt) => {
const obj = opt.target as FabricObjectWithPenId
if (!obj?.penNodeId) return
const text =
'text' in obj ? (obj as fabric.IText | fabric.Textbox).text : undefined
if (text === undefined) return
setFabricSyncLock(true)
useDocumentStore.getState().updateNode(obj.penNodeId, {
content: text,
} as Partial<PenNode>)
setFabricSyncLock(false)
})
return () => {
if (pendingBatchCloseRaf !== null) {
cancelAnimationFrame(pendingBatchCloseRaf)
}
closeTransformBatch()
unsubTool()
unsubStreaming()
upperEl.removeEventListener('pointerdown', onPointerDown)
upperEl.removeEventListener('pointermove', onPointerMove)
upperEl.removeEventListener('pointerup', onPointerUp)
upperEl.removeEventListener('dblclick', onDoubleClick)
}
}, 100)
return () => clearInterval(interval)
}, [])
}

View file

@ -1,81 +0,0 @@
import { useEffect } from 'react'
import { useCanvasStore } from '@/stores/canvas-store'
import { activeGuides, clearGuides } from './guide-utils'
import { GUIDE_COLOR, GUIDE_DASH } from './canvas-constants'
/**
* Renders smart guide lines on the canvas overlay after Fabric's render pass.
* Guide positions are calculated in use-canvas-events.ts via calculateAndSnap().
*/
export function useCanvasGuides() {
useEffect(() => {
const interval = setInterval(() => {
const canvas = useCanvasStore.getState().fabricCanvas
if (!canvas) return
clearInterval(interval)
// Draw guide lines after Fabric renders (on the lower canvas)
const onAfterRender = () => {
if (activeGuides.length === 0) return
const el = canvas.lowerCanvasEl
if (!el) return
const ctx = el.getContext('2d')
if (!ctx) return
const vpt = canvas.viewportTransform
if (!vpt) return
const zoom = vpt[0] // uniform zoom assumed
ctx.save()
// Apply viewport transform so we draw in scene coordinates
ctx.transform(vpt[0], vpt[1], vpt[2], vpt[3], vpt[4], vpt[5])
ctx.strokeStyle = GUIDE_COLOR
ctx.lineWidth = 1 / zoom
ctx.setLineDash(GUIDE_DASH.map((v) => v / zoom))
for (const guide of activeGuides) {
ctx.beginPath()
if (guide.orientation === 'vertical') {
ctx.moveTo(guide.position, guide.start)
ctx.lineTo(guide.position, guide.end)
} else {
ctx.moveTo(guide.start, guide.position)
ctx.lineTo(guide.end, guide.position)
}
ctx.stroke()
}
ctx.restore()
}
// Clear guides when the user stops interacting
const onMouseUp = () => {
if (activeGuides.length > 0) {
clearGuides()
canvas.requestRenderAll()
}
}
const onModified = () => {
if (activeGuides.length > 0) {
clearGuides()
canvas.requestRenderAll()
}
}
canvas.on('after:render', onAfterRender)
canvas.on('mouse:up', onMouseUp)
canvas.on('object:modified', onModified)
return () => {
canvas.off('after:render', onAfterRender)
canvas.off('mouse:up', onMouseUp)
canvas.off('object:modified', onModified)
}
}, 100)
return () => clearInterval(interval)
}, [])
}

View file

@ -1,162 +0,0 @@
import { useEffect } from 'react'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore, getActivePageChildren } from '@/stores/document-store'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { resolveTargetAtDepth, getChildIds } from './selection-context'
import { COMPONENT_COLOR, INSTANCE_COLOR, HOVER_BLUE, HOVER_LINE_WIDTH, HOVER_DASH } from './canvas-constants'
import type { PenNode } from '@/types/pen'
function collectReusableIds(nodes: PenNode[], result: Set<string>) {
for (const node of nodes) {
if ('reusable' in node && node.reusable === true) result.add(node.id)
if ('children' in node && node.children) collectReusableIds(node.children, result)
}
}
function collectInstanceIds(nodes: PenNode[], result: Set<string>) {
for (const node of nodes) {
if (node.type === 'ref') result.add(node.id)
if ('children' in node && node.children) collectInstanceIds(node.children, result)
}
}
export function useCanvasHover() {
useEffect(() => {
const interval = setInterval(() => {
const canvas = useCanvasStore.getState().fabricCanvas
if (!canvas) return
clearInterval(interval)
// --- Hover tracking via mouse:move ---
// Always track hoveredId regardless of selection state,
// so dashed child outlines appear on hover even for selected elements.
canvas.on('mouse:move', (opt) => {
const tool = useCanvasStore.getState().activeTool
if (tool !== 'select') return
const target = opt.target as FabricObjectWithPenId | null
const { hoveredId } = useCanvasStore.getState().selection
if (!target?.penNodeId) {
if (hoveredId !== null) {
useCanvasStore.getState().setHoveredId(null)
canvas.requestRenderAll()
}
return
}
const resolved = resolveTargetAtDepth(target.penNodeId)
if (resolved !== hoveredId) {
useCanvasStore.getState().setHoveredId(resolved)
canvas.requestRenderAll()
}
})
// Clear hover when mouse leaves the canvas
canvas.on('mouse:out', () => {
const { hoveredId } = useCanvasStore.getState().selection
if (hoveredId !== null) {
useCanvasStore.getState().setHoveredId(null)
canvas.requestRenderAll()
}
})
// --- Outline rendering on the lower canvas ---
canvas.on('after:render', () => {
const { hoveredId, selectedIds } = useCanvasStore.getState().selection
if (!hoveredId) return
const isSelected = selectedIds.includes(hoveredId)
const el = canvas.lowerCanvasEl
if (!el) return
const ctx = el.getContext('2d')
if (!ctx) return
const vpt = canvas.viewportTransform
if (!vpt) return
const zoom = vpt[0]
const dpr = el.width / el.offsetWidth
const docChildren = getActivePageChildren(useDocumentStore.getState().document, useCanvasStore.getState().activePageId)
const reusableIds = new Set<string>()
const instanceIds = new Set<string>()
collectReusableIds(docChildren, reusableIds)
collectInstanceIds(docChildren, instanceIds)
ctx.save()
ctx.setTransform(
vpt[0] * dpr, vpt[1] * dpr,
vpt[2] * dpr, vpt[3] * dpr,
vpt[4] * dpr, vpt[5] * dpr,
)
// Solid outline on hovered target — skip if already selected
// (Fabric draws its own selection handles)
if (!isSelected) {
drawNodeOutline(ctx, hoveredId, false, zoom, reusableIds, instanceIds)
}
// Dashed outlines on direct children — always draw on hover
const childIds = getChildIds(hoveredId)
for (const childId of childIds) {
drawNodeOutline(ctx, childId, true, zoom, reusableIds, instanceIds)
}
ctx.restore()
})
function drawNodeOutline(
ctx: CanvasRenderingContext2D,
nodeId: string,
dashed: boolean,
zoom: number,
reusableIds: Set<string>,
instanceIds: Set<string>,
) {
const objects = canvas!.getObjects() as FabricObjectWithPenId[]
const obj = objects.find((o) => o.penNodeId === nodeId)
if (!obj) return
const left = obj.left ?? 0
const top = obj.top ?? 0
const w = (obj.width ?? 0) * (obj.scaleX ?? 1)
const h = (obj.height ?? 0) * (obj.scaleY ?? 1)
const angle = obj.angle ?? 0
const isReusable = reusableIds.has(nodeId)
const isInstance = instanceIds.has(nodeId)
ctx.save()
if (angle !== 0) {
const cx = left + w / 2
const cy = top + h / 2
ctx.translate(cx, cy)
ctx.rotate((angle * Math.PI) / 180)
ctx.translate(-cx, -cy)
}
ctx.strokeStyle = isReusable
? COMPONENT_COLOR
: isInstance
? INSTANCE_COLOR
: HOVER_BLUE
ctx.lineWidth = HOVER_LINE_WIDTH / zoom
if (isInstance) {
ctx.setLineDash(HOVER_DASH.map((d) => d / zoom))
} else if (dashed) {
ctx.setLineDash(HOVER_DASH.map((d) => d / zoom))
} else {
ctx.setLineDash([])
}
ctx.strokeRect(left, top, w, h)
ctx.restore()
}
}, 100)
return () => clearInterval(interval)
}, [])
}

View file

@ -1,160 +0,0 @@
import { useEffect } from 'react'
import type { FabricObject } from 'fabric'
import { ActiveSelection } from 'fabric'
import { useCanvasStore } from '@/stores/canvas-store'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { resolveTargetAtDepth } from './selection-context'
/**
* When true, the next selection event will skip depth-resolution and
* pass through as-is. Used by the layer panel to programmatically select
* children without the handler resolving them back to their parent.
*/
let skipNextDepthResolve = false
export function setSkipNextDepthResolve() {
skipNextDepthResolve = true
}
/**
* Resolve a list of Fabric selected objects to node IDs at the current
* entered-frame depth. If any target falls outside the current context,
* exits all frames and retries at root level.
*/
function resolveIds(selected: FabricObject[]): string[] {
const resolved = new Set<string>()
let hasUnresolved = false
for (const obj of selected) {
const penId = (obj as FabricObjectWithPenId).penNodeId
if (!penId) continue
const target = resolveTargetAtDepth(penId)
if (target) resolved.add(target)
else hasUnresolved = true
}
// If any target is outside current context, exit all frames and retry
if (hasUnresolved) {
useCanvasStore.getState().exitAllFrames()
resolved.clear()
for (const obj of selected) {
const penId = (obj as FabricObjectWithPenId).penNodeId
if (!penId) continue
const target = resolveTargetAtDepth(penId)
if (target) resolved.add(target)
}
}
return [...resolved]
}
export function useCanvasSelection() {
useEffect(() => {
const interval = setInterval(() => {
const canvas = useCanvasStore.getState().fabricCanvas
if (!canvas) return
clearInterval(interval)
// Guard against re-entry: setActiveObject fires selection events
// synchronously, which would call handleSelection again and cause
// infinite recursion.
let updatingSelection = false
const handleSelection = (e: { selected?: FabricObject[]; e?: unknown }) => {
if (updatingSelection) return
// Programmatic selection from layer panel — skip depth resolution
if (skipNextDepthResolve) {
skipNextDepthResolve = false
return
}
// `selection:updated` payload `selected` may contain only delta objects.
// Always read the full active selection from canvas for accurate multi-select.
const selected = canvas.getActiveObjects()
const fallbackSelected = e.selected ?? []
const effectiveSelected = selected.length > 0 ? selected : fallbackSelected
const prevIds = useCanvasStore.getState().selection.selectedIds
const mouseEvent = e.e as MouseEvent | undefined
// If user already has a multi-selection and clicks one selected object
// (without Shift), keep the whole selection so dragging moves all.
if (!mouseEvent?.shiftKey && prevIds.length > 1 && effectiveSelected.length === 1) {
const clicked = effectiveSelected[0] as FabricObjectWithPenId
const resolvedClicked = clicked?.penNodeId
? resolveTargetAtDepth(clicked.penNodeId)
: null
if (resolvedClicked && prevIds.includes(resolvedClicked)) {
const objects = canvas.getObjects() as FabricObjectWithPenId[]
const prevSet = new Set(prevIds)
const restoredObjects = objects.filter(
(o) => o.penNodeId && prevSet.has(o.penNodeId),
)
if (restoredObjects.length > 1) {
updatingSelection = true
try {
const restored = new ActiveSelection(restoredObjects, { canvas })
canvas.setActiveObject(restored)
canvas.requestRenderAll()
useCanvasStore.getState().setSelection(prevIds, prevIds[0] ?? null)
} finally {
updatingSelection = false
}
return
}
}
}
const ids = resolveIds(effectiveSelected)
useCanvasStore.getState().setSelection(ids, ids[0] ?? null)
// Correct Fabric's active object to match the depth-resolved target.
// Without this, Fabric keeps the deeply-nested child as active,
// showing selection handles on the wrong element.
if (ids.length === 0) return
const objects = canvas.getObjects() as FabricObjectWithPenId[]
updatingSelection = true
try {
if (ids.length === 1) {
const currentActive = effectiveSelected[0] as FabricObjectWithPenId
if (currentActive?.penNodeId !== ids[0]) {
const correctObj = objects.find((o) => o.penNodeId === ids[0])
if (correctObj) {
canvas.setActiveObject(correctObj)
canvas.requestRenderAll()
}
}
} else {
// Multi-select: build an ActiveSelection from the resolved objects
const resolvedSet = new Set(ids)
const resolvedObjs = objects.filter(
(o) => o.penNodeId && resolvedSet.has(o.penNodeId),
)
if (resolvedObjs.length > 1) {
const sel = new ActiveSelection(resolvedObjs, { canvas })
canvas.setActiveObject(sel)
canvas.requestRenderAll()
} else if (resolvedObjs.length === 1) {
canvas.setActiveObject(resolvedObjs[0])
canvas.requestRenderAll()
}
}
} finally {
updatingSelection = false
}
}
canvas.on('selection:created', handleSelection)
canvas.on('selection:updated', handleSelection)
canvas.on('selection:cleared', () => {
useCanvasStore.getState().clearSelection()
})
}, 100)
return () => clearInterval(interval)
}, [])
}

Some files were not shown because too many files have changed in this diff Show more