web based infinite canvas
2
fork

Configure Feed

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

feat: select & move tools

+899 -37
+16 -21
TODO.txt
··· 245 245 Goal: tools are explicit, testable state machines (RxJS based) 246 246 247 247 Tools (/packages/core/src/tools): 248 - [ ] Define ToolId: "select" | "rect" | "ellipse" | "line" | "arrow" | "text" | "pen" 249 - [ ] Define Tool interface: 248 + [x] Define ToolId: "select" | "rect" | "ellipse" | "line" | "arrow" | "text" | "pen" 249 + [x] Define Tool interface: 250 250 - id 251 251 - onEnter(state) 252 252 - onAction(state, action) -> newState 253 253 - onExit(state) 254 254 255 255 Tool router: 256 - [ ] routeAction(state, action) -> newState (delegates to active tool) 256 + [x] routeAction(state, action) -> newState (delegates to active tool) 257 257 258 258 Tests: 259 - [ ] Switching tools calls onExit/onEnter in correct order 260 - [ ] Tool ignores actions it doesn't care about 259 + [x] Switching tools calls onExit/onEnter in correct order 260 + [x] Tool ignores actions it doesn't care about 261 261 262 262 (DoD): 263 263 - A dummy tool can consume pointer events and update state deterministically. ··· 270 270 Goal: select shapes and drag them. 271 271 272 272 Selection: 273 - [ ] PointerDown: 273 + [x] PointerDown: 274 274 - if hit shape: selection = [shapeId] (or add with shift) 275 275 - else selection = [] 276 - [ ] PointerMove while dragging selected: 276 + [x] PointerMove while dragging selected: 277 277 - translate selected shapes by deltaWorld 278 - [ ] PointerUp: 278 + [x] PointerUp: 279 279 - end drag 280 280 281 281 Marquee select (smallest slices): 282 - [ ] Implement marquee start (on empty canvas pointerdown) 283 - [ ] Render marquee rectangle overlay 284 - [ ] On pointerup, select shapes whose bounds intersect marquee 282 + [x] Implement marquee start (on empty canvas pointerdown) 283 + [x] Render marquee rectangle overlay 284 + [x] On pointerup, select shapes whose bounds intersect marquee 285 285 286 286 UX: 287 - [ ] Escape clears selection 288 - [ ] Delete removes selected shapes 287 + [x] Escape clears selection 288 + [x] Delete removes selected shapes 289 289 290 290 Tests: 291 - [ ] drag moves exactly by delta 292 - [ ] shift-click toggles membership 293 - [ ] delete removes and clears selection 291 + [x] drag moves exactly by delta 292 + [x] shift-click toggles membership 293 + [x] delete removes and clears selection 294 294 295 295 (DoD): 296 296 - You can select and move shapes reliably. 297 - 298 297 299 298 ============================================================================== 300 299 10. Milestone J: Create basic shapes via tools *wb-J* ··· 326 325 (DoD): 327 326 - You can draw rect/ellipse/line/arrow/text on the canvas. 328 327 329 - 330 328 ============================================================================== 331 329 11. Milestone K: Bindings for arrows (v0) *wb-K* 332 330 ============================================================================== ··· 353 351 354 352 (DoD): 355 353 - Arrows remain connected to moved shapes (center-to-center is fine for v0). 356 - 357 354 358 355 ============================================================================== 359 356 12. Milestone L: History (undo/redo) *wb-L* ··· 387 384 (DoD): 388 385 - Undo/redo works for create/move/delete and camera changes. 389 386 390 - 391 387 ============================================================================== 392 388 13. Milestone M: Persistence (web) *wb-M* 393 389 ============================================================================== ··· 430 426 431 427 (DoD): 432 428 - Desktop app opens/saves JSON files on disk and reopens them correctly. 433 - 434 429 435 430 ============================================================================== 436 431 15. Milestone O: Export (PNG/SVG) *wb-O*
+1 -1
apps/web/package.json
··· 15 15 "test": "npm run test:unit -- --run", 16 16 "format": "prettier --write ." 17 17 }, 18 - "dependencies": { "inkfinite-core": "workspace:*" }, 18 + "dependencies": { "inkfinite-core": "workspace:*", "inkfinite-renderer": "workspace:*" }, 19 19 "devDependencies": { 20 20 "@eslint/compat": "^1.4.0", 21 21 "@eslint/js": "^9.39.1",
+116
apps/web/src/lib/canvas/Canvas.svelte
··· 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'; 17 + 18 + // Create the editor store 19 + const store = new Store(); 20 + 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 + ); 42 + 43 + page.shapeIds.push(rect1.id, rect2.id, ellipse.id); 44 + 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 + }); 62 + 63 + // Set up tools 64 + const selectTool = new SelectTool(); 65 + const tools = createToolMap([selectTool]); 66 + 67 + // Handle actions from input adapter 68 + function handleAction(action: Action) { 69 + store.setState((state) => routeAction(state, action, tools)); 70 + } 71 + 72 + let canvas: HTMLCanvasElement; 73 + let renderer: Renderer | null = null; 74 + let inputAdapter: InputAdapter | null = null; 75 + 76 + onMount(() => { 77 + // Create renderer 78 + renderer = createRenderer(canvas, store); 79 + 80 + // Get viewport dimensions 81 + function getViewport(): Viewport { 82 + const rect = canvas.getBoundingClientRect(); 83 + return { width: rect.width, height: rect.height }; 84 + } 85 + 86 + // Get current camera 87 + function getCamera() { 88 + return store.getState().camera; 89 + } 90 + 91 + // Create input adapter 92 + inputAdapter = createInputAdapter({ 93 + canvas, 94 + getCamera, 95 + getViewport, 96 + onAction: handleAction, 97 + }); 98 + }); 99 + 100 + onDestroy(() => { 101 + renderer?.dispose(); 102 + inputAdapter?.dispose(); 103 + }); 104 + </script> 105 + 106 + <canvas bind:this={canvas}></canvas> 107 + 108 + <style> 109 + canvas { 110 + width: 100%; 111 + height: 100%; 112 + display: block; 113 + touch-action: none; 114 + cursor: default; 115 + } 116 + </style>
+52
apps/web/src/lib/tests/canvas.svelte.test.ts
··· 1 + import { beforeEach, describe, expect, it } from "vitest"; 2 + import { cleanup, render } from "vitest-browser-svelte"; 3 + import Canvas from "../canvas/Canvas.svelte"; 4 + 5 + describe("Canvas component", () => { 6 + beforeEach(() => { 7 + cleanup(); 8 + }); 9 + 10 + it("should render a canvas element", () => { 11 + const { container } = render(Canvas); 12 + const canvas = container.querySelector("canvas"); 13 + 14 + expect(canvas).toBeTruthy(); 15 + expect(canvas?.tagName).toBe("CANVAS"); 16 + }); 17 + 18 + it("should create canvas with full dimensions", () => { 19 + const { container } = render(Canvas); 20 + const canvas = container.querySelector("canvas") as HTMLCanvasElement; 21 + 22 + const style = window.getComputedStyle(canvas); 23 + expect(style.width).toBeTruthy(); 24 + expect(style.height).toBeTruthy(); 25 + expect(style.display).toBe("block"); 26 + }); 27 + 28 + it("should have touch-action: none for pointer events", () => { 29 + const { container } = render(Canvas); 30 + const canvas = container.querySelector("canvas") as HTMLCanvasElement; 31 + 32 + const style = window.getComputedStyle(canvas); 33 + expect(style.touchAction).toBe("none"); 34 + }); 35 + 36 + it("should get 2D rendering context", () => { 37 + const { container } = render(Canvas); 38 + const canvas = container.querySelector("canvas") as HTMLCanvasElement; 39 + 40 + const context = canvas.getContext("2d"); 41 + expect(context).toBeTruthy(); 42 + expect(context).toBeInstanceOf(CanvasRenderingContext2D); 43 + }); 44 + 45 + it("should initialize with test shapes", async () => { 46 + const { component } = render(Canvas); 47 + 48 + // Canvas component initializes store with test shapes 49 + // FIXME: We can't directly access the store 50 + expect(component).toBeTruthy(); 51 + }); 52 + });
+2
apps/web/src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import favicon from '$lib/assets/favicon.svg'; 3 + import '../app.css'; 3 4 4 5 let { children } = $props(); 5 6 </script> 6 7 7 8 <svelte:head> 8 9 <link rel="icon" href={favicon} /> 10 + <title>Inkfinite - Infinite Canvas</title> 9 11 </svelte:head> 10 12 11 13 {@render children()}
+18 -2
apps/web/src/routes/+page.svelte
··· 1 - <h1>Welcome to SvelteKit</h1> 2 - <p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> 1 + <script lang="ts"> 2 + import Canvas from '$lib/canvas/Canvas.svelte'; 3 + </script> 4 + 5 + <div class="editor"> 6 + <Canvas /> 7 + </div> 8 + 9 + <style> 10 + .editor { 11 + position: fixed; 12 + top: 0; 13 + left: 0; 14 + width: 100vw; 15 + height: 100vh; 16 + overflow: hidden; 17 + } 18 + </style>
-13
apps/web/src/routes/page.svelte.spec.ts
··· 1 - import { page } from 'vitest/browser'; 2 - import { describe, expect, it } from 'vitest'; 3 - import { render } from 'vitest-browser-svelte'; 4 - import Page from './+page.svelte'; 5 - 6 - describe('/+page.svelte', () => { 7 - it('should render h1', async () => { 8 - render(Page); 9 - 10 - const heading = page.getByRole('heading', { level: 1 }); 11 - await expect.element(heading).toBeInTheDocument(); 12 - }); 13 - });
+1
eslint.config.js
··· 20 20 "varsIgnorePattern": "^_", 21 21 "ignoreRestSiblings": true, 22 22 }], 23 + "unicorn/prefer-ternary": "off", 23 24 "unicorn/no-null": "off", 24 25 "unicorn/prevent-abbreviations": ["error", { "replacements": { "i": false, "props": false, "doc": false } }], 25 26 },
+1
packages/core/src/index.ts
··· 4 4 export * from "./math"; 5 5 export * from "./model"; 6 6 export * from "./reactivity"; 7 + export * from "./tools";
+385
packages/core/src/tools.test.ts
··· 1 + import { beforeEach, describe, expect, it } from "vitest"; 2 + import { Action, Modifiers, PointerButtons } from "./actions"; 3 + import { PageRecord, ShapeRecord } from "./model"; 4 + import { EditorState } from "./reactivity"; 5 + import { SelectTool } from "./tools"; 6 + 7 + describe("SelectTool", () => { 8 + let tool: SelectTool; 9 + let initialState: EditorState; 10 + let page: PageRecord; 11 + let shape1: ShapeRecord; 12 + let shape2: ShapeRecord; 13 + let shape3: ShapeRecord; 14 + 15 + beforeEach(() => { 16 + tool = new SelectTool(); 17 + page = PageRecord.create("Test Page"); 18 + shape1 = ShapeRecord.createRect(page.id, 0, 0, { w: 100, h: 100, fill: "#ff0000", stroke: "#000000", radius: 0 }); 19 + shape2 = ShapeRecord.createRect(page.id, 200, 0, { w: 100, h: 100, fill: "#00ff00", stroke: "#000000", radius: 0 }); 20 + shape3 = ShapeRecord.createEllipse(page.id, 0, 200, { w: 80, h: 80, fill: "#0000ff", stroke: "#000000" }); 21 + 22 + page.shapeIds = [shape1.id, shape2.id, shape3.id]; 23 + 24 + initialState = { 25 + ...EditorState.create(), 26 + doc: { 27 + pages: { [page.id]: page }, 28 + shapes: { [shape1.id]: shape1, [shape2.id]: shape2, [shape3.id]: shape3 }, 29 + bindings: {}, 30 + }, 31 + ui: { currentPageId: page.id, selectionIds: [], toolId: "select" }, 32 + }; 33 + }); 34 + 35 + describe("onEnter/onExit", () => { 36 + it("should not modify state on enter", () => { 37 + const result = tool.onEnter(initialState); 38 + expect(result).toBe(initialState); 39 + }); 40 + 41 + it("should not modify state on exit", () => { 42 + const result = tool.onExit(initialState); 43 + expect(result).toBe(initialState); 44 + }); 45 + }); 46 + 47 + describe("shape selection", () => { 48 + it("should select shape when clicking on it", () => { 49 + const action = Action.pointerDown( 50 + { x: 50, y: 50 }, 51 + { x: 50, y: 50 }, 52 + 0, 53 + PointerButtons.create(true, false, false), 54 + Modifiers.create(), 55 + ); 56 + 57 + const result = tool.onAction(initialState, action); 58 + 59 + expect(result.ui.selectionIds).toEqual([shape1.id]); 60 + }); 61 + 62 + it("should replace selection when clicking on different shape", () => { 63 + const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } }; 64 + 65 + const action = Action.pointerDown( 66 + { x: 250, y: 50 }, 67 + { x: 250, y: 50 }, 68 + 0, 69 + PointerButtons.create(true, false, false), 70 + Modifiers.create(), 71 + ); 72 + 73 + const result = tool.onAction(state, action); 74 + 75 + expect(result.ui.selectionIds).toEqual([shape2.id]); 76 + }); 77 + 78 + it("should keep selection when clicking on already selected shape", () => { 79 + const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } }; 80 + 81 + const action = Action.pointerDown( 82 + { x: 50, y: 50 }, 83 + { x: 50, y: 50 }, 84 + 0, 85 + PointerButtons.create(true, false, false), 86 + Modifiers.create(), 87 + ); 88 + 89 + const result = tool.onAction(state, action); 90 + expect(result.ui.selectionIds).toEqual([shape1.id]); 91 + }); 92 + 93 + it("should clear selection when clicking on empty space", () => { 94 + const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } }; 95 + 96 + const action = Action.pointerDown( 97 + { x: 500, y: 500 }, 98 + { x: 500, y: 500 }, 99 + 0, 100 + PointerButtons.create(true, false, false), 101 + Modifiers.create(), 102 + ); 103 + 104 + const result = tool.onAction(state, action); 105 + expect(result.ui.selectionIds).toEqual([]); 106 + }); 107 + }); 108 + 109 + describe("shift-click selection", () => { 110 + it("should add unselected shape to selection when shift-clicking", () => { 111 + const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } }; 112 + 113 + const action = Action.pointerDown( 114 + { x: 250, y: 50 }, 115 + { x: 250, y: 50 }, 116 + 0, 117 + PointerButtons.create(true, false, false), 118 + Modifiers.create(false, true, false, false), 119 + ); 120 + 121 + const result = tool.onAction(state, action); 122 + 123 + expect(result.ui.selectionIds).toEqual([shape1.id, shape2.id]); 124 + }); 125 + 126 + it("should remove selected shape from selection when shift-clicking", () => { 127 + const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } }; 128 + 129 + const action = Action.pointerDown( 130 + { x: 50, y: 50 }, 131 + { x: 50, y: 50 }, 132 + 0, 133 + PointerButtons.create(true, false, false), 134 + Modifiers.create(false, true, false, false), 135 + ); 136 + 137 + const result = tool.onAction(state, action); 138 + 139 + expect(result.ui.selectionIds).toEqual([shape2.id]); 140 + }); 141 + }); 142 + 143 + describe("dragging shapes", () => { 144 + it("should move selected shape by exact delta", () => { 145 + const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } }; 146 + 147 + let result = tool.onAction( 148 + state, 149 + Action.pointerDown( 150 + { x: 50, y: 50 }, 151 + { x: 50, y: 50 }, 152 + 0, 153 + PointerButtons.create(true, false, false), 154 + Modifiers.create(), 155 + ), 156 + ); 157 + 158 + result = tool.onAction( 159 + result, 160 + Action.pointerMove( 161 + { x: 150, y: 100 }, 162 + { x: 150, y: 100 }, 163 + PointerButtons.create(true, false, false), 164 + Modifiers.create(), 165 + ), 166 + ); 167 + 168 + const movedShape = result.doc.shapes[shape1.id]; 169 + expect(movedShape.x).toBe(100); 170 + expect(movedShape.y).toBe(50); 171 + }); 172 + 173 + it("should move multiple selected shapes together", () => { 174 + const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } }; 175 + 176 + let result = tool.onAction( 177 + state, 178 + Action.pointerDown( 179 + { x: 50, y: 50 }, 180 + { x: 50, y: 50 }, 181 + 0, 182 + PointerButtons.create(true, false, false), 183 + Modifiers.create(), 184 + ), 185 + ); 186 + 187 + result = tool.onAction( 188 + result, 189 + Action.pointerMove( 190 + { x: 100, y: 150 }, 191 + { x: 100, y: 150 }, 192 + PointerButtons.create(true, false, false), 193 + Modifiers.create(), 194 + ), 195 + ); 196 + 197 + const movedShape1 = result.doc.shapes[shape1.id]; 198 + const movedShape2 = result.doc.shapes[shape2.id]; 199 + 200 + expect(movedShape1.x).toBe(50); 201 + expect(movedShape1.y).toBe(100); 202 + expect(movedShape2.x).toBe(250); 203 + expect(movedShape2.y).toBe(100); 204 + }); 205 + 206 + it("should reset drag state on pointer up", () => { 207 + const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } }; 208 + 209 + let result = tool.onAction( 210 + state, 211 + Action.pointerDown( 212 + { x: 50, y: 50 }, 213 + { x: 50, y: 50 }, 214 + 0, 215 + PointerButtons.create(true, false, false), 216 + Modifiers.create(), 217 + ), 218 + ); 219 + 220 + result = tool.onAction( 221 + result, 222 + Action.pointerMove( 223 + { x: 100, y: 100 }, 224 + { x: 100, y: 100 }, 225 + PointerButtons.create(true, false, false), 226 + Modifiers.create(), 227 + ), 228 + ); 229 + 230 + result = tool.onAction( 231 + result, 232 + Action.pointerUp( 233 + { x: 100, y: 100 }, 234 + { x: 100, y: 100 }, 235 + 0, 236 + PointerButtons.create(false, false, false), 237 + Modifiers.create(), 238 + ), 239 + ); 240 + 241 + const movedShape = result.doc.shapes[shape1.id]; 242 + expect(movedShape.x).toBe(50); 243 + expect(movedShape.y).toBe(50); 244 + }); 245 + }); 246 + 247 + describe("marquee selection", () => { 248 + it("should select shapes within marquee bounds", () => { 249 + let result = tool.onAction( 250 + initialState, 251 + Action.pointerDown( 252 + { x: -50, y: -50 }, 253 + { x: -50, y: -50 }, 254 + 0, 255 + PointerButtons.create(true, false, false), 256 + Modifiers.create(), 257 + ), 258 + ); 259 + 260 + result = tool.onAction( 261 + result, 262 + Action.pointerMove( 263 + { x: 350, y: 150 }, 264 + { x: 350, y: 150 }, 265 + PointerButtons.create(true, false, false), 266 + Modifiers.create(), 267 + ), 268 + ); 269 + 270 + result = tool.onAction( 271 + result, 272 + Action.pointerUp( 273 + { x: 350, y: 150 }, 274 + { x: 350, y: 150 }, 275 + 0, 276 + PointerButtons.create(false, false, false), 277 + Modifiers.create(), 278 + ), 279 + ); 280 + 281 + expect(result.ui.selectionIds).toContain(shape1.id); 282 + expect(result.ui.selectionIds).toContain(shape2.id); 283 + expect(result.ui.selectionIds).not.toContain(shape3.id); 284 + }); 285 + 286 + it("should select all shapes when marquee covers entire canvas", () => { 287 + let result = tool.onAction( 288 + initialState, 289 + Action.pointerDown( 290 + { x: -100, y: -100 }, 291 + { x: -100, y: -100 }, 292 + 0, 293 + PointerButtons.create(true, false, false), 294 + Modifiers.create(), 295 + ), 296 + ); 297 + 298 + result = tool.onAction( 299 + result, 300 + Action.pointerMove( 301 + { x: 500, y: 500 }, 302 + { x: 500, y: 500 }, 303 + PointerButtons.create(true, false, false), 304 + Modifiers.create(), 305 + ), 306 + ); 307 + 308 + result = tool.onAction( 309 + result, 310 + Action.pointerUp( 311 + { x: 500, y: 500 }, 312 + { x: 500, y: 500 }, 313 + 0, 314 + PointerButtons.create(false, false, false), 315 + Modifiers.create(), 316 + ), 317 + ); 318 + 319 + expect(result.ui.selectionIds).toContain(shape1.id); 320 + expect(result.ui.selectionIds).toContain(shape2.id); 321 + expect(result.ui.selectionIds).toContain(shape3.id); 322 + }); 323 + }); 324 + 325 + describe("keyboard shortcuts", () => { 326 + it("should clear selection on Escape", () => { 327 + const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } }; 328 + const result = tool.onAction(state, Action.keyDown("Escape", "Escape", Modifiers.create())); 329 + expect(result.ui.selectionIds).toEqual([]); 330 + }); 331 + 332 + it.each([{ description: "Delete key removes selected shapes", key: "Delete", code: "Delete" }, { 333 + description: "Backspace key removes selected shapes", 334 + key: "Backspace", 335 + code: "Backspace", 336 + }])("should handle $description", ({ key, code }) => { 337 + const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } }; 338 + const result = tool.onAction(state, Action.keyDown(key, code, Modifiers.create())); 339 + 340 + expect(result.doc.shapes[shape1.id]).toBeUndefined(); 341 + expect(result.doc.shapes[shape2.id]).toBeUndefined(); 342 + expect(result.doc.shapes[shape3.id]).toBeDefined(); 343 + 344 + expect(result.ui.selectionIds).toEqual([]); 345 + 346 + const updatedPage = result.doc.pages[page.id]; 347 + expect(updatedPage.shapeIds).toEqual([shape3.id]); 348 + }); 349 + 350 + it("should do nothing when delete pressed with no selection", () => { 351 + const result = tool.onAction(initialState, Action.keyDown("Delete", "Delete", Modifiers.create())); 352 + 353 + expect(result.doc.shapes).toEqual(initialState.doc.shapes); 354 + expect(result.ui.selectionIds).toEqual([]); 355 + }); 356 + }); 357 + 358 + describe("edge cases", () => { 359 + it("should handle clicking on overlapping shapes (topmost wins)", () => { 360 + const overlappingState = { 361 + ...initialState, 362 + doc: { ...initialState.doc, shapes: { ...initialState.doc.shapes, [shape2.id]: { ...shape2, x: 50, y: 50 } } }, 363 + }; 364 + 365 + const action = Action.pointerDown( 366 + { x: 75, y: 75 }, 367 + { x: 75, y: 75 }, 368 + 0, 369 + PointerButtons.create(true, false, false), 370 + Modifiers.create(), 371 + ); 372 + 373 + const result = tool.onAction(overlappingState, action); 374 + expect(result.ui.selectionIds).toEqual([shape2.id]); 375 + }); 376 + 377 + it("should ignore unrelated action types", () => { 378 + const wheelAction = Action.wheel({ x: 100, y: 100 }, { x: 100, y: 100 }, -10, Modifiers.create()); 379 + 380 + const result = tool.onAction(initialState, wheelAction); 381 + 382 + expect(result).toBe(initialState); 383 + }); 384 + }); 385 + });
+304
packages/core/src/tools.ts
··· 1 1 import type { Action } from "./actions"; 2 + import { hitTestPoint, shapeBounds } from "./geom"; 3 + import { Box2, Vec2 } from "./math"; 2 4 import type { EditorState, ToolId } from "./reactivity"; 5 + import { getCurrentPage } from "./reactivity"; 3 6 4 7 /** 5 8 * Tool interface - defines behavior for each editor tool ··· 95 98 } 96 99 return map; 97 100 } 101 + 102 + /** 103 + * Internal state for the select tool 104 + */ 105 + type SelectToolState = { 106 + /** Whether we're currently dragging selected shapes */ 107 + isDragging: boolean; 108 + /** World coordinates where drag started */ 109 + dragStartWorld: Vec2 | null; 110 + /** Initial positions of shapes being dragged (shape id -> {x, y}) */ 111 + initialShapePositions: Map<string, Vec2>; 112 + /** Marquee selection start point in world coordinates */ 113 + marqueeStart: Vec2 | null; 114 + /** Marquee selection end point in world coordinates */ 115 + marqueeEnd: Vec2 | null; 116 + }; 117 + 118 + /** 119 + * Select tool - allows selecting and moving shapes 120 + * 121 + * Features: 122 + * - Click to select shapes (clears previous selection unless shift is held) 123 + * - Shift-click to add/remove shapes from selection 124 + * - Drag selected shapes to move them 125 + * - Drag on empty canvas to create marquee selection 126 + * - Escape key to clear selection 127 + * - Delete/Backspace to remove selected shapes 128 + */ 129 + export class SelectTool implements Tool { 130 + readonly id: ToolId = "select"; 131 + private toolState: SelectToolState; 132 + 133 + constructor() { 134 + this.toolState = { 135 + isDragging: false, 136 + dragStartWorld: null, 137 + initialShapePositions: new Map(), 138 + marqueeStart: null, 139 + marqueeEnd: null, 140 + }; 141 + } 142 + 143 + onEnter(state: EditorState): EditorState { 144 + this.resetToolState(); 145 + return state; 146 + } 147 + 148 + onExit(state: EditorState): EditorState { 149 + this.resetToolState(); 150 + return state; 151 + } 152 + 153 + onAction(state: EditorState, action: Action): EditorState { 154 + switch (action.type) { 155 + case "pointer-down": { 156 + return this.handlePointerDown(state, action); 157 + } 158 + case "pointer-move": { 159 + return this.handlePointerMove(state, action); 160 + } 161 + case "pointer-up": { 162 + return this.handlePointerUp(state, action); 163 + } 164 + case "key-down": { 165 + return this.handleKeyDown(state, action); 166 + } 167 + default: { 168 + return state; 169 + } 170 + } 171 + } 172 + 173 + /** 174 + * Handle pointer down - select shapes or start marquee 175 + */ 176 + private handlePointerDown(state: EditorState, action: Action): EditorState { 177 + if (action.type !== "pointer-down") return state; 178 + 179 + const hitShapeId = hitTestPoint(state, action.world); 180 + 181 + return hitShapeId ? this.handleShapeClick(state, hitShapeId, action) : this.handleEmptyClick(state, action); 182 + } 183 + 184 + /** 185 + * Handle clicking on a shape 186 + */ 187 + private handleShapeClick(state: EditorState, shapeId: string, action: Action): EditorState { 188 + if (action.type !== "pointer-down") return state; 189 + 190 + const isShiftHeld = action.modifiers.shift; 191 + const isAlreadySelected = state.ui.selectionIds.includes(shapeId); 192 + 193 + let newSelectionIds: string[]; 194 + 195 + if (isShiftHeld) { 196 + newSelectionIds = isAlreadySelected 197 + ? state.ui.selectionIds.filter((id) => id !== shapeId) 198 + : [...state.ui.selectionIds, shapeId]; 199 + } else { 200 + newSelectionIds = isAlreadySelected ? state.ui.selectionIds : [shapeId]; 201 + } 202 + 203 + this.toolState.isDragging = true; 204 + this.toolState.dragStartWorld = action.world; 205 + this.toolState.initialShapePositions.clear(); 206 + 207 + for (const id of newSelectionIds) { 208 + const shape = state.doc.shapes[id]; 209 + if (shape) { 210 + this.toolState.initialShapePositions.set(id, { x: shape.x, y: shape.y }); 211 + } 212 + } 213 + 214 + return { ...state, ui: { ...state.ui, selectionIds: newSelectionIds } }; 215 + } 216 + 217 + /** 218 + * Handle clicking on empty canvas - clear selection or start marquee 219 + */ 220 + private handleEmptyClick(state: EditorState, action: Action): EditorState { 221 + if (action.type !== "pointer-down") return state; 222 + 223 + const isShiftHeld = action.modifiers.shift; 224 + 225 + if (!isShiftHeld) { 226 + this.toolState.marqueeStart = action.world; 227 + this.toolState.marqueeEnd = action.world; 228 + 229 + return { ...state, ui: { ...state.ui, selectionIds: [] } }; 230 + } 231 + 232 + return state; 233 + } 234 + 235 + /** 236 + * Handle pointer move - drag shapes or update marquee 237 + */ 238 + private handlePointerMove(state: EditorState, action: Action): EditorState { 239 + if (action.type !== "pointer-move") return state; 240 + 241 + if (this.toolState.isDragging && this.toolState.dragStartWorld) { 242 + return this.handleDragMove(state, action); 243 + } else if (this.toolState.marqueeStart) { 244 + return this.handleMarqueeMove(state, action); 245 + } 246 + 247 + return state; 248 + } 249 + 250 + /** 251 + * Handle dragging selected shapes 252 + */ 253 + private handleDragMove(state: EditorState, action: Action): EditorState { 254 + if (action.type !== "pointer-move" || !this.toolState.dragStartWorld) return state; 255 + 256 + const delta = Vec2.sub(action.world, this.toolState.dragStartWorld); 257 + 258 + const newShapes = { ...state.doc.shapes }; 259 + 260 + for (const [shapeId, initialPos] of this.toolState.initialShapePositions) { 261 + const shape = newShapes[shapeId]; 262 + if (shape) { 263 + newShapes[shapeId] = { ...shape, x: initialPos.x + delta.x, y: initialPos.y + delta.y }; 264 + } 265 + } 266 + 267 + return { ...state, doc: { ...state.doc, shapes: newShapes } }; 268 + } 269 + 270 + /** 271 + * Handle updating marquee selection 272 + */ 273 + private handleMarqueeMove(state: EditorState, action: Action): EditorState { 274 + if (action.type !== "pointer-move") return state; 275 + 276 + this.toolState.marqueeEnd = action.world; 277 + 278 + return state; 279 + } 280 + 281 + /** 282 + * Handle pointer up - end drag or complete marquee selection 283 + */ 284 + private handlePointerUp(state: EditorState, action: Action): EditorState { 285 + if (action.type !== "pointer-up") return state; 286 + 287 + let newState = state; 288 + 289 + if (this.toolState.marqueeStart && this.toolState.marqueeEnd) { 290 + newState = this.completeMarqueeSelection(state); 291 + } 292 + 293 + this.toolState.isDragging = false; 294 + this.toolState.dragStartWorld = null; 295 + this.toolState.initialShapePositions.clear(); 296 + this.toolState.marqueeStart = null; 297 + this.toolState.marqueeEnd = null; 298 + 299 + return newState; 300 + } 301 + 302 + /** 303 + * Complete marquee selection - select shapes whose bounds intersect the marquee 304 + */ 305 + private completeMarqueeSelection(state: EditorState): EditorState { 306 + if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return state; 307 + 308 + const marqueeBox = Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); 309 + const currentPage = getCurrentPage(state); 310 + 311 + if (!currentPage) return state; 312 + 313 + const selectedIds: string[] = []; 314 + 315 + for (const shapeId of currentPage.shapeIds) { 316 + const shape = state.doc.shapes[shapeId]; 317 + if (shape) { 318 + const bounds = shapeBounds(shape); 319 + if (Box2.intersectsBox(marqueeBox, bounds)) { 320 + selectedIds.push(shapeId); 321 + } 322 + } 323 + } 324 + 325 + return { ...state, ui: { ...state.ui, selectionIds: selectedIds } }; 326 + } 327 + 328 + /** 329 + * Handle keyboard input - Escape to clear selection, Delete to remove shapes 330 + */ 331 + private handleKeyDown(state: EditorState, action: Action): EditorState { 332 + if (action.type !== "key-down") return state; 333 + 334 + if (action.key === "Escape") { 335 + return { ...state, ui: { ...state.ui, selectionIds: [] } }; 336 + } 337 + 338 + if (action.key === "Delete" || action.key === "Backspace") { 339 + return this.deleteSelectedShapes(state); 340 + } 341 + 342 + return state; 343 + } 344 + 345 + /** 346 + * Delete all selected shapes 347 + */ 348 + private deleteSelectedShapes(state: EditorState): EditorState { 349 + const shapesToDelete = new Set(state.ui.selectionIds); 350 + 351 + if (shapesToDelete.size === 0) return state; 352 + 353 + const newShapes = { ...state.doc.shapes }; 354 + const newBindings = { ...state.doc.bindings }; 355 + const newPages = { ...state.doc.pages }; 356 + 357 + for (const shapeId of shapesToDelete) { 358 + delete newShapes[shapeId]; 359 + } 360 + 361 + for (const [bindingId, binding] of Object.entries(newBindings)) { 362 + if (shapesToDelete.has(binding.fromShapeId) || shapesToDelete.has(binding.toShapeId)) { 363 + delete newBindings[bindingId]; 364 + } 365 + } 366 + 367 + for (const [pageId, page] of Object.entries(newPages)) { 368 + const filteredShapeIds = page.shapeIds.filter((id) => !shapesToDelete.has(id)); 369 + if (filteredShapeIds.length !== page.shapeIds.length) { 370 + newPages[pageId] = { ...page, shapeIds: filteredShapeIds }; 371 + } 372 + } 373 + 374 + return { 375 + ...state, 376 + doc: { ...state.doc, shapes: newShapes, bindings: newBindings, pages: newPages }, 377 + ui: { ...state.ui, selectionIds: [] }, 378 + }; 379 + } 380 + 381 + /** 382 + * Reset internal tool state 383 + */ 384 + private resetToolState(): void { 385 + this.toolState = { 386 + isDragging: false, 387 + dragStartWorld: null, 388 + initialShapePositions: new Map(), 389 + marqueeStart: null, 390 + marqueeEnd: null, 391 + }; 392 + } 393 + 394 + /** 395 + * Get current marquee bounds (for rendering) 396 + */ 397 + getMarqueeBounds(): Box2 | null { 398 + if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return null; 399 + return Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); 400 + } 401 + }
+3
pnpm-lock.yaml
··· 35 35 inkfinite-core: 36 36 specifier: workspace:* 37 37 version: link:../../packages/core 38 + inkfinite-renderer: 39 + specifier: workspace:* 40 + version: link:../../packages/renderer 38 41 devDependencies: 39 42 '@eslint/compat': 40 43 specifier: ^1.4.0