web based infinite canvas

feat: update pen tool with draft synchronization and frame coalescing

+199 -163
+4 -130
TODO.txt
··· 123 16. Milestone P: Desktop packaging (Tauri) *wb-P* 124 ================================================================================ 125 126 - Goal: same app works as a desktop app with filesystem access. 127 - 128 - Tauri + SvelteKit integration: 129 - [x] Configure SvelteKit for static/SPA output 130 - [x] Ensure SSR is disabled for desktop build 131 - [x] Configure Tauri to load the built assets 132 - 133 - File dialogs + FS: 134 - [x] Implement "Save As…" using Tauri dialog + fs APIs 135 - [x] Implement "Open…" using Tauri dialog + fs APIs 136 - [x] Add recent files list (v0: store paths in Tauri local storage) 137 - 138 - (DoD): 139 - - Desktop app opens/saves JSON files on disk and reopens them correctly. 140 141 ================================================================================ 142 17. Milestone Q: Performance + big docs (pragmatic) *wb-Q* ··· 260 20. Milestone T: Sketching / Pen Tool (perfect-freehand) *wb-T* 261 ================================================================================ 262 263 - Goal: 264 - Add a pen tool that produces smooth freehand strokes using perfect-freehand. 265 - Strokes are shapes, undo/redo-able (L), persisted (M), selectable, and render on 266 - Canvas2D. 267 - 268 - Refs: 269 - - perfect-freehand getStroke returns outline polygon points. 270 - - Options: size/thinning/smoothing/streamline/simulatePressure. 271 - 272 - ------------------------------------------------------------------------------ 273 - T1. Data model: Stroke shape 274 - ------------------------------------------------------------------------------ 275 - 276 - /packages/core/src/model.ts: 277 - [x] Add ShapeType: 'stroke' 278 - [x] StrokeShape props (persisted): 279 - - points: Array<[x,y,p?]> " world coords + optional pressure 280 - - style: { color, opacity } 281 - - brush: { size, thinning, smoothing, streamline, simulatePressure } 282 - [x] Derived (NOT persisted): 283 - - outline: computed via computeOutline() from geom.ts 284 - - bounds: computed via shapeBounds() from geom.ts 285 - 286 - (DoD): stroke serializes to JSON and loads back identically. 287 - 288 - ------------------------------------------------------------------------------ 289 - T2. Tool: pen (state machine) 290 - ------------------------------------------------------------------------------ 291 - 292 - /packages/core/src/tools/pen.ts (PenTool) 293 - [x] PointerDown: start draft, push first point 294 - [x] PointerMove: append point if moved > eps; include pressure if available 295 - [x] PointerUp: create ONE history command that inserts the stroke; clear draft 296 - 297 - Perf: 298 - [ ] Coalesce draft updates (rAF) so you don't recompute per event. 299 - 300 - (DoD): one stroke == one undo step; no DB writes until finalize (via M). 301 - 302 - ------------------------------------------------------------------------------ 303 - T3. Geometry: compute outline via perfect-freehand 304 - ------------------------------------------------------------------------------ 305 - 306 - /packages/core/src/geom.ts: 307 - [x] computeOutline(points, brush) -> outlinePoints using getStroke() 308 - [x] boundsFromOutline(outline) -> Box2 309 - 310 - (DoD): outline non-empty for >= 2 points; bounds contain outline. 311 - 312 - ------------------------------------------------------------------------------ 313 - T4. Rendering: fill the outline polygon 314 - ------------------------------------------------------------------------------ 315 - 316 - /packages/renderer/src/index.ts: 317 - [x] drawStroke(ctx, stroke): 318 - - outline = computeOutline(...) (computed on each draw) 319 - - ctx.beginPath(); moveTo/lineTo...; closePath(); fill() 320 - [x] Render draft stroke inline with shapes (using same rendering path) 321 - [x] Selection outline for strokes 322 - 323 - (DoD): strokes look stable while drawing; committed strokes match preview. 324 - 325 - ------------------------------------------------------------------------------ 326 - T5. Hit testing 327 - ------------------------------------------------------------------------------ 328 - 329 - /packages/core/src/geom.ts: 330 - [x] hitTestStroke(p, stroke): 331 - - bounds check first 332 - - inside-outline polygon test (ray cast) 333 - [x] Add stroke case to hitTestPoint 334 - 335 - (DoD): clicking a stroke selects it reliably. 336 - 337 - ------------------------------------------------------------------------------ 338 - T6. Brush settings (thin UI slice) 339 - ------------------------------------------------------------------------------ 340 - 341 - /apps/web/src/lib/components/BrushPopover.svelte: 342 - [x] Sliders: size, thinning, smoothing, streamline 343 - [x] Toggle: simulatePressure 344 - (All map to perfect-freehand options.) 345 - 346 - (DoD): 347 - - settings affect newly drawn strokes immediately. 348 - - tests in BrushPopover.svelte.test.ts 349 - 350 - ------------------------------------------------------------------------------ 351 - T7. Tests 352 - ------------------------------------------------------------------------------ 353 - 354 - /packages/core/tests/geom-stroke.test.ts: 355 - [x] outline computed for simple polyline 356 - [x] bounds correctness 357 - [x] hit test inside/outside sanity 358 - [x] bounds from outline helper 359 - [x] shapeBounds for stroke shapes 360 - 361 - /packages/core/tests/pen-tool.test.ts: 362 - [x] Tool lifecycle tests 363 - [x] Drawing strokes (pointer down/move/up) 364 - [x] Keyboard interactions (Escape to cancel) 365 - [x] Edge cases 366 - 367 - (DoD): All tests passing 368 - 369 - Integration: 370 - [x] one history command per stroke (tested in pen-tool.test.ts) 371 - 372 - ------------------------------------------------------------------------------ 373 - Definition of Done 374 - ------------------------------------------------------------------------------ 375 - 376 - - Pen tool draws smooth strokes using perfect-freehand outlines. 377 - - Strokes are selectable, undo/redo-able in one step, and persisted via Dexie. 378 - - Brush controls change appearance of new strokes. 379 380 ================================================================================ 381 References (URLs) *wb-refs*
··· 123 16. Milestone P: Desktop packaging (Tauri) *wb-P* 124 ================================================================================ 125 126 + The Tauri build now ships the static SvelteKit bundle with native file dialogs 127 + for open/save/new/rename/delete workflows so the desktop app works end-to-end 128 + offline. 129 130 ================================================================================ 131 17. Milestone Q: Performance + big docs (pragmatic) *wb-Q* ··· 249 20. Milestone T: Sketching / Pen Tool (perfect-freehand) *wb-T* 250 ================================================================================ 251 252 + Perfect-freehand pen strokes now behave like first-class shapes with frame-coalesced drafting, geometry/rendering integration, brush controls, and thorough tests across core and web layers. 253 254 ================================================================================ 255 References (URLs) *wb-refs*
+13 -11
apps/web/src/lib/filebrowser/FileBrowser.svelte
··· 8 FileBrowserViewModel, 9 InkfiniteDB 10 } from 'inkfinite-core'; 11 - import { BoardStatsOps } from 'inkfinite-core'; 12 import type { Snippet } from 'svelte'; 13 14 type Props = { ··· 34 desktopRepo = null 35 }: Props = $props(); 36 37 - let searchQuery = $state(vm.query); 38 let inspectorOpen = $state(false); 39 let inspectorData = $state<BoardInspectorData | null>(null); 40 let inspectorLoading = $state(false); ··· 55 } 56 }); 57 58 function handleSearchInput(event: Event) { 59 const target = event.target as HTMLInputElement; 60 - searchQuery = target.value; 61 - const updated = vm; 62 - onUpdate?.(updated); 63 } 64 65 function handleSearchChange() { 66 - const updated = { ...vm, query: searchQuery }; 67 - onUpdate?.(updated); 68 } 69 70 function closeBrowser() { ··· 238 {/if} 239 240 <div class="filebrowser__search"> 241 - <!-- FIXME: reactivity is broken --> 242 <input 243 type="search" 244 class="filebrowser__search-input" 245 placeholder="Search boards..." 246 - value={searchQuery} 247 oninput={handleSearchInput} 248 onchange={handleSearchChange} 249 - aria-label="Search boards" 250 - disabled /> 251 </div> 252 253 {#if isCreating}
··· 8 FileBrowserViewModel, 9 InkfiniteDB 10 } from 'inkfinite-core'; 11 + import { BoardStatsOps, FileBrowserVM } from 'inkfinite-core'; 12 import type { Snippet } from 'svelte'; 13 14 type Props = { ··· 34 desktopRepo = null 35 }: Props = $props(); 36 37 + let searchQuery = $derived(vm.query); 38 let inspectorOpen = $state(false); 39 let inspectorData = $state<BoardInspectorData | null>(null); 40 let inspectorLoading = $state(false); ··· 55 } 56 }); 57 58 + function applySearchQuery(nextQuery: string) { 59 + searchQuery = nextQuery; 60 + const updated = FileBrowserVM.setQuery(vm, nextQuery); 61 + vm = updated; 62 + onUpdate?.(updated); 63 + } 64 + 65 function handleSearchInput(event: Event) { 66 const target = event.target as HTMLInputElement; 67 + applySearchQuery(target.value); 68 } 69 70 function handleSearchChange() { 71 + applySearchQuery(searchQuery); 72 } 73 74 function closeBrowser() { ··· 242 {/if} 243 244 <div class="filebrowser__search"> 245 <input 246 type="search" 247 class="filebrowser__search-input" 248 placeholder="Search boards..." 249 + bind:value={searchQuery} 250 oninput={handleSearchInput} 251 onchange={handleSearchChange} 252 + aria-label="Search boards" /> 253 </div> 254 255 {#if isCreating}
+6
apps/web/src/lib/tests/Canvas.history.test.ts
··· 59 update: () => {}, 60 set: () => {}, 61 }), 62 }), 63 ); 64
··· 59 update: () => {}, 60 set: () => {}, 61 }), 62 + createBrushStore: () => ({ 63 + get: () => ({ size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }), 64 + subscribe: () => () => {}, 65 + update: () => {}, 66 + set: () => {}, 67 + }), 68 }), 69 ); 70
+6
apps/web/src/lib/tests/Canvas.keyboard.test.ts
··· 78 update: () => {}, 79 set: () => {}, 80 }), 81 }), 82 ); 83
··· 78 update: () => {}, 79 set: () => {}, 80 }), 81 + createBrushStore: () => ({ 82 + get: () => ({ size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }), 83 + subscribe: () => () => {}, 84 + update: () => {}, 85 + set: () => {}, 86 + }), 87 }), 88 ); 89
+9 -2
apps/web/src/lib/tests/Canvas.svelte.test.ts
··· 28 update: () => {}, 29 set: () => {}, 30 }), 31 }; 32 }); 33 ··· 115 const { container } = render(Canvas); 116 const toolButtons = container.querySelectorAll(".tool-button"); 117 118 - expect(toolButtons.length).toBe(7); 119 120 const toolIds = Array.from(toolButtons).map((btn) => btn.getAttribute("data-tool-id")); 121 - expect(toolIds.slice(0, 6)).toEqual(["select", "rect", "ellipse", "line", "arrow", "text"]); 122 123 const historyButton = container.querySelector(".tool-button.history-button"); 124 expect(historyButton).toBeTruthy();
··· 28 update: () => {}, 29 set: () => {}, 30 }), 31 + createBrushStore: () => ({ 32 + get: () => ({ size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }), 33 + subscribe: () => () => {}, 34 + update: () => {}, 35 + set: () => {}, 36 + }), 37 }; 38 }); 39 ··· 121 const { container } = render(Canvas); 122 const toolButtons = container.querySelectorAll(".tool-button"); 123 124 + expect(toolButtons.length).toBe(8); 125 126 const toolIds = Array.from(toolButtons).map((btn) => btn.getAttribute("data-tool-id")); 127 + const coreToolIds = toolIds.filter((id) => id && id !== "history"); 128 + expect(coreToolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text", "pen"]); 129 130 const historyButton = container.querySelector(".tool-button.history-button"); 131 expect(historyButton).toBeTruthy();
+78 -11
packages/core/src/tools/pen.ts
··· 15 draftPoints: StrokePoint[]; 16 /** ID of the shape being created */ 17 draftShapeId: string | null; 18 }; 19 20 /** ··· 26 * Minimum distance (in world units) between points to avoid redundant data 27 */ 28 const MIN_POINT_DISTANCE = 1; 29 30 /** 31 * Default brush configuration ··· 52 private getBrush: () => BrushConfig; 53 54 constructor(getBrush?: () => BrushConfig) { 55 - this.toolState = { isDrawing: false, draftPoints: [], draftShapeId: null }; 56 this.getBrush = getBrush ?? (() => DEFAULT_BRUSH); 57 } 58 ··· 108 this.toolState.isDrawing = true; 109 this.toolState.draftPoints = [firstPoint]; 110 this.toolState.draftShapeId = shapeId; 111 112 const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 113 ··· 140 141 const newPoint: StrokePoint = [action.world.x, action.world.y]; 142 this.toolState.draftPoints.push(newPoint); 143 144 - const updatedShape = { ...shape, props: { ...shape.props, points: [...this.toolState.draftPoints] } }; 145 146 - return { 147 - ...state, 148 - doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.draftShapeId]: updatedShape } }, 149 - }; 150 } 151 152 private handlePointerUp(state: EditorState, action: Action): EditorState { 153 if (action.type !== "pointer-up" || !this.toolState.draftShapeId) return state; 154 155 - const shape = state.doc.shapes[this.toolState.draftShapeId]; 156 - if (!shape || shape.type !== "stroke") return state; 157 158 - let newState = state; 159 160 if (this.toolState.draftPoints.length < MIN_POINTS) { 161 - newState = this.cancelStroke(state); 162 } 163 164 this.resetToolState(); 165 return newState; 166 } ··· 202 } 203 204 private resetToolState(): void { 205 - this.toolState = { isDrawing: false, draftPoints: [], draftShapeId: null }; 206 } 207 }
··· 15 draftPoints: StrokePoint[]; 16 /** ID of the shape being created */ 17 draftShapeId: string | null; 18 + /** Whether draft points are unsynced with document */ 19 + draftNeedsSync: boolean; 20 + /** Frame bucket when draft was last synced */ 21 + lastUpdateFrame: number | null; 22 }; 23 24 /** ··· 30 * Minimum distance (in world units) between points to avoid redundant data 31 */ 32 const MIN_POINT_DISTANCE = 1; 33 + 34 + /** 35 + * Duration for a render frame (~60 FPS) 36 + */ 37 + const FRAME_DURATION_MS = 1000 / 60; 38 39 /** 40 * Default brush configuration ··· 61 private getBrush: () => BrushConfig; 62 63 constructor(getBrush?: () => BrushConfig) { 64 + this.toolState = { 65 + isDrawing: false, 66 + draftPoints: [], 67 + draftShapeId: null, 68 + draftNeedsSync: false, 69 + lastUpdateFrame: null, 70 + }; 71 this.getBrush = getBrush ?? (() => DEFAULT_BRUSH); 72 } 73 ··· 123 this.toolState.isDrawing = true; 124 this.toolState.draftPoints = [firstPoint]; 125 this.toolState.draftShapeId = shapeId; 126 + this.toolState.draftNeedsSync = false; 127 + this.toolState.lastUpdateFrame = frameFromTimestamp(action.timestamp); 128 129 const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 130 ··· 157 158 const newPoint: StrokePoint = [action.world.x, action.world.y]; 159 this.toolState.draftPoints.push(newPoint); 160 + this.toolState.draftNeedsSync = true; 161 162 + if (this.shouldSyncNow(action.timestamp)) { 163 + return this.syncDraftShape(state); 164 + } 165 166 + return state; 167 } 168 169 private handlePointerUp(state: EditorState, action: Action): EditorState { 170 if (action.type !== "pointer-up" || !this.toolState.draftShapeId) return state; 171 172 + let newState = this.syncDraftShape(state); 173 174 + const shape = newState.doc.shapes[this.toolState.draftShapeId]; 175 + if (!shape || shape.type !== "stroke") { 176 + this.resetToolState(); 177 + return newState; 178 + } 179 180 if (this.toolState.draftPoints.length < MIN_POINTS) { 181 + newState = this.cancelStroke(newState); 182 } 183 184 + this.toolState.lastUpdateFrame = frameFromTimestamp(action.timestamp); 185 this.resetToolState(); 186 return newState; 187 } ··· 223 } 224 225 private resetToolState(): void { 226 + this.toolState = { 227 + isDrawing: false, 228 + draftPoints: [], 229 + draftShapeId: null, 230 + draftNeedsSync: false, 231 + lastUpdateFrame: null, 232 + }; 233 } 234 + 235 + private shouldSyncNow(timestamp: number): boolean { 236 + const frame = frameFromTimestamp(timestamp); 237 + if (this.toolState.lastUpdateFrame === null) { 238 + this.toolState.lastUpdateFrame = frame; 239 + return true; 240 + } 241 + if (frame !== this.toolState.lastUpdateFrame) { 242 + this.toolState.lastUpdateFrame = frame; 243 + return true; 244 + } 245 + return false; 246 + } 247 + 248 + private syncDraftShape(state: EditorState): EditorState { 249 + if (!this.toolState.draftShapeId || !this.toolState.draftNeedsSync) { 250 + return state; 251 + } 252 + 253 + const shape = state.doc.shapes[this.toolState.draftShapeId]; 254 + if (!shape || shape.type !== "stroke") { 255 + this.toolState.draftNeedsSync = false; 256 + return state; 257 + } 258 + 259 + const updatedShape = { ...shape, props: { ...shape.props, points: [...this.toolState.draftPoints] } }; 260 + this.toolState.draftNeedsSync = false; 261 + 262 + return { 263 + ...state, 264 + doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.draftShapeId]: updatedShape } }, 265 + }; 266 + } 267 + } 268 + 269 + function frameFromTimestamp(timestamp: number): number { 270 + if (!Number.isFinite(timestamp) || timestamp < 0) { 271 + return 0; 272 + } 273 + return Math.floor(timestamp / FRAME_DURATION_MS); 274 }
+83 -9
packages/core/tests/pen-tool.test.ts
··· 1 - import { describe, expect, it } from "vitest"; 2 import { PageRecord, Store } from "../src"; 3 import type { Action } from "../src/actions"; 4 import { Modifiers, PointerButtons } from "../src/actions"; 5 import { PenTool } from "../src/tools/pen"; 6 7 - function createPointerDownAction(worldX: number, worldY: number): Action { 8 return { 9 type: "pointer-down", 10 world: { x: worldX, y: worldY }, ··· 12 button: 0, 13 buttons: PointerButtons.create(true, false, false), 14 modifiers: Modifiers.create(false, false, false, false), 15 - timestamp: Date.now(), 16 }; 17 } 18 19 - function createPointerMoveAction(worldX: number, worldY: number): Action { 20 return { 21 type: "pointer-move", 22 world: { x: worldX, y: worldY }, 23 screen: { x: worldX, y: worldY }, 24 buttons: PointerButtons.create(true, false, false), 25 modifiers: Modifiers.create(false, false, false, false), 26 - timestamp: Date.now(), 27 }; 28 } 29 30 - function createPointerUpAction(worldX: number, worldY: number): Action { 31 return { 32 type: "pointer-up", 33 world: { x: worldX, y: worldY }, ··· 35 button: 0, 36 buttons: PointerButtons.create(false, false, false), 37 modifiers: Modifiers.create(false, false, false, false), 38 - timestamp: Date.now(), 39 }; 40 } 41 42 - function createKeyDownAction(key: string): Action { 43 return { 44 type: "key-down", 45 key, 46 code: key, 47 modifiers: Modifiers.create(false, false, false, false), 48 repeat: false, 49 - timestamp: Date.now(), 50 }; 51 } 52 53 describe("PenTool", () => { 54 describe("Tool lifecycle", () => { 55 it("should have correct id", () => { 56 const tool = new PenTool(); ··· 147 expect(shape.type).toBe("stroke"); 148 if (shape.type === "stroke") { 149 expect(shape.props.points.length).toBe(3); 150 } 151 }); 152
··· 1 + import { beforeEach, describe, expect, it } from "vitest"; 2 import { PageRecord, Store } from "../src"; 3 import type { Action } from "../src/actions"; 4 import { Modifiers, PointerButtons } from "../src/actions"; 5 import { PenTool } from "../src/tools/pen"; 6 7 + let currentTimestamp = 1_000; 8 + 9 + function resetTimestamp(): void { 10 + currentTimestamp = 1_000; 11 + } 12 + 13 + function nextTimestamp(step = 17): number { 14 + currentTimestamp += step; 15 + return currentTimestamp; 16 + } 17 + 18 + function createPointerDownAction(worldX: number, worldY: number, timestamp = nextTimestamp()): Action { 19 return { 20 type: "pointer-down", 21 world: { x: worldX, y: worldY }, ··· 23 button: 0, 24 buttons: PointerButtons.create(true, false, false), 25 modifiers: Modifiers.create(false, false, false, false), 26 + timestamp, 27 }; 28 } 29 30 + function createPointerMoveAction(worldX: number, worldY: number, timestamp = nextTimestamp()): Action { 31 return { 32 type: "pointer-move", 33 world: { x: worldX, y: worldY }, 34 screen: { x: worldX, y: worldY }, 35 buttons: PointerButtons.create(true, false, false), 36 modifiers: Modifiers.create(false, false, false, false), 37 + timestamp, 38 }; 39 } 40 41 + function createPointerUpAction(worldX: number, worldY: number, timestamp = nextTimestamp()): Action { 42 return { 43 type: "pointer-up", 44 world: { x: worldX, y: worldY }, ··· 46 button: 0, 47 buttons: PointerButtons.create(false, false, false), 48 modifiers: Modifiers.create(false, false, false, false), 49 + timestamp, 50 }; 51 } 52 53 + function createKeyDownAction(key: string, timestamp = nextTimestamp()): Action { 54 return { 55 type: "key-down", 56 key, 57 code: key, 58 modifiers: Modifiers.create(false, false, false, false), 59 repeat: false, 60 + timestamp, 61 }; 62 } 63 64 describe("PenTool", () => { 65 + beforeEach(() => { 66 + resetTimestamp(); 67 + }); 68 + 69 describe("Tool lifecycle", () => { 70 it("should have correct id", () => { 71 const tool = new PenTool(); ··· 162 expect(shape.type).toBe("stroke"); 163 if (shape.type === "stroke") { 164 expect(shape.props.points.length).toBe(3); 165 + } 166 + }); 167 + 168 + it("coalesces pointer updates within the same frame", () => { 169 + const tool = new PenTool(); 170 + const store = new Store(); 171 + const page = PageRecord.create("Page 1", "page:1"); 172 + 173 + store.setState((state) => ({ 174 + ...state, 175 + doc: { ...state.doc, pages: { [page.id]: page } }, 176 + ui: { ...state.ui, currentPageId: page.id }, 177 + })); 178 + 179 + let state = store.getState(); 180 + 181 + const downTimestamp = nextTimestamp(); 182 + state = tool.onAction(state, createPointerDownAction(100, 100, downTimestamp)); 183 + 184 + state = tool.onAction(state, createPointerMoveAction(110, 110, downTimestamp)); 185 + let shape = state.doc.shapes[Object.keys(state.doc.shapes)[0]]; 186 + if (shape?.type === "stroke") { 187 + expect(shape.props.points.length).toBe(1); 188 + } 189 + 190 + state = tool.onAction(state, createPointerMoveAction(120, 120, nextTimestamp())); 191 + shape = state.doc.shapes[Object.keys(state.doc.shapes)[0]]; 192 + if (shape?.type === "stroke") { 193 + expect(shape.props.points.length).toBe(3); 194 + } 195 + }); 196 + 197 + it("flushes pending points on pointer up even without a new frame", () => { 198 + const tool = new PenTool(); 199 + const store = new Store(); 200 + const page = PageRecord.create("Page 1", "page:1"); 201 + 202 + store.setState((state) => ({ 203 + ...state, 204 + doc: { ...state.doc, pages: { [page.id]: page } }, 205 + ui: { ...state.ui, currentPageId: page.id }, 206 + })); 207 + 208 + let state = store.getState(); 209 + 210 + const downTimestamp = nextTimestamp(); 211 + state = tool.onAction(state, createPointerDownAction(100, 100, downTimestamp)); 212 + 213 + state = tool.onAction(state, createPointerMoveAction(110, 110, downTimestamp)); 214 + 215 + state = tool.onAction(state, createPointerUpAction(110, 110, downTimestamp)); 216 + 217 + const shapeId = Object.keys(state.doc.shapes)[0]; 218 + const shape = state.doc.shapes[shapeId]; 219 + 220 + expect(shape?.type).toBe("stroke"); 221 + if (shape?.type === "stroke") { 222 + expect(shape.props.points.length).toBe(2); 223 + expect(shape.props.points[1]).toEqual([110, 110]); 224 } 225 }); 226