From 323a1f90c445816a06314589bb1c096474b26ffe Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 27 Feb 2026 21:25:31 +0300 Subject: [PATCH] =?UTF-8?q?Migrate=20React=20=E2=86=92=20Vue=203=20+=20Vue?= =?UTF-8?q?Use=20+=20Reka=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vue 3 SFC components with +
+ diff --git a/oxlint.json b/oxlint.json index 6443c3dd1..64ff28a77 100644 --- a/oxlint.json +++ b/oxlint.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["react", "react-hooks", "typescript", "import", "unicorn"], + "plugins": ["typescript", "import", "unicorn"], "env": { "browser": true, "es2024": true @@ -9,18 +9,6 @@ "no-unused-vars": "warn", "no-console": "off", - "react/jsx-key": "error", - "react/jsx-no-duplicate-props": "error", - "react/jsx-no-undef": "error", - "react/no-children-prop": "error", - "react/no-danger-with-children": "error", - "react/no-direct-mutation-state": "error", - "react/no-string-refs": "error", - "react/require-render-return": "error", - - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn", - "typescript/no-explicit-any": "warn", "typescript/no-non-null-assertion": "warn", diff --git a/package.json b/package.json index 438cf7a30..54acb2172 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 3dffa13d8..000000000 --- a/src/App.tsx +++ /dev/null @@ -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 = { - v: 'SELECT', - f: 'FRAME', - r: 'RECTANGLE', - o: 'ELLIPSE', - l: 'LINE' -} - -const TOOL_COLORS: Record = { - 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(null) - const graphRef = useRef(new SceneGraph()) - const rendererRef = useRef(null) - const undoRef = useRef(new UndoManager()) - const [activeTool, setActiveTool] = useState('SELECT') - const [selectedIds, setSelectedIds] = useState>(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 - } | 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) { - 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() - 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 = { - 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) { - 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() - 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) { - 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 ( -
- {/* Canvas */} -
- {/* Left panel — Layers */} -
-
- Layers -
- {graphRef.current.getChildren(graphRef.current.rootId).map((node) => ( -
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' - }} - > - - {node.type === 'ELLIPSE' ? '○' : node.type === 'FRAME' ? '🔲' : '□'} {node.name} - - {node.id} -
- ))} -
- - {/* Canvas */} - - - {/* Right panel — Properties */} -
- {selectedNode ? ( - <> -
-
{selectedNode.name}
-
- {selectedNode.type} · {selectedNode.id} -
-
-
-
Appearance
-
- - - - - - -
-
-
-
Fill
- {selectedNode.fills.map((fill, i) => ( -
-
- - # - {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')} - -
- ))} -
-
-
Opacity
- { - graphRef.current.updateNode(selectedNode.id, { opacity: +e.target.value / 100 }) - requestRender() - }} - style={{ width: '100%' }} - /> -
- - ) : ( -
No selection
- )} -
-
- - {/* Bottom toolbar */} -
- {tools.map((t) => ( - - ))} -
- - {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/**'] + } + } +}))