web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: basic shape creation

+1748 -129
+1
.gitignore
··· 3 3 *.log 4 4 .DS_Store 5 5 coverage 6 + __screenshots__
+10 -27
TODO.txt
··· 140 140 - getShapesOnCurrentPage(state) 141 141 - getSelectedShapes(state) 142 142 143 - Invariants (pick “repair” and test it): 143 + Invariants (pick "repair" and test it): 144 144 [x] Implement enforceInvariants(state): EditorState (repair strategy): 145 145 - selectionIds := selectionIds filtered to existing shapes 146 146 - currentPageId must exist: ··· 204 204 [x] Define draw order = page.shapeIds order 205 205 [x] hitTest uses reverse order for topmost selection 206 206 207 - Tests: 208 - [x] hitTestPoint returns expected shapeId for overlapping shapes 209 - [x] tolerance works for line/arrow selection 210 - 211 207 (DoD): 212 208 - You can hover shapes and log the hit shape id (no selection yet). 213 209 ··· 254 250 255 251 Tool router: 256 252 [x] routeAction(state, action) -> newState (delegates to active tool) 257 - 258 - Tests: 259 - [x] Switching tools calls onExit/onEnter in correct order 260 - [x] Tool ignores actions it doesn't care about 261 253 262 254 (DoD): 263 255 - A dummy tool can consume pointer events and update state deterministically. ··· 287 279 [x] Escape clears selection 288 280 [x] Delete removes selected shapes 289 281 290 - Tests: 291 - [x] drag moves exactly by delta 292 - [x] shift-click toggles membership 293 - [x] delete removes and clears selection 294 - 295 282 (DoD): 296 283 - You can select and move shapes reliably. 297 284 ··· 302 289 Goal: place shapes with dedicated tools. 303 290 304 291 Rect tool (repeat pattern for others): 305 - [ ] PointerDown on canvas: 292 + [x] PointerDown on canvas: 306 293 - create a draft rect shape with w/h=0 at startWorld 307 294 - selection = [newId] 308 - [ ] PointerMove: 295 + [x] PointerMove: 309 296 - update w/h based on currentWorld - startWorld 310 - [ ] PointerUp: 297 + [x] PointerUp: 311 298 - if too small, delete it (click-cancel behavior) 312 299 - else finalize 313 300 314 - [ ] Implement ellipse tool (same mechanics) 315 - [ ] Implement line tool (a=startWorld, b=currentWorld) 316 - [ ] Implement arrow tool (same as line but type="arrow") 317 - [ ] Implement text tool: 301 + [x] Implement ellipse tool (same mechanics) 302 + [x] Implement line tool (a=startWorld, b=currentWorld) 303 + [x] Implement arrow tool (same as line but type="arrow") 304 + [x] Implement text tool: 318 305 - click to create text shape 319 306 - open in-place editor overlay (contenteditable) in Svelte 320 - 321 - Tests: 322 - [ ] shape creation creates one record with correct props 323 - [ ] click-cancel removes zero-area shapes 324 307 325 308 (DoD): 326 309 - You can draw rect/ellipse/line/arrow/text on the canvas. ··· 329 312 11. Milestone K: Bindings for arrows (v0) *wb-K* 330 313 ============================================================================== 331 314 332 - Goal: arrow endpoints can “stick” to shapes. 315 + Goal: arrow endpoints can "stick" to shapes. 333 316 334 317 Binding creation: 335 318 [ ] On arrow finalize: ··· 506 489 18. Milestone R: Quality polish (what makes it feel "real") *wb-R* 507 490 ============================================================================== 508 491 509 - Goal: the UX crosses the “this is legit” threshold. 492 + Goal: the UX crosses the "this is legit" threshold. 510 493 511 494 [ ] Snapping: 512 495 - snap move to grid
+3 -1
apps/web/.prettierrc
··· 2 2 "useTabs": true, 3 3 "singleQuote": true, 4 4 "trailingComma": "none", 5 - "printWidth": 100, 5 + "printWidth": 120, 6 + "objectWrap": "collapse", 7 + "bracketSameLine": true, 6 8 "plugins": [ 7 9 "prettier-plugin-svelte" 8 10 ],
+4 -1
apps/web/src/lib/assets/favicon.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg> 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24"> 2 + <path fill="#5e81ac" 3 + d="M9.75 20.85c1.78-.7 1.39-2.63.49-3.85c-.89-1.25-2.12-2.11-3.36-2.94A9.8 9.8 0 0 1 4.54 12c-.28-.33-.85-.94-.27-1.06c.59-.12 1.61.46 2.13.68c.91.38 1.81.82 2.65 1.34l1.01-1.7C8.5 10.23 6.5 9.32 4.64 9.05c-1.06-.16-2.18.06-2.54 1.21c-.32.99.19 1.99.77 2.77c1.37 1.83 3.5 2.71 5.09 4.29c.34.33.75.72.95 1.18c.21.44.16.47-.31.47c-1.24 0-2.79-.97-3.8-1.61l-1.01 1.7c1.53.94 4.09 2.41 5.96 1.79m9.21-13.52L13.29 13H11v-2.29l5.67-5.68zm3.4-.78c-.01.3-.32.61-.64.92L19.2 10l-.87-.87l2.6-2.59l-.59-.59l-.67.67l-2.29-2.29l2.15-2.15c.24-.24.63-.24.86 0l1.43 1.43c.24.22.24.62 0 .86c-.21.21-.41.41-.41.61c-.02.2.18.42.38.59c.29.3.58.58.57.88" /> 4 + </svg>
+110 -98
apps/web/src/lib/canvas/Canvas.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount, onDestroy } from 'svelte'; 3 - import { 4 - Store, 5 - EditorState, 6 - PageRecord, 7 - ShapeRecord, 8 - SelectTool, 9 - routeAction, 10 - createToolMap, 11 - type Action, 12 - type Viewport, 13 - Camera, 14 - } from 'inkfinite-core'; 15 - import { createRenderer, type Renderer } from 'inkfinite-renderer'; 16 - import { createInputAdapter, type InputAdapter } from '../input'; 2 + import { 3 + ArrowTool, 4 + EllipseTool, 5 + LineTool, 6 + PageRecord, 7 + RectTool, 8 + SelectTool, 9 + ShapeRecord, 10 + Store, 11 + TextTool, 12 + createToolMap, 13 + routeAction, 14 + switchTool, 15 + type Action, 16 + type ToolId, 17 + type Viewport 18 + } from 'inkfinite-core'; 19 + import { createRenderer, type Renderer } from 'inkfinite-renderer'; 20 + import { onDestroy, onMount } from 'svelte'; 21 + import Toolbar from '../components/Toolbar.svelte'; 22 + import { createInputAdapter, type InputAdapter } from '../input'; 17 23 18 - // Create the editor store 19 - const store = new Store(); 24 + const store = new Store(); 20 25 21 - // Initialize with a default page and some test shapes 22 - store.setState((state) => { 23 - const page = PageRecord.create('Page 1'); 24 - const rect1 = ShapeRecord.createRect( 25 - page.id, 26 - -200, 27 - -100, 28 - { w: 150, h: 100, fill: '#ff6b6b', stroke: '#c92a2a', radius: 8 }, 29 - ); 30 - const rect2 = ShapeRecord.createRect( 31 - page.id, 32 - 50, 33 - -50, 34 - { w: 120, h: 80, fill: '#4dabf7', stroke: '#1971c2', radius: 8 }, 35 - ); 36 - const ellipse = ShapeRecord.createEllipse( 37 - page.id, 38 - -100, 39 - 100, 40 - { w: 100, h: 100, fill: '#51cf66', stroke: '#2f9e44' }, 41 - ); 26 + store.setState((state) => { 27 + const page = PageRecord.create('Page 1'); 28 + const rect1 = ShapeRecord.createRect(page.id, -200, -100, { 29 + w: 150, 30 + h: 100, 31 + fill: '#ff6b6b', 32 + stroke: '#c92a2a', 33 + radius: 8 34 + }); 35 + const rect2 = ShapeRecord.createRect(page.id, 50, -50, { 36 + w: 120, 37 + h: 80, 38 + fill: '#4dabf7', 39 + stroke: '#1971c2', 40 + radius: 8 41 + }); 42 + const ellipse = ShapeRecord.createEllipse(page.id, -100, 100, { 43 + w: 100, 44 + h: 100, 45 + fill: '#51cf66', 46 + stroke: '#2f9e44' 47 + }); 42 48 43 - page.shapeIds.push(rect1.id, rect2.id, ellipse.id); 49 + page.shapeIds.push(rect1.id, rect2.id, ellipse.id); 44 50 45 - return { 46 - ...state, 47 - doc: { 48 - ...state.doc, 49 - pages: { [page.id]: page }, 50 - shapes: { 51 - [rect1.id]: rect1, 52 - [rect2.id]: rect2, 53 - [ellipse.id]: ellipse, 54 - }, 55 - }, 56 - ui: { 57 - ...state.ui, 58 - currentPageId: page.id, 59 - }, 60 - }; 61 - }); 51 + return { 52 + ...state, 53 + doc: { 54 + ...state.doc, 55 + pages: { [page.id]: page }, 56 + shapes: { [rect1.id]: rect1, [rect2.id]: rect2, [ellipse.id]: ellipse } 57 + }, 58 + ui: { ...state.ui, currentPageId: page.id } 59 + }; 60 + }); 62 61 63 - // Set up tools 64 - const selectTool = new SelectTool(); 65 - const tools = createToolMap([selectTool]); 62 + const selectTool = new SelectTool(); 63 + const rectTool = new RectTool(); 64 + const ellipseTool = new EllipseTool(); 65 + const lineTool = new LineTool(); 66 + const arrowTool = new ArrowTool(); 67 + const textTool = new TextTool(); 68 + const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool]); 66 69 67 - // Handle actions from input adapter 68 - function handleAction(action: Action) { 69 - store.setState((state) => routeAction(state, action, tools)); 70 - } 70 + let currentToolId = $state<ToolId>('select'); 71 71 72 - let canvas: HTMLCanvasElement; 73 - let renderer: Renderer | null = null; 74 - let inputAdapter: InputAdapter | null = null; 72 + store.subscribe((state) => { 73 + currentToolId = state.ui.toolId; 74 + }); 75 75 76 - onMount(() => { 77 - // Create renderer 78 - renderer = createRenderer(canvas, store); 76 + function handleToolChange(toolId: ToolId) { 77 + store.setState((state) => switchTool(state, toolId, tools)); 78 + } 79 79 80 - // Get viewport dimensions 81 - function getViewport(): Viewport { 82 - const rect = canvas.getBoundingClientRect(); 83 - return { width: rect.width, height: rect.height }; 84 - } 80 + function handleAction(action: Action) { 81 + store.setState((state) => routeAction(state, action, tools)); 82 + } 85 83 86 - // Get current camera 87 - function getCamera() { 88 - return store.getState().camera; 89 - } 84 + let canvas: HTMLCanvasElement; 85 + let renderer: Renderer | null = null; 86 + let inputAdapter: InputAdapter | null = null; 90 87 91 - // Create input adapter 92 - inputAdapter = createInputAdapter({ 93 - canvas, 94 - getCamera, 95 - getViewport, 96 - onAction: handleAction, 97 - }); 98 - }); 88 + onMount(() => { 89 + renderer = createRenderer(canvas, store); 99 90 100 - onDestroy(() => { 101 - renderer?.dispose(); 102 - inputAdapter?.dispose(); 103 - }); 91 + function getViewport(): Viewport { 92 + const rect = canvas.getBoundingClientRect(); 93 + return { width: rect.width, height: rect.height }; 94 + } 95 + 96 + function getCamera() { 97 + return store.getState().camera; 98 + } 99 + 100 + inputAdapter = createInputAdapter({ canvas, getCamera, getViewport, onAction: handleAction }); 101 + }); 102 + 103 + onDestroy(() => { 104 + renderer?.dispose(); 105 + inputAdapter?.dispose(); 106 + }); 104 107 </script> 105 108 106 - <canvas bind:this={canvas}></canvas> 109 + <div class="editor"> 110 + <Toolbar currentTool={currentToolId} onToolChange={handleToolChange} /> 111 + <canvas bind:this={canvas}></canvas> 112 + </div> 107 113 108 114 <style> 109 - canvas { 110 - width: 100%; 111 - height: 100%; 112 - display: block; 113 - touch-action: none; 114 - cursor: default; 115 - } 115 + .editor { 116 + width: 100%; 117 + height: 100%; 118 + display: flex; 119 + flex-direction: column; 120 + } 121 + 122 + canvas { 123 + flex: 1; 124 + display: block; 125 + touch-action: none; 126 + cursor: default; 127 + } 116 128 </style>
+86
apps/web/src/lib/components/Toolbar.svelte
··· 1 + <script lang="ts"> 2 + import type { ToolId } from 'inkfinite-core'; 3 + 4 + type Props = { currentTool: ToolId; onToolChange: (toolId: ToolId) => void }; 5 + 6 + let { currentTool, onToolChange }: Props = $props(); 7 + 8 + const tools: Array<{ id: ToolId; label: string; icon: string }> = [ 9 + { id: 'select', label: 'Select', icon: '⌖' }, 10 + { id: 'rect', label: 'Rectangle', icon: '▭' }, 11 + { id: 'ellipse', label: 'Ellipse', icon: '○' }, 12 + { id: 'line', label: 'Line', icon: '╱' }, 13 + { id: 'arrow', label: 'Arrow', icon: '→' }, 14 + { id: 'text', label: 'Text', icon: 'T' } 15 + ]; 16 + 17 + function handleToolClick(toolId: ToolId) { 18 + onToolChange(toolId); 19 + } 20 + </script> 21 + 22 + <div class="toolbar" role="toolbar" aria-label="Drawing tools"> 23 + {#each tools as tool} 24 + <button 25 + class="tool-button" 26 + class:active={currentTool === tool.id} 27 + onclick={() => handleToolClick(tool.id)} 28 + aria-label={tool.label} 29 + aria-pressed={currentTool === tool.id} 30 + data-tool-id={tool.id}> 31 + <span class="tool-icon">{tool.icon}</span> 32 + <span class="tool-label">{tool.label}</span> 33 + </button> 34 + {/each} 35 + </div> 36 + 37 + <style> 38 + .toolbar { 39 + display: flex; 40 + gap: 8px; 41 + padding: 12px; 42 + background: #f5f5f5; 43 + border-bottom: 1px solid #e0e0e0; 44 + } 45 + 46 + .tool-button { 47 + display: flex; 48 + flex-direction: column; 49 + align-items: center; 50 + gap: 4px; 51 + padding: 8px 12px; 52 + border: 1px solid #d0d0d0; 53 + border-radius: 4px; 54 + background: white; 55 + cursor: pointer; 56 + transition: all 0.2s; 57 + min-width: 60px; 58 + } 59 + 60 + .tool-button:hover { 61 + background: #f0f0f0; 62 + border-color: #b0b0b0; 63 + } 64 + 65 + .tool-button:focus { 66 + outline: 2px solid #4a90e2; 67 + outline-offset: 2px; 68 + } 69 + 70 + .tool-button.active { 71 + background: #4a90e2; 72 + color: white; 73 + border-color: #357abd; 74 + } 75 + 76 + .tool-icon { 77 + font-size: 20px; 78 + line-height: 1; 79 + } 80 + 81 + .tool-label { 82 + font-size: 11px; 83 + line-height: 1; 84 + white-space: nowrap; 85 + } 86 + </style>
+55
apps/web/src/lib/tests/canvas.svelte.test.ts
··· 49 49 // FIXME: We can't directly access the store 50 50 expect(component).toBeTruthy(); 51 51 }); 52 + 53 + it("should render the Toolbar component", () => { 54 + const { container } = render(Canvas); 55 + const toolbar = container.querySelector(".toolbar"); 56 + 57 + expect(toolbar).toBeTruthy(); 58 + expect(toolbar?.getAttribute("role")).toBe("toolbar"); 59 + }); 60 + 61 + it("should render editor wrapper with correct layout", () => { 62 + const { container } = render(Canvas); 63 + const editor = container.querySelector(".editor"); 64 + 65 + expect(editor).toBeTruthy(); 66 + const style = window.getComputedStyle(editor as Element); 67 + expect(style.display).toBe("flex"); 68 + expect(style.flexDirection).toBe("column"); 69 + }); 70 + 71 + it("should render all tool buttons in toolbar", () => { 72 + const { container } = render(Canvas); 73 + const toolButtons = container.querySelectorAll(".tool-button"); 74 + 75 + expect(toolButtons.length).toBe(6); 76 + 77 + const toolIds = Array.from(toolButtons).map((btn) => btn.getAttribute("data-tool-id")); 78 + expect(toolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text"]); 79 + }); 80 + 81 + it("should have select tool active by default", () => { 82 + const { container } = render(Canvas); 83 + const selectButton = container.querySelector(".tool-button[data-tool-id=\"select\"]"); 84 + 85 + expect(selectButton?.classList.contains("active")).toBe(true); 86 + }); 87 + 88 + it("should change active tool when toolbar button is clicked", async () => { 89 + const { container } = render(Canvas); 90 + 91 + const selectButton = container.querySelector(".tool-button[data-tool-id=\"select\"]"); 92 + const rectButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]") as HTMLButtonElement; 93 + 94 + expect(selectButton?.classList.contains("active")).toBe(true); 95 + expect(rectButton?.classList.contains("active")).toBe(false); 96 + 97 + rectButton.click(); 98 + 99 + await new Promise((resolve) => setTimeout(resolve, 50)); 100 + 101 + const selectButtonAfter = container.querySelector(".tool-button[data-tool-id=\"select\"]"); 102 + const rectButtonAfter = container.querySelector(".tool-button[data-tool-id=\"rect\"]"); 103 + 104 + expect(selectButtonAfter?.classList.contains("active")).toBe(false); 105 + expect(rectButtonAfter?.classList.contains("active")).toBe(true); 106 + }); 52 107 });
+86
apps/web/src/lib/tests/components/Toolbar.svelte.test.ts
··· 1 + import type { ToolId } from "inkfinite-core"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { cleanup, render } from "vitest-browser-svelte"; 4 + import Toolbar from "../../components/Toolbar.svelte"; 5 + 6 + describe("Toolbar component", () => { 7 + beforeEach(() => { 8 + cleanup(); 9 + }); 10 + 11 + it("should render all tool buttons", () => { 12 + const onToolChange = vi.fn(); 13 + const { container } = render(Toolbar, { currentTool: "select", onToolChange }); 14 + 15 + const buttons = container.querySelectorAll(".tool-button"); 16 + expect(buttons.length).toBe(6); 17 + 18 + const toolIds = Array.from(buttons).map((btn) => btn.getAttribute("data-tool-id")); 19 + expect(toolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text"]); 20 + }); 21 + 22 + it("should mark the current tool as active", () => { 23 + const onToolChange = vi.fn(); 24 + const { container } = render(Toolbar, { currentTool: "rect", onToolChange }); 25 + 26 + const activeButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]"); 27 + expect(activeButton?.classList.contains("active")).toBe(true); 28 + expect(activeButton?.getAttribute("aria-pressed")).toBe("true"); 29 + }); 30 + 31 + it("should call onToolChange when a tool button is clicked", async () => { 32 + const onToolChange = vi.fn(); 33 + const { container } = render(Toolbar, { currentTool: "select", onToolChange }); 34 + 35 + const ellipseButton = container.querySelector(".tool-button[data-tool-id=\"ellipse\"]") as HTMLButtonElement; 36 + expect(ellipseButton).toBeTruthy(); 37 + 38 + ellipseButton.click(); 39 + 40 + expect(onToolChange).toHaveBeenCalledTimes(1); 41 + expect(onToolChange).toHaveBeenCalledWith("ellipse"); 42 + }); 43 + 44 + it.each([ 45 + { toolId: "select" as ToolId, label: "Select" }, 46 + { toolId: "rect" as ToolId, label: "Rectangle" }, 47 + { toolId: "ellipse" as ToolId, label: "Ellipse" }, 48 + { toolId: "line" as ToolId, label: "Line" }, 49 + { toolId: "arrow" as ToolId, label: "Arrow" }, 50 + { toolId: "text" as ToolId, label: "Text" }, 51 + ])("should have correct aria-label for $toolId tool", ({ toolId, label }) => { 52 + const onToolChange = vi.fn(); 53 + const { container } = render(Toolbar, { currentTool: "select", onToolChange }); 54 + 55 + const button = container.querySelector(`.tool-button[data-tool-id="${toolId}"]`); 56 + expect(button?.getAttribute("aria-label")).toBe(label); 57 + }); 58 + 59 + it("should update active state when currentTool prop changes", async () => { 60 + const onToolChange = vi.fn(); 61 + const { container, rerender } = render(Toolbar, { currentTool: "select", onToolChange }); 62 + 63 + let selectButton = container.querySelector(".tool-button[data-tool-id=\"select\"]"); 64 + let rectButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]"); 65 + 66 + expect(selectButton?.classList.contains("active")).toBe(true); 67 + expect(rectButton?.classList.contains("active")).toBe(false); 68 + 69 + await rerender({ currentTool: "rect", onToolChange }); 70 + 71 + selectButton = container.querySelector(".tool-button[data-tool-id=\"select\"]"); 72 + rectButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]"); 73 + 74 + expect(selectButton?.classList.contains("active")).toBe(false); 75 + expect(rectButton?.classList.contains("active")).toBe(true); 76 + }); 77 + 78 + it("should have proper accessibility attributes", () => { 79 + const onToolChange = vi.fn(); 80 + const { container } = render(Toolbar, { currentTool: "select", onToolChange }); 81 + 82 + const toolbar = container.querySelector(".toolbar"); 83 + expect(toolbar?.getAttribute("role")).toBe("toolbar"); 84 + expect(toolbar?.getAttribute("aria-label")).toBe("Drawing tools"); 85 + }); 86 + });
+1
apps/web/src/routes/+layout.ts
··· 1 + export const prerender = true;
+683 -2
packages/core/src/tools.test.ts
··· 1 1 import { beforeEach, describe, expect, it } from "vitest"; 2 2 import { Action, Modifiers, PointerButtons } from "./actions"; 3 - import { PageRecord, ShapeRecord } from "./model"; 3 + import { 4 + type ArrowProps, 5 + type EllipseProps, 6 + type LineProps, 7 + PageRecord, 8 + type RectProps, 9 + ShapeRecord, 10 + type TextProps, 11 + } from "./model"; 4 12 import { EditorState } from "./reactivity"; 5 - import { SelectTool } from "./tools"; 13 + import { ArrowTool, EllipseTool, LineTool, RectTool, SelectTool, TextTool } from "./tools"; 6 14 7 15 describe("SelectTool", () => { 8 16 let tool: SelectTool; ··· 383 391 }); 384 392 }); 385 393 }); 394 + 395 + describe("RectTool", () => { 396 + let tool: RectTool; 397 + let initialState: EditorState; 398 + let page: PageRecord; 399 + 400 + beforeEach(() => { 401 + tool = new RectTool(); 402 + page = PageRecord.create("Test Page"); 403 + 404 + initialState = { 405 + ...EditorState.create(), 406 + doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, 407 + ui: { currentPageId: page.id, selectionIds: [], toolId: "rect" }, 408 + }; 409 + }); 410 + 411 + describe("shape creation", () => { 412 + it("should create a rect shape on pointer down", () => { 413 + const action = Action.pointerDown( 414 + { x: 100, y: 100 }, 415 + { x: 100, y: 100 }, 416 + 0, 417 + PointerButtons.create(true, false, false), 418 + Modifiers.create(), 419 + ); 420 + 421 + const result = tool.onAction(initialState, action); 422 + 423 + const shapeIds = Object.keys(result.doc.shapes); 424 + expect(shapeIds.length).toBe(1); 425 + 426 + const shape = result.doc.shapes[shapeIds[0]]; 427 + expect(shape.type).toBe("rect"); 428 + expect(shape.x).toBe(100); 429 + expect(shape.y).toBe(100); 430 + expect((shape.props as RectProps).w).toBe(0); 431 + expect((shape.props as RectProps).h).toBe(0); 432 + expect(result.ui.selectionIds).toEqual([shape.id]); 433 + }); 434 + 435 + it("should update rect dimensions on pointer move", () => { 436 + let result = tool.onAction( 437 + initialState, 438 + Action.pointerDown( 439 + { x: 100, y: 100 }, 440 + { x: 100, y: 100 }, 441 + 0, 442 + PointerButtons.create(true, false, false), 443 + Modifiers.create(), 444 + ), 445 + ); 446 + 447 + result = tool.onAction( 448 + result, 449 + Action.pointerMove( 450 + { x: 200, y: 150 }, 451 + { x: 200, y: 150 }, 452 + PointerButtons.create(true, false, false), 453 + Modifiers.create(), 454 + ), 455 + ); 456 + 457 + const shapeId = Object.keys(result.doc.shapes)[0]; 458 + const shape = result.doc.shapes[shapeId]; 459 + 460 + expect(shape.type).toBe("rect"); 461 + expect(shape.x).toBe(100); 462 + expect(shape.y).toBe(100); 463 + expect((shape.props as RectProps).w).toBe(100); 464 + expect((shape.props as RectProps).h).toBe(50); 465 + }); 466 + 467 + it("should handle negative dragging (drag up-left)", () => { 468 + let result = tool.onAction( 469 + initialState, 470 + Action.pointerDown( 471 + { x: 200, y: 200 }, 472 + { x: 200, y: 200 }, 473 + 0, 474 + PointerButtons.create(true, false, false), 475 + Modifiers.create(), 476 + ), 477 + ); 478 + 479 + result = tool.onAction( 480 + result, 481 + Action.pointerMove( 482 + { x: 100, y: 100 }, 483 + { x: 100, y: 100 }, 484 + PointerButtons.create(true, false, false), 485 + Modifiers.create(), 486 + ), 487 + ); 488 + 489 + const shapeId = Object.keys(result.doc.shapes)[0]; 490 + const shape = result.doc.shapes[shapeId]; 491 + 492 + expect(shape.type).toBe("rect"); 493 + expect(shape.x).toBe(100); 494 + expect(shape.y).toBe(100); 495 + expect((shape.props as RectProps).w).toBe(100); 496 + expect((shape.props as RectProps).h).toBe(100); 497 + }); 498 + 499 + it("should remove shape if too small on pointer up", () => { 500 + let result = tool.onAction( 501 + initialState, 502 + Action.pointerDown( 503 + { x: 100, y: 100 }, 504 + { x: 100, y: 100 }, 505 + 0, 506 + PointerButtons.create(true, false, false), 507 + Modifiers.create(), 508 + ), 509 + ); 510 + 511 + result = tool.onAction( 512 + result, 513 + Action.pointerMove( 514 + { x: 102, y: 102 }, 515 + { x: 102, y: 102 }, 516 + PointerButtons.create(true, false, false), 517 + Modifiers.create(), 518 + ), 519 + ); 520 + 521 + result = tool.onAction( 522 + result, 523 + Action.pointerUp( 524 + { x: 102, y: 102 }, 525 + { x: 102, y: 102 }, 526 + 0, 527 + PointerButtons.create(false, false, false), 528 + Modifiers.create(), 529 + ), 530 + ); 531 + 532 + expect(Object.keys(result.doc.shapes).length).toBe(0); 533 + expect(result.ui.selectionIds).toEqual([]); 534 + }); 535 + 536 + it("should keep shape if large enough on pointer up", () => { 537 + let result = tool.onAction( 538 + initialState, 539 + Action.pointerDown( 540 + { x: 100, y: 100 }, 541 + { x: 100, y: 100 }, 542 + 0, 543 + PointerButtons.create(true, false, false), 544 + Modifiers.create(), 545 + ), 546 + ); 547 + 548 + result = tool.onAction( 549 + result, 550 + Action.pointerMove( 551 + { x: 200, y: 200 }, 552 + { x: 200, y: 200 }, 553 + PointerButtons.create(true, false, false), 554 + Modifiers.create(), 555 + ), 556 + ); 557 + 558 + result = tool.onAction( 559 + result, 560 + Action.pointerUp( 561 + { x: 200, y: 200 }, 562 + { x: 200, y: 200 }, 563 + 0, 564 + PointerButtons.create(false, false, false), 565 + Modifiers.create(), 566 + ), 567 + ); 568 + 569 + expect(Object.keys(result.doc.shapes).length).toBe(1); 570 + }); 571 + 572 + it("should cancel shape creation on Escape", () => { 573 + let result = tool.onAction( 574 + initialState, 575 + Action.pointerDown( 576 + { x: 100, y: 100 }, 577 + { x: 100, y: 100 }, 578 + 0, 579 + PointerButtons.create(true, false, false), 580 + Modifiers.create(), 581 + ), 582 + ); 583 + 584 + result = tool.onAction(result, Action.keyDown("Escape", "Escape", Modifiers.create())); 585 + 586 + expect(Object.keys(result.doc.shapes).length).toBe(0); 587 + expect(result.ui.selectionIds).toEqual([]); 588 + }); 589 + 590 + it("should cleanup on tool exit", () => { 591 + let result = tool.onAction( 592 + initialState, 593 + Action.pointerDown( 594 + { x: 100, y: 100 }, 595 + { x: 100, y: 100 }, 596 + 0, 597 + PointerButtons.create(true, false, false), 598 + Modifiers.create(), 599 + ), 600 + ); 601 + 602 + result = tool.onExit(result); 603 + 604 + expect(Object.keys(result.doc.shapes).length).toBe(0); 605 + expect(result.ui.selectionIds).toEqual([]); 606 + }); 607 + }); 608 + }); 609 + 610 + describe("EllipseTool", () => { 611 + let tool: EllipseTool; 612 + let initialState: EditorState; 613 + let page: PageRecord; 614 + 615 + beforeEach(() => { 616 + tool = new EllipseTool(); 617 + page = PageRecord.create("Test Page"); 618 + 619 + initialState = { 620 + ...EditorState.create(), 621 + doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, 622 + ui: { currentPageId: page.id, selectionIds: [], toolId: "ellipse" }, 623 + }; 624 + }); 625 + 626 + it("should create an ellipse shape on pointer down", () => { 627 + const result = tool.onAction( 628 + initialState, 629 + Action.pointerDown( 630 + { x: 100, y: 100 }, 631 + { x: 100, y: 100 }, 632 + 0, 633 + PointerButtons.create(true, false, false), 634 + Modifiers.create(), 635 + ), 636 + ); 637 + 638 + const shapeIds = Object.keys(result.doc.shapes); 639 + expect(shapeIds.length).toBe(1); 640 + 641 + const shape = result.doc.shapes[shapeIds[0]]; 642 + expect(shape.type).toBe("ellipse"); 643 + expect(shape.x).toBe(100); 644 + expect(shape.y).toBe(100); 645 + }); 646 + 647 + it("should update ellipse dimensions on pointer move", () => { 648 + let result = tool.onAction( 649 + initialState, 650 + Action.pointerDown( 651 + { x: 100, y: 100 }, 652 + { x: 100, y: 100 }, 653 + 0, 654 + PointerButtons.create(true, false, false), 655 + Modifiers.create(), 656 + ), 657 + ); 658 + 659 + result = tool.onAction( 660 + result, 661 + Action.pointerMove( 662 + { x: 250, y: 200 }, 663 + { x: 250, y: 200 }, 664 + PointerButtons.create(true, false, false), 665 + Modifiers.create(), 666 + ), 667 + ); 668 + 669 + const shapeId = Object.keys(result.doc.shapes)[0]; 670 + const shape = result.doc.shapes[shapeId]; 671 + 672 + expect(shape.type).toBe("ellipse"); 673 + expect((shape.props as EllipseProps).w).toBe(150); 674 + expect((shape.props as EllipseProps).h).toBe(100); 675 + }); 676 + 677 + it("should remove ellipse if too small on pointer up", () => { 678 + let result = tool.onAction( 679 + initialState, 680 + Action.pointerDown( 681 + { x: 100, y: 100 }, 682 + { x: 100, y: 100 }, 683 + 0, 684 + PointerButtons.create(true, false, false), 685 + Modifiers.create(), 686 + ), 687 + ); 688 + 689 + result = tool.onAction( 690 + result, 691 + Action.pointerMove( 692 + { x: 103, y: 103 }, 693 + { x: 103, y: 103 }, 694 + PointerButtons.create(true, false, false), 695 + Modifiers.create(), 696 + ), 697 + ); 698 + 699 + result = tool.onAction( 700 + result, 701 + Action.pointerUp( 702 + { x: 103, y: 103 }, 703 + { x: 103, y: 103 }, 704 + 0, 705 + PointerButtons.create(false, false, false), 706 + Modifiers.create(), 707 + ), 708 + ); 709 + 710 + expect(Object.keys(result.doc.shapes).length).toBe(0); 711 + }); 712 + }); 713 + 714 + describe("LineTool", () => { 715 + let tool: LineTool; 716 + let initialState: EditorState; 717 + let page: PageRecord; 718 + 719 + beforeEach(() => { 720 + tool = new LineTool(); 721 + page = PageRecord.create("Test Page"); 722 + 723 + initialState = { 724 + ...EditorState.create(), 725 + doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, 726 + ui: { currentPageId: page.id, selectionIds: [], toolId: "line" }, 727 + }; 728 + }); 729 + 730 + it("should create a line shape on pointer down", () => { 731 + const result = tool.onAction( 732 + initialState, 733 + Action.pointerDown( 734 + { x: 100, y: 100 }, 735 + { x: 100, y: 100 }, 736 + 0, 737 + PointerButtons.create(true, false, false), 738 + Modifiers.create(), 739 + ), 740 + ); 741 + 742 + const shapeIds = Object.keys(result.doc.shapes); 743 + expect(shapeIds.length).toBe(1); 744 + 745 + const shape = result.doc.shapes[shapeIds[0]]; 746 + expect(shape.type).toBe("line"); 747 + expect(shape.x).toBe(100); 748 + expect(shape.y).toBe(100); 749 + expect((shape.props as LineProps).a).toEqual({ x: 0, y: 0 }); 750 + expect((shape.props as LineProps).b).toEqual({ x: 0, y: 0 }); 751 + }); 752 + 753 + it("should update line endpoint on pointer move", () => { 754 + let result = tool.onAction( 755 + initialState, 756 + Action.pointerDown( 757 + { x: 100, y: 100 }, 758 + { x: 100, y: 100 }, 759 + 0, 760 + PointerButtons.create(true, false, false), 761 + Modifiers.create(), 762 + ), 763 + ); 764 + 765 + result = tool.onAction( 766 + result, 767 + Action.pointerMove( 768 + { x: 200, y: 150 }, 769 + { x: 200, y: 150 }, 770 + PointerButtons.create(true, false, false), 771 + Modifiers.create(), 772 + ), 773 + ); 774 + 775 + const shapeId = Object.keys(result.doc.shapes)[0]; 776 + const shape = result.doc.shapes[shapeId]; 777 + 778 + expect(shape.type).toBe("line"); 779 + expect((shape.props as LineProps).b).toEqual({ x: 100, y: 50 }); 780 + }); 781 + 782 + it("should remove line if too short on pointer up", () => { 783 + let result = tool.onAction( 784 + initialState, 785 + Action.pointerDown( 786 + { x: 100, y: 100 }, 787 + { x: 100, y: 100 }, 788 + 0, 789 + PointerButtons.create(true, false, false), 790 + Modifiers.create(), 791 + ), 792 + ); 793 + 794 + result = tool.onAction( 795 + result, 796 + Action.pointerMove( 797 + { x: 102, y: 102 }, 798 + { x: 102, y: 102 }, 799 + PointerButtons.create(true, false, false), 800 + Modifiers.create(), 801 + ), 802 + ); 803 + 804 + result = tool.onAction( 805 + result, 806 + Action.pointerUp( 807 + { x: 102, y: 102 }, 808 + { x: 102, y: 102 }, 809 + 0, 810 + PointerButtons.create(false, false, false), 811 + Modifiers.create(), 812 + ), 813 + ); 814 + 815 + expect(Object.keys(result.doc.shapes).length).toBe(0); 816 + }); 817 + 818 + it("should keep line if long enough on pointer up", () => { 819 + let result = tool.onAction( 820 + initialState, 821 + Action.pointerDown( 822 + { x: 100, y: 100 }, 823 + { x: 100, y: 100 }, 824 + 0, 825 + PointerButtons.create(true, false, false), 826 + Modifiers.create(), 827 + ), 828 + ); 829 + 830 + result = tool.onAction( 831 + result, 832 + Action.pointerMove( 833 + { x: 200, y: 200 }, 834 + { x: 200, y: 200 }, 835 + PointerButtons.create(true, false, false), 836 + Modifiers.create(), 837 + ), 838 + ); 839 + 840 + result = tool.onAction( 841 + result, 842 + Action.pointerUp( 843 + { x: 200, y: 200 }, 844 + { x: 200, y: 200 }, 845 + 0, 846 + PointerButtons.create(false, false, false), 847 + Modifiers.create(), 848 + ), 849 + ); 850 + 851 + expect(Object.keys(result.doc.shapes).length).toBe(1); 852 + }); 853 + }); 854 + 855 + describe("ArrowTool", () => { 856 + let tool: ArrowTool; 857 + let initialState: EditorState; 858 + let page: PageRecord; 859 + 860 + beforeEach(() => { 861 + tool = new ArrowTool(); 862 + page = PageRecord.create("Test Page"); 863 + 864 + initialState = { 865 + ...EditorState.create(), 866 + doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, 867 + ui: { currentPageId: page.id, selectionIds: [], toolId: "arrow" }, 868 + }; 869 + }); 870 + 871 + it("should create an arrow shape on pointer down", () => { 872 + const result = tool.onAction( 873 + initialState, 874 + Action.pointerDown( 875 + { x: 100, y: 100 }, 876 + { x: 100, y: 100 }, 877 + 0, 878 + PointerButtons.create(true, false, false), 879 + Modifiers.create(), 880 + ), 881 + ); 882 + 883 + const shapeIds = Object.keys(result.doc.shapes); 884 + expect(shapeIds.length).toBe(1); 885 + 886 + const shape = result.doc.shapes[shapeIds[0]]; 887 + expect(shape.type).toBe("arrow"); 888 + expect(shape.x).toBe(100); 889 + expect(shape.y).toBe(100); 890 + }); 891 + 892 + it("should update arrow endpoint on pointer move", () => { 893 + let result = tool.onAction( 894 + initialState, 895 + Action.pointerDown( 896 + { x: 100, y: 100 }, 897 + { x: 100, y: 100 }, 898 + 0, 899 + PointerButtons.create(true, false, false), 900 + Modifiers.create(), 901 + ), 902 + ); 903 + 904 + result = tool.onAction( 905 + result, 906 + Action.pointerMove( 907 + { x: 300, y: 200 }, 908 + { x: 300, y: 200 }, 909 + PointerButtons.create(true, false, false), 910 + Modifiers.create(), 911 + ), 912 + ); 913 + 914 + const shapeId = Object.keys(result.doc.shapes)[0]; 915 + const shape = result.doc.shapes[shapeId]; 916 + 917 + expect(shape.type).toBe("arrow"); 918 + expect((shape.props as ArrowProps).b).toEqual({ x: 200, y: 100 }); 919 + }); 920 + 921 + it("should remove arrow if too short on pointer up", () => { 922 + let result = tool.onAction( 923 + initialState, 924 + Action.pointerDown( 925 + { x: 100, y: 100 }, 926 + { x: 100, y: 100 }, 927 + 0, 928 + PointerButtons.create(true, false, false), 929 + Modifiers.create(), 930 + ), 931 + ); 932 + 933 + result = tool.onAction( 934 + result, 935 + Action.pointerMove( 936 + { x: 101, y: 101 }, 937 + { x: 101, y: 101 }, 938 + PointerButtons.create(true, false, false), 939 + Modifiers.create(), 940 + ), 941 + ); 942 + 943 + result = tool.onAction( 944 + result, 945 + Action.pointerUp( 946 + { x: 101, y: 101 }, 947 + { x: 101, y: 101 }, 948 + 0, 949 + PointerButtons.create(false, false, false), 950 + Modifiers.create(), 951 + ), 952 + ); 953 + 954 + expect(Object.keys(result.doc.shapes).length).toBe(0); 955 + }); 956 + }); 957 + 958 + describe("TextTool", () => { 959 + let tool: TextTool; 960 + let initialState: EditorState; 961 + let page: PageRecord; 962 + 963 + beforeEach(() => { 964 + tool = new TextTool(); 965 + page = PageRecord.create("Test Page"); 966 + 967 + initialState = { 968 + ...EditorState.create(), 969 + doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, 970 + ui: { currentPageId: page.id, selectionIds: [], toolId: "text" }, 971 + }; 972 + }); 973 + 974 + it("should create a text shape on pointer down", () => { 975 + const result = tool.onAction( 976 + initialState, 977 + Action.pointerDown( 978 + { x: 150, y: 200 }, 979 + { x: 150, y: 200 }, 980 + 0, 981 + PointerButtons.create(true, false, false), 982 + Modifiers.create(), 983 + ), 984 + ); 985 + 986 + const shapeIds = Object.keys(result.doc.shapes); 987 + expect(shapeIds.length).toBe(1); 988 + 989 + const shape = result.doc.shapes[shapeIds[0]]; 990 + expect(shape.type).toBe("text"); 991 + expect(shape.x).toBe(150); 992 + expect(shape.y).toBe(200); 993 + expect((shape.props as TextProps).text).toBe("Text"); 994 + expect((shape.props as TextProps).fontSize).toBe(16); 995 + expect(result.ui.selectionIds).toEqual([shape.id]); 996 + }); 997 + 998 + it("should create new text shape on each click", () => { 999 + let result = tool.onAction( 1000 + initialState, 1001 + Action.pointerDown( 1002 + { x: 100, y: 100 }, 1003 + { x: 100, y: 100 }, 1004 + 0, 1005 + PointerButtons.create(true, false, false), 1006 + Modifiers.create(), 1007 + ), 1008 + ); 1009 + 1010 + result = tool.onAction( 1011 + result, 1012 + Action.pointerDown( 1013 + { x: 200, y: 200 }, 1014 + { x: 200, y: 200 }, 1015 + 0, 1016 + PointerButtons.create(true, false, false), 1017 + Modifiers.create(), 1018 + ), 1019 + ); 1020 + 1021 + expect(Object.keys(result.doc.shapes).length).toBe(2); 1022 + }); 1023 + 1024 + it("should not respond to pointer move or up", () => { 1025 + let result = tool.onAction( 1026 + initialState, 1027 + Action.pointerDown( 1028 + { x: 100, y: 100 }, 1029 + { x: 100, y: 100 }, 1030 + 0, 1031 + PointerButtons.create(true, false, false), 1032 + Modifiers.create(), 1033 + ), 1034 + ); 1035 + 1036 + const beforeMove = result; 1037 + const shapeCountBefore = Object.keys(result.doc.shapes).length; 1038 + 1039 + result = tool.onAction( 1040 + result, 1041 + Action.pointerMove( 1042 + { x: 200, y: 200 }, 1043 + { x: 200, y: 200 }, 1044 + PointerButtons.create(true, false, false), 1045 + Modifiers.create(), 1046 + ), 1047 + ); 1048 + 1049 + expect(result).toBe(beforeMove); 1050 + expect(Object.keys(result.doc.shapes).length).toBe(shapeCountBefore); 1051 + 1052 + result = tool.onAction( 1053 + result, 1054 + Action.pointerUp( 1055 + { x: 200, y: 200 }, 1056 + { x: 200, y: 200 }, 1057 + 0, 1058 + PointerButtons.create(false, false, false), 1059 + Modifiers.create(), 1060 + ), 1061 + ); 1062 + 1063 + expect(result).toBe(beforeMove); 1064 + expect(Object.keys(result.doc.shapes).length).toBe(shapeCountBefore); 1065 + }); 1066 + });
+709
packages/core/src/tools.ts
··· 1 1 import type { Action } from "./actions"; 2 2 import { hitTestPoint, shapeBounds } from "./geom"; 3 3 import { Box2, Vec2 } from "./math"; 4 + import { createId, ShapeRecord } from "./model"; 4 5 import type { EditorState, ToolId } from "./reactivity"; 5 6 import { getCurrentPage } from "./reactivity"; 6 7 ··· 399 400 return Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); 400 401 } 401 402 } 403 + 404 + /** 405 + * Internal state for shape creation tools 406 + */ 407 + type ShapeCreationToolState = { 408 + /** Whether we're currently creating a shape */ 409 + isCreating: boolean; 410 + /** World coordinates where creation started */ 411 + startWorld: Vec2 | null; 412 + /** ID of the shape being created */ 413 + creatingShapeId: string | null; 414 + }; 415 + 416 + /** 417 + * Minimum size threshold for shapes (in world units) 418 + * Shapes smaller than this on either dimension will be deleted 419 + */ 420 + const MIN_SHAPE_SIZE = 5; 421 + 422 + /** 423 + * Rect tool - creates rectangle shapes by dragging 424 + * 425 + * Features: 426 + * - Drag to create a rectangle from start point to current point 427 + * - Click-cancel: shapes too small are deleted on pointer up 428 + */ 429 + export class RectTool implements Tool { 430 + readonly id: ToolId = "rect"; 431 + private toolState: ShapeCreationToolState; 432 + 433 + constructor() { 434 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 435 + } 436 + 437 + onEnter(state: EditorState): EditorState { 438 + this.resetToolState(); 439 + return state; 440 + } 441 + 442 + onExit(state: EditorState): EditorState { 443 + let newState = state; 444 + if (this.toolState.creatingShapeId) { 445 + newState = this.cancelShapeCreation(state); 446 + } 447 + this.resetToolState(); 448 + return newState; 449 + } 450 + 451 + onAction(state: EditorState, action: Action): EditorState { 452 + switch (action.type) { 453 + case "pointer-down": { 454 + return this.handlePointerDown(state, action); 455 + } 456 + case "pointer-move": { 457 + return this.handlePointerMove(state, action); 458 + } 459 + case "pointer-up": { 460 + return this.handlePointerUp(state, action); 461 + } 462 + case "key-down": { 463 + return this.handleKeyDown(state, action); 464 + } 465 + default: { 466 + return state; 467 + } 468 + } 469 + } 470 + 471 + private handlePointerDown(state: EditorState, action: Action): EditorState { 472 + if (action.type !== "pointer-down") return state; 473 + 474 + const currentPage = getCurrentPage(state); 475 + if (!currentPage) return state; 476 + 477 + const shapeId = createId("shape"); 478 + 479 + const shape = ShapeRecord.createRect(currentPage.id, action.world.x, action.world.y, { 480 + w: 0, 481 + h: 0, 482 + fill: "#4a90e2", 483 + stroke: "#2e5c8a", 484 + radius: 4, 485 + }, shapeId); 486 + 487 + this.toolState.isCreating = true; 488 + this.toolState.startWorld = action.world; 489 + this.toolState.creatingShapeId = shapeId; 490 + 491 + const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 492 + 493 + return { 494 + ...state, 495 + doc: { 496 + ...state.doc, 497 + shapes: { ...state.doc.shapes, [shapeId]: shape }, 498 + pages: { ...state.doc.pages, [currentPage.id]: newPage }, 499 + }, 500 + ui: { ...state.ui, selectionIds: [shapeId] }, 501 + }; 502 + } 503 + 504 + private handlePointerMove(state: EditorState, action: Action): EditorState { 505 + if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 506 + if (!this.toolState.creatingShapeId) return state; 507 + 508 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 509 + if (!shape || shape.type !== "rect") return state; 510 + 511 + const delta = Vec2.sub(action.world, this.toolState.startWorld); 512 + const w = Math.abs(delta.x); 513 + const h = Math.abs(delta.y); 514 + 515 + const x = delta.x < 0 ? this.toolState.startWorld.x - w : this.toolState.startWorld.x; 516 + const y = delta.y < 0 ? this.toolState.startWorld.y - h : this.toolState.startWorld.y; 517 + 518 + const updatedShape = { ...shape, x, y, props: { ...shape.props, w, h } }; 519 + 520 + return { 521 + ...state, 522 + doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 523 + }; 524 + } 525 + 526 + private handlePointerUp(state: EditorState, action: Action): EditorState { 527 + if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 528 + 529 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 530 + if (!shape || shape.type !== "rect") return state; 531 + 532 + let newState = state; 533 + 534 + if (shape.props.w < MIN_SHAPE_SIZE || shape.props.h < MIN_SHAPE_SIZE) { 535 + newState = this.cancelShapeCreation(state); 536 + } 537 + 538 + this.resetToolState(); 539 + return newState; 540 + } 541 + 542 + private handleKeyDown(state: EditorState, action: Action): EditorState { 543 + if (action.type !== "key-down") return state; 544 + 545 + if (action.key === "Escape" && this.toolState.creatingShapeId) { 546 + const newState = this.cancelShapeCreation(state); 547 + this.resetToolState(); 548 + return newState; 549 + } 550 + 551 + return state; 552 + } 553 + 554 + private cancelShapeCreation(state: EditorState): EditorState { 555 + if (!this.toolState.creatingShapeId) return state; 556 + 557 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 558 + if (!shape) return state; 559 + 560 + const newShapes = { ...state.doc.shapes }; 561 + delete newShapes[this.toolState.creatingShapeId]; 562 + 563 + const currentPage = getCurrentPage(state); 564 + if (!currentPage) return state; 565 + 566 + const newPage = { 567 + ...currentPage, 568 + shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 569 + }; 570 + 571 + return { 572 + ...state, 573 + doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 574 + ui: { ...state.ui, selectionIds: [] }, 575 + }; 576 + } 577 + 578 + private resetToolState(): void { 579 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 580 + } 581 + } 582 + 583 + /** 584 + * Ellipse tool - creates ellipse shapes by dragging 585 + * 586 + * Features: 587 + * - Drag to create an ellipse from start point to current point 588 + * - Click-cancel: shapes too small are deleted on pointer up 589 + */ 590 + export class EllipseTool implements Tool { 591 + readonly id: ToolId = "ellipse"; 592 + private toolState: ShapeCreationToolState; 593 + 594 + constructor() { 595 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 596 + } 597 + 598 + onEnter(state: EditorState): EditorState { 599 + this.resetToolState(); 600 + return state; 601 + } 602 + 603 + onExit(state: EditorState): EditorState { 604 + let newState = state; 605 + if (this.toolState.creatingShapeId) { 606 + newState = this.cancelShapeCreation(state); 607 + } 608 + this.resetToolState(); 609 + return newState; 610 + } 611 + 612 + onAction(state: EditorState, action: Action): EditorState { 613 + switch (action.type) { 614 + case "pointer-down": { 615 + return this.handlePointerDown(state, action); 616 + } 617 + case "pointer-move": { 618 + return this.handlePointerMove(state, action); 619 + } 620 + case "pointer-up": { 621 + return this.handlePointerUp(state, action); 622 + } 623 + case "key-down": { 624 + return this.handleKeyDown(state, action); 625 + } 626 + default: { 627 + return state; 628 + } 629 + } 630 + } 631 + 632 + private handlePointerDown(state: EditorState, action: Action): EditorState { 633 + if (action.type !== "pointer-down") return state; 634 + 635 + const currentPage = getCurrentPage(state); 636 + if (!currentPage) return state; 637 + 638 + const shapeId = createId("shape"); 639 + 640 + const shape = ShapeRecord.createEllipse(currentPage.id, action.world.x, action.world.y, { 641 + w: 0, 642 + h: 0, 643 + fill: "#51cf66", 644 + stroke: "#2f9e44", 645 + }, shapeId); 646 + 647 + this.toolState.isCreating = true; 648 + this.toolState.startWorld = action.world; 649 + this.toolState.creatingShapeId = shapeId; 650 + 651 + const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 652 + 653 + return { 654 + ...state, 655 + doc: { 656 + ...state.doc, 657 + shapes: { ...state.doc.shapes, [shapeId]: shape }, 658 + pages: { ...state.doc.pages, [currentPage.id]: newPage }, 659 + }, 660 + ui: { ...state.ui, selectionIds: [shapeId] }, 661 + }; 662 + } 663 + 664 + private handlePointerMove(state: EditorState, action: Action): EditorState { 665 + if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 666 + if (!this.toolState.creatingShapeId) return state; 667 + 668 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 669 + if (!shape || shape.type !== "ellipse") return state; 670 + 671 + const delta = Vec2.sub(action.world, this.toolState.startWorld); 672 + const w = Math.abs(delta.x); 673 + const h = Math.abs(delta.y); 674 + 675 + const x = delta.x < 0 ? this.toolState.startWorld.x - w : this.toolState.startWorld.x; 676 + const y = delta.y < 0 ? this.toolState.startWorld.y - h : this.toolState.startWorld.y; 677 + 678 + const updatedShape = { ...shape, x, y, props: { ...shape.props, w, h } }; 679 + 680 + return { 681 + ...state, 682 + doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 683 + }; 684 + } 685 + 686 + private handlePointerUp(state: EditorState, action: Action): EditorState { 687 + if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 688 + 689 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 690 + if (!shape || shape.type !== "ellipse") return state; 691 + 692 + let newState = state; 693 + 694 + if (shape.props.w < MIN_SHAPE_SIZE || shape.props.h < MIN_SHAPE_SIZE) { 695 + newState = this.cancelShapeCreation(state); 696 + } 697 + 698 + this.resetToolState(); 699 + return newState; 700 + } 701 + 702 + private handleKeyDown(state: EditorState, action: Action): EditorState { 703 + if (action.type !== "key-down") return state; 704 + 705 + if (action.key === "Escape" && this.toolState.creatingShapeId) { 706 + const newState = this.cancelShapeCreation(state); 707 + this.resetToolState(); 708 + return newState; 709 + } 710 + 711 + return state; 712 + } 713 + 714 + private cancelShapeCreation(state: EditorState): EditorState { 715 + if (!this.toolState.creatingShapeId) return state; 716 + 717 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 718 + if (!shape) return state; 719 + 720 + const newShapes = { ...state.doc.shapes }; 721 + delete newShapes[this.toolState.creatingShapeId]; 722 + 723 + const currentPage = getCurrentPage(state); 724 + if (!currentPage) return state; 725 + 726 + const newPage = { 727 + ...currentPage, 728 + shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 729 + }; 730 + 731 + return { 732 + ...state, 733 + doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 734 + ui: { ...state.ui, selectionIds: [] }, 735 + }; 736 + } 737 + 738 + private resetToolState(): void { 739 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 740 + } 741 + } 742 + 743 + /** 744 + * Line tool - creates line shapes by dragging 745 + * 746 + * Features: 747 + * - Drag to create a line from start point (a) to current point (b) 748 + * - Click-cancel: very short lines are deleted on pointer up 749 + */ 750 + export class LineTool implements Tool { 751 + readonly id: ToolId = "line"; 752 + private toolState: ShapeCreationToolState; 753 + 754 + constructor() { 755 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 756 + } 757 + 758 + onEnter(state: EditorState): EditorState { 759 + this.resetToolState(); 760 + return state; 761 + } 762 + 763 + onExit(state: EditorState): EditorState { 764 + let newState = state; 765 + if (this.toolState.creatingShapeId) { 766 + newState = this.cancelShapeCreation(state); 767 + } 768 + this.resetToolState(); 769 + return newState; 770 + } 771 + 772 + onAction(state: EditorState, action: Action): EditorState { 773 + switch (action.type) { 774 + case "pointer-down": { 775 + return this.handlePointerDown(state, action); 776 + } 777 + case "pointer-move": { 778 + return this.handlePointerMove(state, action); 779 + } 780 + case "pointer-up": { 781 + return this.handlePointerUp(state, action); 782 + } 783 + case "key-down": { 784 + return this.handleKeyDown(state, action); 785 + } 786 + default: { 787 + return state; 788 + } 789 + } 790 + } 791 + 792 + private handlePointerDown(state: EditorState, action: Action): EditorState { 793 + if (action.type !== "pointer-down") return state; 794 + 795 + const currentPage = getCurrentPage(state); 796 + if (!currentPage) return state; 797 + 798 + const shapeId = createId("shape"); 799 + 800 + const shape = ShapeRecord.createLine(currentPage.id, action.world.x, action.world.y, { 801 + a: { x: 0, y: 0 }, 802 + b: { x: 0, y: 0 }, 803 + stroke: "#495057", 804 + width: 2, 805 + }, shapeId); 806 + 807 + this.toolState.isCreating = true; 808 + this.toolState.startWorld = action.world; 809 + this.toolState.creatingShapeId = shapeId; 810 + 811 + const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 812 + 813 + return { 814 + ...state, 815 + doc: { 816 + ...state.doc, 817 + shapes: { ...state.doc.shapes, [shapeId]: shape }, 818 + pages: { ...state.doc.pages, [currentPage.id]: newPage }, 819 + }, 820 + ui: { ...state.ui, selectionIds: [shapeId] }, 821 + }; 822 + } 823 + 824 + private handlePointerMove(state: EditorState, action: Action): EditorState { 825 + if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 826 + if (!this.toolState.creatingShapeId) return state; 827 + 828 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 829 + if (!shape || shape.type !== "line") return state; 830 + 831 + const b = Vec2.sub(action.world, this.toolState.startWorld); 832 + const updatedShape = { ...shape, props: { ...shape.props, b } }; 833 + 834 + return { 835 + ...state, 836 + doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 837 + }; 838 + } 839 + 840 + private handlePointerUp(state: EditorState, action: Action): EditorState { 841 + if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 842 + 843 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 844 + if (!shape || shape.type !== "line") return state; 845 + 846 + let newState = state; 847 + 848 + const lineLength = Vec2.len(shape.props.b); 849 + if (lineLength < MIN_SHAPE_SIZE) { 850 + newState = this.cancelShapeCreation(state); 851 + } 852 + 853 + this.resetToolState(); 854 + return newState; 855 + } 856 + 857 + private handleKeyDown(state: EditorState, action: Action): EditorState { 858 + if (action.type !== "key-down") return state; 859 + 860 + if (action.key === "Escape" && this.toolState.creatingShapeId) { 861 + const newState = this.cancelShapeCreation(state); 862 + this.resetToolState(); 863 + return newState; 864 + } 865 + 866 + return state; 867 + } 868 + 869 + private cancelShapeCreation(state: EditorState): EditorState { 870 + if (!this.toolState.creatingShapeId) return state; 871 + 872 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 873 + if (!shape) return state; 874 + 875 + const newShapes = { ...state.doc.shapes }; 876 + delete newShapes[this.toolState.creatingShapeId]; 877 + 878 + const currentPage = getCurrentPage(state); 879 + if (!currentPage) return state; 880 + 881 + const newPage = { 882 + ...currentPage, 883 + shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 884 + }; 885 + 886 + return { 887 + ...state, 888 + doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 889 + ui: { ...state.ui, selectionIds: [] }, 890 + }; 891 + } 892 + 893 + private resetToolState(): void { 894 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 895 + } 896 + } 897 + 898 + /** 899 + * Arrow tool - creates arrow shapes by dragging 900 + * 901 + * Features: 902 + * - Drag to create an arrow from start point (a) to current point (b) 903 + * - Click-cancel: very short arrows are deleted on pointer up 904 + */ 905 + export class ArrowTool implements Tool { 906 + readonly id: ToolId = "arrow"; 907 + private toolState: ShapeCreationToolState; 908 + 909 + constructor() { 910 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 911 + } 912 + 913 + onEnter(state: EditorState): EditorState { 914 + this.resetToolState(); 915 + return state; 916 + } 917 + 918 + onExit(state: EditorState): EditorState { 919 + let newState = state; 920 + if (this.toolState.creatingShapeId) { 921 + newState = this.cancelShapeCreation(state); 922 + } 923 + this.resetToolState(); 924 + return newState; 925 + } 926 + 927 + onAction(state: EditorState, action: Action): EditorState { 928 + switch (action.type) { 929 + case "pointer-down": { 930 + return this.handlePointerDown(state, action); 931 + } 932 + case "pointer-move": { 933 + return this.handlePointerMove(state, action); 934 + } 935 + case "pointer-up": { 936 + return this.handlePointerUp(state, action); 937 + } 938 + case "key-down": { 939 + return this.handleKeyDown(state, action); 940 + } 941 + default: { 942 + return state; 943 + } 944 + } 945 + } 946 + 947 + private handlePointerDown(state: EditorState, action: Action): EditorState { 948 + if (action.type !== "pointer-down") return state; 949 + 950 + const currentPage = getCurrentPage(state); 951 + if (!currentPage) return state; 952 + 953 + const shapeId = createId("shape"); 954 + 955 + const shape = ShapeRecord.createArrow(currentPage.id, action.world.x, action.world.y, { 956 + a: { x: 0, y: 0 }, 957 + b: { x: 0, y: 0 }, 958 + stroke: "#495057", 959 + width: 2, 960 + }, shapeId); 961 + 962 + this.toolState.isCreating = true; 963 + this.toolState.startWorld = action.world; 964 + this.toolState.creatingShapeId = shapeId; 965 + 966 + const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 967 + 968 + return { 969 + ...state, 970 + doc: { 971 + ...state.doc, 972 + shapes: { ...state.doc.shapes, [shapeId]: shape }, 973 + pages: { ...state.doc.pages, [currentPage.id]: newPage }, 974 + }, 975 + ui: { ...state.ui, selectionIds: [shapeId] }, 976 + }; 977 + } 978 + 979 + private handlePointerMove(state: EditorState, action: Action): EditorState { 980 + if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 981 + if (!this.toolState.creatingShapeId) return state; 982 + 983 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 984 + if (!shape || shape.type !== "arrow") return state; 985 + 986 + const b = Vec2.sub(action.world, this.toolState.startWorld); 987 + const updatedShape = { ...shape, props: { ...shape.props, b } }; 988 + 989 + return { 990 + ...state, 991 + doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 992 + }; 993 + } 994 + 995 + private handlePointerUp(state: EditorState, action: Action): EditorState { 996 + if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 997 + 998 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 999 + if (!shape || shape.type !== "arrow") return state; 1000 + 1001 + let newState = state; 1002 + 1003 + const arrowLength = Vec2.len(shape.props.b); 1004 + if (arrowLength < MIN_SHAPE_SIZE) { 1005 + newState = this.cancelShapeCreation(state); 1006 + } 1007 + 1008 + this.resetToolState(); 1009 + return newState; 1010 + } 1011 + 1012 + private handleKeyDown(state: EditorState, action: Action): EditorState { 1013 + if (action.type !== "key-down") return state; 1014 + 1015 + if (action.key === "Escape" && this.toolState.creatingShapeId) { 1016 + const newState = this.cancelShapeCreation(state); 1017 + this.resetToolState(); 1018 + return newState; 1019 + } 1020 + 1021 + return state; 1022 + } 1023 + 1024 + private cancelShapeCreation(state: EditorState): EditorState { 1025 + if (!this.toolState.creatingShapeId) return state; 1026 + 1027 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 1028 + if (!shape) return state; 1029 + 1030 + const newShapes = { ...state.doc.shapes }; 1031 + delete newShapes[this.toolState.creatingShapeId]; 1032 + 1033 + const currentPage = getCurrentPage(state); 1034 + if (!currentPage) return state; 1035 + 1036 + const newPage = { 1037 + ...currentPage, 1038 + shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 1039 + }; 1040 + 1041 + return { 1042 + ...state, 1043 + doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 1044 + ui: { ...state.ui, selectionIds: [] }, 1045 + }; 1046 + } 1047 + 1048 + private resetToolState(): void { 1049 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 1050 + } 1051 + } 1052 + 1053 + /** 1054 + * Text tool - creates text shapes on click 1055 + * 1056 + * Features: 1057 + * - Click to create a text shape at the pointer position 1058 + * - Text is created with default content "Text" 1059 + * - Shape is immediately selected after creation 1060 + */ 1061 + export class TextTool implements Tool { 1062 + readonly id: ToolId = "text"; 1063 + 1064 + onEnter(state: EditorState): EditorState { 1065 + return state; 1066 + } 1067 + 1068 + onExit(state: EditorState): EditorState { 1069 + return state; 1070 + } 1071 + 1072 + onAction(state: EditorState, action: Action): EditorState { 1073 + switch (action.type) { 1074 + case "pointer-down": { 1075 + return this.handlePointerDown(state, action); 1076 + } 1077 + default: { 1078 + return state; 1079 + } 1080 + } 1081 + } 1082 + 1083 + private handlePointerDown(state: EditorState, action: Action): EditorState { 1084 + if (action.type !== "pointer-down") return state; 1085 + 1086 + const currentPage = getCurrentPage(state); 1087 + if (!currentPage) return state; 1088 + 1089 + const shapeId = createId("shape"); 1090 + 1091 + const shape = ShapeRecord.createText(currentPage.id, action.world.x, action.world.y, { 1092 + text: "Text", 1093 + fontSize: 16, 1094 + fontFamily: "sans-serif", 1095 + color: "#000000", 1096 + }, shapeId); 1097 + 1098 + const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 1099 + 1100 + return { 1101 + ...state, 1102 + doc: { 1103 + ...state.doc, 1104 + shapes: { ...state.doc.shapes, [shapeId]: shape }, 1105 + pages: { ...state.doc.pages, [currentPage.id]: newPage }, 1106 + }, 1107 + ui: { ...state.ui, selectionIds: [shapeId] }, 1108 + }; 1109 + } 1110 + }