Migrate React → Vue 3 + VueUse + Reka UI
- Vue 3 SFC components with <script setup> - Reactive store (Vue reactivity) replacing React refs/state - Composables: useCanvas, useCanvasInput, useKeyboard - Components: EditorCanvas, LayersPanel, PropertiesPanel, Toolbar - Floating toolbar (Figma UI3 style) with pill shape + shadow - Properties panel with Design/Prototype tabs + zoom display - Sections: Position, Rotation, Layout, Appearance, Fill, Stroke, Effects, Export - CSS custom properties for theming (--panel-bg, --border, --accent, etc.) - Add Figma UI3 CDP inspection findings (toolbar, panels, sections)
This commit is contained in:
parent
ec90f35df8
commit
323a1f90c4
137
FIGMA_UI3_CDP_FINDINGS.md
Normal file
137
FIGMA_UI3_CDP_FINDINGS.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Figma UI3 Layout — CDP Inspection Findings
|
||||
|
||||
Inspected via CDP on Figma desktop app (Feb 2026). Window: 1487×901, DPR 2.
|
||||
|
||||
## Overall Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Left Panel (309px) │ Canvas (936px CSS, 1872px WebGL)│ Right Panel (241px) │
|
||||
│ │ │ │
|
||||
│ File name header │ Single <canvas> element │ Tabs: Design │ Proto│
|
||||
│ Tabs: File │ Assets │ class="gpu-view-content" │ Zoom: 100% button │
|
||||
│ Pages list │ No WebGL context exposed │ │
|
||||
│ Layers tree │ (Figma uses own GPU renderer) │ [Scroll container] │
|
||||
│ (309×804 scroll area) │ │ (240×820) │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────┐ │ │
|
||||
│ │ │ Toolbar (floating) │ │ │
|
||||
│ │ │ centered at bottom │ │ │
|
||||
│ │ └──────────────────────┘ │ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Toolbar (Bottom, Floating)
|
||||
|
||||
Three rows discovered at different y-positions. The main design toolbar is at y≈849.
|
||||
|
||||
### Main Toolbar Row (y≈849) — Design Mode
|
||||
| x | Tool | Flyout ▾ | Notes |
|
||||
|-----|------------|----------|-------|
|
||||
| 511 | Move | ▾ Move tools | Scale nested inside |
|
||||
| 568 | Frame | ▾ Region tools | Section, Slice nested |
|
||||
| 625 | Rectangle | ▾ Shape tools | Ellipse, Line, Star, Polygon, Arrow |
|
||||
| 682 | Pen | ▾ Creation tools | Pencil nested |
|
||||
| 739 | Text | — | |
|
||||
| 779 | Comment | ▾ Comment tools | |
|
||||
| 836 | Actions | — | AI/search menu (⚡) |
|
||||
|
||||
### View Toolbar Row (y≈801) — DevMode/Inspect
|
||||
| x | Tool |
|
||||
|-----|------|
|
||||
| 511 | Move |
|
||||
| 551 | Copy colors |
|
||||
| 591 | Measurement |
|
||||
| 631 | Annotation |
|
||||
| 671 | Comment |
|
||||
|
||||
### Tool button specs
|
||||
- Each button: 32×32px, class `toolbelt_button--topLevelButtonNew`
|
||||
- Flyout chevrons: 16×32px, class `tool_group--flyoutChevron`
|
||||
- Tool+chevron combined: 49×32px (32 icon + 16 chevron + 1px gap)
|
||||
- All buttons are `<button>` with `aria-label`
|
||||
|
||||
## Left Panel (309px)
|
||||
|
||||
```
|
||||
┌─ File name header (309×48) ──────────┐
|
||||
│ [Figma logo] [File actions ⋯] │
|
||||
├─ File browser toggle (309×44) ───────┤
|
||||
│ │
|
||||
├─ File name bar (309×53) ─────────────┤
|
||||
│ "Project name" + "File name" │
|
||||
├─ Tab strip (309×41) ─────────────────┤
|
||||
│ [File] [Assets] │
|
||||
├─ Pages section (309×234) ────────────┤
|
||||
│ Collapsible list of pages │
|
||||
├─ Layers tree (309×529) ──────────────┤
|
||||
│ Class: object_row--row │
|
||||
│ Each row: 309×32 │
|
||||
│ - 16×32 expand caret (if has children)│
|
||||
│ - Icon (node type) │
|
||||
│ - Name text │
|
||||
│ Virtual-scrolled │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Layer row class: `object_row--row--RUzis object_row--topLevel--SXq9o`
|
||||
Has `aria-label` on icons: Rectangle, Frame, Auto layout, Section, Instance
|
||||
|
||||
## Right Panel (241px)
|
||||
|
||||
### Header (above scroll, 240×48)
|
||||
- Tab strip: **Design** | **Prototype** (tabs__tabButton)
|
||||
- Zoom button: "100%" (60×24), clickable
|
||||
|
||||
### Properties Sections (with a Rectangle selected)
|
||||
Panel scroll area: 240×820, class `properties_panel--scrollContainer`
|
||||
|
||||
| y offset | Section | Content |
|
||||
|----------|---------|---------|
|
||||
| 0 | **Node header** | Type "Rectangle" + "Use as mask" toggle |
|
||||
| 49 | **Position** | Section label |
|
||||
| 89 | Alignment | 9-grid alignment buttons |
|
||||
| 121 | Position X/Y | Two number inputs (88px each) |
|
||||
| 153 | Rotation | Number input |
|
||||
| 198 | **Layout** | Section with "Dimensions" subsection |
|
||||
| 238 | Dimensions W/H | Two number inputs + lock aspect ratio |
|
||||
| 283 | **Appearance** | "Hide" toggle |
|
||||
| 368 | **Fill** | Color swatch + opacity input + visibility toggle |
|
||||
| 453 | **Stroke** | Section (collapsed when empty) |
|
||||
| 494 | **Effects** | Section (collapsed when empty) |
|
||||
| 535 | **Export** | Section |
|
||||
|
||||
### Section ordering (top to bottom)
|
||||
1. Node type + name + mask toggle
|
||||
2. Position (X, Y)
|
||||
3. Alignment (9-grid)
|
||||
4. Rotation
|
||||
5. Layout / Dimensions (W, H)
|
||||
6. Appearance (opacity, hide)
|
||||
7. Fill
|
||||
8. Stroke
|
||||
9. Effects
|
||||
10. Export
|
||||
|
||||
## Canvas
|
||||
|
||||
- Single `<canvas>` element, no id, class `(none)`
|
||||
- Parent: `div.view.gpu-view-content` with `inset: 0` (positioned absolutely)
|
||||
- Physical size: 1872×1802 (2× DPR)
|
||||
- CSS size: 936×901
|
||||
- WebGL context: **not exposed** (Figma uses its own GPU rendering, likely via CanvasKit/Skia internally but doesn't expose the GL context to JS)
|
||||
- Canvas receives all mouse/keyboard events
|
||||
|
||||
## CSS Variables
|
||||
- **18,395 CSS variables** defined (massive design system)
|
||||
- Figma uses CSS Modules with hashed class names (e.g., `toolbar--horizontalButton--lp3qk`)
|
||||
|
||||
## Key Findings for OpenPencil
|
||||
|
||||
1. **Toolbar is floating**, not pinned to window bottom edge — sits above canvas
|
||||
2. **Three toolbar rows** exist: view tools, design tools, devmode tools (context-dependent)
|
||||
3. **Tool groups with flyouts** — Move▾, Frame▾, Rectangle▾, Pen▾, Comment▾
|
||||
4. **Properties panel sections** are ordered differently than our plan: Position → Alignment → Rotation → Layout/Dimensions → Appearance → Fill → Stroke → Effects → Export
|
||||
5. **Zoom control** is in the right panel header, not the toolbar
|
||||
6. **Left panel** has File/Assets tabs, then Pages, then Layers
|
||||
7. **Canvas has no accessible WebGL context** — Figma's renderer is fully internal
|
||||
145
bun.lock
145
bun.lock
|
|
@ -7,18 +7,17 @@
|
|||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"kiwi-schema": "^0.5.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"reka-ui": "^2.8.2",
|
||||
"vue": "^3.5.29",
|
||||
"yoga-layout": "^3.2.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260227.1",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"oxfmt": "^0.35.0",
|
||||
"oxlint": "^1.50.0",
|
||||
"typescript": "~5.8.3",
|
||||
|
|
@ -30,42 +29,12 @@
|
|||
"kiwi-schema@0.5.0": "patches/kiwi-schema@0.5.0.patch",
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||
|
|
@ -120,16 +89,20 @@
|
|||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@floating-ui/vue": ["@floating-ui/vue@1.1.10", "", { "dependencies": { "@floating-ui/dom": "^1.7.5", "@floating-ui/utils": "^0.2.10", "vue-demi": ">=0.13.0" } }, "sha512-vdf8f6rHnFPPLRsmL4p12wYl+Ux4mOJOkjzKEMYVnwdf7UFdvBtHlLvQyx8iKG5vhPRbDRgZxdtpmyigDPjzYg=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q=="],
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.35.0", "", { "os": "android", "cpu": "arm" }, "sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA=="],
|
||||
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.35.0", "", { "os": "android", "cpu": "arm64" }, "sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ=="],
|
||||
|
|
@ -206,7 +179,7 @@
|
|||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.50.0", "", { "os": "win32", "cpu": "x64" }, "sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||
|
||||
|
|
@ -258,6 +231,12 @@
|
|||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.19", "", {}, "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g=="],
|
||||
|
||||
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.19", "", { "dependencies": { "@tanstack/virtual-core": "3.13.19" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-07Fp1TYuIziB4zIDA/moeDKHODePy3K1fN4c4VIAGnkxo1+uOvBJP7m54CoxKiQX6Q9a1dZnznrwOg9C86yvvA=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.0", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.0", "@tauri-apps/cli-darwin-x64": "2.10.0", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", "@tauri-apps/cli-linux-arm64-musl": "2.10.0", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-musl": "2.10.0", "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", "@tauri-apps/cli-win32-x64-msvc": "2.10.0" }, "bin": { "tauri": "tauri.js" } }, "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q=="],
|
||||
|
|
@ -286,19 +265,9 @@
|
|||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260227.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260227.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260227.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260227.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260227.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260227.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260227.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260227.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-MCQZuIj7mk152lHB0kcDpHZrPjfoEijPhhigE62cHtrxukheNy+beYt5rW44wxbiapsfJWsFbRPt+cB90M/A/A=="],
|
||||
|
||||
|
|
@ -316,51 +285,59 @@
|
|||
|
||||
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260227.1", "", { "os": "win32", "cpu": "x64" }, "sha512-T26qE0bTzw+rS17OVE+vnHdBBMeOo5veWg04rYRRtiTAXvGZyscq8/C7v1GfnVotpxWi6ckjJ112wn0gJspjnQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.29", "", { "dependencies": { "@vue/compiler-core": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.29", "@vue/compiler-dom": "3.5.29", "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.29", "", { "dependencies": { "@vue/shared": "3.5.29" } }, "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.29", "", { "dependencies": { "@vue/reactivity": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.29", "", { "dependencies": { "@vue/reactivity": "3.5.29", "@vue/runtime-core": "3.5.29", "@vue/shared": "3.5.29", "csstype": "^3.2.3" } }, "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.29", "", { "dependencies": { "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29" }, "peerDependencies": { "vue": "3.5.29" } }, "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@14.2.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.2.1", "@vueuse/shared": "14.2.1" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ=="],
|
||||
|
||||
"@vueuse/metadata": ["@vueuse/metadata@14.2.1", "", {}, "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw=="],
|
||||
|
||||
"@vueuse/shared": ["@vueuse/shared@14.2.1", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="],
|
||||
|
||||
"@webgpu/types": ["@webgpu/types@0.1.21", "", {}, "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"canvaskit-wasm": ["canvaskit-wasm@0.40.0", "", { "dependencies": { "@webgpu/types": "0.1.21" } }, "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="],
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"kiwi-schema": ["kiwi-schema@0.5.0", "", { "bin": { "kiwic": "cli.js" } }, "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"oxfmt": ["oxfmt@0.35.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.35.0", "@oxfmt/binding-android-arm64": "0.35.0", "@oxfmt/binding-darwin-arm64": "0.35.0", "@oxfmt/binding-darwin-x64": "0.35.0", "@oxfmt/binding-freebsd-x64": "0.35.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.35.0", "@oxfmt/binding-linux-arm-musleabihf": "0.35.0", "@oxfmt/binding-linux-arm64-gnu": "0.35.0", "@oxfmt/binding-linux-arm64-musl": "0.35.0", "@oxfmt/binding-linux-ppc64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-musl": "0.35.0", "@oxfmt/binding-linux-s390x-gnu": "0.35.0", "@oxfmt/binding-linux-x64-gnu": "0.35.0", "@oxfmt/binding-linux-x64-musl": "0.35.0", "@oxfmt/binding-openharmony-arm64": "0.35.0", "@oxfmt/binding-win32-arm64-msvc": "0.35.0", "@oxfmt/binding-win32-ia32-msvc": "0.35.0", "@oxfmt/binding-win32-x64-msvc": "0.35.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q=="],
|
||||
|
||||
|
|
@ -372,31 +349,25 @@
|
|||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
"reka-ui": ["reka-ui@2.8.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-8lTKcJhmG+D3UyJxhBnNnW/720sLzm0pbA9AC1MWazmJ5YchJAyTSl+O00xP/kxBmEN0fw5JqWVHguiFmsGjzA=="],
|
||||
|
||||
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
"vue": ["vue@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", "@vue/runtime-dom": "3.5.29", "@vue/server-renderer": "3.5.29", "@vue/shared": "3.5.29" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,11 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenPencil</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
14
oxlint.json
14
oxlint.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "react-hooks", "typescript", "import", "unicorn"],
|
||||
"plugins": ["typescript", "import", "unicorn"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2024": true
|
||||
|
|
@ -9,18 +9,6 @@
|
|||
"no-unused-vars": "warn",
|
||||
"no-console": "off",
|
||||
|
||||
"react/jsx-key": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/no-children-prop": "error",
|
||||
"react/no-danger-with-children": "error",
|
||||
"react/no-direct-mutation-state": "error",
|
||||
"react/no-string-refs": "error",
|
||||
"react/require-render-return": "error",
|
||||
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
|
||||
"typescript/no-explicit-any": "warn",
|
||||
"typescript/no-non-null-assertion": "warn",
|
||||
|
||||
|
|
|
|||
|
|
@ -16,18 +16,17 @@
|
|||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"kiwi-schema": "^0.5.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"reka-ui": "^2.8.2",
|
||||
"vue": "^3.5.29",
|
||||
"yoga-layout": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260227.1",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"oxfmt": "^0.35.0",
|
||||
"oxlint": "^1.50.0",
|
||||
"typescript": "~5.8.3",
|
||||
|
|
|
|||
731
src/App.tsx
731
src/App.tsx
|
|
@ -1,731 +0,0 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
import { getCanvasKit } from './engine/canvaskit'
|
||||
import { SkiaRenderer } from './engine/renderer'
|
||||
import { SceneGraph } from './engine/scene-graph'
|
||||
import { UndoManager } from './engine/undo'
|
||||
|
||||
import type { NodeType, Fill } from './engine/scene-graph'
|
||||
|
||||
type Tool = 'SELECT' | 'FRAME' | 'RECTANGLE' | 'ELLIPSE' | 'LINE'
|
||||
|
||||
const TOOL_SHORTCUTS: Record<string, Tool> = {
|
||||
v: 'SELECT',
|
||||
f: 'FRAME',
|
||||
r: 'RECTANGLE',
|
||||
o: 'ELLIPSE',
|
||||
l: 'LINE'
|
||||
}
|
||||
|
||||
const TOOL_COLORS: Record<string, { r: number; g: number; b: number; a: number }> = {
|
||||
FRAME: { r: 1, g: 1, b: 1, a: 1 },
|
||||
RECTANGLE: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
|
||||
ELLIPSE: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
|
||||
LINE: { r: 0, g: 0, b: 0, a: 1 }
|
||||
}
|
||||
|
||||
function App() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const graphRef = useRef(new SceneGraph())
|
||||
const rendererRef = useRef<SkiaRenderer | null>(null)
|
||||
const undoRef = useRef(new UndoManager())
|
||||
const [activeTool, setActiveTool] = useState<Tool>('SELECT')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [nodeCount, setNodeCount] = useState(0)
|
||||
const drawingRef = useRef<{
|
||||
startX: number
|
||||
startY: number
|
||||
nodeId: string
|
||||
} | null>(null)
|
||||
const panningRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(
|
||||
null
|
||||
)
|
||||
const movingRef = useRef<{
|
||||
startX: number
|
||||
startY: number
|
||||
originals: Map<string, { x: number; y: number }>
|
||||
} | null>(null)
|
||||
|
||||
const requestRender = useCallback(() => {
|
||||
const renderer = rendererRef.current
|
||||
if (!renderer) return
|
||||
renderer.render(graphRef.current, selectedIds)
|
||||
}, [selectedIds])
|
||||
|
||||
// Initialize CanvasKit
|
||||
useEffect(() => {
|
||||
let destroyed = false
|
||||
|
||||
async function init() {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ck = await getCanvasKit()
|
||||
if (destroyed) return
|
||||
|
||||
// Wait for layout to settle
|
||||
await new Promise((r) => requestAnimationFrame(r))
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = canvas.clientWidth
|
||||
const h = canvas.clientHeight
|
||||
console.log(`Canvas size: ${w}x${h}, DPR: ${dpr}`)
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
|
||||
const surface = ck.MakeWebGLCanvasSurface(canvas)
|
||||
if (!surface) {
|
||||
console.error('Failed to create WebGL surface')
|
||||
return
|
||||
}
|
||||
console.log('WebGL surface created successfully')
|
||||
|
||||
const renderer = new SkiaRenderer(ck, surface)
|
||||
rendererRef.current = renderer
|
||||
|
||||
// Create some demo shapes
|
||||
const graph = graphRef.current
|
||||
graph.createNode('FRAME', graph.rootId, {
|
||||
name: 'Desktop',
|
||||
x: 100,
|
||||
y: 80,
|
||||
width: 800,
|
||||
height: 500,
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
|
||||
strokes: [
|
||||
{
|
||||
color: { r: 0.87, g: 0.87, b: 0.87, a: 1 },
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'INSIDE'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
graph.createNode('RECTANGLE', graph.rootId, {
|
||||
name: 'Blue card',
|
||||
x: 150,
|
||||
y: 140,
|
||||
width: 240,
|
||||
height: 160,
|
||||
cornerRadius: 12,
|
||||
fills: [
|
||||
{ type: 'SOLID', color: { r: 0.23, g: 0.51, b: 0.96, a: 1 }, opacity: 1, visible: true }
|
||||
],
|
||||
effects: [
|
||||
{
|
||||
type: 'DROP_SHADOW',
|
||||
color: { r: 0, g: 0, b: 0, a: 0.15 },
|
||||
offset: { x: 0, y: 4 },
|
||||
radius: 12,
|
||||
spread: 0,
|
||||
visible: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
graph.createNode('ELLIPSE', graph.rootId, {
|
||||
name: 'Green circle',
|
||||
x: 440,
|
||||
y: 160,
|
||||
width: 120,
|
||||
height: 120,
|
||||
fills: [
|
||||
{ type: 'SOLID', color: { r: 0.13, g: 0.77, b: 0.42, a: 1 }, opacity: 1, visible: true }
|
||||
]
|
||||
})
|
||||
|
||||
graph.createNode('RECTANGLE', graph.rootId, {
|
||||
name: 'Orange rect',
|
||||
x: 620,
|
||||
y: 140,
|
||||
width: 200,
|
||||
height: 100,
|
||||
cornerRadius: 8,
|
||||
fills: [
|
||||
{ type: 'SOLID', color: { r: 0.96, g: 0.52, b: 0.13, a: 1 }, opacity: 1, visible: true }
|
||||
]
|
||||
})
|
||||
|
||||
graph.createNode('RECTANGLE', graph.rootId, {
|
||||
name: 'Purple pill',
|
||||
x: 150,
|
||||
y: 360,
|
||||
width: 300,
|
||||
height: 56,
|
||||
cornerRadius: 28,
|
||||
fills: [
|
||||
{ type: 'SOLID', color: { r: 0.55, g: 0.36, b: 0.96, a: 1 }, opacity: 1, visible: true }
|
||||
]
|
||||
})
|
||||
|
||||
setNodeCount(graph.nodes.size - 1)
|
||||
renderer.render(graph, new Set())
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
destroyed = true
|
||||
rendererRef.current?.destroy()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Re-render when selection changes
|
||||
useEffect(() => {
|
||||
requestRender()
|
||||
}, [selectedIds, requestRender])
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = canvas.clientWidth * dpr
|
||||
canvas.height = canvas.clientHeight * dpr
|
||||
requestRender()
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [requestRender])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement) return
|
||||
|
||||
const tool = TOOL_SHORTCUTS[e.key.toLowerCase()]
|
||||
if (tool) {
|
||||
setActiveTool(tool)
|
||||
return
|
||||
}
|
||||
|
||||
// Undo/Redo
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
undoRef.current.undo()
|
||||
requestRender()
|
||||
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
|
||||
e.preventDefault()
|
||||
undoRef.current.redo()
|
||||
requestRender()
|
||||
}
|
||||
}
|
||||
|
||||
// Delete
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
const graph = graphRef.current
|
||||
const undo = undoRef.current
|
||||
undo.beginBatch('Delete')
|
||||
for (const id of selectedIds) {
|
||||
const node = graph.getNode(id)
|
||||
if (!node) continue
|
||||
const snapshot = { ...node }
|
||||
const parentId = node.parentId ?? graphRef.current.rootId
|
||||
undo.apply({
|
||||
label: 'Delete',
|
||||
forward: () => graph.deleteNode(id),
|
||||
inverse: () => {
|
||||
const restored = graph.createNode(snapshot.type, parentId, snapshot)
|
||||
// ID won't match, but good enough for PoC
|
||||
void restored
|
||||
}
|
||||
})
|
||||
}
|
||||
undo.commitBatch()
|
||||
setSelectedIds(new Set())
|
||||
setNodeCount(graph.nodes.size - 1)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedIds, requestRender])
|
||||
|
||||
function handleMouseDown(e: React.MouseEvent<HTMLCanvasElement>) {
|
||||
const renderer = rendererRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!renderer || !canvas) return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const sx = e.clientX - rect.left
|
||||
const sy = e.clientY - rect.top
|
||||
const { x, y } = renderer.screenToCanvas(sx, sy)
|
||||
|
||||
// Space + click = pan
|
||||
if (e.button === 1 || (activeTool === 'SELECT' && e.altKey)) {
|
||||
panningRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
panX: renderer.panX,
|
||||
panY: renderer.panY
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTool === 'SELECT') {
|
||||
const hit = graphRef.current.hitTest(x, y)
|
||||
if (hit) {
|
||||
const newSelected = e.shiftKey
|
||||
? (() => {
|
||||
const s = new Set(selectedIds)
|
||||
if (s.has(hit.id)) s.delete(hit.id)
|
||||
else s.add(hit.id)
|
||||
return s
|
||||
})()
|
||||
: new Set([hit.id])
|
||||
setSelectedIds(newSelected)
|
||||
|
||||
// Start moving
|
||||
const originals = new Map<string, { x: number; y: number }>()
|
||||
for (const id of newSelected) {
|
||||
const n = graphRef.current.getNode(id)
|
||||
if (n) originals.set(id, { x: n.x, y: n.y })
|
||||
}
|
||||
movingRef.current = { startX: x, startY: y, originals }
|
||||
} else {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Shape creation tools
|
||||
const typeMap: Record<string, NodeType> = {
|
||||
FRAME: 'FRAME',
|
||||
RECTANGLE: 'RECTANGLE',
|
||||
ELLIPSE: 'ELLIPSE',
|
||||
LINE: 'LINE'
|
||||
}
|
||||
const nodeType = typeMap[activeTool]
|
||||
if (!nodeType) return
|
||||
|
||||
const fill: Fill = {
|
||||
type: 'SOLID',
|
||||
color: TOOL_COLORS[activeTool] ?? { r: 0.83, g: 0.83, b: 0.83, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true
|
||||
}
|
||||
|
||||
const node = graphRef.current.createNode(nodeType, graphRef.current.rootId, {
|
||||
x,
|
||||
y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fills: [fill]
|
||||
})
|
||||
|
||||
drawingRef.current = { startX: x, startY: y, nodeId: node.id }
|
||||
setSelectedIds(new Set([node.id]))
|
||||
setNodeCount(graphRef.current.nodes.size - 1)
|
||||
}
|
||||
|
||||
function handleMouseMove(e: React.MouseEvent<HTMLCanvasElement>) {
|
||||
const renderer = rendererRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!renderer || !canvas) return
|
||||
|
||||
// Pan
|
||||
if (panningRef.current) {
|
||||
const dx = e.clientX - panningRef.current.startX
|
||||
const dy = e.clientY - panningRef.current.startY
|
||||
renderer.panX = panningRef.current.panX + dx
|
||||
renderer.panY = panningRef.current.panY + dy
|
||||
requestRender()
|
||||
return
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const sx = e.clientX - rect.left
|
||||
const sy = e.clientY - rect.top
|
||||
const { x, y } = renderer.screenToCanvas(sx, sy)
|
||||
|
||||
// Move selected nodes
|
||||
if (movingRef.current) {
|
||||
const dx = x - movingRef.current.startX
|
||||
const dy = y - movingRef.current.startY
|
||||
for (const [id, orig] of movingRef.current.originals) {
|
||||
graphRef.current.updateNode(id, { x: orig.x + dx, y: orig.y + dy })
|
||||
}
|
||||
requestRender()
|
||||
return
|
||||
}
|
||||
|
||||
// Draw shape
|
||||
if (drawingRef.current) {
|
||||
const { startX, startY, nodeId } = drawingRef.current
|
||||
const w = x - startX
|
||||
const h = y - startY
|
||||
graphRef.current.updateNode(nodeId, {
|
||||
x: w < 0 ? x : startX,
|
||||
y: h < 0 ? y : startY,
|
||||
width: Math.abs(w),
|
||||
height: Math.abs(h)
|
||||
})
|
||||
requestRender()
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
// Commit move to undo stack
|
||||
if (movingRef.current) {
|
||||
const graph = graphRef.current
|
||||
const undo = undoRef.current
|
||||
const originals = movingRef.current.originals
|
||||
const finals = new Map<string, { x: number; y: number }>()
|
||||
for (const [id] of originals) {
|
||||
const n = graph.getNode(id)
|
||||
if (n) finals.set(id, { x: n.x, y: n.y })
|
||||
}
|
||||
undo.apply({
|
||||
label: 'Move',
|
||||
forward: () => {
|
||||
for (const [id, pos] of finals) graph.updateNode(id, pos)
|
||||
},
|
||||
inverse: () => {
|
||||
for (const [id, pos] of originals) graph.updateNode(id, pos)
|
||||
}
|
||||
})
|
||||
movingRef.current = null
|
||||
}
|
||||
|
||||
// Commit shape drawing
|
||||
if (drawingRef.current) {
|
||||
const { nodeId } = drawingRef.current
|
||||
const node = graphRef.current.getNode(nodeId)
|
||||
if (node && node.width < 2 && node.height < 2) {
|
||||
// Too small — set default size
|
||||
graphRef.current.updateNode(nodeId, { width: 100, height: 100 })
|
||||
requestRender()
|
||||
}
|
||||
drawingRef.current = null
|
||||
setActiveTool('SELECT')
|
||||
}
|
||||
|
||||
panningRef.current = null
|
||||
}
|
||||
|
||||
function handleWheel(e: React.WheelEvent<HTMLCanvasElement>) {
|
||||
const renderer = rendererRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!renderer || !canvas) return
|
||||
e.preventDefault()
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Zoom
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const sx = e.clientX - rect.left
|
||||
const sy = e.clientY - rect.top
|
||||
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9
|
||||
const newZoom = Math.max(0.1, Math.min(10, renderer.zoom * zoomFactor))
|
||||
|
||||
// Zoom toward cursor
|
||||
renderer.panX = sx - (sx - renderer.panX) * (newZoom / renderer.zoom)
|
||||
renderer.panY = sy - (sy - renderer.panY) * (newZoom / renderer.zoom)
|
||||
renderer.zoom = newZoom
|
||||
} else {
|
||||
// Pan
|
||||
renderer.panX -= e.deltaX
|
||||
renderer.panY -= e.deltaY
|
||||
}
|
||||
requestRender()
|
||||
}
|
||||
|
||||
const tools: { key: Tool; label: string; shortcut: string }[] = [
|
||||
{ key: 'SELECT', label: '▶ Select', shortcut: 'V' },
|
||||
{ key: 'FRAME', label: '# Frame', shortcut: 'F' },
|
||||
{ key: 'RECTANGLE', label: '□ Rect', shortcut: 'R' },
|
||||
{ key: 'ELLIPSE', label: '○ Ellipse', shortcut: 'O' },
|
||||
{ key: 'LINE', label: '/ Line', shortcut: 'L' }
|
||||
]
|
||||
|
||||
const selectedNode =
|
||||
selectedIds.size === 1 ? graphRef.current.getNode([...selectedIds][0]) : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#1e1e1e',
|
||||
color: '#e0e0e0',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize: 13,
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
{/* Canvas */}
|
||||
<div style={{ flex: 1, position: 'relative', display: 'flex' }}>
|
||||
{/* Left panel — Layers */}
|
||||
<div
|
||||
style={{
|
||||
width: 240,
|
||||
background: '#2a2a2a',
|
||||
borderRight: '1px solid #3a3a3a',
|
||||
overflowY: 'auto',
|
||||
padding: '8px 0'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: 11,
|
||||
color: '#888',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1
|
||||
}}
|
||||
>
|
||||
Layers
|
||||
</div>
|
||||
{graphRef.current.getChildren(graphRef.current.rootId).map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
onClick={() => setSelectedIds(new Set([node.id]))}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
background: selectedIds.has(node.id) ? '#3b82f6' : 'transparent',
|
||||
borderRadius: 4,
|
||||
margin: '1px 4px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{node.type === 'ELLIPSE' ? '○' : node.type === 'FRAME' ? '🔲' : '□'} {node.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: '#888' }}>{node.id}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'block',
|
||||
cursor: activeTool === 'SELECT' ? 'default' : 'crosshair',
|
||||
minWidth: 0,
|
||||
minHeight: 0
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
/>
|
||||
|
||||
{/* Right panel — Properties */}
|
||||
<div
|
||||
style={{
|
||||
width: 260,
|
||||
background: '#2a2a2a',
|
||||
borderLeft: '1px solid #3a3a3a',
|
||||
overflowY: 'auto',
|
||||
padding: '8px 0'
|
||||
}}
|
||||
>
|
||||
{selectedNode ? (
|
||||
<>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid #3a3a3a' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{selectedNode.name}</div>
|
||||
<div style={{ fontSize: 11, color: '#888' }}>
|
||||
{selectedNode.type} · {selectedNode.id}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>Appearance</div>
|
||||
<div
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, fontSize: 12 }}
|
||||
>
|
||||
<label>
|
||||
W:{' '}
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(selectedNode.width)}
|
||||
onChange={(e) => {
|
||||
graphRef.current.updateNode(selectedNode.id, { width: +e.target.value })
|
||||
requestRender()
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
H:{' '}
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(selectedNode.height)}
|
||||
onChange={(e) => {
|
||||
graphRef.current.updateNode(selectedNode.id, { height: +e.target.value })
|
||||
requestRender()
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
X:{' '}
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(selectedNode.x)}
|
||||
onChange={(e) => {
|
||||
graphRef.current.updateNode(selectedNode.id, { x: +e.target.value })
|
||||
requestRender()
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Y:{' '}
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(selectedNode.y)}
|
||||
onChange={(e) => {
|
||||
graphRef.current.updateNode(selectedNode.id, { y: +e.target.value })
|
||||
requestRender()
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
R:{' '}
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(selectedNode.rotation)}
|
||||
onChange={(e) => {
|
||||
graphRef.current.updateNode(selectedNode.id, { rotation: +e.target.value })
|
||||
requestRender()
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
°
|
||||
</label>
|
||||
<label>
|
||||
↻:{' '}
|
||||
<input
|
||||
type="number"
|
||||
value={selectedNode.cornerRadius}
|
||||
onChange={(e) => {
|
||||
graphRef.current.updateNode(selectedNode.id, {
|
||||
cornerRadius: +e.target.value
|
||||
})
|
||||
requestRender()
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px', borderTop: '1px solid #3a3a3a' }}>
|
||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>Fill</div>
|
||||
{selectedNode.fills.map((fill, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
background: `rgba(${fill.color.r * 255}, ${fill.color.g * 255}, ${fill.color.b * 255}, ${fill.color.a})`,
|
||||
border: '1px solid #555'
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 12 }}>
|
||||
#
|
||||
{Math.round(fill.color.r * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')}
|
||||
{Math.round(fill.color.g * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')}
|
||||
{Math.round(fill.color.b * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px', borderTop: '1px solid #3a3a3a' }}>
|
||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>Opacity</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedNode.opacity * 100}
|
||||
onChange={(e) => {
|
||||
graphRef.current.updateNode(selectedNode.id, { opacity: +e.target.value / 100 })
|
||||
requestRender()
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '12px', color: '#666' }}>No selection</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div
|
||||
style={{
|
||||
height: 48,
|
||||
background: '#2a2a2a',
|
||||
borderTop: '1px solid #3a3a3a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
padding: '0 16px'
|
||||
}}
|
||||
>
|
||||
{tools.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setActiveTool(t.key)}
|
||||
title={`${t.label} (${t.shortcut})`}
|
||||
style={{
|
||||
background: activeTool === t.key ? '#3b82f6' : 'transparent',
|
||||
color: activeTool === t.key ? '#fff' : '#ccc',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
padding: '6px 14px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontFamily: 'inherit',
|
||||
transition: 'background 0.1s'
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<div style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 11, color: '#666' }}>
|
||||
{nodeCount} nodes · Zoom:{' '}
|
||||
{rendererRef.current ? `${Math.round(rendererRef.current.zoom * 100)}%` : '100%'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: 56,
|
||||
background: '#1e1e1e',
|
||||
border: '1px solid #444',
|
||||
borderRadius: 4,
|
||||
color: '#e0e0e0',
|
||||
padding: '2px 4px',
|
||||
fontSize: 12,
|
||||
marginLeft: 4
|
||||
}
|
||||
|
||||
export default App
|
||||
154
src/App.vue
Normal file
154
src/App.vue
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<script setup lang="ts">
|
||||
import { useKeyboard } from './composables/use-keyboard'
|
||||
import { provideEditorStore } from './stores/editor'
|
||||
|
||||
import EditorCanvas from './components/EditorCanvas.vue'
|
||||
import LayersPanel from './components/LayersPanel.vue'
|
||||
import PropertiesPanel from './components/PropertiesPanel.vue'
|
||||
import Toolbar from './components/Toolbar.vue'
|
||||
|
||||
const store = provideEditorStore()
|
||||
useKeyboard(store)
|
||||
|
||||
// Demo shapes
|
||||
store.createShape('FRAME', 100, 80, 800, 500)
|
||||
store.graph.updateNode(store.graph.getChildren(store.graph.rootId)[0].id, {
|
||||
name: 'Desktop',
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
|
||||
strokes: [
|
||||
{
|
||||
color: { r: 0.87, g: 0.87, b: 0.87, a: 1 },
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'INSIDE'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const demo = [
|
||||
{
|
||||
type: 'RECTANGLE' as const,
|
||||
name: 'Blue card',
|
||||
x: 150,
|
||||
y: 140,
|
||||
w: 240,
|
||||
h: 160,
|
||||
color: { r: 0.23, g: 0.51, b: 0.96, a: 1 },
|
||||
radius: 12
|
||||
},
|
||||
{
|
||||
type: 'ELLIPSE' as const,
|
||||
name: 'Green circle',
|
||||
x: 440,
|
||||
y: 160,
|
||||
w: 120,
|
||||
h: 120,
|
||||
color: { r: 0.13, g: 0.77, b: 0.42, a: 1 }
|
||||
},
|
||||
{
|
||||
type: 'RECTANGLE' as const,
|
||||
name: 'Orange rect',
|
||||
x: 620,
|
||||
y: 140,
|
||||
w: 200,
|
||||
h: 100,
|
||||
color: { r: 0.96, g: 0.52, b: 0.13, a: 1 },
|
||||
radius: 8
|
||||
},
|
||||
{
|
||||
type: 'RECTANGLE' as const,
|
||||
name: 'Purple pill',
|
||||
x: 150,
|
||||
y: 360,
|
||||
w: 300,
|
||||
h: 56,
|
||||
color: { r: 0.55, g: 0.36, b: 0.96, a: 1 },
|
||||
radius: 28
|
||||
}
|
||||
]
|
||||
|
||||
for (const d of demo) {
|
||||
const id = store.createShape(d.type, d.x, d.y, d.w, d.h)
|
||||
store.graph.updateNode(id, {
|
||||
name: d.name,
|
||||
cornerRadius: d.radius ?? 0,
|
||||
fills: [{ type: 'SOLID', color: d.color, opacity: 1, visible: true }]
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor">
|
||||
<div class="editor-main">
|
||||
<LayersPanel />
|
||||
<div class="canvas-area">
|
||||
<EditorCanvas />
|
||||
<Toolbar />
|
||||
</div>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--panel-bg: #2a2a2a;
|
||||
--canvas-bg: #1e1e1e;
|
||||
--border: #3a3a3a;
|
||||
--hover: #353535;
|
||||
--accent: #3b82f6;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--input-bg: #1e1e1e;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
Inter,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--canvas-bg);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.editor {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
41
src/components/EditorCanvas.vue
Normal file
41
src/components/EditorCanvas.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
import { useCanvas } from '../composables/use-canvas'
|
||||
import { useCanvasInput } from '../composables/use-canvas-input'
|
||||
import { useEditorStore } from '../stores/editor'
|
||||
|
||||
const store = useEditorStore()
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
useCanvas(canvasRef, store)
|
||||
const { onMouseDown, onMouseMove, onMouseUp } = useCanvasInput(canvasRef, store)
|
||||
|
||||
const cursor = computed(() => {
|
||||
const tool = store.state.activeTool
|
||||
if (tool === 'HAND') return 'grab'
|
||||
if (tool === 'SELECT') return 'default'
|
||||
return 'crosshair'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
:style="{ cursor }"
|
||||
class="editor-canvas"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseUp"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-canvas {
|
||||
flex: 1;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
102
src/components/LayersPanel.vue
Normal file
102
src/components/LayersPanel.vue
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<script setup lang="ts">
|
||||
import { useEditorStore } from '../stores/editor'
|
||||
|
||||
const store = useEditorStore()
|
||||
|
||||
function nodeIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'ELLIPSE':
|
||||
return '○'
|
||||
case 'FRAME':
|
||||
return '⊞'
|
||||
case 'LINE':
|
||||
return '╱'
|
||||
case 'TEXT':
|
||||
return 'T'
|
||||
default:
|
||||
return '□'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="layers-panel">
|
||||
<header class="panel-section-label">Layers</header>
|
||||
<div class="layers-list">
|
||||
<button
|
||||
v-for="node in store.layerNodes.value"
|
||||
:key="node.id"
|
||||
class="layer-row"
|
||||
:class="{ selected: store.state.selectedIds.has(node.id) }"
|
||||
@click="store.select([node.id])"
|
||||
>
|
||||
<span class="layer-icon">{{ nodeIcon(node.type) }}</span>
|
||||
<span class="layer-name">{{ node.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layers-panel {
|
||||
width: 240px;
|
||||
background: var(--panel-bg);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-section-label {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layers-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.layer-row:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.layer-row.selected {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
315
src/components/PropertiesPanel.vue
Normal file
315
src/components/PropertiesPanel.vue
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
<script setup lang="ts">
|
||||
import { useEditorStore } from '../stores/editor'
|
||||
|
||||
const store = useEditorStore()
|
||||
|
||||
function updateProp(key: string, value: number) {
|
||||
const node = store.selectedNode.value
|
||||
if (!node) return
|
||||
store.updateNode(node.id, { [key]: value })
|
||||
}
|
||||
|
||||
function colorHex(c: { r: number; g: number; b: number }) {
|
||||
const hex = (v: number) =>
|
||||
Math.round(v * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`
|
||||
}
|
||||
|
||||
function colorRgba(c: { r: number; g: number; b: number; a: number }) {
|
||||
return `rgba(${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}, ${c.a})`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="properties-panel">
|
||||
<!-- Tabs -->
|
||||
<div class="panel-tabs">
|
||||
<button class="tab active">Design</button>
|
||||
<button class="tab">Prototype</button>
|
||||
<span class="zoom-display">{{ Math.round(store.state.zoom * 100) }}%</span>
|
||||
</div>
|
||||
|
||||
<div v-if="store.selectedNode.value" class="panel-scroll">
|
||||
<!-- Node header -->
|
||||
<div class="section node-header">
|
||||
<span class="node-type">{{ store.selectedNode.value.type }}</span>
|
||||
<span class="node-name">{{ store.selectedNode.value.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Position -->
|
||||
<div class="section">
|
||||
<label class="section-label">Position</label>
|
||||
<div class="input-row">
|
||||
<label class="prop-input">
|
||||
<span class="prop-label">X</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="Math.round(store.selectedNode.value.x)"
|
||||
@change="updateProp('x', +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<label class="prop-input">
|
||||
<span class="prop-label">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="Math.round(store.selectedNode.value.y)"
|
||||
@change="updateProp('y', +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rotation -->
|
||||
<div class="section">
|
||||
<div class="input-row">
|
||||
<label class="prop-input">
|
||||
<span class="prop-label">R</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="Math.round(store.selectedNode.value.rotation)"
|
||||
@change="updateProp('rotation', +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dimensions -->
|
||||
<div class="section">
|
||||
<label class="section-label">Layout</label>
|
||||
<div class="input-row">
|
||||
<label class="prop-input">
|
||||
<span class="prop-label">W</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="Math.round(store.selectedNode.value.width)"
|
||||
@change="updateProp('width', +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<label class="prop-input">
|
||||
<span class="prop-label">H</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="Math.round(store.selectedNode.value.height)"
|
||||
@change="updateProp('height', +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<label class="prop-input">
|
||||
<span class="prop-label">↻</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="store.selectedNode.value.cornerRadius"
|
||||
@change="updateProp('cornerRadius', +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance -->
|
||||
<div class="section">
|
||||
<label class="section-label">Appearance</label>
|
||||
<div class="input-row">
|
||||
<label class="prop-input full">
|
||||
<span class="prop-label">Opacity</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="store.selectedNode.value.opacity * 100"
|
||||
@input="updateProp('opacity', +($event.target as HTMLInputElement).value / 100)"
|
||||
/>
|
||||
<span class="prop-value"
|
||||
>{{ Math.round(store.selectedNode.value.opacity * 100) }}%</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fill -->
|
||||
<div class="section">
|
||||
<label class="section-label">Fill</label>
|
||||
<div v-for="(fill, i) in store.selectedNode.value.fills" :key="i" class="fill-row">
|
||||
<div class="color-swatch" :style="{ background: colorRgba(fill.color) }" />
|
||||
<span class="color-hex">{{ colorHex(fill.color) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stroke -->
|
||||
<div class="section">
|
||||
<label class="section-label">Stroke</label>
|
||||
</div>
|
||||
|
||||
<!-- Effects -->
|
||||
<div class="section">
|
||||
<label class="section-label">Effects</label>
|
||||
</div>
|
||||
|
||||
<!-- Export -->
|
||||
<div class="section">
|
||||
<label class="section-label">Export</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="panel-empty">No selection</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.properties-panel {
|
||||
width: 241px;
|
||||
background: var(--panel-bg);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.zoom-display {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.zoom-display:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.panel-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel-empty {
|
||||
padding: 16px 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.node-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.prop-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prop-input.full {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.prop-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prop-input input[type='number'] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
padding: 3px 6px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prop-input input[type='range'] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prop-value {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fill-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
75
src/components/Toolbar.vue
Normal file
75
src/components/Toolbar.vue
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<script setup lang="ts">
|
||||
import { TOOLS, useEditorStore } from '../stores/editor'
|
||||
|
||||
const store = useEditorStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-tools">
|
||||
<button
|
||||
v-for="tool in TOOLS"
|
||||
:key="tool.key"
|
||||
class="tool-button"
|
||||
:class="{ active: store.state.activeTool === tool.key }"
|
||||
:title="`${tool.label} (${tool.shortcut})`"
|
||||
@click="store.setTool(tool.key)"
|
||||
>
|
||||
<span class="tool-icon">{{ tool.icon }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.toolbar-tools {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.tool-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
transition:
|
||||
background 0.1s,
|
||||
color 0.1s;
|
||||
}
|
||||
|
||||
.tool-button:hover {
|
||||
background: var(--hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tool-button.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
201
src/composables/use-canvas-input.ts
Normal file
201
src/composables/use-canvas-input.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
||||
|
||||
import type { NodeType } from '../engine/scene-graph'
|
||||
import type { EditorStore, Tool } from '../stores/editor'
|
||||
|
||||
interface DragState {
|
||||
type: 'draw' | 'move' | 'pan'
|
||||
startScreenX: number
|
||||
startScreenY: number
|
||||
startCanvasX: number
|
||||
startCanvasY: number
|
||||
nodeId?: string
|
||||
originals?: Map<string, { x: number; y: number }>
|
||||
startPanX?: number
|
||||
startPanY?: number
|
||||
}
|
||||
|
||||
const TOOL_TO_NODE: Partial<Record<Tool, NodeType>> = {
|
||||
FRAME: 'FRAME',
|
||||
RECTANGLE: 'RECTANGLE',
|
||||
ELLIPSE: 'ELLIPSE',
|
||||
LINE: 'LINE'
|
||||
}
|
||||
|
||||
export function useCanvasInput(canvasRef: Ref<HTMLCanvasElement | null>, store: EditorStore) {
|
||||
const drag = ref<DragState | null>(null)
|
||||
|
||||
function getCanvasCoords(e: MouseEvent) {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return { sx: 0, sy: 0, cx: 0, cy: 0 }
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const sx = e.clientX - rect.left
|
||||
const sy = e.clientY - rect.top
|
||||
const { x: cx, y: cy } = store.screenToCanvas(sx, sy)
|
||||
return { sx, sy, cx, cy }
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
const { sx, sy, cx, cy } = getCanvasCoords(e)
|
||||
const tool = store.state.activeTool
|
||||
|
||||
// Middle mouse or Hand tool or space → pan
|
||||
if (e.button === 1 || tool === 'HAND') {
|
||||
drag.value = {
|
||||
type: 'pan',
|
||||
startScreenX: e.clientX,
|
||||
startScreenY: e.clientY,
|
||||
startCanvasX: cx,
|
||||
startCanvasY: cy,
|
||||
startPanX: store.state.panX,
|
||||
startPanY: store.state.panY
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Alt+click → pan
|
||||
if (tool === 'SELECT' && e.altKey) {
|
||||
drag.value = {
|
||||
type: 'pan',
|
||||
startScreenX: e.clientX,
|
||||
startScreenY: e.clientY,
|
||||
startCanvasX: cx,
|
||||
startCanvasY: cy,
|
||||
startPanX: store.state.panX,
|
||||
startPanY: store.state.panY
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (tool === 'SELECT') {
|
||||
const hit = store.graph.hitTest(cx, cy)
|
||||
if (hit) {
|
||||
store.select([hit.id], e.shiftKey)
|
||||
|
||||
const originals = new Map<string, { x: number; y: number }>()
|
||||
const ids = e.shiftKey ? store.state.selectedIds : new Set([hit.id])
|
||||
for (const id of ids) {
|
||||
const n = store.graph.getNode(id)
|
||||
if (n) originals.set(id, { x: n.x, y: n.y })
|
||||
}
|
||||
|
||||
drag.value = {
|
||||
type: 'move',
|
||||
startScreenX: sx,
|
||||
startScreenY: sy,
|
||||
startCanvasX: cx,
|
||||
startCanvasY: cy,
|
||||
originals
|
||||
}
|
||||
} else {
|
||||
store.clearSelection()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Shape creation
|
||||
const nodeType = TOOL_TO_NODE[tool]
|
||||
if (!nodeType) return
|
||||
|
||||
const nodeId = store.createShape(nodeType, cx, cy, 0, 0)
|
||||
store.select([nodeId])
|
||||
|
||||
drag.value = {
|
||||
type: 'draw',
|
||||
startScreenX: sx,
|
||||
startScreenY: sy,
|
||||
startCanvasX: cx,
|
||||
startCanvasY: cy,
|
||||
nodeId
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!drag.value) return
|
||||
const d = drag.value
|
||||
|
||||
if (d.type === 'pan') {
|
||||
const dx = e.clientX - d.startScreenX
|
||||
const dy = e.clientY - d.startScreenY
|
||||
store.state.panX = (d.startPanX ?? 0) + dx
|
||||
store.state.panY = (d.startPanY ?? 0) + dy
|
||||
store.requestRender()
|
||||
return
|
||||
}
|
||||
|
||||
const { cx, cy } = getCanvasCoords(e)
|
||||
|
||||
if (d.type === 'move' && d.originals) {
|
||||
const dx = cx - d.startCanvasX
|
||||
const dy = cy - d.startCanvasY
|
||||
for (const [id, orig] of d.originals) {
|
||||
store.updateNode(id, { x: orig.x + dx, y: orig.y + dy })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (d.type === 'draw' && d.nodeId) {
|
||||
const w = cx - d.startCanvasX
|
||||
const h = cy - d.startCanvasY
|
||||
store.updateNode(d.nodeId, {
|
||||
x: w < 0 ? cx : d.startCanvasX,
|
||||
y: h < 0 ? cy : d.startCanvasY,
|
||||
width: Math.abs(w),
|
||||
height: Math.abs(h)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
if (!drag.value) return
|
||||
const d = drag.value
|
||||
|
||||
if (d.type === 'move' && d.originals) {
|
||||
store.commitMove(d.originals)
|
||||
}
|
||||
|
||||
if (d.type === 'draw' && d.nodeId) {
|
||||
const node = store.graph.getNode(d.nodeId)
|
||||
if (node && node.width < 2 && node.height < 2) {
|
||||
store.updateNode(d.nodeId, { width: 100, height: 100 })
|
||||
}
|
||||
store.setTool('SELECT')
|
||||
}
|
||||
|
||||
drag.value = null
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const sx = e.clientX - rect.left
|
||||
const sy = e.clientY - rect.top
|
||||
store.applyZoom(e.deltaY, sx, sy)
|
||||
} else {
|
||||
store.pan(-e.deltaX, -e.deltaY)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
canvas.removeEventListener('wheel', onWheel)
|
||||
})
|
||||
|
||||
return {
|
||||
drag,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp
|
||||
}
|
||||
}
|
||||
78
src/composables/use-canvas.ts
Normal file
78
src/composables/use-canvas.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { onMounted, onUnmounted, watch, type Ref } from 'vue'
|
||||
|
||||
import { getCanvasKit } from '../engine/canvaskit'
|
||||
import { SkiaRenderer } from '../engine/renderer'
|
||||
|
||||
import type { EditorStore } from '../stores/editor'
|
||||
|
||||
export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: EditorStore) {
|
||||
let renderer: SkiaRenderer | null = null
|
||||
let destroyed = false
|
||||
|
||||
async function init() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas || destroyed) return
|
||||
|
||||
const ck = await getCanvasKit()
|
||||
if (destroyed) return
|
||||
|
||||
await new Promise((r) => requestAnimationFrame(r))
|
||||
resizeCanvas(canvas)
|
||||
|
||||
const surface = ck.MakeWebGLCanvasSurface(canvas)
|
||||
if (!surface) {
|
||||
console.error('Failed to create WebGL surface')
|
||||
return
|
||||
}
|
||||
|
||||
renderer = new SkiaRenderer(ck, surface)
|
||||
render()
|
||||
}
|
||||
|
||||
function resizeCanvas(canvas: HTMLCanvasElement) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = canvas.clientWidth
|
||||
const h = canvas.clientHeight
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!renderer) return
|
||||
renderer.panX = store.state.panX
|
||||
renderer.panY = store.state.panY
|
||||
renderer.zoom = store.state.zoom
|
||||
renderer.render(store.graph, store.state.selectedIds)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyed = true
|
||||
renderer?.destroy()
|
||||
})
|
||||
|
||||
useResizeObserver(canvasRef, () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas || !renderer) return
|
||||
resizeCanvas(canvas)
|
||||
render()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => store.state.renderVersion,
|
||||
() => render()
|
||||
)
|
||||
|
||||
watch(
|
||||
() => store.state.selectedIds,
|
||||
() => render()
|
||||
)
|
||||
|
||||
return { render }
|
||||
}
|
||||
42
src/composables/use-keyboard.ts
Normal file
42
src/composables/use-keyboard.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import { TOOL_SHORTCUTS } from '../stores/editor'
|
||||
|
||||
import type { EditorStore } from '../stores/editor'
|
||||
|
||||
export function useKeyboard(store: EditorStore) {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
|
||||
const tool = TOOL_SHORTCUTS[e.key.toLowerCase()]
|
||||
if (tool) {
|
||||
store.setTool(tool)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
store.undoAction()
|
||||
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
|
||||
e.preventDefault()
|
||||
store.redoAction()
|
||||
} else if (e.key === '0') {
|
||||
e.preventDefault()
|
||||
store.zoomToFit()
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
store.deleteSelected()
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
store.clearSelection()
|
||||
store.setTool('SELECT')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKeyDown))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKeyDown))
|
||||
}
|
||||
7
src/env.d.ts
vendored
Normal file
7
src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
10
src/main.tsx
10
src/main.tsx
|
|
@ -1,10 +0,0 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
271
src/stores/editor.ts
Normal file
271
src/stores/editor.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { reactive, shallowRef, computed } from 'vue'
|
||||
|
||||
import { SceneGraph } from '../engine/scene-graph'
|
||||
import { UndoManager } from '../engine/undo'
|
||||
|
||||
import type { SceneNode, NodeType, Fill } from '../engine/scene-graph'
|
||||
|
||||
export type Tool = 'SELECT' | 'FRAME' | 'RECTANGLE' | 'ELLIPSE' | 'LINE' | 'TEXT' | 'PEN' | 'HAND'
|
||||
|
||||
export interface ToolDef {
|
||||
key: Tool
|
||||
label: string
|
||||
icon: string
|
||||
shortcut: string
|
||||
flyout?: Tool[]
|
||||
}
|
||||
|
||||
export const TOOLS: ToolDef[] = [
|
||||
{ key: 'SELECT', label: 'Move', icon: '↖', shortcut: 'V' },
|
||||
{ key: 'FRAME', label: 'Frame', icon: '#', shortcut: 'F' },
|
||||
{
|
||||
key: 'RECTANGLE',
|
||||
label: 'Rectangle',
|
||||
icon: '□',
|
||||
shortcut: 'R',
|
||||
flyout: ['RECTANGLE', 'ELLIPSE', 'LINE']
|
||||
},
|
||||
{ key: 'PEN', label: 'Pen', icon: '✒', shortcut: 'P' },
|
||||
{ key: 'TEXT', label: 'Text', icon: 'T', shortcut: 'T' },
|
||||
{ key: 'HAND', label: 'Hand', icon: '✋', shortcut: 'H' }
|
||||
]
|
||||
|
||||
export const TOOL_SHORTCUTS: Record<string, Tool> = {
|
||||
v: 'SELECT',
|
||||
f: 'FRAME',
|
||||
r: 'RECTANGLE',
|
||||
o: 'ELLIPSE',
|
||||
l: 'LINE',
|
||||
t: 'TEXT',
|
||||
p: 'PEN',
|
||||
h: 'HAND'
|
||||
}
|
||||
|
||||
const DEFAULT_FILLS: Record<string, Fill> = {
|
||||
FRAME: { type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true },
|
||||
RECTANGLE: {
|
||||
type: 'SOLID',
|
||||
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true
|
||||
},
|
||||
ELLIPSE: {
|
||||
type: 'SOLID',
|
||||
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true
|
||||
},
|
||||
LINE: { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
|
||||
}
|
||||
|
||||
export function createEditorStore() {
|
||||
const graph = new SceneGraph()
|
||||
const undo = new UndoManager()
|
||||
|
||||
const state = reactive({
|
||||
activeTool: 'SELECT' as Tool,
|
||||
selectedIds: new Set<string>(),
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
zoom: 1,
|
||||
renderVersion: 0
|
||||
})
|
||||
|
||||
const selectedNodes = computed(() => {
|
||||
const nodes: SceneNode[] = []
|
||||
for (const id of state.selectedIds) {
|
||||
const n = graph.getNode(id)
|
||||
if (n) nodes.push(n)
|
||||
}
|
||||
return nodes
|
||||
})
|
||||
|
||||
const selectedNode = computed(() =>
|
||||
selectedNodes.value.length === 1 ? selectedNodes.value[0] : undefined
|
||||
)
|
||||
|
||||
const layerNodes = computed(() => {
|
||||
void state.renderVersion
|
||||
return graph.getChildren(graph.rootId)
|
||||
})
|
||||
|
||||
function requestRender() {
|
||||
state.renderVersion++
|
||||
}
|
||||
|
||||
function setTool(tool: Tool) {
|
||||
state.activeTool = tool
|
||||
}
|
||||
|
||||
function select(ids: string[], additive = false) {
|
||||
if (additive) {
|
||||
const next = new Set(state.selectedIds)
|
||||
for (const id of ids) {
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
}
|
||||
state.selectedIds = next
|
||||
} else {
|
||||
state.selectedIds = new Set(ids)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
state.selectedIds = new Set()
|
||||
}
|
||||
|
||||
function updateNode(id: string, changes: Partial<SceneNode>) {
|
||||
graph.updateNode(id, changes)
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function createShape(type: NodeType, x: number, y: number, w: number, h: number): string {
|
||||
const fill = DEFAULT_FILLS[type] ?? DEFAULT_FILLS.RECTANGLE
|
||||
const node = graph.createNode(type, graph.rootId, {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
fills: [{ ...fill }]
|
||||
})
|
||||
requestRender()
|
||||
return node.id
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
undo.beginBatch('Delete')
|
||||
for (const id of state.selectedIds) {
|
||||
const node = graph.getNode(id)
|
||||
if (!node) continue
|
||||
const snapshot = { ...node }
|
||||
const parentId = node.parentId ?? graph.rootId
|
||||
undo.apply({
|
||||
label: 'Delete',
|
||||
forward: () => graph.deleteNode(id),
|
||||
inverse: () => {
|
||||
graph.createNode(snapshot.type, parentId, snapshot)
|
||||
}
|
||||
})
|
||||
}
|
||||
undo.commitBatch()
|
||||
clearSelection()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function commitMove(originals: Map<string, { x: number; y: number }>) {
|
||||
const finals = new Map<string, { x: number; y: number }>()
|
||||
for (const [id] of originals) {
|
||||
const n = graph.getNode(id)
|
||||
if (n) finals.set(id, { x: n.x, y: n.y })
|
||||
}
|
||||
undo.apply({
|
||||
label: 'Move',
|
||||
forward: () => {
|
||||
for (const [id, pos] of finals) graph.updateNode(id, pos)
|
||||
},
|
||||
inverse: () => {
|
||||
for (const [id, pos] of originals) graph.updateNode(id, pos)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function undoAction() {
|
||||
undo.undo()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function redoAction() {
|
||||
undo.redo()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function screenToCanvas(sx: number, sy: number) {
|
||||
return {
|
||||
x: (sx - state.panX) / state.zoom,
|
||||
y: (sy - state.panY) / state.zoom
|
||||
}
|
||||
}
|
||||
|
||||
function applyZoom(delta: number, centerX: number, centerY: number) {
|
||||
const factor = delta < 0 ? 1.1 : 0.9
|
||||
const newZoom = Math.max(0.02, Math.min(256, state.zoom * factor))
|
||||
state.panX = centerX - (centerX - state.panX) * (newZoom / state.zoom)
|
||||
state.panY = centerY - (centerY - state.panY) * (newZoom / state.zoom)
|
||||
state.zoom = newZoom
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function pan(dx: number, dy: number) {
|
||||
state.panX += dx
|
||||
state.panY += dy
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function zoomToFit() {
|
||||
const nodes = graph.getChildren(graph.rootId)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const n of nodes) {
|
||||
minX = Math.min(minX, n.x)
|
||||
minY = Math.min(minY, n.y)
|
||||
maxX = Math.max(maxX, n.x + n.width)
|
||||
maxY = Math.max(maxY, n.y + n.height)
|
||||
}
|
||||
|
||||
const padding = 80
|
||||
const w = maxX - minX + padding * 2
|
||||
const h = maxY - minY + padding * 2
|
||||
|
||||
// Will be set by canvas composable
|
||||
const viewW = 800
|
||||
const viewH = 600
|
||||
const zoom = Math.min(viewW / w, viewH / h, 1)
|
||||
|
||||
state.zoom = zoom
|
||||
state.panX = (viewW - w * zoom) / 2 - minX * zoom + padding * zoom
|
||||
state.panY = (viewH - h * zoom) / 2 - minY * zoom + padding * zoom
|
||||
requestRender()
|
||||
}
|
||||
|
||||
return {
|
||||
graph,
|
||||
undo,
|
||||
state,
|
||||
selectedNodes,
|
||||
selectedNode,
|
||||
layerNodes,
|
||||
requestRender,
|
||||
setTool,
|
||||
select,
|
||||
clearSelection,
|
||||
updateNode,
|
||||
createShape,
|
||||
deleteSelected,
|
||||
commitMove,
|
||||
undoAction,
|
||||
redoAction,
|
||||
screenToCanvas,
|
||||
applyZoom,
|
||||
pan,
|
||||
zoomToFit
|
||||
}
|
||||
}
|
||||
|
||||
export type EditorStore = ReturnType<typeof createEditorStore>
|
||||
|
||||
const storeRef = shallowRef<EditorStore>()
|
||||
|
||||
export function provideEditorStore(): EditorStore {
|
||||
const store = createEditorStore()
|
||||
storeRef.value = store
|
||||
return store
|
||||
}
|
||||
|
||||
export function useEditorStore(): EditorStore {
|
||||
if (!storeRef.value) throw new Error('Editor store not provided')
|
||||
return storeRef.value
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -5,21 +5,17 @@
|
|||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,38 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { copyFileSync, existsSync } from "fs";
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { copyFileSync, existsSync } from 'fs'
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
const host = process.env.TAURI_DEV_HOST
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [
|
||||
{
|
||||
name: "copy-canvaskit-wasm",
|
||||
name: 'copy-canvaskit-wasm',
|
||||
buildStart() {
|
||||
const src = "node_modules/canvaskit-wasm/bin/canvaskit.wasm";
|
||||
const dest = "public/canvaskit.wasm";
|
||||
const src = 'node_modules/canvaskit-wasm/bin/canvaskit.wasm'
|
||||
const dest = 'public/canvaskit.wasm'
|
||||
if (existsSync(src) && !existsSync(dest)) {
|
||||
copyFileSync(src, dest);
|
||||
copyFileSync(src, dest)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
react(),
|
||||
vue()
|
||||
],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
protocol: 'ws',
|
||||
host,
|
||||
port: 1421,
|
||||
port: 1421
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
ignored: ['**/src-tauri/**']
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
|
|
|||
Loading…
Reference in a new issue