web based infinite canvas
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}