mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
V0.4.0 (#44)
* 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:
parent
c1587b1a55
commit
90bbcb16fd
164 changed files with 6989 additions and 7653 deletions
179
CLAUDE.md
179
CLAUDE.md
|
|
@ -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`
|
||||
|
|
|
|||
10
README.de.md
10
README.de.md
|
|
@ -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
|
||||
|
|
|
|||
10
README.es.md
10
README.es.md
|
|
@ -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
|
||||
|
|
|
|||
10
README.fr.md
10
README.fr.md
|
|
@ -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
|
||||
|
|
|
|||
10
README.hi.md
10
README.hi.md
|
|
@ -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 बाइनरी इम्पोर्ट पाइपलाइन
|
||||
|
|
|
|||
10
README.id.md
10
README.id.md
|
|
@ -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
|
||||
|
|
|
|||
10
README.ja.md
10
README.ja.md
|
|
@ -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/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 バイナリインポートパイプライン
|
||||
|
|
|
|||
10
README.ko.md
10
README.ko.md
|
|
@ -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 바이너리 가져오기 파이프라인
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -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
|
||||
|
|
|
|||
10
README.pt.md
10
README.pt.md
|
|
@ -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
|
||||
|
|
|
|||
10
README.ru.md
10
README.ru.md
|
|
@ -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
|
||||
|
|
|
|||
10
README.th.md
10
README.th.md
|
|
@ -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
|
||||
|
|
|
|||
10
README.tr.md
10
README.tr.md
|
|
@ -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
|
||||
|
|
|
|||
10
README.vi.md
10
README.vi.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/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 二進位檔案匯入管線
|
||||
|
|
|
|||
11
README.zh.md
11
README.zh.md
|
|
@ -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/Skia(WASM, 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 服务器工具
|
||||
|
|
|
|||
93
bun.lock
93
bun.lock
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
package.json
21
package.json
|
|
@ -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
BIN
public/canvaskit/canvaskit.wasm
Executable file
Binary file not shown.
BIN
public/fonts/dm-sans-400.woff2
Normal file
BIN
public/fonts/dm-sans-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/dm-sans-500.woff2
Normal file
BIN
public/fonts/dm-sans-500.woff2
Normal file
Binary file not shown.
BIN
public/fonts/dm-sans-700.woff2
Normal file
BIN
public/fonts/dm-sans-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-400.woff2
Normal file
BIN
public/fonts/inter-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-500.woff2
Normal file
BIN
public/fonts/inter-500.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-600.woff2
Normal file
BIN
public/fonts/inter-600.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-700.woff2
Normal file
BIN
public/fonts/inter-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-ext-400.woff2
Normal file
BIN
public/fonts/inter-ext-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-ext-500.woff2
Normal file
BIN
public/fonts/inter-ext-500.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-ext-600.woff2
Normal file
BIN
public/fonts/inter-ext-600.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-ext-700.woff2
Normal file
BIN
public/fonts/inter-ext-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-latin.woff2
Normal file
BIN
public/fonts/inter-latin.woff2
Normal file
Binary file not shown.
BIN
public/fonts/lato-400.woff2
Normal file
BIN
public/fonts/lato-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/lato-700.woff2
Normal file
BIN
public/fonts/lato-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/montserrat-400.woff2
Normal file
BIN
public/fonts/montserrat-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/montserrat-500.woff2
Normal file
BIN
public/fonts/montserrat-500.woff2
Normal file
Binary file not shown.
BIN
public/fonts/montserrat-600.woff2
Normal file
BIN
public/fonts/montserrat-600.woff2
Normal file
Binary file not shown.
BIN
public/fonts/montserrat-700.woff2
Normal file
BIN
public/fonts/montserrat-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/noto-sans-sc-400.woff2
Normal file
BIN
public/fonts/noto-sans-sc-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/noto-sans-sc-700.woff2
Normal file
BIN
public/fonts/noto-sans-sc-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/noto-sans-sc-latin-400.woff2
Normal file
BIN
public/fonts/noto-sans-sc-latin-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/noto-sans-sc-latin-700.woff2
Normal file
BIN
public/fonts/noto-sans-sc-latin-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/nunito-400.woff2
Normal file
BIN
public/fonts/nunito-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/nunito-600.woff2
Normal file
BIN
public/fonts/nunito-600.woff2
Normal file
Binary file not shown.
BIN
public/fonts/nunito-700.woff2
Normal file
BIN
public/fonts/nunito-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/open-sans-400.woff2
Normal file
BIN
public/fonts/open-sans-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/open-sans-600.woff2
Normal file
BIN
public/fonts/open-sans-600.woff2
Normal file
Binary file not shown.
BIN
public/fonts/open-sans-700.woff2
Normal file
BIN
public/fonts/open-sans-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/playfair-display-400.woff2
Normal file
BIN
public/fonts/playfair-display-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/playfair-display-700.woff2
Normal file
BIN
public/fonts/playfair-display-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/poppins-400.woff2
Normal file
BIN
public/fonts/poppins-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/poppins-500.woff2
Normal file
BIN
public/fonts/poppins-500.woff2
Normal file
Binary file not shown.
BIN
public/fonts/poppins-600.woff2
Normal file
BIN
public/fonts/poppins-600.woff2
Normal file
Binary file not shown.
BIN
public/fonts/poppins-700.woff2
Normal file
BIN
public/fonts/poppins-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/raleway-400.woff2
Normal file
BIN
public/fonts/raleway-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/raleway-500.woff2
Normal file
BIN
public/fonts/raleway-500.woff2
Normal file
Binary file not shown.
BIN
public/fonts/raleway-600.woff2
Normal file
BIN
public/fonts/raleway-600.woff2
Normal file
Binary file not shown.
BIN
public/fonts/raleway-700.woff2
Normal file
BIN
public/fonts/raleway-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/roboto-400.woff2
Normal file
BIN
public/fonts/roboto-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/roboto-500.woff2
Normal file
BIN
public/fonts/roboto-500.woff2
Normal file
Binary file not shown.
BIN
public/fonts/roboto-700.woff2
Normal file
BIN
public/fonts/roboto-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/source-sans-3-400.woff2
Normal file
BIN
public/fonts/source-sans-3-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/source-sans-3-600.woff2
Normal file
BIN
public/fonts/source-sans-3-600.woff2
Normal file
Binary file not shown.
BIN
public/fonts/source-sans-3-700.woff2
Normal file
BIN
public/fonts/source-sans-3-700.woff2
Normal file
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
58
src/canvas/skia-engine-ref.ts
Normal file
58
src/canvas/skia-engine-ref.ts
Normal 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
|
||||
}
|
||||
941
src/canvas/skia/skia-canvas.tsx
Normal file
941
src/canvas/skia/skia-canvas.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
742
src/canvas/skia/skia-engine.ts
Normal file
742
src/canvas/skia/skia-engine.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
362
src/canvas/skia/skia-font-manager.ts
Normal file
362
src/canvas/skia/skia-font-manager.ts
Normal 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))
|
||||
}
|
||||
89
src/canvas/skia/skia-hit-test.ts
Normal file
89
src/canvas/skia/skia-hit-test.ts
Normal 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
|
||||
}
|
||||
}
|
||||
93
src/canvas/skia/skia-image-loader.ts
Normal file
93
src/canvas/skia/skia-image-loader.ts
Normal 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
|
||||
}
|
||||
}
|
||||
35
src/canvas/skia/skia-init.ts
Normal file
35
src/canvas/skia/skia-init.ts
Normal 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
|
||||
}
|
||||
561
src/canvas/skia/skia-overlays.ts
Normal file
561
src/canvas/skia/skia-overlays.ts
Normal 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()
|
||||
}
|
||||
146
src/canvas/skia/skia-paint-utils.ts
Normal file
146
src/canvas/skia/skia-paint-utils.ts
Normal 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)
|
||||
}
|
||||
228
src/canvas/skia/skia-path-utils.ts
Normal file
228
src/canvas/skia/skia-path-utils.ts
Normal 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
|
||||
}
|
||||
}
|
||||
244
src/canvas/skia/skia-pen-tool.ts
Normal file
244
src/canvas/skia/skia-pen-tool.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
1199
src/canvas/skia/skia-renderer.ts
Normal file
1199
src/canvas/skia/skia-renderer.ts
Normal file
File diff suppressed because it is too large
Load diff
105
src/canvas/skia/skia-viewport.ts
Normal file
105
src/canvas/skia/skia-viewport.ts
Normal 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
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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)
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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)
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue