web based infinite canvas
at main 999 lines 33 kB view raw
1import type { Action } from "../actions"; 2import { 3 computeNormalizedAnchor, 4 computePolylineLength, 5 getPointAtDistance, 6 hitTestPoint, 7 resolveArrowEndpoints, 8 shapeBounds, 9} from "../geom"; 10import { Box2, type Vec2, Vec2 as Vec2Ops } from "../math"; 11import { BindingRecord, ShapeRecord } from "../model"; 12import { EditorState, getCurrentPage, type ToolId } from "../reactivity"; 13import type { Tool } from "./base"; 14 15/** 16 * Internal state for the select tool 17 */ 18type SelectToolState = { 19 /** Whether we're currently dragging selected shapes */ 20 isDragging: boolean; 21 /** World coordinates where drag started */ 22 dragStartWorld: Vec2 | null; 23 /** Initial positions of shapes being dragged (shape id -> {x, y}) */ 24 initialShapePositions: Map<string, Vec2>; 25 /** Marquee selection start point in world coordinates */ 26 marqueeStart: Vec2 | null; 27 /** Marquee selection end point in world coordinates */ 28 marqueeEnd: Vec2 | null; 29 /** Active resize/rotate handle identifier */ 30 activeHandle: HandleKind | null; 31 /** Shape being manipulated by handle */ 32 handleShapeId: string | null; 33 /** Bounds snapshot at the time handle drag started */ 34 handleStartBounds: Box2 | null; 35 /** Initial shapes snapshot for handle drags */ 36 handleInitialShapes: Map<string, ShapeRecord>; 37 /** Rotation pivot in world coordinates */ 38 rotationCenter: Vec2 | null; 39 /** Starting angle for rotation handle */ 40 rotationStartAngle: number | null; 41}; 42 43type RectHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; 44 45type HandleKind = RectHandle | "rotate" | "line-start" | "line-end" | `arrow-point-${number}` | "arrow-label"; 46 47const HANDLE_HIT_RADIUS = 10; 48const ROTATE_HANDLE_OFFSET = 40; 49const MIN_RESIZE_SIZE = 5; 50 51/** 52 * Select tool - allows selecting and moving shapes 53 * 54 * Features: 55 * - Click to select shapes (clears previous selection unless shift is held) 56 * - Shift-click to add/remove shapes from selection 57 * - Drag selected shapes to move them 58 * - Drag on empty canvas to create marquee selection 59 * - Escape key to clear selection 60 * - Delete/Backspace to remove selected shapes 61 */ 62export class SelectTool implements Tool { 63 readonly id: ToolId = "select"; 64 private toolState: SelectToolState; 65 private readonly marqueeListener?: (bounds: Box2 | null) => void; 66 67 constructor(onMarqueeChange?: (bounds: Box2 | null) => void) { 68 this.marqueeListener = onMarqueeChange; 69 this.toolState = { 70 isDragging: false, 71 dragStartWorld: null, 72 initialShapePositions: new Map(), 73 marqueeStart: null, 74 marqueeEnd: null, 75 activeHandle: null, 76 handleShapeId: null, 77 handleStartBounds: null, 78 handleInitialShapes: new Map(), 79 rotationCenter: null, 80 rotationStartAngle: null, 81 }; 82 } 83 84 onEnter(state: EditorState): EditorState { 85 this.resetToolState(); 86 return state; 87 } 88 89 onExit(state: EditorState): EditorState { 90 this.resetToolState(); 91 return state; 92 } 93 94 onAction(state: EditorState, action: Action): EditorState { 95 switch (action.type) { 96 case "pointer-down": { 97 return this.handlePointerDown(state, action); 98 } 99 case "pointer-move": { 100 return this.handlePointerMove(state, action); 101 } 102 case "pointer-up": { 103 return this.handlePointerUp(state, action); 104 } 105 case "key-down": { 106 return this.handleKeyDown(state, action); 107 } 108 default: { 109 return state; 110 } 111 } 112 } 113 114 /** 115 * Handle pointer down - select shapes or start marquee 116 */ 117 private handlePointerDown(state: EditorState, action: Action): EditorState { 118 if (action.type !== "pointer-down") return state; 119 120 if (action.modifiers.alt && state.ui.selectionIds.length === 1) { 121 const shapeId = state.ui.selectionIds[0]; 122 const shape = state.doc.shapes[shapeId]; 123 if (shape?.type === "arrow") { 124 const result = this.tryAddPointToArrowSegment(state, shape, action.world); 125 if (result) { 126 return result; 127 } 128 } 129 } 130 131 const handleHit = this.hitTestHandle(state, action.world); 132 if (handleHit) { 133 return this.beginHandleDrag(state, handleHit.shape, handleHit.handle, action.world); 134 } 135 136 const hitShapeId = hitTestPoint(state, action.world); 137 138 return hitShapeId ? this.handleShapeClick(state, hitShapeId, action) : this.handleEmptyClick(state, action); 139 } 140 141 private hitTestHandle(state: EditorState, point: Vec2): { handle: HandleKind; shape: ShapeRecord } | null { 142 if (state.ui.selectionIds.length !== 1) { 143 return null; 144 } 145 const shapeId = state.ui.selectionIds[0]; 146 const shape = state.doc.shapes[shapeId]; 147 if (!shape) { 148 return null; 149 } 150 const handles = this.getHandlePositions(state, shape); 151 for (const handle of handles) { 152 if (Vec2Ops.dist(point, handle.position) <= HANDLE_HIT_RADIUS) { 153 return { handle: handle.id, shape }; 154 } 155 } 156 return null; 157 } 158 159 private beginHandleDrag(state: EditorState, shape: ShapeRecord, handle: HandleKind, point: Vec2): EditorState { 160 this.toolState.activeHandle = handle; 161 this.toolState.handleShapeId = shape.id; 162 this.toolState.handleStartBounds = shapeBounds(shape); 163 this.toolState.handleInitialShapes.clear(); 164 this.toolState.handleInitialShapes.set(shape.id, ShapeRecord.clone(shape)); 165 this.toolState.isDragging = false; 166 this.toolState.dragStartWorld = point; 167 const bounds = this.toolState.handleStartBounds; 168 this.toolState.rotationCenter = bounds 169 ? { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2 } 170 : null; 171 this.toolState.rotationStartAngle = this.toolState.rotationCenter 172 ? Math.atan2(point.y - this.toolState.rotationCenter.y, point.x - this.toolState.rotationCenter.x) 173 : null; 174 return state; 175 } 176 177 /** 178 * Handle clicking on a shape 179 */ 180 private handleShapeClick(state: EditorState, shapeId: string, action: Action): EditorState { 181 if (action.type !== "pointer-down") return state; 182 183 const clickedShape = state.doc.shapes[shapeId]; 184 if (!clickedShape) return state; 185 186 const isShiftHeld = action.modifiers.shift; 187 188 let idsToInteractWith: string[] = [shapeId]; 189 if (clickedShape.groupId) { 190 idsToInteractWith = Object.values(state.doc.shapes).filter((s) => s.groupId === clickedShape.groupId).map((s) => 191 s.id 192 ); 193 } 194 195 const isAnySelected = idsToInteractWith.some(id => state.ui.selectionIds.includes(id)); 196 197 let newSelectionIds: string[]; 198 199 if (isShiftHeld) { 200 if (isAnySelected) { 201 newSelectionIds = state.ui.selectionIds.filter((id) => !idsToInteractWith.includes(id)); 202 } else { 203 newSelectionIds = [...state.ui.selectionIds, ...idsToInteractWith]; 204 } 205 } else { 206 if (isAnySelected && !isShiftHeld) { 207 newSelectionIds = state.ui.selectionIds; 208 } else { 209 newSelectionIds = idsToInteractWith; 210 } 211 } 212 213 if (isShiftHeld) { 214 const shouldSelect = !isAnySelected; 215 if (shouldSelect) { 216 newSelectionIds = [...new Set([...state.ui.selectionIds, ...idsToInteractWith])]; 217 } else { 218 newSelectionIds = state.ui.selectionIds.filter(id => !idsToInteractWith.includes(id)); 219 } 220 } else { 221 if (isAnySelected) { 222 newSelectionIds = state.ui.selectionIds; 223 } else { 224 newSelectionIds = idsToInteractWith; 225 } 226 } 227 228 this.toolState.isDragging = true; 229 this.toolState.dragStartWorld = action.world; 230 this.toolState.initialShapePositions.clear(); 231 232 for (const id of newSelectionIds) { 233 const shape = state.doc.shapes[id]; 234 if (shape) { 235 this.toolState.initialShapePositions.set(id, { x: shape.x, y: shape.y }); 236 } 237 } 238 239 return { ...state, ui: { ...state.ui, selectionIds: newSelectionIds } }; 240 } 241 242 /** 243 * Handle clicking on empty canvas - clear selection or start marquee 244 */ 245 private handleEmptyClick(state: EditorState, action: Action): EditorState { 246 if (action.type !== "pointer-down") return state; 247 248 const isShiftHeld = action.modifiers.shift; 249 250 if (!isShiftHeld) { 251 this.toolState.marqueeStart = action.world; 252 this.toolState.marqueeEnd = action.world; 253 this.notifyMarqueeChange(); 254 255 return { ...state, ui: { ...state.ui, selectionIds: [] } }; 256 } 257 258 return state; 259 } 260 261 /** 262 * Handle pointer move - drag shapes or update marquee 263 */ 264 private handlePointerMove(state: EditorState, action: Action): EditorState { 265 if (action.type !== "pointer-move") return state; 266 267 if (this.toolState.activeHandle && this.toolState.handleShapeId) { 268 return this.handleHandleDrag(state, action); 269 } 270 271 if (this.toolState.isDragging && this.toolState.dragStartWorld) { 272 return this.handleDragMove(state, action); 273 } else if (this.toolState.marqueeStart) { 274 return this.handleMarqueeMove(state, action); 275 } 276 277 return state; 278 } 279 280 private handleHandleDrag(state: EditorState, action: Action): EditorState { 281 if (action.type !== "pointer-move" || !this.toolState.handleShapeId || !this.toolState.activeHandle) { 282 return state; 283 } 284 const shapeId = this.toolState.handleShapeId; 285 const currentShape = state.doc.shapes[shapeId]; 286 const initialShape = this.toolState.handleInitialShapes.get(shapeId); 287 if (!currentShape || !initialShape) { 288 return state; 289 } 290 291 let updated: ShapeRecord | null = null; 292 if (this.toolState.activeHandle === "rotate") { 293 updated = this.rotateShape(initialShape, action.world); 294 } else if (this.toolState.activeHandle === "arrow-label") { 295 updated = this.adjustArrowLabel(initialShape, action.world); 296 } else if ( 297 this.toolState.activeHandle === "line-start" 298 || this.toolState.activeHandle === "line-end" 299 || this.toolState.activeHandle.startsWith("arrow-point-") 300 ) { 301 updated = this.resizeLineShape(initialShape, action.world, this.toolState.activeHandle); 302 } else if (this.toolState.handleStartBounds) { 303 updated = this.resizeRectLikeShape( 304 initialShape, 305 this.toolState.handleStartBounds, 306 action.world, 307 this.toolState.activeHandle, 308 ); 309 } 310 311 if (!updated) { 312 return state; 313 } 314 315 let newState = { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [shapeId]: updated } } }; 316 317 if ( 318 currentShape.type === "arrow" 319 && (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") 320 ) { 321 const handle = this.toolState.activeHandle === "line-start" ? "start" : "end"; 322 323 const stateWithoutArrow = { 324 ...newState, 325 doc: { 326 ...newState.doc, 327 shapes: Object.fromEntries(Object.entries(newState.doc.shapes).filter(([id]) => id !== shapeId)), 328 }, 329 }; 330 331 const hitShapeId = hitTestPoint(stateWithoutArrow, action.world); 332 333 if (hitShapeId) { 334 newState = { 335 ...newState, 336 ui: { ...newState.ui, bindingPreview: { arrowId: shapeId, targetShapeId: hitShapeId, handle } }, 337 }; 338 } else { 339 newState = { ...newState, ui: { ...newState.ui, bindingPreview: undefined } }; 340 } 341 } 342 343 return newState; 344 } 345 346 /** 347 * Handle dragging selected shapes 348 */ 349 private handleDragMove(state: EditorState, action: Action): EditorState { 350 if (action.type !== "pointer-move" || !this.toolState.dragStartWorld) return state; 351 352 const delta = Vec2Ops.sub(action.world, this.toolState.dragStartWorld); 353 354 const newShapes = { ...state.doc.shapes }; 355 356 for (const [shapeId, initialPos] of this.toolState.initialShapePositions) { 357 const shape = newShapes[shapeId]; 358 if (shape) { 359 newShapes[shapeId] = { ...shape, x: initialPos.x + delta.x, y: initialPos.y + delta.y }; 360 } 361 } 362 363 return { ...state, doc: { ...state.doc, shapes: newShapes } }; 364 } 365 366 /** 367 * Handle updating marquee selection 368 */ 369 private handleMarqueeMove(state: EditorState, action: Action): EditorState { 370 if (action.type !== "pointer-move") return state; 371 372 this.toolState.marqueeEnd = action.world; 373 this.notifyMarqueeChange(); 374 375 return state; 376 } 377 378 /** 379 * Handle pointer up - end drag or complete marquee selection 380 */ 381 private handlePointerUp(state: EditorState, action: Action): EditorState { 382 if (action.type !== "pointer-up") return state; 383 384 let newState = state; 385 386 if (this.toolState.marqueeStart && this.toolState.marqueeEnd) { 387 newState = this.completeMarqueeSelection(state); 388 } 389 390 if (this.toolState.isDragging && !this.toolState.activeHandle) { 391 newState = this.removeBindingsForMovedArrows(newState); 392 } 393 394 if ( 395 this.toolState.handleShapeId 396 && (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") 397 ) { 398 newState = this.updateArrowBindings(newState, this.toolState.handleShapeId, action.world); 399 } 400 401 this.toolState.activeHandle = null; 402 this.toolState.handleShapeId = null; 403 this.toolState.handleStartBounds = null; 404 this.toolState.handleInitialShapes.clear(); 405 this.toolState.rotationCenter = null; 406 this.toolState.rotationStartAngle = null; 407 this.toolState.isDragging = false; 408 this.toolState.dragStartWorld = null; 409 this.toolState.initialShapePositions.clear(); 410 this.toolState.marqueeStart = null; 411 this.toolState.marqueeEnd = null; 412 this.notifyMarqueeChange(); 413 414 if (newState.ui.bindingPreview) { 415 newState = { ...newState, ui: { ...newState.ui, bindingPreview: undefined } }; 416 } 417 418 return newState; 419 } 420 421 /** 422 * Complete marquee selection - select shapes whose bounds intersect the marquee 423 */ 424 private completeMarqueeSelection(state: EditorState): EditorState { 425 if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return state; 426 427 const marqueeBox = Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); 428 const currentPage = getCurrentPage(state); 429 430 if (!currentPage) return state; 431 432 const selectedIds: string[] = []; 433 434 for (const shapeId of currentPage.shapeIds) { 435 const shape = state.doc.shapes[shapeId]; 436 if (shape) { 437 const bounds = shapeBounds(shape); 438 if (Box2.intersectsBox(marqueeBox, bounds)) { 439 selectedIds.push(shapeId); 440 } 441 } 442 } 443 444 return { ...state, ui: { ...state.ui, selectionIds: selectedIds } }; 445 } 446 447 /** 448 * Handle keyboard input - Escape to clear selection, Delete to remove shapes 449 */ 450 private handleKeyDown(state: EditorState, action: Action): EditorState { 451 if (action.type !== "key-down") return state; 452 453 if (action.key === "Escape") { 454 return { ...state, ui: { ...state.ui, selectionIds: [] } }; 455 } 456 457 if (action.key === "Delete" || action.key === "Backspace") { 458 if ( 459 this.toolState.activeHandle 460 && typeof this.toolState.activeHandle === "string" 461 && this.toolState.activeHandle.startsWith("arrow-point-") 462 && this.toolState.handleShapeId 463 ) { 464 return this.removeArrowPoint(state, this.toolState.handleShapeId, this.toolState.activeHandle); 465 } 466 467 return this.deleteSelectedShapes(state); 468 } 469 470 return state; 471 } 472 473 /** 474 * Delete all selected shapes 475 */ 476 private deleteSelectedShapes(state: EditorState): EditorState { 477 const shapesToDelete = new Set(state.ui.selectionIds); 478 479 if (shapesToDelete.size === 0) return state; 480 481 const newShapes = { ...state.doc.shapes }; 482 const newBindings = { ...state.doc.bindings }; 483 const newPages = { ...state.doc.pages }; 484 485 for (const shapeId of shapesToDelete) { 486 delete newShapes[shapeId]; 487 } 488 489 for (const [bindingId, binding] of Object.entries(newBindings)) { 490 if (shapesToDelete.has(binding.fromShapeId) || shapesToDelete.has(binding.toShapeId)) { 491 delete newBindings[bindingId]; 492 } 493 } 494 495 for (const [pageId, page] of Object.entries(newPages)) { 496 const filteredShapeIds = page.shapeIds.filter((id) => !shapesToDelete.has(id)); 497 if (filteredShapeIds.length !== page.shapeIds.length) { 498 newPages[pageId] = { ...page, shapeIds: filteredShapeIds }; 499 } 500 } 501 502 return { 503 ...state, 504 doc: { ...state.doc, shapes: newShapes, bindings: newBindings, pages: newPages }, 505 ui: { ...state.ui, selectionIds: [] }, 506 }; 507 } 508 509 /** 510 * Reset internal tool state 511 */ 512 private resetToolState(): void { 513 this.toolState = { 514 isDragging: false, 515 dragStartWorld: null, 516 initialShapePositions: new Map(), 517 marqueeStart: null, 518 marqueeEnd: null, 519 activeHandle: null, 520 handleShapeId: null, 521 handleStartBounds: null, 522 handleInitialShapes: new Map(), 523 rotationCenter: null, 524 rotationStartAngle: null, 525 }; 526 this.notifyMarqueeChange(); 527 } 528 529 /** 530 * Get current marquee bounds (for rendering) 531 */ 532 getMarqueeBounds(): Box2 | null { 533 if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return null; 534 return Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); 535 } 536 537 private notifyMarqueeChange(): void { 538 if (this.marqueeListener) { 539 this.marqueeListener(this.getMarqueeBounds()); 540 } 541 } 542 543 getHandleAtPoint(state: EditorState, point: Vec2): HandleKind | null { 544 const hit = this.hitTestHandle(state, point); 545 return hit?.handle ?? null; 546 } 547 548 getActiveHandle(): HandleKind | null { 549 return this.toolState.activeHandle; 550 } 551 552 private getHandlePositions(state: EditorState, shape: ShapeRecord): Array<{ id: HandleKind; position: Vec2 }> { 553 const handles: Array<{ id: HandleKind; position: Vec2 }> = []; 554 if (shape.type === "rect" || shape.type === "ellipse" || shape.type === "text") { 555 const bounds = shapeBounds(shape); 556 const minX = bounds.min.x; 557 const maxX = bounds.max.x; 558 const minY = bounds.min.y; 559 const maxY = bounds.max.y; 560 const centerX = (minX + maxX) / 2; 561 const centerY = (minY + maxY) / 2; 562 handles.push( 563 { id: "nw", position: { x: minX, y: minY } }, 564 { id: "n", position: { x: centerX, y: minY } }, 565 { id: "ne", position: { x: maxX, y: minY } }, 566 { id: "e", position: { x: maxX, y: centerY } }, 567 { id: "se", position: { x: maxX, y: maxY } }, 568 { id: "s", position: { x: centerX, y: maxY } }, 569 { id: "sw", position: { x: minX, y: maxY } }, 570 { id: "w", position: { x: minX, y: centerY } }, 571 { id: "rotate", position: { x: centerX, y: minY - ROTATE_HANDLE_OFFSET } }, 572 ); 573 } else if (shape.type === "line") { 574 const start = this.localToWorld(shape, shape.props.a); 575 const end = this.localToWorld(shape, shape.props.b); 576 handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); 577 } else if (shape.type === "arrow") { 578 const resolved = resolveArrowEndpoints(state, shape.id); 579 if (resolved && shape.props.points && shape.props.points.length >= 2) { 580 handles.push({ id: "line-start", position: resolved.a }); 581 582 for (let i = 1; i < shape.props.points.length - 1; i++) { 583 const point = shape.props.points[i]; 584 const worldPos = this.localToWorld(shape, point); 585 handles.push({ id: `arrow-point-${i}` as HandleKind, position: worldPos }); 586 } 587 588 handles.push({ id: "line-end", position: resolved.b }); 589 590 if (shape.props.label) { 591 const polylineLength = computePolylineLength(shape.props.points); 592 const align = shape.props.label.align ?? "center"; 593 const offset = shape.props.label.offset ?? 0; 594 595 let distance: number; 596 if (align === "center") { 597 distance = polylineLength / 2 + offset; 598 } else if (align === "start") { 599 distance = offset; 600 } else { 601 distance = polylineLength - offset; 602 } 603 604 distance = Math.max(0, Math.min(distance, polylineLength)); 605 const labelPos = getPointAtDistance(shape.props.points, distance); 606 const worldLabelPos = this.localToWorld(shape, labelPos); 607 handles.push({ id: "arrow-label", position: worldLabelPos }); 608 } 609 } 610 } 611 return handles; 612 } 613 614 private resizeRectLikeShape( 615 initial: ShapeRecord, 616 bounds: Box2, 617 pointer: Vec2, 618 handle: HandleKind, 619 ): ShapeRecord | null { 620 if ( 621 initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text" && initial.type !== "markdown" 622 ) { 623 return null; 624 } 625 let minX = bounds.min.x; 626 let maxX = bounds.max.x; 627 let minY = bounds.min.y; 628 let maxY = bounds.max.y; 629 630 const clampX = (value: number) => Math.min(Math.max(value, -1e6), 1e6); 631 const clampY = (value: number) => Math.min(Math.max(value, -1e6), 1e6); 632 633 switch (handle) { 634 case "nw": { 635 minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); 636 minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); 637 break; 638 } 639 case "n": { 640 minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); 641 break; 642 } 643 case "ne": { 644 maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); 645 minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); 646 break; 647 } 648 case "e": { 649 maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); 650 break; 651 } 652 case "se": { 653 maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); 654 maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); 655 break; 656 } 657 case "s": { 658 maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); 659 break; 660 } 661 case "sw": { 662 minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); 663 maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); 664 break; 665 } 666 case "w": { 667 minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); 668 break; 669 } 670 } 671 672 const width = Math.max(maxX - minX, MIN_RESIZE_SIZE); 673 const height = Math.max(maxY - minY, MIN_RESIZE_SIZE); 674 675 if (initial.type === "text") { 676 return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width } }; 677 } 678 679 if (initial.type === "markdown") { 680 return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } }; 681 } 682 683 // @ts-expect-error union mismatch 684 return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } }; 685 } 686 687 private adjustArrowLabel(initial: ShapeRecord, pointer: Vec2): ShapeRecord | null { 688 if (initial.type !== "arrow" || !initial.props.points || initial.props.points.length < 2 || !initial.props.label) { 689 return null; 690 } 691 692 const localPointer = this.worldToLocal(initial, pointer); 693 const points = initial.props.points; 694 const polylineLength = computePolylineLength(points); 695 696 let closestDistance = 0; 697 let minDistToLine = Number.POSITIVE_INFINITY; 698 699 for (let i = 0; i < points.length - 1; i++) { 700 const a = points[i]; 701 const b = points[i + 1]; 702 const segmentLength = Vec2Ops.dist(a, b); 703 704 const ab = Vec2Ops.sub(b, a); 705 const ap = Vec2Ops.sub(localPointer, a); 706 const t = Math.max(0, Math.min(1, Vec2Ops.dot(ap, ab) / Vec2Ops.dot(ab, ab))); 707 const projection = Vec2Ops.add(a, Vec2Ops.mulScalar(ab, t)); 708 const distToLine = Vec2Ops.dist(localPointer, projection); 709 710 if (distToLine < minDistToLine) { 711 minDistToLine = distToLine; 712 let distanceToSegmentStart = 0; 713 for (let j = 0; j < i; j++) { 714 distanceToSegmentStart += Vec2Ops.dist(points[j], points[j + 1]); 715 } 716 closestDistance = distanceToSegmentStart + t * segmentLength; 717 } 718 } 719 720 const align = initial.props.label.align ?? "center"; 721 let newOffset: number; 722 723 if (align === "center") { 724 newOffset = closestDistance - polylineLength / 2; 725 } else if (align === "start") { 726 newOffset = closestDistance; 727 } else { 728 newOffset = polylineLength - closestDistance; 729 } 730 731 return { ...initial, props: { ...initial.props, label: { ...initial.props.label, offset: newOffset } } }; 732 } 733 734 private resizeLineShape(initial: ShapeRecord, pointer: Vec2, handle: HandleKind): ShapeRecord | null { 735 if (initial.type !== "line" && initial.type !== "arrow") { 736 return null; 737 } 738 739 if (initial.type === "arrow" && typeof handle === "string" && handle.startsWith("arrow-point-")) { 740 const pointIndex = Number.parseInt(handle.replace("arrow-point-", ""), 10); 741 if (!initial.props.points || pointIndex < 1 || pointIndex >= initial.props.points.length - 1) { 742 return null; 743 } 744 745 const newPoints = initial.props.points.map((p, i) => { 746 if (i === pointIndex) { 747 return { x: pointer.x - initial.x, y: pointer.y - initial.y }; 748 } 749 return p; 750 }); 751 752 const newProps = { ...initial.props, points: newPoints }; 753 return { ...initial, props: newProps }; 754 } 755 756 if (handle !== "line-start" && handle !== "line-end") { 757 return null; 758 } 759 760 let startPoint: Vec2, endPoint: Vec2; 761 762 if (initial.type === "line") { 763 startPoint = initial.props.a; 764 endPoint = initial.props.b; 765 } else { 766 if (!initial.props.points || initial.props.points.length < 2) { 767 return null; 768 } 769 startPoint = initial.props.points[0]; 770 endPoint = initial.props.points[initial.props.points.length - 1]; 771 } 772 773 const startWorld = this.localToWorld(initial, startPoint); 774 const endWorld = this.localToWorld(initial, endPoint); 775 const newStart = handle === "line-start" ? pointer : startWorld; 776 const newEnd = handle === "line-end" ? pointer : endWorld; 777 778 if (initial.type === "line") { 779 const newProps = { 780 ...initial.props, 781 a: { x: 0, y: 0 }, 782 b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }, 783 }; 784 return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; 785 } else { 786 const newPoints = initial.props.points.map((p, i) => { 787 if (i === 0) { 788 return { x: 0, y: 0 }; 789 } else if (i === initial.props.points.length - 1) { 790 return { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }; 791 } else { 792 const worldPos = this.localToWorld(initial, p); 793 return { x: worldPos.x - newStart.x, y: worldPos.y - newStart.y }; 794 } 795 }); 796 797 const newProps = { ...initial.props, points: newPoints }; 798 return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; 799 } 800 } 801 802 private rotateShape(initial: ShapeRecord, pointer: Vec2): ShapeRecord | null { 803 if (!this.toolState.rotationCenter || this.toolState.rotationStartAngle === null) { 804 return null; 805 } 806 if ( 807 initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text" && initial.type !== "markdown" 808 ) { 809 return null; 810 } 811 const currentAngle = Math.atan2( 812 pointer.y - this.toolState.rotationCenter.y, 813 pointer.x - this.toolState.rotationCenter.x, 814 ); 815 const delta = currentAngle - this.toolState.rotationStartAngle; 816 return { ...initial, rot: initial.rot + delta }; 817 } 818 819 private localToWorld(shape: ShapeRecord, point: Vec2): Vec2 { 820 if (shape.rot === 0) { 821 return { x: shape.x + point.x, y: shape.y + point.y }; 822 } 823 const cos = Math.cos(shape.rot); 824 const sin = Math.sin(shape.rot); 825 return { x: shape.x + point.x * cos - point.y * sin, y: shape.y + point.x * sin + point.y * cos }; 826 } 827 828 private worldToLocal(shape: ShapeRecord, point: Vec2): Vec2 { 829 if (shape.rot === 0) { 830 return { x: point.x - shape.x, y: point.y - shape.y }; 831 } 832 const dx = point.x - shape.x; 833 const dy = point.y - shape.y; 834 const cos = Math.cos(-shape.rot); 835 const sin = Math.sin(-shape.rot); 836 return { x: dx * cos - dy * sin, y: dx * sin + dy * cos }; 837 } 838 839 /** 840 * Remove an intermediate point from an arrow 841 */ 842 private removeArrowPoint(state: EditorState, arrowId: string, handle: HandleKind): EditorState { 843 const arrow = state.doc.shapes[arrowId]; 844 if (!arrow || arrow.type !== "arrow" || !arrow.props.points) { 845 return state; 846 } 847 848 const pointIndex = Number.parseInt((handle as string).replace("arrow-point-", ""), 10); 849 if (Number.isNaN(pointIndex) || pointIndex < 1 || pointIndex >= arrow.props.points.length - 1) { 850 return state; 851 } 852 853 const newPoints = arrow.props.points.filter((_, i) => i !== pointIndex); 854 855 if (newPoints.length < 2) { 856 return state; 857 } 858 859 const updatedArrow = { ...arrow, props: { ...arrow.props, points: newPoints } }; 860 861 this.resetToolState(); 862 863 return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [arrowId]: updatedArrow } } }; 864 } 865 866 /** 867 * Try to add a point to an arrow segment at the clicked location 868 * Returns updated state if successful, null otherwise 869 */ 870 private tryAddPointToArrowSegment(state: EditorState, arrow: ShapeRecord, clickWorld: Vec2): EditorState | null { 871 if (arrow.type !== "arrow" || !arrow.props.points || arrow.props.points.length < 2) { 872 return null; 873 } 874 875 const clickLocal = { x: clickWorld.x - arrow.x, y: clickWorld.y - arrow.y }; 876 const tolerance = 10; 877 878 for (let i = 0; i < arrow.props.points.length - 1; i++) { 879 const a = arrow.props.points[i]; 880 const b = arrow.props.points[i + 1]; 881 882 const ab = Vec2Ops.sub(b, a); 883 const ap = Vec2Ops.sub(clickLocal, a); 884 const abLengthSq = Vec2Ops.lenSq(ab); 885 886 if (abLengthSq === 0) continue; 887 888 const t = Math.max(0, Math.min(1, Vec2Ops.dot(ap, ab) / abLengthSq)); 889 const projection = Vec2Ops.add(a, Vec2Ops.mulScalar(ab, t)); 890 const distance = Vec2Ops.dist(clickLocal, projection); 891 892 if (distance <= tolerance) { 893 const newPoints = [...arrow.props.points.slice(0, i + 1), clickLocal, ...arrow.props.points.slice(i + 1)]; 894 895 const updatedArrow = { ...arrow, props: { ...arrow.props, points: newPoints } }; 896 return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [arrow.id]: updatedArrow } } }; 897 } 898 } 899 900 return null; 901 } 902 903 /** 904 * Remove bindings for arrows that were moved with the select tool 905 * 906 * When an arrow is moved (not just its endpoints), its bindings should be removed 907 * to prevent the endpoints from snapping back to the old binding positions. 908 */ 909 private removeBindingsForMovedArrows(state: EditorState): EditorState { 910 const movedArrowIds = Array.from(this.toolState.initialShapePositions.keys()).filter((shapeId) => { 911 const shape = state.doc.shapes[shapeId]; 912 return shape && shape.type === "arrow"; 913 }); 914 915 if (movedArrowIds.length === 0) { 916 return state; 917 } 918 919 const newBindings = { ...state.doc.bindings }; 920 const newShapes = { ...state.doc.shapes }; 921 let bindingsRemoved = false; 922 923 for (const arrowId of movedArrowIds) { 924 const arrow = newShapes[arrowId]; 925 if (!arrow || arrow.type !== "arrow") continue; 926 927 for (const [bindingId, binding] of Object.entries(newBindings)) { 928 if (binding.fromShapeId === arrowId) { 929 delete newBindings[bindingId]; 930 bindingsRemoved = true; 931 932 console.log("[Arrow Movement Fix] Removing binding", { 933 arrowId, 934 bindingId, 935 handle: binding.handle, 936 targetShapeId: binding.toShapeId, 937 }); 938 } 939 } 940 941 if (bindingsRemoved) { 942 newShapes[arrowId] = { ...arrow, props: { ...arrow.props, start: { kind: "free" }, end: { kind: "free" } } }; 943 } 944 } 945 946 if (!bindingsRemoved) { 947 return state; 948 } 949 950 return { ...state, doc: { ...state.doc, shapes: newShapes, bindings: newBindings } }; 951 } 952 953 /** 954 * Update arrow bindings when an endpoint is dragged 955 * 956 * Creates or updates bindings for arrow endpoints based on hit testing. 957 * If the endpoint is over a shape, creates/updates an edge anchor binding. 958 * If the endpoint is not over a shape, removes any existing binding. 959 */ 960 private updateArrowBindings(state: EditorState, arrowId: string, endpointWorld: Vec2): EditorState { 961 const arrow = state.doc.shapes[arrowId]; 962 if (!arrow || arrow.type !== "arrow") return state; 963 964 const handle = this.toolState.activeHandle === "line-start" ? "start" : "end"; 965 966 const stateWithoutArrow = { 967 ...state, 968 doc: { 969 ...state.doc, 970 shapes: Object.fromEntries(Object.entries(state.doc.shapes).filter(([id]) => id !== arrowId)), 971 }, 972 }; 973 974 const hitShapeId = hitTestPoint(stateWithoutArrow, endpointWorld); 975 976 const newBindings = { ...state.doc.bindings }; 977 978 for (const [bindingId, binding] of Object.entries(newBindings)) { 979 if (binding.fromShapeId === arrowId && binding.handle === handle) { 980 delete newBindings[bindingId]; 981 } 982 } 983 984 if (hitShapeId) { 985 const targetShape = state.doc.shapes[hitShapeId]; 986 if (targetShape) { 987 const anchor = computeNormalizedAnchor(endpointWorld, targetShape); 988 const binding = BindingRecord.create(arrowId, hitShapeId, handle, { 989 kind: "edge", 990 nx: anchor.nx, 991 ny: anchor.ny, 992 }); 993 newBindings[binding.id] = binding; 994 } 995 } 996 997 return { ...state, doc: { ...state.doc, bindings: newBindings } }; 998 } 999}