web based infinite canvas
2
fork

Configure Feed

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

refactor: split up tools

+1424 -1405
+1
packages/core/src/tools.test.ts
··· 1 + // TODO: split up and move to test dir 1 2 import { beforeEach, describe, expect, it } from "vitest"; 2 3 import { Action, Modifiers, PointerButtons } from "./actions"; 3 4 import {
-1403
packages/core/src/tools.ts
··· 1 - import type { Action } from "./actions"; 2 - import { hitTestPoint, shapeBounds } from "./geom"; 3 - import { Box2, Vec2, Vec2 as Vec2Ops } from "./math"; 4 - import { BindingRecord, createId, ShapeRecord } from "./model"; 5 - import type { EditorState, ToolId } from "./reactivity"; 6 - import { getCurrentPage } from "./reactivity"; 7 - 8 - /** 9 - * Tool interface - defines behavior for each editor tool 10 - * 11 - * Tools are explicit state machines that handle user input actions. 12 - * Each tool decides how to respond to actions and can update editor state. 13 - */ 14 - export interface Tool { 15 - /** Unique identifier for this tool */ 16 - readonly id: ToolId; 17 - 18 - /** 19 - * Called when the tool becomes active 20 - * 21 - * @param state - Current editor state 22 - * @returns Updated editor state 23 - */ 24 - onEnter(state: EditorState): EditorState; 25 - 26 - /** 27 - * Called when an action occurs while this tool is active 28 - * 29 - * @param state - Current editor state 30 - * @param action - The action to handle 31 - * @returns Updated editor state 32 - */ 33 - onAction(state: EditorState, action: Action): EditorState; 34 - 35 - /** 36 - * Called when the tool becomes inactive 37 - * 38 - * @param state - Current editor state 39 - * @returns Updated editor state 40 - */ 41 - onExit(state: EditorState): EditorState; 42 - } 43 - 44 - /** 45 - * Route an action to the currently active tool 46 - * 47 - * @param state - Current editor state 48 - * @param action - Action to route 49 - * @param tools - Map of tool ID to tool instance 50 - * @returns Updated editor state after tool handles the action 51 - */ 52 - export function routeAction(state: EditorState, action: Action, tools: Map<ToolId, Tool>): EditorState { 53 - const currentTool = tools.get(state.ui.toolId); 54 - if (!currentTool) return state; 55 - return currentTool.onAction(state, action); 56 - } 57 - 58 - /** 59 - * Switch from current tool to a new tool 60 - * 61 - * Calls onExit on the current tool (if it exists), then onEnter on the new tool. 62 - * 63 - * @param state - Current editor state 64 - * @param newToolId - ID of tool to switch to 65 - * @param tools - Map of tool ID to tool instance 66 - * @returns Updated editor state with new tool active 67 - */ 68 - export function switchTool(state: EditorState, newToolId: ToolId, tools: Map<ToolId, Tool>): EditorState { 69 - if (state.ui.toolId === newToolId) { 70 - return state; 71 - } 72 - 73 - const currentTool = tools.get(state.ui.toolId); 74 - let nextState = state; 75 - if (currentTool) { 76 - nextState = currentTool.onExit(nextState); 77 - } 78 - 79 - nextState = { ...nextState, ui: { ...nextState.ui, toolId: newToolId } }; 80 - 81 - const newTool = tools.get(newToolId); 82 - if (newTool) { 83 - nextState = newTool.onEnter(nextState); 84 - } 85 - 86 - return nextState; 87 - } 88 - 89 - /** 90 - * Create a map of tools from an array 91 - * 92 - * @param toolList - Array of tool instances 93 - * @returns Map of tool ID to tool instance 94 - */ 95 - export function createToolMap(toolList: Tool[]): Map<ToolId, Tool> { 96 - const map = new Map<ToolId, Tool>(); 97 - for (const tool of toolList) { 98 - map.set(tool.id, tool); 99 - } 100 - return map; 101 - } 102 - 103 - /** 104 - * Internal state for the select tool 105 - */ 106 - type SelectToolState = { 107 - /** Whether we're currently dragging selected shapes */ 108 - isDragging: boolean; 109 - /** World coordinates where drag started */ 110 - dragStartWorld: Vec2 | null; 111 - /** Initial positions of shapes being dragged (shape id -> {x, y}) */ 112 - initialShapePositions: Map<string, Vec2>; 113 - /** Marquee selection start point in world coordinates */ 114 - marqueeStart: Vec2 | null; 115 - /** Marquee selection end point in world coordinates */ 116 - marqueeEnd: Vec2 | null; 117 - /** Active resize/rotate handle identifier */ 118 - activeHandle: HandleKind | null; 119 - /** Shape being manipulated by handle */ 120 - handleShapeId: string | null; 121 - /** Bounds snapshot at the time handle drag started */ 122 - handleStartBounds: Box2 | null; 123 - /** Initial shapes snapshot for handle drags */ 124 - handleInitialShapes: Map<string, ShapeRecord>; 125 - /** Rotation pivot in world coordinates */ 126 - rotationCenter: Vec2 | null; 127 - /** Starting angle for rotation handle */ 128 - rotationStartAngle: number | null; 129 - }; 130 - 131 - type RectHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; 132 - 133 - type HandleKind = RectHandle | "rotate" | "line-start" | "line-end"; 134 - 135 - const HANDLE_HIT_RADIUS = 10; 136 - const ROTATE_HANDLE_OFFSET = 40; 137 - const MIN_RESIZE_SIZE = 5; 138 - 139 - /** 140 - * Select tool - allows selecting and moving shapes 141 - * 142 - * Features: 143 - * - Click to select shapes (clears previous selection unless shift is held) 144 - * - Shift-click to add/remove shapes from selection 145 - * - Drag selected shapes to move them 146 - * - Drag on empty canvas to create marquee selection 147 - * - Escape key to clear selection 148 - * - Delete/Backspace to remove selected shapes 149 - */ 150 - export class SelectTool implements Tool { 151 - readonly id: ToolId = "select"; 152 - private toolState: SelectToolState; 153 - 154 - constructor() { 155 - this.toolState = { 156 - isDragging: false, 157 - dragStartWorld: null, 158 - initialShapePositions: new Map(), 159 - marqueeStart: null, 160 - marqueeEnd: null, 161 - activeHandle: null, 162 - handleShapeId: null, 163 - handleStartBounds: null, 164 - handleInitialShapes: new Map(), 165 - rotationCenter: null, 166 - rotationStartAngle: null, 167 - }; 168 - } 169 - 170 - onEnter(state: EditorState): EditorState { 171 - this.resetToolState(); 172 - return state; 173 - } 174 - 175 - onExit(state: EditorState): EditorState { 176 - this.resetToolState(); 177 - return state; 178 - } 179 - 180 - onAction(state: EditorState, action: Action): EditorState { 181 - switch (action.type) { 182 - case "pointer-down": { 183 - return this.handlePointerDown(state, action); 184 - } 185 - case "pointer-move": { 186 - return this.handlePointerMove(state, action); 187 - } 188 - case "pointer-up": { 189 - return this.handlePointerUp(state, action); 190 - } 191 - case "key-down": { 192 - return this.handleKeyDown(state, action); 193 - } 194 - default: { 195 - return state; 196 - } 197 - } 198 - } 199 - 200 - /** 201 - * Handle pointer down - select shapes or start marquee 202 - */ 203 - private handlePointerDown(state: EditorState, action: Action): EditorState { 204 - if (action.type !== "pointer-down") return state; 205 - 206 - const handleHit = this.hitTestHandle(state, action.world); 207 - if (handleHit) { 208 - return this.beginHandleDrag(state, handleHit.shape, handleHit.handle, action.world); 209 - } 210 - 211 - const hitShapeId = hitTestPoint(state, action.world); 212 - 213 - return hitShapeId ? this.handleShapeClick(state, hitShapeId, action) : this.handleEmptyClick(state, action); 214 - } 215 - 216 - private hitTestHandle(state: EditorState, point: Vec2): { handle: HandleKind; shape: ShapeRecord } | null { 217 - if (state.ui.selectionIds.length !== 1) { 218 - return null; 219 - } 220 - const shapeId = state.ui.selectionIds[0]; 221 - const shape = state.doc.shapes[shapeId]; 222 - if (!shape) { 223 - return null; 224 - } 225 - const handles = this.getHandlePositions(shape); 226 - for (const handle of handles) { 227 - if (Vec2Ops.dist(point, handle.position) <= HANDLE_HIT_RADIUS) { 228 - return { handle: handle.id, shape }; 229 - } 230 - } 231 - return null; 232 - } 233 - 234 - private beginHandleDrag(state: EditorState, shape: ShapeRecord, handle: HandleKind, point: Vec2): EditorState { 235 - this.toolState.activeHandle = handle; 236 - this.toolState.handleShapeId = shape.id; 237 - this.toolState.handleStartBounds = shapeBounds(shape); 238 - this.toolState.handleInitialShapes.clear(); 239 - this.toolState.handleInitialShapes.set(shape.id, ShapeRecord.clone(shape)); 240 - this.toolState.isDragging = false; 241 - this.toolState.dragStartWorld = point; 242 - const bounds = this.toolState.handleStartBounds; 243 - this.toolState.rotationCenter = bounds 244 - ? { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2 } 245 - : null; 246 - this.toolState.rotationStartAngle = this.toolState.rotationCenter 247 - ? Math.atan2(point.y - this.toolState.rotationCenter.y, point.x - this.toolState.rotationCenter.x) 248 - : null; 249 - return state; 250 - } 251 - 252 - /** 253 - * Handle clicking on a shape 254 - */ 255 - private handleShapeClick(state: EditorState, shapeId: string, action: Action): EditorState { 256 - if (action.type !== "pointer-down") return state; 257 - 258 - const isShiftHeld = action.modifiers.shift; 259 - const isAlreadySelected = state.ui.selectionIds.includes(shapeId); 260 - 261 - let newSelectionIds: string[]; 262 - 263 - if (isShiftHeld) { 264 - newSelectionIds = isAlreadySelected 265 - ? state.ui.selectionIds.filter((id) => id !== shapeId) 266 - : [...state.ui.selectionIds, shapeId]; 267 - } else { 268 - newSelectionIds = isAlreadySelected ? state.ui.selectionIds : [shapeId]; 269 - } 270 - 271 - this.toolState.isDragging = true; 272 - this.toolState.dragStartWorld = action.world; 273 - this.toolState.initialShapePositions.clear(); 274 - 275 - for (const id of newSelectionIds) { 276 - const shape = state.doc.shapes[id]; 277 - if (shape) { 278 - this.toolState.initialShapePositions.set(id, { x: shape.x, y: shape.y }); 279 - } 280 - } 281 - 282 - return { ...state, ui: { ...state.ui, selectionIds: newSelectionIds } }; 283 - } 284 - 285 - /** 286 - * Handle clicking on empty canvas - clear selection or start marquee 287 - */ 288 - private handleEmptyClick(state: EditorState, action: Action): EditorState { 289 - if (action.type !== "pointer-down") return state; 290 - 291 - const isShiftHeld = action.modifiers.shift; 292 - 293 - if (!isShiftHeld) { 294 - this.toolState.marqueeStart = action.world; 295 - this.toolState.marqueeEnd = action.world; 296 - 297 - return { ...state, ui: { ...state.ui, selectionIds: [] } }; 298 - } 299 - 300 - return state; 301 - } 302 - 303 - /** 304 - * Handle pointer move - drag shapes or update marquee 305 - */ 306 - private handlePointerMove(state: EditorState, action: Action): EditorState { 307 - if (action.type !== "pointer-move") return state; 308 - 309 - if (this.toolState.activeHandle && this.toolState.handleShapeId) { 310 - return this.handleHandleDrag(state, action); 311 - } 312 - 313 - if (this.toolState.isDragging && this.toolState.dragStartWorld) { 314 - return this.handleDragMove(state, action); 315 - } else if (this.toolState.marqueeStart) { 316 - return this.handleMarqueeMove(state, action); 317 - } 318 - 319 - return state; 320 - } 321 - 322 - private handleHandleDrag(state: EditorState, action: Action): EditorState { 323 - if (action.type !== "pointer-move" || !this.toolState.handleShapeId || !this.toolState.activeHandle) { 324 - return state; 325 - } 326 - const shapeId = this.toolState.handleShapeId; 327 - const currentShape = state.doc.shapes[shapeId]; 328 - const initialShape = this.toolState.handleInitialShapes.get(shapeId); 329 - if (!currentShape || !initialShape) { 330 - return state; 331 - } 332 - 333 - let updated: ShapeRecord | null = null; 334 - if (this.toolState.activeHandle === "rotate") { 335 - updated = this.rotateShape(initialShape, action.world); 336 - } else if (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") { 337 - updated = this.resizeLineShape(initialShape, action.world, this.toolState.activeHandle); 338 - } else if (this.toolState.handleStartBounds) { 339 - updated = this.resizeRectLikeShape( 340 - initialShape, 341 - this.toolState.handleStartBounds, 342 - action.world, 343 - this.toolState.activeHandle, 344 - ); 345 - } 346 - 347 - if (!updated) { 348 - return state; 349 - } 350 - 351 - return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [shapeId]: updated } } }; 352 - } 353 - 354 - /** 355 - * Handle dragging selected shapes 356 - */ 357 - private handleDragMove(state: EditorState, action: Action): EditorState { 358 - if (action.type !== "pointer-move" || !this.toolState.dragStartWorld) return state; 359 - 360 - const delta = Vec2.sub(action.world, this.toolState.dragStartWorld); 361 - 362 - const newShapes = { ...state.doc.shapes }; 363 - 364 - for (const [shapeId, initialPos] of this.toolState.initialShapePositions) { 365 - const shape = newShapes[shapeId]; 366 - if (shape) { 367 - newShapes[shapeId] = { ...shape, x: initialPos.x + delta.x, y: initialPos.y + delta.y }; 368 - } 369 - } 370 - 371 - return { ...state, doc: { ...state.doc, shapes: newShapes } }; 372 - } 373 - 374 - /** 375 - * Handle updating marquee selection 376 - */ 377 - private handleMarqueeMove(state: EditorState, action: Action): EditorState { 378 - if (action.type !== "pointer-move") return state; 379 - 380 - this.toolState.marqueeEnd = action.world; 381 - 382 - return state; 383 - } 384 - 385 - /** 386 - * Handle pointer up - end drag or complete marquee selection 387 - */ 388 - private handlePointerUp(state: EditorState, action: Action): EditorState { 389 - if (action.type !== "pointer-up") return state; 390 - 391 - let newState = state; 392 - 393 - if (this.toolState.marqueeStart && this.toolState.marqueeEnd) { 394 - newState = this.completeMarqueeSelection(state); 395 - } 396 - 397 - this.toolState.activeHandle = null; 398 - this.toolState.handleShapeId = null; 399 - this.toolState.handleStartBounds = null; 400 - this.toolState.handleInitialShapes.clear(); 401 - this.toolState.rotationCenter = null; 402 - this.toolState.rotationStartAngle = null; 403 - this.toolState.isDragging = false; 404 - this.toolState.dragStartWorld = null; 405 - this.toolState.initialShapePositions.clear(); 406 - this.toolState.marqueeStart = null; 407 - this.toolState.marqueeEnd = null; 408 - 409 - return newState; 410 - } 411 - 412 - /** 413 - * Complete marquee selection - select shapes whose bounds intersect the marquee 414 - */ 415 - private completeMarqueeSelection(state: EditorState): EditorState { 416 - if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return state; 417 - 418 - const marqueeBox = Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); 419 - const currentPage = getCurrentPage(state); 420 - 421 - if (!currentPage) return state; 422 - 423 - const selectedIds: string[] = []; 424 - 425 - for (const shapeId of currentPage.shapeIds) { 426 - const shape = state.doc.shapes[shapeId]; 427 - if (shape) { 428 - const bounds = shapeBounds(shape); 429 - if (Box2.intersectsBox(marqueeBox, bounds)) { 430 - selectedIds.push(shapeId); 431 - } 432 - } 433 - } 434 - 435 - return { ...state, ui: { ...state.ui, selectionIds: selectedIds } }; 436 - } 437 - 438 - /** 439 - * Handle keyboard input - Escape to clear selection, Delete to remove shapes 440 - */ 441 - private handleKeyDown(state: EditorState, action: Action): EditorState { 442 - if (action.type !== "key-down") return state; 443 - 444 - if (action.key === "Escape") { 445 - return { ...state, ui: { ...state.ui, selectionIds: [] } }; 446 - } 447 - 448 - if (action.key === "Delete" || action.key === "Backspace") { 449 - return this.deleteSelectedShapes(state); 450 - } 451 - 452 - return state; 453 - } 454 - 455 - /** 456 - * Delete all selected shapes 457 - */ 458 - private deleteSelectedShapes(state: EditorState): EditorState { 459 - const shapesToDelete = new Set(state.ui.selectionIds); 460 - 461 - if (shapesToDelete.size === 0) return state; 462 - 463 - const newShapes = { ...state.doc.shapes }; 464 - const newBindings = { ...state.doc.bindings }; 465 - const newPages = { ...state.doc.pages }; 466 - 467 - for (const shapeId of shapesToDelete) { 468 - delete newShapes[shapeId]; 469 - } 470 - 471 - for (const [bindingId, binding] of Object.entries(newBindings)) { 472 - if (shapesToDelete.has(binding.fromShapeId) || shapesToDelete.has(binding.toShapeId)) { 473 - delete newBindings[bindingId]; 474 - } 475 - } 476 - 477 - for (const [pageId, page] of Object.entries(newPages)) { 478 - const filteredShapeIds = page.shapeIds.filter((id) => !shapesToDelete.has(id)); 479 - if (filteredShapeIds.length !== page.shapeIds.length) { 480 - newPages[pageId] = { ...page, shapeIds: filteredShapeIds }; 481 - } 482 - } 483 - 484 - return { 485 - ...state, 486 - doc: { ...state.doc, shapes: newShapes, bindings: newBindings, pages: newPages }, 487 - ui: { ...state.ui, selectionIds: [] }, 488 - }; 489 - } 490 - 491 - /** 492 - * Reset internal tool state 493 - */ 494 - private resetToolState(): void { 495 - this.toolState = { 496 - isDragging: false, 497 - dragStartWorld: null, 498 - initialShapePositions: new Map(), 499 - marqueeStart: null, 500 - marqueeEnd: null, 501 - activeHandle: null, 502 - handleShapeId: null, 503 - handleStartBounds: null, 504 - handleInitialShapes: new Map(), 505 - rotationCenter: null, 506 - rotationStartAngle: null, 507 - }; 508 - } 509 - 510 - /** 511 - * Get current marquee bounds (for rendering) 512 - */ 513 - getMarqueeBounds(): Box2 | null { 514 - if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return null; 515 - return Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); 516 - } 517 - 518 - getHandleAtPoint(state: EditorState, point: Vec2): HandleKind | null { 519 - const hit = this.hitTestHandle(state, point); 520 - return hit?.handle ?? null; 521 - } 522 - 523 - getActiveHandle(): HandleKind | null { 524 - return this.toolState.activeHandle; 525 - } 526 - 527 - private getHandlePositions(shape: ShapeRecord): Array<{ id: HandleKind; position: Vec2 }> { 528 - const handles: Array<{ id: HandleKind; position: Vec2 }> = []; 529 - if (shape.type === "rect" || shape.type === "ellipse" || shape.type === "text") { 530 - const bounds = shapeBounds(shape); 531 - const minX = bounds.min.x; 532 - const maxX = bounds.max.x; 533 - const minY = bounds.min.y; 534 - const maxY = bounds.max.y; 535 - const centerX = (minX + maxX) / 2; 536 - const centerY = (minY + maxY) / 2; 537 - handles.push( 538 - { id: "nw", position: { x: minX, y: minY } }, 539 - { id: "n", position: { x: centerX, y: minY } }, 540 - { id: "ne", position: { x: maxX, y: minY } }, 541 - { id: "e", position: { x: maxX, y: centerY } }, 542 - { id: "se", position: { x: maxX, y: maxY } }, 543 - { id: "s", position: { x: centerX, y: maxY } }, 544 - { id: "sw", position: { x: minX, y: maxY } }, 545 - { id: "w", position: { x: minX, y: centerY } }, 546 - { id: "rotate", position: { x: centerX, y: minY - ROTATE_HANDLE_OFFSET } }, 547 - ); 548 - } else if (shape.type === "line" || shape.type === "arrow") { 549 - const start = this.localToWorld(shape, shape.props.a); 550 - const end = this.localToWorld(shape, shape.props.b); 551 - handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); 552 - } 553 - return handles; 554 - } 555 - 556 - private resizeRectLikeShape( 557 - initial: ShapeRecord, 558 - bounds: Box2, 559 - pointer: Vec2, 560 - handle: HandleKind, 561 - ): ShapeRecord | null { 562 - if (initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text") { 563 - return null; 564 - } 565 - let minX = bounds.min.x; 566 - let maxX = bounds.max.x; 567 - let minY = bounds.min.y; 568 - let maxY = bounds.max.y; 569 - 570 - const clampX = (value: number) => Math.min(Math.max(value, -1e6), 1e6); 571 - const clampY = (value: number) => Math.min(Math.max(value, -1e6), 1e6); 572 - 573 - switch (handle) { 574 - case "nw": { 575 - minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); 576 - minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); 577 - break; 578 - } 579 - case "n": { 580 - minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); 581 - break; 582 - } 583 - case "ne": { 584 - maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); 585 - minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); 586 - break; 587 - } 588 - case "e": { 589 - maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); 590 - break; 591 - } 592 - case "se": { 593 - maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); 594 - maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); 595 - break; 596 - } 597 - case "s": { 598 - maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); 599 - break; 600 - } 601 - case "sw": { 602 - minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); 603 - maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); 604 - break; 605 - } 606 - case "w": { 607 - minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); 608 - break; 609 - } 610 - } 611 - 612 - const width = Math.max(maxX - minX, MIN_RESIZE_SIZE); 613 - const height = Math.max(maxY - minY, MIN_RESIZE_SIZE); 614 - 615 - if (initial.type === "text") { 616 - return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width } }; 617 - } 618 - 619 - // @ts-expect-error union mismatch 620 - return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } }; 621 - } 622 - 623 - private resizeLineShape(initial: ShapeRecord, pointer: Vec2, handle: "line-start" | "line-end"): ShapeRecord | null { 624 - if (initial.type !== "line" && initial.type !== "arrow") { 625 - return null; 626 - } 627 - const startWorld = this.localToWorld(initial, initial.props.a); 628 - const endWorld = this.localToWorld(initial, initial.props.b); 629 - const newStart = handle === "line-start" ? pointer : startWorld; 630 - const newEnd = handle === "line-end" ? pointer : endWorld; 631 - const newProps = { ...initial.props, a: { x: 0, y: 0 }, b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y } }; 632 - return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; 633 - } 634 - 635 - private rotateShape(initial: ShapeRecord, pointer: Vec2): ShapeRecord | null { 636 - if (!this.toolState.rotationCenter || this.toolState.rotationStartAngle === null) { 637 - return null; 638 - } 639 - if (initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text") { 640 - return null; 641 - } 642 - const currentAngle = Math.atan2( 643 - pointer.y - this.toolState.rotationCenter.y, 644 - pointer.x - this.toolState.rotationCenter.x, 645 - ); 646 - const delta = currentAngle - this.toolState.rotationStartAngle; 647 - return { ...initial, rot: initial.rot + delta }; 648 - } 649 - 650 - private localToWorld(shape: ShapeRecord, point: Vec2): Vec2 { 651 - if (shape.rot === 0) { 652 - return { x: shape.x + point.x, y: shape.y + point.y }; 653 - } 654 - const cos = Math.cos(shape.rot); 655 - const sin = Math.sin(shape.rot); 656 - return { x: shape.x + point.x * cos - point.y * sin, y: shape.y + point.x * sin + point.y * cos }; 657 - } 658 - } 659 - 660 - /** 661 - * Internal state for shape creation tools 662 - */ 663 - type ShapeCreationToolState = { 664 - /** Whether we're currently creating a shape */ 665 - isCreating: boolean; 666 - /** World coordinates where creation started */ 667 - startWorld: Vec2 | null; 668 - /** ID of the shape being created */ 669 - creatingShapeId: string | null; 670 - }; 671 - 672 - /** 673 - * Minimum size threshold for shapes (in world units) 674 - * Shapes smaller than this on either dimension will be deleted 675 - */ 676 - const MIN_SHAPE_SIZE = 5; 677 - 678 - /** 679 - * Rect tool - creates rectangle shapes by dragging 680 - * 681 - * Features: 682 - * - Drag to create a rectangle from start point to current point 683 - * - Click-cancel: shapes too small are deleted on pointer up 684 - */ 685 - export class RectTool implements Tool { 686 - readonly id: ToolId = "rect"; 687 - private toolState: ShapeCreationToolState; 688 - 689 - constructor() { 690 - this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 691 - } 692 - 693 - onEnter(state: EditorState): EditorState { 694 - this.resetToolState(); 695 - return state; 696 - } 697 - 698 - onExit(state: EditorState): EditorState { 699 - let newState = state; 700 - if (this.toolState.creatingShapeId) { 701 - newState = this.cancelShapeCreation(state); 702 - } 703 - this.resetToolState(); 704 - return newState; 705 - } 706 - 707 - onAction(state: EditorState, action: Action): EditorState { 708 - switch (action.type) { 709 - case "pointer-down": { 710 - return this.handlePointerDown(state, action); 711 - } 712 - case "pointer-move": { 713 - return this.handlePointerMove(state, action); 714 - } 715 - case "pointer-up": { 716 - return this.handlePointerUp(state, action); 717 - } 718 - case "key-down": { 719 - return this.handleKeyDown(state, action); 720 - } 721 - default: { 722 - return state; 723 - } 724 - } 725 - } 726 - 727 - private handlePointerDown(state: EditorState, action: Action): EditorState { 728 - if (action.type !== "pointer-down") return state; 729 - 730 - const currentPage = getCurrentPage(state); 731 - if (!currentPage) return state; 732 - 733 - const shapeId = createId("shape"); 734 - 735 - const shape = ShapeRecord.createRect(currentPage.id, action.world.x, action.world.y, { 736 - w: 0, 737 - h: 0, 738 - fill: "#4a90e2", 739 - stroke: "#2e5c8a", 740 - radius: 4, 741 - }, shapeId); 742 - 743 - this.toolState.isCreating = true; 744 - this.toolState.startWorld = action.world; 745 - this.toolState.creatingShapeId = shapeId; 746 - 747 - const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 748 - 749 - return { 750 - ...state, 751 - doc: { 752 - ...state.doc, 753 - shapes: { ...state.doc.shapes, [shapeId]: shape }, 754 - pages: { ...state.doc.pages, [currentPage.id]: newPage }, 755 - }, 756 - ui: { ...state.ui, selectionIds: [shapeId] }, 757 - }; 758 - } 759 - 760 - private handlePointerMove(state: EditorState, action: Action): EditorState { 761 - if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 762 - if (!this.toolState.creatingShapeId) return state; 763 - 764 - const shape = state.doc.shapes[this.toolState.creatingShapeId]; 765 - if (!shape || shape.type !== "rect") return state; 766 - 767 - const delta = Vec2.sub(action.world, this.toolState.startWorld); 768 - const w = Math.abs(delta.x); 769 - const h = Math.abs(delta.y); 770 - 771 - const x = delta.x < 0 ? this.toolState.startWorld.x - w : this.toolState.startWorld.x; 772 - const y = delta.y < 0 ? this.toolState.startWorld.y - h : this.toolState.startWorld.y; 773 - 774 - const updatedShape = { ...shape, x, y, props: { ...shape.props, w, h } }; 775 - 776 - return { 777 - ...state, 778 - doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 779 - }; 780 - } 781 - 782 - private handlePointerUp(state: EditorState, action: Action): EditorState { 783 - if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 784 - 785 - const shape = state.doc.shapes[this.toolState.creatingShapeId]; 786 - if (!shape || shape.type !== "rect") return state; 787 - 788 - let newState = state; 789 - 790 - if (shape.props.w < MIN_SHAPE_SIZE || shape.props.h < MIN_SHAPE_SIZE) { 791 - newState = this.cancelShapeCreation(state); 792 - } 793 - 794 - this.resetToolState(); 795 - return newState; 796 - } 797 - 798 - private handleKeyDown(state: EditorState, action: Action): EditorState { 799 - if (action.type !== "key-down") return state; 800 - 801 - if (action.key === "Escape" && this.toolState.creatingShapeId) { 802 - const newState = this.cancelShapeCreation(state); 803 - this.resetToolState(); 804 - return newState; 805 - } 806 - 807 - return state; 808 - } 809 - 810 - private cancelShapeCreation(state: EditorState): EditorState { 811 - if (!this.toolState.creatingShapeId) return state; 812 - 813 - const shape = state.doc.shapes[this.toolState.creatingShapeId]; 814 - if (!shape) return state; 815 - 816 - const newShapes = { ...state.doc.shapes }; 817 - delete newShapes[this.toolState.creatingShapeId]; 818 - 819 - const currentPage = getCurrentPage(state); 820 - if (!currentPage) return state; 821 - 822 - const newPage = { 823 - ...currentPage, 824 - shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 825 - }; 826 - 827 - return { 828 - ...state, 829 - doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 830 - ui: { ...state.ui, selectionIds: [] }, 831 - }; 832 - } 833 - 834 - private resetToolState(): void { 835 - this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 836 - } 837 - } 838 - 839 - /** 840 - * Ellipse tool - creates ellipse shapes by dragging 841 - * 842 - * Features: 843 - * - Drag to create an ellipse from start point to current point 844 - * - Click-cancel: shapes too small are deleted on pointer up 845 - */ 846 - export class EllipseTool implements Tool { 847 - readonly id: ToolId = "ellipse"; 848 - private toolState: ShapeCreationToolState; 849 - 850 - constructor() { 851 - this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 852 - } 853 - 854 - onEnter(state: EditorState): EditorState { 855 - this.resetToolState(); 856 - return state; 857 - } 858 - 859 - onExit(state: EditorState): EditorState { 860 - let newState = state; 861 - if (this.toolState.creatingShapeId) { 862 - newState = this.cancelShapeCreation(state); 863 - } 864 - this.resetToolState(); 865 - return newState; 866 - } 867 - 868 - onAction(state: EditorState, action: Action): EditorState { 869 - switch (action.type) { 870 - case "pointer-down": { 871 - return this.handlePointerDown(state, action); 872 - } 873 - case "pointer-move": { 874 - return this.handlePointerMove(state, action); 875 - } 876 - case "pointer-up": { 877 - return this.handlePointerUp(state, action); 878 - } 879 - case "key-down": { 880 - return this.handleKeyDown(state, action); 881 - } 882 - default: { 883 - return state; 884 - } 885 - } 886 - } 887 - 888 - private handlePointerDown(state: EditorState, action: Action): EditorState { 889 - if (action.type !== "pointer-down") return state; 890 - 891 - const currentPage = getCurrentPage(state); 892 - if (!currentPage) return state; 893 - 894 - const shapeId = createId("shape"); 895 - 896 - const shape = ShapeRecord.createEllipse(currentPage.id, action.world.x, action.world.y, { 897 - w: 0, 898 - h: 0, 899 - fill: "#51cf66", 900 - stroke: "#2f9e44", 901 - }, shapeId); 902 - 903 - this.toolState.isCreating = true; 904 - this.toolState.startWorld = action.world; 905 - this.toolState.creatingShapeId = shapeId; 906 - 907 - const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 908 - 909 - return { 910 - ...state, 911 - doc: { 912 - ...state.doc, 913 - shapes: { ...state.doc.shapes, [shapeId]: shape }, 914 - pages: { ...state.doc.pages, [currentPage.id]: newPage }, 915 - }, 916 - ui: { ...state.ui, selectionIds: [shapeId] }, 917 - }; 918 - } 919 - 920 - private handlePointerMove(state: EditorState, action: Action): EditorState { 921 - if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 922 - if (!this.toolState.creatingShapeId) return state; 923 - 924 - const shape = state.doc.shapes[this.toolState.creatingShapeId]; 925 - if (!shape || shape.type !== "ellipse") return state; 926 - 927 - const delta = Vec2.sub(action.world, this.toolState.startWorld); 928 - const w = Math.abs(delta.x); 929 - const h = Math.abs(delta.y); 930 - 931 - const x = delta.x < 0 ? this.toolState.startWorld.x - w : this.toolState.startWorld.x; 932 - const y = delta.y < 0 ? this.toolState.startWorld.y - h : this.toolState.startWorld.y; 933 - 934 - const updatedShape = { ...shape, x, y, props: { ...shape.props, w, h } }; 935 - 936 - return { 937 - ...state, 938 - doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 939 - }; 940 - } 941 - 942 - private handlePointerUp(state: EditorState, action: Action): EditorState { 943 - if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 944 - 945 - const shape = state.doc.shapes[this.toolState.creatingShapeId]; 946 - if (!shape || shape.type !== "ellipse") return state; 947 - 948 - let newState = state; 949 - 950 - if (shape.props.w < MIN_SHAPE_SIZE || shape.props.h < MIN_SHAPE_SIZE) { 951 - newState = this.cancelShapeCreation(state); 952 - } 953 - 954 - this.resetToolState(); 955 - return newState; 956 - } 957 - 958 - private handleKeyDown(state: EditorState, action: Action): EditorState { 959 - if (action.type !== "key-down") return state; 960 - 961 - if (action.key === "Escape" && this.toolState.creatingShapeId) { 962 - const newState = this.cancelShapeCreation(state); 963 - this.resetToolState(); 964 - return newState; 965 - } 966 - 967 - return state; 968 - } 969 - 970 - private cancelShapeCreation(state: EditorState): EditorState { 971 - if (!this.toolState.creatingShapeId) return state; 972 - 973 - const shape = state.doc.shapes[this.toolState.creatingShapeId]; 974 - if (!shape) return state; 975 - 976 - const newShapes = { ...state.doc.shapes }; 977 - delete newShapes[this.toolState.creatingShapeId]; 978 - 979 - const currentPage = getCurrentPage(state); 980 - if (!currentPage) return state; 981 - 982 - const newPage = { 983 - ...currentPage, 984 - shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 985 - }; 986 - 987 - return { 988 - ...state, 989 - doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 990 - ui: { ...state.ui, selectionIds: [] }, 991 - }; 992 - } 993 - 994 - private resetToolState(): void { 995 - this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 996 - } 997 - } 998 - 999 - /** 1000 - * Line tool - creates line shapes by dragging 1001 - * 1002 - * Features: 1003 - * - Drag to create a line from start point (a) to current point (b) 1004 - * - Click-cancel: very short lines are deleted on pointer up 1005 - */ 1006 - export class LineTool implements Tool { 1007 - readonly id: ToolId = "line"; 1008 - private toolState: ShapeCreationToolState; 1009 - 1010 - constructor() { 1011 - this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 1012 - } 1013 - 1014 - onEnter(state: EditorState): EditorState { 1015 - this.resetToolState(); 1016 - return state; 1017 - } 1018 - 1019 - onExit(state: EditorState): EditorState { 1020 - let newState = state; 1021 - if (this.toolState.creatingShapeId) { 1022 - newState = this.cancelShapeCreation(state); 1023 - } 1024 - this.resetToolState(); 1025 - return newState; 1026 - } 1027 - 1028 - onAction(state: EditorState, action: Action): EditorState { 1029 - switch (action.type) { 1030 - case "pointer-down": { 1031 - return this.handlePointerDown(state, action); 1032 - } 1033 - case "pointer-move": { 1034 - return this.handlePointerMove(state, action); 1035 - } 1036 - case "pointer-up": { 1037 - return this.handlePointerUp(state, action); 1038 - } 1039 - case "key-down": { 1040 - return this.handleKeyDown(state, action); 1041 - } 1042 - default: { 1043 - return state; 1044 - } 1045 - } 1046 - } 1047 - 1048 - private handlePointerDown(state: EditorState, action: Action): EditorState { 1049 - if (action.type !== "pointer-down") return state; 1050 - 1051 - const currentPage = getCurrentPage(state); 1052 - if (!currentPage) return state; 1053 - 1054 - const shapeId = createId("shape"); 1055 - 1056 - const shape = ShapeRecord.createLine(currentPage.id, action.world.x, action.world.y, { 1057 - a: { x: 0, y: 0 }, 1058 - b: { x: 0, y: 0 }, 1059 - stroke: "#495057", 1060 - width: 2, 1061 - }, shapeId); 1062 - 1063 - this.toolState.isCreating = true; 1064 - this.toolState.startWorld = action.world; 1065 - this.toolState.creatingShapeId = shapeId; 1066 - 1067 - const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 1068 - 1069 - return { 1070 - ...state, 1071 - doc: { 1072 - ...state.doc, 1073 - shapes: { ...state.doc.shapes, [shapeId]: shape }, 1074 - pages: { ...state.doc.pages, [currentPage.id]: newPage }, 1075 - }, 1076 - ui: { ...state.ui, selectionIds: [shapeId] }, 1077 - }; 1078 - } 1079 - 1080 - private handlePointerMove(state: EditorState, action: Action): EditorState { 1081 - if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 1082 - if (!this.toolState.creatingShapeId) return state; 1083 - 1084 - const shape = state.doc.shapes[this.toolState.creatingShapeId]; 1085 - if (!shape || shape.type !== "line") return state; 1086 - 1087 - const b = Vec2.sub(action.world, this.toolState.startWorld); 1088 - const updatedShape = { ...shape, props: { ...shape.props, b } }; 1089 - 1090 - return { 1091 - ...state, 1092 - doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 1093 - }; 1094 - } 1095 - 1096 - private handlePointerUp(state: EditorState, action: Action): EditorState { 1097 - if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 1098 - 1099 - const shape = state.doc.shapes[this.toolState.creatingShapeId]; 1100 - if (!shape || shape.type !== "line") return state; 1101 - 1102 - let newState = state; 1103 - 1104 - const lineLength = Vec2.len(shape.props.b); 1105 - if (lineLength < MIN_SHAPE_SIZE) { 1106 - newState = this.cancelShapeCreation(state); 1107 - } 1108 - 1109 - this.resetToolState(); 1110 - return newState; 1111 - } 1112 - 1113 - private handleKeyDown(state: EditorState, action: Action): EditorState { 1114 - if (action.type !== "key-down") return state; 1115 - 1116 - if (action.key === "Escape" && this.toolState.creatingShapeId) { 1117 - const newState = this.cancelShapeCreation(state); 1118 - this.resetToolState(); 1119 - return newState; 1120 - } 1121 - 1122 - return state; 1123 - } 1124 - 1125 - private cancelShapeCreation(state: EditorState): EditorState { 1126 - if (!this.toolState.creatingShapeId) return state; 1127 - 1128 - const shape = state.doc.shapes[this.toolState.creatingShapeId]; 1129 - if (!shape) return state; 1130 - 1131 - const newShapes = { ...state.doc.shapes }; 1132 - delete newShapes[this.toolState.creatingShapeId]; 1133 - 1134 - const currentPage = getCurrentPage(state); 1135 - if (!currentPage) return state; 1136 - 1137 - const newPage = { 1138 - ...currentPage, 1139 - shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 1140 - }; 1141 - 1142 - return { 1143 - ...state, 1144 - doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 1145 - ui: { ...state.ui, selectionIds: [] }, 1146 - }; 1147 - } 1148 - 1149 - private resetToolState(): void { 1150 - this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 1151 - } 1152 - } 1153 - 1154 - /** 1155 - * Arrow tool - creates arrow shapes by dragging 1156 - * 1157 - * Features: 1158 - * - Drag to create an arrow from start point (a) to current point (b) 1159 - * - Click-cancel: very short arrows are deleted on pointer up 1160 - */ 1161 - export class ArrowTool implements Tool { 1162 - readonly id: ToolId = "arrow"; 1163 - private toolState: ShapeCreationToolState; 1164 - 1165 - constructor() { 1166 - this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 1167 - } 1168 - 1169 - onEnter(state: EditorState): EditorState { 1170 - this.resetToolState(); 1171 - return state; 1172 - } 1173 - 1174 - onExit(state: EditorState): EditorState { 1175 - let newState = state; 1176 - if (this.toolState.creatingShapeId) { 1177 - newState = this.cancelShapeCreation(state); 1178 - } 1179 - this.resetToolState(); 1180 - return newState; 1181 - } 1182 - 1183 - onAction(state: EditorState, action: Action): EditorState { 1184 - switch (action.type) { 1185 - case "pointer-down": { 1186 - return this.handlePointerDown(state, action); 1187 - } 1188 - case "pointer-move": { 1189 - return this.handlePointerMove(state, action); 1190 - } 1191 - case "pointer-up": { 1192 - return this.handlePointerUp(state, action); 1193 - } 1194 - case "key-down": { 1195 - return this.handleKeyDown(state, action); 1196 - } 1197 - default: { 1198 - return state; 1199 - } 1200 - } 1201 - } 1202 - 1203 - private handlePointerDown(state: EditorState, action: Action): EditorState { 1204 - if (action.type !== "pointer-down") return state; 1205 - 1206 - const currentPage = getCurrentPage(state); 1207 - if (!currentPage) return state; 1208 - 1209 - const shapeId = createId("shape"); 1210 - 1211 - const shape = ShapeRecord.createArrow(currentPage.id, action.world.x, action.world.y, { 1212 - a: { x: 0, y: 0 }, 1213 - b: { x: 0, y: 0 }, 1214 - stroke: "#495057", 1215 - width: 2, 1216 - }, shapeId); 1217 - 1218 - this.toolState.isCreating = true; 1219 - this.toolState.startWorld = action.world; 1220 - this.toolState.creatingShapeId = shapeId; 1221 - 1222 - const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 1223 - 1224 - return { 1225 - ...state, 1226 - doc: { 1227 - ...state.doc, 1228 - shapes: { ...state.doc.shapes, [shapeId]: shape }, 1229 - pages: { ...state.doc.pages, [currentPage.id]: newPage }, 1230 - }, 1231 - ui: { ...state.ui, selectionIds: [shapeId] }, 1232 - }; 1233 - } 1234 - 1235 - private handlePointerMove(state: EditorState, action: Action): EditorState { 1236 - if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 1237 - if (!this.toolState.creatingShapeId) return state; 1238 - 1239 - const shape = state.doc.shapes[this.toolState.creatingShapeId]; 1240 - if (!shape || shape.type !== "arrow") return state; 1241 - 1242 - const b = Vec2.sub(action.world, this.toolState.startWorld); 1243 - const updatedShape = { ...shape, props: { ...shape.props, b } }; 1244 - 1245 - return { 1246 - ...state, 1247 - doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 1248 - }; 1249 - } 1250 - 1251 - private handlePointerUp(state: EditorState, action: Action): EditorState { 1252 - if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 1253 - 1254 - const shape = state.doc.shapes[this.toolState.creatingShapeId]; 1255 - if (!shape || shape.type !== "arrow") return state; 1256 - 1257 - let newState = state; 1258 - 1259 - const arrowLength = Vec2.len(shape.props.b); 1260 - if (arrowLength < MIN_SHAPE_SIZE) { 1261 - newState = this.cancelShapeCreation(state); 1262 - } else { 1263 - newState = this.createBindingsForArrow(state, this.toolState.creatingShapeId); 1264 - } 1265 - 1266 - this.resetToolState(); 1267 - return newState; 1268 - } 1269 - 1270 - /** 1271 - * Create bindings for arrow endpoints that hit other shapes 1272 - */ 1273 - private createBindingsForArrow(state: EditorState, arrowId: string): EditorState { 1274 - const arrow = state.doc.shapes[arrowId]; 1275 - if (!arrow || arrow.type !== "arrow") return state; 1276 - 1277 - const startWorld = { x: arrow.x + arrow.props.a.x, y: arrow.y + arrow.props.a.y }; 1278 - const endWorld = { x: arrow.x + arrow.props.b.x, y: arrow.y + arrow.props.b.y }; 1279 - 1280 - const newBindings = { ...state.doc.bindings }; 1281 - 1282 - const stateWithoutArrow = { 1283 - ...state, 1284 - doc: { 1285 - ...state.doc, 1286 - shapes: Object.fromEntries(Object.entries(state.doc.shapes).filter(([id]) => id !== arrowId)), 1287 - }, 1288 - }; 1289 - 1290 - const startHitId = hitTestPoint(stateWithoutArrow, startWorld); 1291 - if (startHitId) { 1292 - const binding = BindingRecord.create(arrowId, startHitId, "start"); 1293 - newBindings[binding.id] = binding; 1294 - } 1295 - 1296 - const endHitId = hitTestPoint(stateWithoutArrow, endWorld); 1297 - if (endHitId) { 1298 - const binding = BindingRecord.create(arrowId, endHitId, "end"); 1299 - newBindings[binding.id] = binding; 1300 - } 1301 - 1302 - return { ...state, doc: { ...state.doc, bindings: newBindings } }; 1303 - } 1304 - 1305 - private handleKeyDown(state: EditorState, action: Action): EditorState { 1306 - if (action.type !== "key-down") return state; 1307 - 1308 - if (action.key === "Escape" && this.toolState.creatingShapeId) { 1309 - const newState = this.cancelShapeCreation(state); 1310 - this.resetToolState(); 1311 - return newState; 1312 - } 1313 - 1314 - return state; 1315 - } 1316 - 1317 - private cancelShapeCreation(state: EditorState): EditorState { 1318 - if (!this.toolState.creatingShapeId) return state; 1319 - 1320 - const shape = state.doc.shapes[this.toolState.creatingShapeId]; 1321 - if (!shape) return state; 1322 - 1323 - const newShapes = { ...state.doc.shapes }; 1324 - delete newShapes[this.toolState.creatingShapeId]; 1325 - 1326 - const currentPage = getCurrentPage(state); 1327 - if (!currentPage) return state; 1328 - 1329 - const newPage = { 1330 - ...currentPage, 1331 - shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 1332 - }; 1333 - 1334 - return { 1335 - ...state, 1336 - doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 1337 - ui: { ...state.ui, selectionIds: [] }, 1338 - }; 1339 - } 1340 - 1341 - private resetToolState(): void { 1342 - this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 1343 - } 1344 - } 1345 - 1346 - /** 1347 - * Text tool - creates text shapes on click 1348 - * 1349 - * Features: 1350 - * - Click to create a text shape at the pointer position 1351 - * - Text is created with default content "Text" 1352 - * - Shape is immediately selected after creation 1353 - */ 1354 - export class TextTool implements Tool { 1355 - readonly id: ToolId = "text"; 1356 - 1357 - onEnter(state: EditorState): EditorState { 1358 - return state; 1359 - } 1360 - 1361 - onExit(state: EditorState): EditorState { 1362 - return state; 1363 - } 1364 - 1365 - onAction(state: EditorState, action: Action): EditorState { 1366 - switch (action.type) { 1367 - case "pointer-down": { 1368 - return this.handlePointerDown(state, action); 1369 - } 1370 - default: { 1371 - return state; 1372 - } 1373 - } 1374 - } 1375 - 1376 - private handlePointerDown(state: EditorState, action: Action): EditorState { 1377 - if (action.type !== "pointer-down") return state; 1378 - 1379 - const currentPage = getCurrentPage(state); 1380 - if (!currentPage) return state; 1381 - 1382 - const shapeId = createId("shape"); 1383 - 1384 - const shape = ShapeRecord.createText(currentPage.id, action.world.x, action.world.y, { 1385 - text: "Text", 1386 - fontSize: 16, 1387 - fontFamily: "sans-serif", 1388 - color: "#1f2933", 1389 - }, shapeId); 1390 - 1391 - const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 1392 - 1393 - return { 1394 - ...state, 1395 - doc: { 1396 - ...state.doc, 1397 - shapes: { ...state.doc.shapes, [shapeId]: shape }, 1398 - pages: { ...state.doc.pages, [currentPage.id]: newPage }, 1399 - }, 1400 - ui: { ...state.ui, selectionIds: [shapeId] }, 1401 - }; 1402 - } 1403 - }
+97
packages/core/src/tools/base.ts
··· 1 + import type { Action } from "../actions"; 2 + import type { EditorState, ToolId } from "../reactivity"; 3 + 4 + /** 5 + * Tool interface - defines behavior for each editor tool 6 + * 7 + * Tools are explicit state machines that handle user input actions. 8 + * Each tool decides how to respond to actions and can update editor state. 9 + */ 10 + export interface Tool { 11 + /** Unique identifier for this tool */ 12 + readonly id: ToolId; 13 + 14 + /** 15 + * Called when the tool becomes active 16 + * 17 + * @param state - Current editor state 18 + * @returns Updated editor state 19 + */ 20 + onEnter(state: EditorState): EditorState; 21 + 22 + /** 23 + * Called when an action occurs while this tool is active 24 + * 25 + * @param state - Current editor state 26 + * @param action - The action to handle 27 + * @returns Updated editor state 28 + */ 29 + onAction(state: EditorState, action: Action): EditorState; 30 + 31 + /** 32 + * Called when the tool becomes inactive 33 + * 34 + * @param state - Current editor state 35 + * @returns Updated editor state 36 + */ 37 + onExit(state: EditorState): EditorState; 38 + } 39 + 40 + /** 41 + * Route an action to the currently active tool 42 + * 43 + * @param state - Current editor state 44 + * @param action - Action to route 45 + * @param tools - Map of tool ID to tool instance 46 + * @returns Updated editor state after tool handles the action 47 + */ 48 + export function routeAction(state: EditorState, action: Action, tools: Map<ToolId, Tool>): EditorState { 49 + const currentTool = tools.get(state.ui.toolId); 50 + if (!currentTool) return state; 51 + return currentTool.onAction(state, action); 52 + } 53 + 54 + /** 55 + * Switch from current tool to a new tool 56 + * 57 + * Calls onExit on the current tool (if it exists), then onEnter on the new tool. 58 + * 59 + * @param state - Current editor state 60 + * @param newToolId - ID of tool to switch to 61 + * @param tools - Map of tool ID to tool instance 62 + * @returns Updated editor state with new tool active 63 + */ 64 + export function switchTool(state: EditorState, newToolId: ToolId, tools: Map<ToolId, Tool>): EditorState { 65 + if (state.ui.toolId === newToolId) { 66 + return state; 67 + } 68 + 69 + const currentTool = tools.get(state.ui.toolId); 70 + let nextState = state; 71 + if (currentTool) { 72 + nextState = currentTool.onExit(nextState); 73 + } 74 + 75 + nextState = { ...nextState, ui: { ...nextState.ui, toolId: newToolId } }; 76 + 77 + const newTool = tools.get(newToolId); 78 + if (newTool) { 79 + nextState = newTool.onEnter(nextState); 80 + } 81 + 82 + return nextState; 83 + } 84 + 85 + /** 86 + * Create a map of tools from an array 87 + * 88 + * @param toolList - Array of tool instances 89 + * @returns Map of tool ID to tool instance 90 + */ 91 + export function createToolMap(toolList: Tool[]): Map<ToolId, Tool> { 92 + const map = new Map<ToolId, Tool>(); 93 + for (const tool of toolList) { 94 + map.set(tool.id, tool); 95 + } 96 + return map; 97 + }
+4
packages/core/src/tools/index.ts
··· 1 + export * from "./base"; 2 + export * from "./select"; 3 + export * from "./shape"; 4 + export * from "./text";
+563
packages/core/src/tools/select.ts
··· 1 + import type { Action } from "../actions"; 2 + import { hitTestPoint, shapeBounds } from "../geom"; 3 + import { Box2, type Vec2, Vec2 as Vec2Ops } from "../math"; 4 + import { ShapeRecord } from "../model"; 5 + import { EditorState, getCurrentPage, type ToolId } from "../reactivity"; 6 + import type { Tool } from "./base"; 7 + 8 + /** 9 + * Internal state for the select tool 10 + */ 11 + type SelectToolState = { 12 + /** Whether we're currently dragging selected shapes */ 13 + isDragging: boolean; 14 + /** World coordinates where drag started */ 15 + dragStartWorld: Vec2 | null; 16 + /** Initial positions of shapes being dragged (shape id -> {x, y}) */ 17 + initialShapePositions: Map<string, Vec2>; 18 + /** Marquee selection start point in world coordinates */ 19 + marqueeStart: Vec2 | null; 20 + /** Marquee selection end point in world coordinates */ 21 + marqueeEnd: Vec2 | null; 22 + /** Active resize/rotate handle identifier */ 23 + activeHandle: HandleKind | null; 24 + /** Shape being manipulated by handle */ 25 + handleShapeId: string | null; 26 + /** Bounds snapshot at the time handle drag started */ 27 + handleStartBounds: Box2 | null; 28 + /** Initial shapes snapshot for handle drags */ 29 + handleInitialShapes: Map<string, ShapeRecord>; 30 + /** Rotation pivot in world coordinates */ 31 + rotationCenter: Vec2 | null; 32 + /** Starting angle for rotation handle */ 33 + rotationStartAngle: number | null; 34 + }; 35 + 36 + type RectHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; 37 + 38 + type HandleKind = RectHandle | "rotate" | "line-start" | "line-end"; 39 + 40 + const HANDLE_HIT_RADIUS = 10; 41 + const ROTATE_HANDLE_OFFSET = 40; 42 + const MIN_RESIZE_SIZE = 5; 43 + 44 + /** 45 + * Select tool - allows selecting and moving shapes 46 + * 47 + * Features: 48 + * - Click to select shapes (clears previous selection unless shift is held) 49 + * - Shift-click to add/remove shapes from selection 50 + * - Drag selected shapes to move them 51 + * - Drag on empty canvas to create marquee selection 52 + * - Escape key to clear selection 53 + * - Delete/Backspace to remove selected shapes 54 + */ 55 + export class SelectTool implements Tool { 56 + readonly id: ToolId = "select"; 57 + private toolState: SelectToolState; 58 + 59 + constructor() { 60 + this.toolState = { 61 + isDragging: false, 62 + dragStartWorld: null, 63 + initialShapePositions: new Map(), 64 + marqueeStart: null, 65 + marqueeEnd: null, 66 + activeHandle: null, 67 + handleShapeId: null, 68 + handleStartBounds: null, 69 + handleInitialShapes: new Map(), 70 + rotationCenter: null, 71 + rotationStartAngle: null, 72 + }; 73 + } 74 + 75 + onEnter(state: EditorState): EditorState { 76 + this.resetToolState(); 77 + return state; 78 + } 79 + 80 + onExit(state: EditorState): EditorState { 81 + this.resetToolState(); 82 + return state; 83 + } 84 + 85 + onAction(state: EditorState, action: Action): EditorState { 86 + switch (action.type) { 87 + case "pointer-down": { 88 + return this.handlePointerDown(state, action); 89 + } 90 + case "pointer-move": { 91 + return this.handlePointerMove(state, action); 92 + } 93 + case "pointer-up": { 94 + return this.handlePointerUp(state, action); 95 + } 96 + case "key-down": { 97 + return this.handleKeyDown(state, action); 98 + } 99 + default: { 100 + return state; 101 + } 102 + } 103 + } 104 + 105 + /** 106 + * Handle pointer down - select shapes or start marquee 107 + */ 108 + private handlePointerDown(state: EditorState, action: Action): EditorState { 109 + if (action.type !== "pointer-down") return state; 110 + 111 + const handleHit = this.hitTestHandle(state, action.world); 112 + if (handleHit) { 113 + return this.beginHandleDrag(state, handleHit.shape, handleHit.handle, action.world); 114 + } 115 + 116 + const hitShapeId = hitTestPoint(state, action.world); 117 + 118 + return hitShapeId ? this.handleShapeClick(state, hitShapeId, action) : this.handleEmptyClick(state, action); 119 + } 120 + 121 + private hitTestHandle(state: EditorState, point: Vec2): { handle: HandleKind; shape: ShapeRecord } | null { 122 + if (state.ui.selectionIds.length !== 1) { 123 + return null; 124 + } 125 + const shapeId = state.ui.selectionIds[0]; 126 + const shape = state.doc.shapes[shapeId]; 127 + if (!shape) { 128 + return null; 129 + } 130 + const handles = this.getHandlePositions(shape); 131 + for (const handle of handles) { 132 + if (Vec2Ops.dist(point, handle.position) <= HANDLE_HIT_RADIUS) { 133 + return { handle: handle.id, shape }; 134 + } 135 + } 136 + return null; 137 + } 138 + 139 + private beginHandleDrag(state: EditorState, shape: ShapeRecord, handle: HandleKind, point: Vec2): EditorState { 140 + this.toolState.activeHandle = handle; 141 + this.toolState.handleShapeId = shape.id; 142 + this.toolState.handleStartBounds = shapeBounds(shape); 143 + this.toolState.handleInitialShapes.clear(); 144 + this.toolState.handleInitialShapes.set(shape.id, ShapeRecord.clone(shape)); 145 + this.toolState.isDragging = false; 146 + this.toolState.dragStartWorld = point; 147 + const bounds = this.toolState.handleStartBounds; 148 + this.toolState.rotationCenter = bounds 149 + ? { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2 } 150 + : null; 151 + this.toolState.rotationStartAngle = this.toolState.rotationCenter 152 + ? Math.atan2(point.y - this.toolState.rotationCenter.y, point.x - this.toolState.rotationCenter.x) 153 + : null; 154 + return state; 155 + } 156 + 157 + /** 158 + * Handle clicking on a shape 159 + */ 160 + private handleShapeClick(state: EditorState, shapeId: string, action: Action): EditorState { 161 + if (action.type !== "pointer-down") return state; 162 + 163 + const isShiftHeld = action.modifiers.shift; 164 + const isAlreadySelected = state.ui.selectionIds.includes(shapeId); 165 + 166 + let newSelectionIds: string[]; 167 + 168 + if (isShiftHeld) { 169 + newSelectionIds = isAlreadySelected 170 + ? state.ui.selectionIds.filter((id) => id !== shapeId) 171 + : [...state.ui.selectionIds, shapeId]; 172 + } else { 173 + newSelectionIds = isAlreadySelected ? state.ui.selectionIds : [shapeId]; 174 + } 175 + 176 + this.toolState.isDragging = true; 177 + this.toolState.dragStartWorld = action.world; 178 + this.toolState.initialShapePositions.clear(); 179 + 180 + for (const id of newSelectionIds) { 181 + const shape = state.doc.shapes[id]; 182 + if (shape) { 183 + this.toolState.initialShapePositions.set(id, { x: shape.x, y: shape.y }); 184 + } 185 + } 186 + 187 + return { ...state, ui: { ...state.ui, selectionIds: newSelectionIds } }; 188 + } 189 + 190 + /** 191 + * Handle clicking on empty canvas - clear selection or start marquee 192 + */ 193 + private handleEmptyClick(state: EditorState, action: Action): EditorState { 194 + if (action.type !== "pointer-down") return state; 195 + 196 + const isShiftHeld = action.modifiers.shift; 197 + 198 + if (!isShiftHeld) { 199 + this.toolState.marqueeStart = action.world; 200 + this.toolState.marqueeEnd = action.world; 201 + 202 + return { ...state, ui: { ...state.ui, selectionIds: [] } }; 203 + } 204 + 205 + return state; 206 + } 207 + 208 + /** 209 + * Handle pointer move - drag shapes or update marquee 210 + */ 211 + private handlePointerMove(state: EditorState, action: Action): EditorState { 212 + if (action.type !== "pointer-move") return state; 213 + 214 + if (this.toolState.activeHandle && this.toolState.handleShapeId) { 215 + return this.handleHandleDrag(state, action); 216 + } 217 + 218 + if (this.toolState.isDragging && this.toolState.dragStartWorld) { 219 + return this.handleDragMove(state, action); 220 + } else if (this.toolState.marqueeStart) { 221 + return this.handleMarqueeMove(state, action); 222 + } 223 + 224 + return state; 225 + } 226 + 227 + private handleHandleDrag(state: EditorState, action: Action): EditorState { 228 + if (action.type !== "pointer-move" || !this.toolState.handleShapeId || !this.toolState.activeHandle) { 229 + return state; 230 + } 231 + const shapeId = this.toolState.handleShapeId; 232 + const currentShape = state.doc.shapes[shapeId]; 233 + const initialShape = this.toolState.handleInitialShapes.get(shapeId); 234 + if (!currentShape || !initialShape) { 235 + return state; 236 + } 237 + 238 + let updated: ShapeRecord | null = null; 239 + if (this.toolState.activeHandle === "rotate") { 240 + updated = this.rotateShape(initialShape, action.world); 241 + } else if (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") { 242 + updated = this.resizeLineShape(initialShape, action.world, this.toolState.activeHandle); 243 + } else if (this.toolState.handleStartBounds) { 244 + updated = this.resizeRectLikeShape( 245 + initialShape, 246 + this.toolState.handleStartBounds, 247 + action.world, 248 + this.toolState.activeHandle, 249 + ); 250 + } 251 + 252 + if (!updated) { 253 + return state; 254 + } 255 + 256 + return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [shapeId]: updated } } }; 257 + } 258 + 259 + /** 260 + * Handle dragging selected shapes 261 + */ 262 + private handleDragMove(state: EditorState, action: Action): EditorState { 263 + if (action.type !== "pointer-move" || !this.toolState.dragStartWorld) return state; 264 + 265 + const delta = Vec2Ops.sub(action.world, this.toolState.dragStartWorld); 266 + 267 + const newShapes = { ...state.doc.shapes }; 268 + 269 + for (const [shapeId, initialPos] of this.toolState.initialShapePositions) { 270 + const shape = newShapes[shapeId]; 271 + if (shape) { 272 + newShapes[shapeId] = { ...shape, x: initialPos.x + delta.x, y: initialPos.y + delta.y }; 273 + } 274 + } 275 + 276 + return { ...state, doc: { ...state.doc, shapes: newShapes } }; 277 + } 278 + 279 + /** 280 + * Handle updating marquee selection 281 + */ 282 + private handleMarqueeMove(state: EditorState, action: Action): EditorState { 283 + if (action.type !== "pointer-move") return state; 284 + 285 + this.toolState.marqueeEnd = action.world; 286 + 287 + return state; 288 + } 289 + 290 + /** 291 + * Handle pointer up - end drag or complete marquee selection 292 + */ 293 + private handlePointerUp(state: EditorState, action: Action): EditorState { 294 + if (action.type !== "pointer-up") return state; 295 + 296 + let newState = state; 297 + 298 + if (this.toolState.marqueeStart && this.toolState.marqueeEnd) { 299 + newState = this.completeMarqueeSelection(state); 300 + } 301 + 302 + this.toolState.activeHandle = null; 303 + this.toolState.handleShapeId = null; 304 + this.toolState.handleStartBounds = null; 305 + this.toolState.handleInitialShapes.clear(); 306 + this.toolState.rotationCenter = null; 307 + this.toolState.rotationStartAngle = null; 308 + this.toolState.isDragging = false; 309 + this.toolState.dragStartWorld = null; 310 + this.toolState.initialShapePositions.clear(); 311 + this.toolState.marqueeStart = null; 312 + this.toolState.marqueeEnd = null; 313 + 314 + return newState; 315 + } 316 + 317 + /** 318 + * Complete marquee selection - select shapes whose bounds intersect the marquee 319 + */ 320 + private completeMarqueeSelection(state: EditorState): EditorState { 321 + if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return state; 322 + 323 + const marqueeBox = Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); 324 + const currentPage = getCurrentPage(state); 325 + 326 + if (!currentPage) return state; 327 + 328 + const selectedIds: string[] = []; 329 + 330 + for (const shapeId of currentPage.shapeIds) { 331 + const shape = state.doc.shapes[shapeId]; 332 + if (shape) { 333 + const bounds = shapeBounds(shape); 334 + if (Box2.intersectsBox(marqueeBox, bounds)) { 335 + selectedIds.push(shapeId); 336 + } 337 + } 338 + } 339 + 340 + return { ...state, ui: { ...state.ui, selectionIds: selectedIds } }; 341 + } 342 + 343 + /** 344 + * Handle keyboard input - Escape to clear selection, Delete to remove shapes 345 + */ 346 + private handleKeyDown(state: EditorState, action: Action): EditorState { 347 + if (action.type !== "key-down") return state; 348 + 349 + if (action.key === "Escape") { 350 + return { ...state, ui: { ...state.ui, selectionIds: [] } }; 351 + } 352 + 353 + if (action.key === "Delete" || action.key === "Backspace") { 354 + return this.deleteSelectedShapes(state); 355 + } 356 + 357 + return state; 358 + } 359 + 360 + /** 361 + * Delete all selected shapes 362 + */ 363 + private deleteSelectedShapes(state: EditorState): EditorState { 364 + const shapesToDelete = new Set(state.ui.selectionIds); 365 + 366 + if (shapesToDelete.size === 0) return state; 367 + 368 + const newShapes = { ...state.doc.shapes }; 369 + const newBindings = { ...state.doc.bindings }; 370 + const newPages = { ...state.doc.pages }; 371 + 372 + for (const shapeId of shapesToDelete) { 373 + delete newShapes[shapeId]; 374 + } 375 + 376 + for (const [bindingId, binding] of Object.entries(newBindings)) { 377 + if (shapesToDelete.has(binding.fromShapeId) || shapesToDelete.has(binding.toShapeId)) { 378 + delete newBindings[bindingId]; 379 + } 380 + } 381 + 382 + for (const [pageId, page] of Object.entries(newPages)) { 383 + const filteredShapeIds = page.shapeIds.filter((id) => !shapesToDelete.has(id)); 384 + if (filteredShapeIds.length !== page.shapeIds.length) { 385 + newPages[pageId] = { ...page, shapeIds: filteredShapeIds }; 386 + } 387 + } 388 + 389 + return { 390 + ...state, 391 + doc: { ...state.doc, shapes: newShapes, bindings: newBindings, pages: newPages }, 392 + ui: { ...state.ui, selectionIds: [] }, 393 + }; 394 + } 395 + 396 + /** 397 + * Reset internal tool state 398 + */ 399 + private resetToolState(): void { 400 + this.toolState = { 401 + isDragging: false, 402 + dragStartWorld: null, 403 + initialShapePositions: new Map(), 404 + marqueeStart: null, 405 + marqueeEnd: null, 406 + activeHandle: null, 407 + handleShapeId: null, 408 + handleStartBounds: null, 409 + handleInitialShapes: new Map(), 410 + rotationCenter: null, 411 + rotationStartAngle: null, 412 + }; 413 + } 414 + 415 + /** 416 + * Get current marquee bounds (for rendering) 417 + */ 418 + getMarqueeBounds(): Box2 | null { 419 + if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return null; 420 + return Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); 421 + } 422 + 423 + getHandleAtPoint(state: EditorState, point: Vec2): HandleKind | null { 424 + const hit = this.hitTestHandle(state, point); 425 + return hit?.handle ?? null; 426 + } 427 + 428 + getActiveHandle(): HandleKind | null { 429 + return this.toolState.activeHandle; 430 + } 431 + 432 + private getHandlePositions(shape: ShapeRecord): Array<{ id: HandleKind; position: Vec2 }> { 433 + const handles: Array<{ id: HandleKind; position: Vec2 }> = []; 434 + if (shape.type === "rect" || shape.type === "ellipse" || shape.type === "text") { 435 + const bounds = shapeBounds(shape); 436 + const minX = bounds.min.x; 437 + const maxX = bounds.max.x; 438 + const minY = bounds.min.y; 439 + const maxY = bounds.max.y; 440 + const centerX = (minX + maxX) / 2; 441 + const centerY = (minY + maxY) / 2; 442 + handles.push( 443 + { id: "nw", position: { x: minX, y: minY } }, 444 + { id: "n", position: { x: centerX, y: minY } }, 445 + { id: "ne", position: { x: maxX, y: minY } }, 446 + { id: "e", position: { x: maxX, y: centerY } }, 447 + { id: "se", position: { x: maxX, y: maxY } }, 448 + { id: "s", position: { x: centerX, y: maxY } }, 449 + { id: "sw", position: { x: minX, y: maxY } }, 450 + { id: "w", position: { x: minX, y: centerY } }, 451 + { id: "rotate", position: { x: centerX, y: minY - ROTATE_HANDLE_OFFSET } }, 452 + ); 453 + } else if (shape.type === "line" || shape.type === "arrow") { 454 + const start = this.localToWorld(shape, shape.props.a); 455 + const end = this.localToWorld(shape, shape.props.b); 456 + handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); 457 + } 458 + return handles; 459 + } 460 + 461 + private resizeRectLikeShape( 462 + initial: ShapeRecord, 463 + bounds: Box2, 464 + pointer: Vec2, 465 + handle: HandleKind, 466 + ): ShapeRecord | null { 467 + if (initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text") { 468 + return null; 469 + } 470 + let minX = bounds.min.x; 471 + let maxX = bounds.max.x; 472 + let minY = bounds.min.y; 473 + let maxY = bounds.max.y; 474 + 475 + const clampX = (value: number) => Math.min(Math.max(value, -1e6), 1e6); 476 + const clampY = (value: number) => Math.min(Math.max(value, -1e6), 1e6); 477 + 478 + switch (handle) { 479 + case "nw": { 480 + minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); 481 + minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); 482 + break; 483 + } 484 + case "n": { 485 + minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); 486 + break; 487 + } 488 + case "ne": { 489 + maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); 490 + minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); 491 + break; 492 + } 493 + case "e": { 494 + maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); 495 + break; 496 + } 497 + case "se": { 498 + maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); 499 + maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); 500 + break; 501 + } 502 + case "s": { 503 + maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); 504 + break; 505 + } 506 + case "sw": { 507 + minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); 508 + maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); 509 + break; 510 + } 511 + case "w": { 512 + minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); 513 + break; 514 + } 515 + } 516 + 517 + const width = Math.max(maxX - minX, MIN_RESIZE_SIZE); 518 + const height = Math.max(maxY - minY, MIN_RESIZE_SIZE); 519 + 520 + if (initial.type === "text") { 521 + return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width } }; 522 + } 523 + 524 + // @ts-expect-error union mismatch 525 + return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } }; 526 + } 527 + 528 + private resizeLineShape(initial: ShapeRecord, pointer: Vec2, handle: "line-start" | "line-end"): ShapeRecord | null { 529 + if (initial.type !== "line" && initial.type !== "arrow") { 530 + return null; 531 + } 532 + const startWorld = this.localToWorld(initial, initial.props.a); 533 + const endWorld = this.localToWorld(initial, initial.props.b); 534 + const newStart = handle === "line-start" ? pointer : startWorld; 535 + const newEnd = handle === "line-end" ? pointer : endWorld; 536 + const newProps = { ...initial.props, a: { x: 0, y: 0 }, b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y } }; 537 + return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; 538 + } 539 + 540 + private rotateShape(initial: ShapeRecord, pointer: Vec2): ShapeRecord | null { 541 + if (!this.toolState.rotationCenter || this.toolState.rotationStartAngle === null) { 542 + return null; 543 + } 544 + if (initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text") { 545 + return null; 546 + } 547 + const currentAngle = Math.atan2( 548 + pointer.y - this.toolState.rotationCenter.y, 549 + pointer.x - this.toolState.rotationCenter.x, 550 + ); 551 + const delta = currentAngle - this.toolState.rotationStartAngle; 552 + return { ...initial, rot: initial.rot + delta }; 553 + } 554 + 555 + private localToWorld(shape: ShapeRecord, point: Vec2): Vec2 { 556 + if (shape.rot === 0) { 557 + return { x: shape.x + point.x, y: shape.y + point.y }; 558 + } 559 + const cos = Math.cos(shape.rot); 560 + const sin = Math.sin(shape.rot); 561 + return { x: shape.x + point.x * cos - point.y * sin, y: shape.y + point.x * sin + point.y * cos }; 562 + } 563 + }
+693
packages/core/src/tools/shape.ts
··· 1 + import type { Action } from "../actions"; 2 + import { hitTestPoint } from "../geom"; 3 + import { Vec2 } from "../math"; 4 + import { BindingRecord, createId, ShapeRecord } from "../model"; 5 + import type { EditorState, ToolId } from "../reactivity"; 6 + import { getCurrentPage } from "../reactivity"; 7 + import type { Tool } from "../tools/base"; 8 + 9 + /** 10 + * Internal state for shape creation tools 11 + */ 12 + type ShapeCreationToolState = { 13 + /** Whether we're currently creating a shape */ 14 + isCreating: boolean; 15 + /** World coordinates where creation started */ 16 + startWorld: Vec2 | null; 17 + /** ID of the shape being created */ 18 + creatingShapeId: string | null; 19 + }; 20 + 21 + /** 22 + * Minimum size threshold for shapes (in world units) 23 + * Shapes smaller than this on either dimension will be deleted 24 + */ 25 + const MIN_SHAPE_SIZE = 5; 26 + 27 + /** 28 + * Rect tool - creates rectangle shapes by dragging 29 + * 30 + * Features: 31 + * - Drag to create a rectangle from start point to current point 32 + * - Click-cancel: shapes too small are deleted on pointer up 33 + */ 34 + export class RectTool implements Tool { 35 + readonly id: ToolId = "rect"; 36 + private toolState: ShapeCreationToolState; 37 + 38 + constructor() { 39 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 40 + } 41 + 42 + onEnter(state: EditorState): EditorState { 43 + this.resetToolState(); 44 + return state; 45 + } 46 + 47 + onExit(state: EditorState): EditorState { 48 + let newState = state; 49 + if (this.toolState.creatingShapeId) { 50 + newState = this.cancelShapeCreation(state); 51 + } 52 + this.resetToolState(); 53 + return newState; 54 + } 55 + 56 + onAction(state: EditorState, action: Action): EditorState { 57 + switch (action.type) { 58 + case "pointer-down": { 59 + return this.handlePointerDown(state, action); 60 + } 61 + case "pointer-move": { 62 + return this.handlePointerMove(state, action); 63 + } 64 + case "pointer-up": { 65 + return this.handlePointerUp(state, action); 66 + } 67 + case "key-down": { 68 + return this.handleKeyDown(state, action); 69 + } 70 + default: { 71 + return state; 72 + } 73 + } 74 + } 75 + 76 + private handlePointerDown(state: EditorState, action: Action): EditorState { 77 + if (action.type !== "pointer-down") return state; 78 + 79 + const currentPage = getCurrentPage(state); 80 + if (!currentPage) return state; 81 + 82 + const shapeId = createId("shape"); 83 + 84 + const shape = ShapeRecord.createRect(currentPage.id, action.world.x, action.world.y, { 85 + w: 0, 86 + h: 0, 87 + fill: "#4a90e2", 88 + stroke: "#2e5c8a", 89 + radius: 4, 90 + }, shapeId); 91 + 92 + this.toolState.isCreating = true; 93 + this.toolState.startWorld = action.world; 94 + this.toolState.creatingShapeId = shapeId; 95 + 96 + const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 97 + 98 + return { 99 + ...state, 100 + doc: { 101 + ...state.doc, 102 + shapes: { ...state.doc.shapes, [shapeId]: shape }, 103 + pages: { ...state.doc.pages, [currentPage.id]: newPage }, 104 + }, 105 + ui: { ...state.ui, selectionIds: [shapeId] }, 106 + }; 107 + } 108 + 109 + private handlePointerMove(state: EditorState, action: Action): EditorState { 110 + if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 111 + if (!this.toolState.creatingShapeId) return state; 112 + 113 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 114 + if (!shape || shape.type !== "rect") return state; 115 + 116 + const delta = Vec2.sub(action.world, this.toolState.startWorld); 117 + const w = Math.abs(delta.x); 118 + const h = Math.abs(delta.y); 119 + 120 + const x = delta.x < 0 ? this.toolState.startWorld.x - w : this.toolState.startWorld.x; 121 + const y = delta.y < 0 ? this.toolState.startWorld.y - h : this.toolState.startWorld.y; 122 + 123 + const updatedShape = { ...shape, x, y, props: { ...shape.props, w, h } }; 124 + 125 + return { 126 + ...state, 127 + doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 128 + }; 129 + } 130 + 131 + private handlePointerUp(state: EditorState, action: Action): EditorState { 132 + if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 133 + 134 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 135 + if (!shape || shape.type !== "rect") return state; 136 + 137 + let newState = state; 138 + 139 + if (shape.props.w < MIN_SHAPE_SIZE || shape.props.h < MIN_SHAPE_SIZE) { 140 + newState = this.cancelShapeCreation(state); 141 + } 142 + 143 + this.resetToolState(); 144 + return newState; 145 + } 146 + 147 + private handleKeyDown(state: EditorState, action: Action): EditorState { 148 + if (action.type !== "key-down") return state; 149 + 150 + if (action.key === "Escape" && this.toolState.creatingShapeId) { 151 + const newState = this.cancelShapeCreation(state); 152 + this.resetToolState(); 153 + return newState; 154 + } 155 + 156 + return state; 157 + } 158 + 159 + private cancelShapeCreation(state: EditorState): EditorState { 160 + if (!this.toolState.creatingShapeId) return state; 161 + 162 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 163 + if (!shape) return state; 164 + 165 + const newShapes = { ...state.doc.shapes }; 166 + delete newShapes[this.toolState.creatingShapeId]; 167 + 168 + const currentPage = getCurrentPage(state); 169 + if (!currentPage) return state; 170 + 171 + const newPage = { 172 + ...currentPage, 173 + shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 174 + }; 175 + 176 + return { 177 + ...state, 178 + doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 179 + ui: { ...state.ui, selectionIds: [] }, 180 + }; 181 + } 182 + 183 + private resetToolState(): void { 184 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 185 + } 186 + } 187 + 188 + /** 189 + * Ellipse tool - creates ellipse shapes by dragging 190 + * 191 + * Features: 192 + * - Drag to create an ellipse from start point to current point 193 + * - Click-cancel: shapes too small are deleted on pointer up 194 + */ 195 + export class EllipseTool implements Tool { 196 + readonly id: ToolId = "ellipse"; 197 + private toolState: ShapeCreationToolState; 198 + 199 + constructor() { 200 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 201 + } 202 + 203 + onEnter(state: EditorState): EditorState { 204 + this.resetToolState(); 205 + return state; 206 + } 207 + 208 + onExit(state: EditorState): EditorState { 209 + let newState = state; 210 + if (this.toolState.creatingShapeId) { 211 + newState = this.cancelShapeCreation(state); 212 + } 213 + this.resetToolState(); 214 + return newState; 215 + } 216 + 217 + onAction(state: EditorState, action: Action): EditorState { 218 + switch (action.type) { 219 + case "pointer-down": { 220 + return this.handlePointerDown(state, action); 221 + } 222 + case "pointer-move": { 223 + return this.handlePointerMove(state, action); 224 + } 225 + case "pointer-up": { 226 + return this.handlePointerUp(state, action); 227 + } 228 + case "key-down": { 229 + return this.handleKeyDown(state, action); 230 + } 231 + default: { 232 + return state; 233 + } 234 + } 235 + } 236 + 237 + private handlePointerDown(state: EditorState, action: Action): EditorState { 238 + if (action.type !== "pointer-down") return state; 239 + 240 + const currentPage = getCurrentPage(state); 241 + if (!currentPage) return state; 242 + 243 + const shapeId = createId("shape"); 244 + 245 + const shape = ShapeRecord.createEllipse(currentPage.id, action.world.x, action.world.y, { 246 + w: 0, 247 + h: 0, 248 + fill: "#51cf66", 249 + stroke: "#2f9e44", 250 + }, shapeId); 251 + 252 + this.toolState.isCreating = true; 253 + this.toolState.startWorld = action.world; 254 + this.toolState.creatingShapeId = shapeId; 255 + 256 + const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 257 + 258 + return { 259 + ...state, 260 + doc: { 261 + ...state.doc, 262 + shapes: { ...state.doc.shapes, [shapeId]: shape }, 263 + pages: { ...state.doc.pages, [currentPage.id]: newPage }, 264 + }, 265 + ui: { ...state.ui, selectionIds: [shapeId] }, 266 + }; 267 + } 268 + 269 + private handlePointerMove(state: EditorState, action: Action): EditorState { 270 + if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 271 + if (!this.toolState.creatingShapeId) return state; 272 + 273 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 274 + if (!shape || shape.type !== "ellipse") return state; 275 + 276 + const delta = Vec2.sub(action.world, this.toolState.startWorld); 277 + const w = Math.abs(delta.x); 278 + const h = Math.abs(delta.y); 279 + 280 + const x = delta.x < 0 ? this.toolState.startWorld.x - w : this.toolState.startWorld.x; 281 + const y = delta.y < 0 ? this.toolState.startWorld.y - h : this.toolState.startWorld.y; 282 + 283 + const updatedShape = { ...shape, x, y, props: { ...shape.props, w, h } }; 284 + 285 + return { 286 + ...state, 287 + doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 288 + }; 289 + } 290 + 291 + private handlePointerUp(state: EditorState, action: Action): EditorState { 292 + if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 293 + 294 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 295 + if (!shape || shape.type !== "ellipse") return state; 296 + 297 + let newState = state; 298 + 299 + if (shape.props.w < MIN_SHAPE_SIZE || shape.props.h < MIN_SHAPE_SIZE) { 300 + newState = this.cancelShapeCreation(state); 301 + } 302 + 303 + this.resetToolState(); 304 + return newState; 305 + } 306 + 307 + private handleKeyDown(state: EditorState, action: Action): EditorState { 308 + if (action.type !== "key-down") return state; 309 + 310 + if (action.key === "Escape" && this.toolState.creatingShapeId) { 311 + const newState = this.cancelShapeCreation(state); 312 + this.resetToolState(); 313 + return newState; 314 + } 315 + 316 + return state; 317 + } 318 + 319 + private cancelShapeCreation(state: EditorState): EditorState { 320 + if (!this.toolState.creatingShapeId) return state; 321 + 322 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 323 + if (!shape) return state; 324 + 325 + const newShapes = { ...state.doc.shapes }; 326 + delete newShapes[this.toolState.creatingShapeId]; 327 + 328 + const currentPage = getCurrentPage(state); 329 + if (!currentPage) return state; 330 + 331 + const newPage = { 332 + ...currentPage, 333 + shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 334 + }; 335 + 336 + return { 337 + ...state, 338 + doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 339 + ui: { ...state.ui, selectionIds: [] }, 340 + }; 341 + } 342 + 343 + private resetToolState(): void { 344 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 345 + } 346 + } 347 + 348 + /** 349 + * Line tool - creates line shapes by dragging 350 + * 351 + * Features: 352 + * - Drag to create a line from start point (a) to current point (b) 353 + * - Click-cancel: very short lines are deleted on pointer up 354 + */ 355 + export class LineTool implements Tool { 356 + readonly id: ToolId = "line"; 357 + private toolState: ShapeCreationToolState; 358 + 359 + constructor() { 360 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 361 + } 362 + 363 + onEnter(state: EditorState): EditorState { 364 + this.resetToolState(); 365 + return state; 366 + } 367 + 368 + onExit(state: EditorState): EditorState { 369 + let newState = state; 370 + if (this.toolState.creatingShapeId) { 371 + newState = this.cancelShapeCreation(state); 372 + } 373 + this.resetToolState(); 374 + return newState; 375 + } 376 + 377 + onAction(state: EditorState, action: Action): EditorState { 378 + switch (action.type) { 379 + case "pointer-down": { 380 + return this.handlePointerDown(state, action); 381 + } 382 + case "pointer-move": { 383 + return this.handlePointerMove(state, action); 384 + } 385 + case "pointer-up": { 386 + return this.handlePointerUp(state, action); 387 + } 388 + case "key-down": { 389 + return this.handleKeyDown(state, action); 390 + } 391 + default: { 392 + return state; 393 + } 394 + } 395 + } 396 + 397 + private handlePointerDown(state: EditorState, action: Action): EditorState { 398 + if (action.type !== "pointer-down") return state; 399 + 400 + const currentPage = getCurrentPage(state); 401 + if (!currentPage) return state; 402 + 403 + const shapeId = createId("shape"); 404 + 405 + const shape = ShapeRecord.createLine(currentPage.id, action.world.x, action.world.y, { 406 + a: { x: 0, y: 0 }, 407 + b: { x: 0, y: 0 }, 408 + stroke: "#495057", 409 + width: 2, 410 + }, shapeId); 411 + 412 + this.toolState.isCreating = true; 413 + this.toolState.startWorld = action.world; 414 + this.toolState.creatingShapeId = shapeId; 415 + 416 + const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 417 + 418 + return { 419 + ...state, 420 + doc: { 421 + ...state.doc, 422 + shapes: { ...state.doc.shapes, [shapeId]: shape }, 423 + pages: { ...state.doc.pages, [currentPage.id]: newPage }, 424 + }, 425 + ui: { ...state.ui, selectionIds: [shapeId] }, 426 + }; 427 + } 428 + 429 + private handlePointerMove(state: EditorState, action: Action): EditorState { 430 + if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 431 + if (!this.toolState.creatingShapeId) return state; 432 + 433 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 434 + if (!shape || shape.type !== "line") return state; 435 + 436 + const b = Vec2.sub(action.world, this.toolState.startWorld); 437 + const updatedShape = { ...shape, props: { ...shape.props, b } }; 438 + 439 + return { 440 + ...state, 441 + doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 442 + }; 443 + } 444 + 445 + private handlePointerUp(state: EditorState, action: Action): EditorState { 446 + if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 447 + 448 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 449 + if (!shape || shape.type !== "line") return state; 450 + 451 + let newState = state; 452 + 453 + const lineLength = Vec2.len(shape.props.b); 454 + if (lineLength < MIN_SHAPE_SIZE) { 455 + newState = this.cancelShapeCreation(state); 456 + } 457 + 458 + this.resetToolState(); 459 + return newState; 460 + } 461 + 462 + private handleKeyDown(state: EditorState, action: Action): EditorState { 463 + if (action.type !== "key-down") return state; 464 + 465 + if (action.key === "Escape" && this.toolState.creatingShapeId) { 466 + const newState = this.cancelShapeCreation(state); 467 + this.resetToolState(); 468 + return newState; 469 + } 470 + 471 + return state; 472 + } 473 + 474 + private cancelShapeCreation(state: EditorState): EditorState { 475 + if (!this.toolState.creatingShapeId) return state; 476 + 477 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 478 + if (!shape) return state; 479 + 480 + const newShapes = { ...state.doc.shapes }; 481 + delete newShapes[this.toolState.creatingShapeId]; 482 + 483 + const currentPage = getCurrentPage(state); 484 + if (!currentPage) return state; 485 + 486 + const newPage = { 487 + ...currentPage, 488 + shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 489 + }; 490 + 491 + return { 492 + ...state, 493 + doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 494 + ui: { ...state.ui, selectionIds: [] }, 495 + }; 496 + } 497 + 498 + private resetToolState(): void { 499 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 500 + } 501 + } 502 + 503 + /** 504 + * Arrow tool - creates arrow shapes by dragging 505 + * 506 + * Features: 507 + * - Drag to create an arrow from start point (a) to current point (b) 508 + * - Click-cancel: very short arrows are deleted on pointer up 509 + */ 510 + export class ArrowTool implements Tool { 511 + readonly id: ToolId = "arrow"; 512 + private toolState: ShapeCreationToolState; 513 + 514 + constructor() { 515 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 516 + } 517 + 518 + onEnter(state: EditorState): EditorState { 519 + this.resetToolState(); 520 + return state; 521 + } 522 + 523 + onExit(state: EditorState): EditorState { 524 + let newState = state; 525 + if (this.toolState.creatingShapeId) { 526 + newState = this.cancelShapeCreation(state); 527 + } 528 + this.resetToolState(); 529 + return newState; 530 + } 531 + 532 + onAction(state: EditorState, action: Action): EditorState { 533 + switch (action.type) { 534 + case "pointer-down": { 535 + return this.handlePointerDown(state, action); 536 + } 537 + case "pointer-move": { 538 + return this.handlePointerMove(state, action); 539 + } 540 + case "pointer-up": { 541 + return this.handlePointerUp(state, action); 542 + } 543 + case "key-down": { 544 + return this.handleKeyDown(state, action); 545 + } 546 + default: { 547 + return state; 548 + } 549 + } 550 + } 551 + 552 + private handlePointerDown(state: EditorState, action: Action): EditorState { 553 + if (action.type !== "pointer-down") return state; 554 + 555 + const currentPage = getCurrentPage(state); 556 + if (!currentPage) return state; 557 + 558 + const shapeId = createId("shape"); 559 + 560 + const shape = ShapeRecord.createArrow(currentPage.id, action.world.x, action.world.y, { 561 + a: { x: 0, y: 0 }, 562 + b: { x: 0, y: 0 }, 563 + stroke: "#495057", 564 + width: 2, 565 + }, shapeId); 566 + 567 + this.toolState.isCreating = true; 568 + this.toolState.startWorld = action.world; 569 + this.toolState.creatingShapeId = shapeId; 570 + 571 + const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 572 + 573 + return { 574 + ...state, 575 + doc: { 576 + ...state.doc, 577 + shapes: { ...state.doc.shapes, [shapeId]: shape }, 578 + pages: { ...state.doc.pages, [currentPage.id]: newPage }, 579 + }, 580 + ui: { ...state.ui, selectionIds: [shapeId] }, 581 + }; 582 + } 583 + 584 + private handlePointerMove(state: EditorState, action: Action): EditorState { 585 + if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 586 + if (!this.toolState.creatingShapeId) return state; 587 + 588 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 589 + if (!shape || shape.type !== "arrow") return state; 590 + 591 + const b = Vec2.sub(action.world, this.toolState.startWorld); 592 + const updatedShape = { ...shape, props: { ...shape.props, b } }; 593 + 594 + return { 595 + ...state, 596 + doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 597 + }; 598 + } 599 + 600 + private handlePointerUp(state: EditorState, action: Action): EditorState { 601 + if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 602 + 603 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 604 + if (!shape || shape.type !== "arrow") return state; 605 + 606 + let newState = state; 607 + 608 + const arrowLength = Vec2.len(shape.props.b); 609 + if (arrowLength < MIN_SHAPE_SIZE) { 610 + newState = this.cancelShapeCreation(state); 611 + } else { 612 + newState = this.createBindingsForArrow(state, this.toolState.creatingShapeId); 613 + } 614 + 615 + this.resetToolState(); 616 + return newState; 617 + } 618 + 619 + /** 620 + * Create bindings for arrow endpoints that hit other shapes 621 + */ 622 + private createBindingsForArrow(state: EditorState, arrowId: string): EditorState { 623 + const arrow = state.doc.shapes[arrowId]; 624 + if (!arrow || arrow.type !== "arrow") return state; 625 + 626 + const startWorld = { x: arrow.x + arrow.props.a.x, y: arrow.y + arrow.props.a.y }; 627 + const endWorld = { x: arrow.x + arrow.props.b.x, y: arrow.y + arrow.props.b.y }; 628 + 629 + const newBindings = { ...state.doc.bindings }; 630 + 631 + const stateWithoutArrow = { 632 + ...state, 633 + doc: { 634 + ...state.doc, 635 + shapes: Object.fromEntries(Object.entries(state.doc.shapes).filter(([id]) => id !== arrowId)), 636 + }, 637 + }; 638 + 639 + const startHitId = hitTestPoint(stateWithoutArrow, startWorld); 640 + if (startHitId) { 641 + const binding = BindingRecord.create(arrowId, startHitId, "start"); 642 + newBindings[binding.id] = binding; 643 + } 644 + 645 + const endHitId = hitTestPoint(stateWithoutArrow, endWorld); 646 + if (endHitId) { 647 + const binding = BindingRecord.create(arrowId, endHitId, "end"); 648 + newBindings[binding.id] = binding; 649 + } 650 + 651 + return { ...state, doc: { ...state.doc, bindings: newBindings } }; 652 + } 653 + 654 + private handleKeyDown(state: EditorState, action: Action): EditorState { 655 + if (action.type !== "key-down") return state; 656 + 657 + if (action.key === "Escape" && this.toolState.creatingShapeId) { 658 + const newState = this.cancelShapeCreation(state); 659 + this.resetToolState(); 660 + return newState; 661 + } 662 + 663 + return state; 664 + } 665 + 666 + private cancelShapeCreation(state: EditorState): EditorState { 667 + if (!this.toolState.creatingShapeId) return state; 668 + 669 + const shape = state.doc.shapes[this.toolState.creatingShapeId]; 670 + if (!shape) return state; 671 + 672 + const newShapes = { ...state.doc.shapes }; 673 + delete newShapes[this.toolState.creatingShapeId]; 674 + 675 + const currentPage = getCurrentPage(state); 676 + if (!currentPage) return state; 677 + 678 + const newPage = { 679 + ...currentPage, 680 + shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 681 + }; 682 + 683 + return { 684 + ...state, 685 + doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 686 + ui: { ...state.ui, selectionIds: [] }, 687 + }; 688 + } 689 + 690 + private resetToolState(): void { 691 + this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 692 + } 693 + }
+64
packages/core/src/tools/text.ts
··· 1 + import type { Action } from "../actions"; 2 + import { createId, ShapeRecord } from "../model"; 3 + import type { EditorState, ToolId } from "../reactivity"; 4 + import { getCurrentPage } from "../reactivity"; 5 + import type { Tool } from "./base"; 6 + 7 + /** 8 + * Text tool - creates text shapes on click 9 + * 10 + * Features: 11 + * - Click to create a text shape at the pointer position 12 + * - Text is created with default content "Text" 13 + * - Shape is immediately selected after creation 14 + */ 15 + export class TextTool implements Tool { 16 + readonly id: ToolId = "text"; 17 + 18 + onEnter(state: EditorState): EditorState { 19 + return state; 20 + } 21 + 22 + onExit(state: EditorState): EditorState { 23 + return state; 24 + } 25 + 26 + onAction(state: EditorState, action: Action): EditorState { 27 + switch (action.type) { 28 + case "pointer-down": { 29 + return this.handlePointerDown(state, action); 30 + } 31 + default: { 32 + return state; 33 + } 34 + } 35 + } 36 + 37 + private handlePointerDown(state: EditorState, action: Action): EditorState { 38 + if (action.type !== "pointer-down") return state; 39 + 40 + const currentPage = getCurrentPage(state); 41 + if (!currentPage) return state; 42 + 43 + const shapeId = createId("shape"); 44 + 45 + const shape = ShapeRecord.createText(currentPage.id, action.world.x, action.world.y, { 46 + text: "Text", 47 + fontSize: 16, 48 + fontFamily: "sans-serif", 49 + color: "#1f2933", 50 + }, shapeId); 51 + 52 + const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 53 + 54 + return { 55 + ...state, 56 + doc: { 57 + ...state.doc, 58 + shapes: { ...state.doc.shapes, [shapeId]: shape }, 59 + pages: { ...state.doc.pages, [currentPage.id]: newPage }, 60 + }, 61 + ui: { ...state.ui, selectionIds: [shapeId] }, 62 + }; 63 + } 64 + }
+1 -1
packages/core/tests/camera.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 - import { Camera, Viewport } from "../src/camera"; 2 + import { Camera, type Viewport } from "../src/camera"; 3 3 4 4 const viewport: Viewport = { width: 800, height: 600 }; 5 5
+1 -1
packages/core/tests/tools.test.ts
··· 3 3 import { Vec2 } from "../src/math"; 4 4 import type { TextProps } from "../src/model"; 5 5 import { EditorState } from "../src/reactivity"; 6 - import type { Tool } from "../src/tools"; 7 6 import { createToolMap, routeAction, switchTool, TextTool } from "../src/tools"; 7 + import type { Tool } from "../src/tools"; 8 8 9 9 describe("Tools", () => { 10 10 describe("Tool interface", () => {