diff --git a/FIGMA_UI3_CDP_FINDINGS.md b/FIGMA_UI3_CDP_FINDINGS.md new file mode 100644 index 000000000..59daec09b --- /dev/null +++ b/FIGMA_UI3_CDP_FINDINGS.md @@ -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 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 ` - ))} -
- - {nodeCount} nodes · Zoom:{' '} - {rendererRef.current ? `${Math.round(rendererRef.current.zoom * 100)}%` : '100%'} - -
- - ) -} - -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 diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 000000000..bc116010d --- /dev/null +++ b/src/App.vue @@ -0,0 +1,154 @@ + + + + + + + diff --git a/src/components/EditorCanvas.vue b/src/components/EditorCanvas.vue new file mode 100644 index 000000000..602baf4cb --- /dev/null +++ b/src/components/EditorCanvas.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/src/components/LayersPanel.vue b/src/components/LayersPanel.vue new file mode 100644 index 000000000..deb1a4cef --- /dev/null +++ b/src/components/LayersPanel.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue new file mode 100644 index 000000000..83e3ffc7b --- /dev/null +++ b/src/components/PropertiesPanel.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/src/components/Toolbar.vue b/src/components/Toolbar.vue new file mode 100644 index 000000000..4e5d73e65 --- /dev/null +++ b/src/components/Toolbar.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/src/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts new file mode 100644 index 000000000..b4ec61339 --- /dev/null +++ b/src/composables/use-canvas-input.ts @@ -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 + startPanX?: number + startPanY?: number +} + +const TOOL_TO_NODE: Partial> = { + FRAME: 'FRAME', + RECTANGLE: 'RECTANGLE', + ELLIPSE: 'ELLIPSE', + LINE: 'LINE' +} + +export function useCanvasInput(canvasRef: Ref, store: EditorStore) { + const drag = ref(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() + 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 + } +} diff --git a/src/composables/use-canvas.ts b/src/composables/use-canvas.ts new file mode 100644 index 000000000..3d45823e3 --- /dev/null +++ b/src/composables/use-canvas.ts @@ -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, 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 } +} diff --git a/src/composables/use-keyboard.ts b/src/composables/use-keyboard.ts new file mode 100644 index 000000000..8e3d6b23c --- /dev/null +++ b/src/composables/use-keyboard.ts @@ -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)) +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 000000000..65c7311dc --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 000000000..912d54f8d --- /dev/null +++ b/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' + +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/src/main.tsx b/src/main.tsx deleted file mode 100644 index e9a8bc0cb..000000000 --- a/src/main.tsx +++ /dev/null @@ -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( - - - -) diff --git a/src/stores/editor.ts b/src/stores/editor.ts new file mode 100644 index 000000000..f484fe0a7 --- /dev/null +++ b/src/stores/editor.ts @@ -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 = { + v: 'SELECT', + f: 'FRAME', + r: 'RECTANGLE', + o: 'ELLIPSE', + l: 'LINE', + t: 'TEXT', + p: 'PEN', + h: 'HAND' +} + +const DEFAULT_FILLS: Record = { + 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(), + 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) { + 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) { + const finals = new Map() + 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 + +const storeRef = shallowRef() + +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 +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a..000000000 --- a/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/tsconfig.json b/tsconfig.json index d4aba3217..8c1c40511 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" }] } diff --git a/vite.config.ts b/vite.config.ts index df7e363af..1a656c98a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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/**'] + } + } +}))