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:
Danila Poyarkov 2026-02-27 21:25:31 +03:00
parent ec90f35df8
commit 323a1f90c4
21 changed files with 1512 additions and 882 deletions

137
FIGMA_UI3_CDP_FINDINGS.md Normal file
View 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
View file

@ -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=="],
}

View file

@ -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>

View file

@ -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",

View file

@ -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",

View file

@ -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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}
}

View 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 }
}

View 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
View 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
View file

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View file

@ -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
View 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
View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -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" }]
}

View file

@ -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/**']
}
}
}))