web based infinite canvas
2
fork

Configure Feed

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

fix: arrow handling

* added stroke width offset to arrow endpoints

* remove bindings when arrows are moved

* use resolved endpoints for handle detection

+580 -16
+25 -3
packages/core/src/geom.ts
··· 510 510 * @param shape - Target shape 511 511 * @param nx - Normalized x coordinate in [-1, 1] where -1 is left edge, 1 is right edge, 0 is center 512 512 * @param ny - Normalized y coordinate in [-1, 1] where -1 is top edge, 1 is bottom edge, 0 is center 513 + * @param offset - Optional offset distance to push the anchor point away from the shape (default: 0) 513 514 * @returns World coordinates of the anchor point 514 515 */ 515 - export function computeEdgeAnchor(shape: ShapeRecord, nx: number, ny: number): Vec2 { 516 + export function computeEdgeAnchor(shape: ShapeRecord, nx: number, ny: number, offset = 0): Vec2 { 516 517 const bounds = shapeBounds(shape); 517 518 const centerX = (bounds.min.x + bounds.max.x) / 2; 518 519 const centerY = (bounds.min.y + bounds.max.y) / 2; 519 520 const halfWidth = (bounds.max.x - bounds.min.x) / 2; 520 521 const halfHeight = (bounds.max.y - bounds.min.y) / 2; 521 522 522 - return { x: centerX + nx * halfWidth, y: centerY + ny * halfHeight }; 523 + const baseX = centerX + nx * halfWidth; 524 + const baseY = centerY + ny * halfHeight; 525 + 526 + if (offset === 0) { 527 + return { x: baseX, y: baseY }; 528 + } 529 + 530 + const dx = baseX - centerX; 531 + const dy = baseY - centerY; 532 + const distance = Math.sqrt(dx * dx + dy * dy); 533 + 534 + if (distance < 0.01) { 535 + return { x: baseX, y: baseY }; 536 + } 537 + 538 + const offsetX = (dx / distance) * offset; 539 + const offsetY = (dy / distance) * offset; 540 + return { x: baseX + offsetX, y: baseY + offsetY }; 523 541 } 524 542 525 543 /** ··· 565 583 let a: Vec2 = { x: arrow.x + firstPoint.x, y: arrow.y + firstPoint.y }; 566 584 let b: Vec2 = { x: arrow.x + lastPoint.x, y: arrow.y + lastPoint.y }; 567 585 586 + const arrowStrokeWidth = arrow.props.style?.width ?? 2; 587 + const targetShapeStrokeWidth = 2; 588 + const offset = targetShapeStrokeWidth / 2 + arrowStrokeWidth / 2; 589 + 568 590 for (const binding of Object.values(state.doc.bindings)) { 569 591 if (binding.fromShapeId !== arrowId) continue; 570 592 ··· 575 597 if (binding.anchor.kind === "center") { 576 598 anchorPoint = shapeCenter(targetShape); 577 599 } else { 578 - anchorPoint = computeEdgeAnchor(targetShape, binding.anchor.nx, binding.anchor.ny); 600 + anchorPoint = computeEdgeAnchor(targetShape, binding.anchor.nx, binding.anchor.ny, offset); 579 601 } 580 602 581 603 if (binding.handle === "start") {
+72 -13
packages/core/src/tools/select.ts
··· 1 1 import type { Action } from "../actions"; 2 - import { computeNormalizedAnchor, computePolylineLength, getPointAtDistance, hitTestPoint, shapeBounds } from "../geom"; 2 + import { 3 + computeNormalizedAnchor, 4 + computePolylineLength, 5 + getPointAtDistance, 6 + hitTestPoint, 7 + resolveArrowEndpoints, 8 + shapeBounds, 9 + } from "../geom"; 3 10 import { Box2, type Vec2, Vec2 as Vec2Ops } from "../math"; 4 11 import { BindingRecord, ShapeRecord } from "../model"; 5 12 import { EditorState, getCurrentPage, type ToolId } from "../reactivity"; ··· 140 147 if (!shape) { 141 148 return null; 142 149 } 143 - const handles = this.getHandlePositions(shape); 150 + const handles = this.getHandlePositions(state, shape); 144 151 for (const handle of handles) { 145 152 if (Vec2Ops.dist(point, handle.position) <= HANDLE_HIT_RADIUS) { 146 153 return { handle: handle.id, shape }; ··· 348 355 newState = this.completeMarqueeSelection(state); 349 356 } 350 357 358 + if (this.toolState.isDragging && !this.toolState.activeHandle) { 359 + newState = this.removeBindingsForMovedArrows(newState); 360 + } 361 + 351 362 if ( 352 363 this.toolState.handleShapeId 353 364 && (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") ··· 506 517 return this.toolState.activeHandle; 507 518 } 508 519 509 - private getHandlePositions(shape: ShapeRecord): Array<{ id: HandleKind; position: Vec2 }> { 520 + private getHandlePositions(state: EditorState, shape: ShapeRecord): Array<{ id: HandleKind; position: Vec2 }> { 510 521 const handles: Array<{ id: HandleKind; position: Vec2 }> = []; 511 522 if (shape.type === "rect" || shape.type === "ellipse" || shape.type === "text") { 512 523 const bounds = shapeBounds(shape); ··· 532 543 const end = this.localToWorld(shape, shape.props.b); 533 544 handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); 534 545 } else if (shape.type === "arrow") { 535 - if (shape.props.points && shape.props.points.length >= 2) { 536 - for (let i = 0; i < shape.props.points.length; i++) { 546 + const resolved = resolveArrowEndpoints(state, shape.id); 547 + if (resolved && shape.props.points && shape.props.points.length >= 2) { 548 + handles.push({ id: "line-start", position: resolved.a }); 549 + 550 + for (let i = 1; i < shape.props.points.length - 1; i++) { 537 551 const point = shape.props.points[i]; 538 552 const worldPos = this.localToWorld(shape, point); 553 + handles.push({ id: `arrow-point-${i}` as HandleKind, position: worldPos }); 554 + } 539 555 540 - if (i === 0) { 541 - handles.push({ id: "line-start", position: worldPos }); 542 - } else if (i === shape.props.points.length - 1) { 543 - handles.push({ id: "line-end", position: worldPos }); 544 - } else { 545 - handles.push({ id: `arrow-point-${i}` as HandleKind, position: worldPos }); 546 - } 547 - } 556 + handles.push({ id: "line-end", position: resolved.b }); 548 557 549 558 if (shape.props.label) { 550 559 const polylineLength = computePolylineLength(shape.props.points); ··· 857 866 } 858 867 859 868 return null; 869 + } 870 + 871 + /** 872 + * Remove bindings for arrows that were moved with the select tool 873 + * 874 + * When an arrow is moved (not just its endpoints), its bindings should be removed 875 + * to prevent the endpoints from snapping back to the old binding positions. 876 + */ 877 + private removeBindingsForMovedArrows(state: EditorState): EditorState { 878 + const movedArrowIds = Array.from(this.toolState.initialShapePositions.keys()).filter((shapeId) => { 879 + const shape = state.doc.shapes[shapeId]; 880 + return shape && shape.type === "arrow"; 881 + }); 882 + 883 + if (movedArrowIds.length === 0) { 884 + return state; 885 + } 886 + 887 + const newBindings = { ...state.doc.bindings }; 888 + const newShapes = { ...state.doc.shapes }; 889 + let bindingsRemoved = false; 890 + 891 + for (const arrowId of movedArrowIds) { 892 + const arrow = newShapes[arrowId]; 893 + if (!arrow || arrow.type !== "arrow") continue; 894 + 895 + for (const [bindingId, binding] of Object.entries(newBindings)) { 896 + if (binding.fromShapeId === arrowId) { 897 + delete newBindings[bindingId]; 898 + bindingsRemoved = true; 899 + 900 + console.log("[Arrow Movement Fix] Removing binding", { 901 + arrowId, 902 + bindingId, 903 + handle: binding.handle, 904 + targetShapeId: binding.toShapeId, 905 + }); 906 + } 907 + } 908 + 909 + if (bindingsRemoved) { 910 + newShapes[arrowId] = { ...arrow, props: { ...arrow.props, start: { kind: "free" }, end: { kind: "free" } } }; 911 + } 912 + } 913 + 914 + if (!bindingsRemoved) { 915 + return state; 916 + } 917 + 918 + return { ...state, doc: { ...state.doc, shapes: newShapes, bindings: newBindings } }; 860 919 } 861 920 862 921 /**
+483
packages/core/tests/arrow-bindings.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { Action } from "../src/actions"; 3 + import { resolveArrowEndpoints } from "../src/geom"; 4 + import { BindingRecord, PageRecord, ShapeRecord } from "../src/model"; 5 + import { EditorState } from "../src/reactivity"; 6 + import { SelectTool } from "../src/tools/select"; 7 + 8 + describe("Arrow binding behavior", () => { 9 + describe("Issue #2: Moving arrows with select tool", () => { 10 + it("should remove bindings when an arrow is moved (dragged)", () => { 11 + let state = EditorState.create(); 12 + 13 + const page = PageRecord.create("Test Page"); 14 + state = { 15 + ...state, 16 + doc: { ...state.doc, pages: { [page.id]: page } }, 17 + ui: { ...state.ui, currentPageId: page.id }, 18 + }; 19 + 20 + const rectStart = ShapeRecord.createRect(page.id, 50, 50, { 21 + w: 50, 22 + h: 50, 23 + fill: "#fff", 24 + stroke: "#000", 25 + radius: 0, 26 + }); 27 + 28 + const rectEnd = ShapeRecord.createRect(page.id, 250, 50, { 29 + w: 50, 30 + h: 50, 31 + fill: "#fff", 32 + stroke: "#000", 33 + radius: 0, 34 + }); 35 + 36 + const arrow = ShapeRecord.createArrow(page.id, 100, 75, { 37 + points: [{ x: 0, y: 0 }, { x: 150, y: 0 }], 38 + start: { kind: "bound", bindingId: "binding-start" }, 39 + end: { kind: "bound", bindingId: "binding-end" }, 40 + style: { stroke: "#000", width: 2, headEnd: true }, 41 + }); 42 + 43 + const bindingStart = BindingRecord.create( 44 + arrow.id, 45 + rectStart.id, 46 + "start", 47 + { kind: "edge", nx: 1, ny: 0 }, 48 + "binding-start", 49 + ); 50 + 51 + const bindingEnd = BindingRecord.create( 52 + arrow.id, 53 + rectEnd.id, 54 + "end", 55 + { kind: "edge", nx: -1, ny: 0 }, 56 + "binding-end", 57 + ); 58 + 59 + state = { 60 + ...state, 61 + doc: { 62 + ...state.doc, 63 + shapes: { ...state.doc.shapes, [arrow.id]: arrow, [rectStart.id]: rectStart, [rectEnd.id]: rectEnd }, 64 + bindings: { [bindingStart.id]: bindingStart, [bindingEnd.id]: bindingEnd }, 65 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [rectStart.id, rectEnd.id, arrow.id] } }, 66 + }, 67 + ui: { ...state.ui, selectionIds: [arrow.id] }, 68 + }; 69 + 70 + expect(Object.keys(state.doc.bindings).length).toBe(2); 71 + expect(state.doc.bindings[bindingStart.id]).toBeDefined(); 72 + expect(state.doc.bindings[bindingEnd.id]).toBeDefined(); 73 + 74 + const tool = new SelectTool(); 75 + tool.onEnter(state); 76 + 77 + const clickWorld = { x: 175, y: 75 }; 78 + const pointerDown = Action.pointerDown( 79 + { x: 0, y: 0 }, 80 + clickWorld, 81 + 0, 82 + { left: true, middle: false, right: false }, 83 + { ctrl: false, shift: false, alt: false, meta: false }, 84 + 0, 85 + ); 86 + state = tool.onAction(state, pointerDown); 87 + 88 + const newWorld = { x: 175, y: 150 }; 89 + const pointerMove = Action.pointerMove({ x: 0, y: 0 }, newWorld, { left: true, middle: false, right: false }, { 90 + ctrl: false, 91 + shift: false, 92 + alt: false, 93 + meta: false, 94 + }, 100); 95 + state = tool.onAction(state, pointerMove); 96 + 97 + const pointerUp = Action.pointerUp({ x: 0, y: 0 }, newWorld, 0, { left: false, middle: false, right: false }, { 98 + ctrl: false, 99 + shift: false, 100 + alt: false, 101 + meta: false, 102 + }, 200); 103 + state = tool.onAction(state, pointerUp); 104 + 105 + expect(Object.keys(state.doc.bindings).length).toBe(0); 106 + expect(state.doc.bindings[bindingStart.id]).toBeUndefined(); 107 + expect(state.doc.bindings[bindingEnd.id]).toBeUndefined(); 108 + 109 + const updatedArrow = state.doc.shapes[arrow.id]; 110 + expect(updatedArrow.type).toBe("arrow"); 111 + if (updatedArrow.type === "arrow") { 112 + expect(updatedArrow.props.start.kind).toBe("free"); 113 + expect(updatedArrow.props.end.kind).toBe("free"); 114 + } 115 + }); 116 + 117 + it("should NOT remove bindings when dragging an endpoint handle", () => { 118 + let state = EditorState.create(); 119 + 120 + const page = PageRecord.create("Test Page"); 121 + state = { 122 + ...state, 123 + doc: { ...state.doc, pages: { [page.id]: page } }, 124 + ui: { ...state.ui, currentPageId: page.id }, 125 + }; 126 + 127 + const rect = ShapeRecord.createRect(page.id, 250, 50, { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }); 128 + 129 + const arrow = ShapeRecord.createArrow(page.id, 100, 75, { 130 + points: [{ x: 0, y: 0 }, { x: 150, y: 0 }], 131 + start: { kind: "free" }, 132 + end: { kind: "bound", bindingId: "binding-end" }, 133 + style: { stroke: "#000", width: 2, headEnd: true }, 134 + }); 135 + 136 + const binding = BindingRecord.create(arrow.id, rect.id, "end", { kind: "edge", nx: -1, ny: 0 }, "binding-end"); 137 + 138 + state = { 139 + ...state, 140 + doc: { 141 + ...state.doc, 142 + shapes: { ...state.doc.shapes, [arrow.id]: arrow, [rect.id]: rect }, 143 + bindings: { [binding.id]: binding }, 144 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [rect.id, arrow.id] } }, 145 + }, 146 + ui: { ...state.ui, selectionIds: [arrow.id] }, 147 + }; 148 + 149 + const tool = new SelectTool(); 150 + tool.onEnter(state); 151 + 152 + const resolved = resolveArrowEndpoints(state, arrow.id); 153 + expect(resolved).not.toBeNull(); 154 + 155 + const endHandleWorld = resolved!.b; 156 + const pointerDown = Action.pointerDown( 157 + { x: 0, y: 0 }, 158 + endHandleWorld, 159 + 0, 160 + { left: true, middle: false, right: false }, 161 + { ctrl: false, shift: false, alt: false, meta: false }, 162 + 0, 163 + ); 164 + state = tool.onAction(state, pointerDown); 165 + 166 + const newWorld = { x: endHandleWorld.x + 50, y: endHandleWorld.y }; 167 + const pointerMove = Action.pointerMove({ x: 0, y: 0 }, newWorld, { left: true, middle: false, right: false }, { 168 + ctrl: false, 169 + shift: false, 170 + alt: false, 171 + meta: false, 172 + }, 100); 173 + state = tool.onAction(state, pointerMove); 174 + 175 + const pointerUp = Action.pointerUp({ x: 0, y: 0 }, newWorld, 0, { left: false, middle: false, right: false }, { 176 + ctrl: false, 177 + shift: false, 178 + alt: false, 179 + meta: false, 180 + }, 200); 181 + state = tool.onAction(state, pointerUp); 182 + 183 + const updatedArrow = state.doc.shapes[arrow.id]; 184 + expect(updatedArrow.type).toBe("arrow"); 185 + }); 186 + }); 187 + 188 + describe("Issue #3: Arrow endpoint handle detection with bindings", () => { 189 + it("should be able to click and drag arrow endpoint handles when arrow is bound", () => { 190 + let state = EditorState.create(); 191 + 192 + const page = PageRecord.create("Test Page"); 193 + state = { 194 + ...state, 195 + doc: { ...state.doc, pages: { [page.id]: page } }, 196 + ui: { ...state.ui, currentPageId: page.id }, 197 + }; 198 + 199 + const rect = ShapeRecord.createRect(page.id, 250, 50, { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }); 200 + 201 + const arrow = ShapeRecord.createArrow(page.id, 100, 75, { 202 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 203 + start: { kind: "free" }, 204 + end: { kind: "bound", bindingId: "binding-end" }, 205 + style: { stroke: "#000", width: 2, headEnd: true }, 206 + }); 207 + 208 + const binding = BindingRecord.create(arrow.id, rect.id, "end", { kind: "center" }, "binding-end"); 209 + 210 + state = { 211 + ...state, 212 + doc: { 213 + ...state.doc, 214 + shapes: { ...state.doc.shapes, [arrow.id]: arrow, [rect.id]: rect }, 215 + bindings: { [binding.id]: binding }, 216 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [rect.id, arrow.id] } }, 217 + }, 218 + ui: { ...state.ui, selectionIds: [arrow.id] }, 219 + }; 220 + 221 + const tool = new SelectTool(); 222 + tool.onEnter(state); 223 + 224 + const resolved = resolveArrowEndpoints(state, arrow.id); 225 + expect(resolved).not.toBeNull(); 226 + const resolvedEndPos = resolved!.b; 227 + 228 + expect(resolvedEndPos.x).toBeCloseTo(275, 0); 229 + expect(resolvedEndPos.y).toBeCloseTo(75, 0); 230 + 231 + const pointerDown = Action.pointerDown( 232 + { x: 0, y: 0 }, 233 + resolvedEndPos, 234 + 0, 235 + { left: true, middle: false, right: false }, 236 + { ctrl: false, shift: false, alt: false, meta: false }, 237 + 0, 238 + ); 239 + state = tool.onAction(state, pointerDown); 240 + 241 + const activeHandle = tool.getActiveHandle(); 242 + expect(activeHandle).toBe("line-end"); 243 + 244 + const newWorld = { x: 300, y: 100 }; 245 + const pointerMove = Action.pointerMove({ x: 0, y: 0 }, newWorld, { left: true, middle: false, right: false }, { 246 + ctrl: false, 247 + shift: false, 248 + alt: false, 249 + meta: false, 250 + }, 100); 251 + state = tool.onAction(state, pointerMove); 252 + 253 + const updatedArrow = state.doc.shapes[arrow.id]; 254 + expect(updatedArrow.type).toBe("arrow"); 255 + if (updatedArrow.type === "arrow") { 256 + expect(updatedArrow.props.points[0]).toEqual({ x: 0, y: 0 }); 257 + 258 + expect(updatedArrow.props.points[updatedArrow.props.points.length - 1].x).toBeGreaterThan(0); 259 + } 260 + }); 261 + }); 262 + 263 + describe("Issue #1: Arrow endpoint offset from bound shapes", () => { 264 + it("should position arrow endpoints with offset to account for stroke widths", () => { 265 + let state = EditorState.create(); 266 + 267 + const page = PageRecord.create("Test Page"); 268 + state = { 269 + ...state, 270 + doc: { ...state.doc, pages: { [page.id]: page } }, 271 + ui: { ...state.ui, currentPageId: page.id }, 272 + }; 273 + 274 + const rect = ShapeRecord.createRect(page.id, 200, 100, { 275 + w: 100, 276 + h: 100, 277 + fill: "#fff", 278 + stroke: "#000", 279 + radius: 0, 280 + }); 281 + 282 + const arrow = ShapeRecord.createArrow(page.id, 100, 150, { 283 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 284 + start: { kind: "free" }, 285 + end: { kind: "bound", bindingId: "binding-end" }, 286 + style: { stroke: "#000", width: 2, headEnd: true }, 287 + }); 288 + 289 + const binding = BindingRecord.create(arrow.id, rect.id, "end", { kind: "edge", nx: -1, ny: 0 }, "binding-end"); 290 + 291 + state = { 292 + ...state, 293 + doc: { 294 + ...state.doc, 295 + shapes: { ...state.doc.shapes, [arrow.id]: arrow, [rect.id]: rect }, 296 + bindings: { [binding.id]: binding }, 297 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [rect.id, arrow.id] } }, 298 + }, 299 + }; 300 + 301 + const resolved = resolveArrowEndpoints(state, arrow.id); 302 + expect(resolved).not.toBeNull(); 303 + 304 + const expectedX = 200 - 2; 305 + const expectedY = 150; 306 + 307 + expect(resolved!.b.x).toBeCloseTo(expectedX, 0); 308 + expect(resolved!.b.y).toBeCloseTo(expectedY, 0); 309 + }); 310 + 311 + it("should apply offset for arrows with different stroke widths", () => { 312 + let state = EditorState.create(); 313 + 314 + const page = PageRecord.create("Test Page"); 315 + state = { 316 + ...state, 317 + doc: { ...state.doc, pages: { [page.id]: page } }, 318 + ui: { ...state.ui, currentPageId: page.id }, 319 + }; 320 + 321 + const rect = ShapeRecord.createRect(page.id, 200, 100, { 322 + w: 100, 323 + h: 100, 324 + fill: "#fff", 325 + stroke: "#000", 326 + radius: 0, 327 + }); 328 + 329 + const arrow = ShapeRecord.createArrow(page.id, 100, 150, { 330 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 331 + start: { kind: "free" }, 332 + end: { kind: "bound", bindingId: "binding-end" }, 333 + style: { stroke: "#000", width: 4, headEnd: true }, 334 + }); 335 + 336 + const binding = BindingRecord.create(arrow.id, rect.id, "end", { kind: "edge", nx: -1, ny: 0 }, "binding-end"); 337 + 338 + state = { 339 + ...state, 340 + doc: { 341 + ...state.doc, 342 + shapes: { ...state.doc.shapes, [arrow.id]: arrow, [rect.id]: rect }, 343 + bindings: { [binding.id]: binding }, 344 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [rect.id, arrow.id] } }, 345 + }, 346 + }; 347 + 348 + const resolved = resolveArrowEndpoints(state, arrow.id); 349 + expect(resolved).not.toBeNull(); 350 + 351 + const expectedX = 200 - 3; 352 + const expectedY = 150; 353 + 354 + expect(resolved!.b.x).toBeCloseTo(expectedX, 0); 355 + expect(resolved!.b.y).toBeCloseTo(expectedY, 0); 356 + }); 357 + 358 + it("should not apply offset for center anchors", () => { 359 + let state = EditorState.create(); 360 + 361 + const page = PageRecord.create("Test Page"); 362 + state = { 363 + ...state, 364 + doc: { ...state.doc, pages: { [page.id]: page } }, 365 + ui: { ...state.ui, currentPageId: page.id }, 366 + }; 367 + 368 + const rect = ShapeRecord.createRect(page.id, 200, 100, { 369 + w: 100, 370 + h: 100, 371 + fill: "#fff", 372 + stroke: "#000", 373 + radius: 0, 374 + }); 375 + 376 + const arrow = ShapeRecord.createArrow(page.id, 100, 150, { 377 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 378 + start: { kind: "free" }, 379 + end: { kind: "bound", bindingId: "binding-end" }, 380 + style: { stroke: "#000", width: 2, headEnd: true }, 381 + }); 382 + 383 + const binding = BindingRecord.create(arrow.id, rect.id, "end", { kind: "center" }, "binding-end"); 384 + 385 + state = { 386 + ...state, 387 + doc: { 388 + ...state.doc, 389 + shapes: { ...state.doc.shapes, [arrow.id]: arrow, [rect.id]: rect }, 390 + bindings: { [binding.id]: binding }, 391 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [rect.id, arrow.id] } }, 392 + }, 393 + }; 394 + 395 + const resolved = resolveArrowEndpoints(state, arrow.id); 396 + expect(resolved).not.toBeNull(); 397 + 398 + expect(resolved!.b.x).toBeCloseTo(250, 0); 399 + expect(resolved!.b.y).toBeCloseTo(150, 0); 400 + }); 401 + }); 402 + 403 + describe("Regression: Arrow endpoint manipulation", () => { 404 + it("should preserve intermediate points when dragging bound endpoints", () => { 405 + let state = EditorState.create(); 406 + 407 + const page = PageRecord.create("Test Page"); 408 + state = { 409 + ...state, 410 + doc: { ...state.doc, pages: { [page.id]: page } }, 411 + ui: { ...state.ui, currentPageId: page.id }, 412 + }; 413 + 414 + const rect = ShapeRecord.createRect(page.id, 300, 100, { 415 + w: 100, 416 + h: 100, 417 + fill: "#fff", 418 + stroke: "#000", 419 + radius: 0, 420 + }); 421 + 422 + const arrow = ShapeRecord.createArrow(page.id, 100, 100, { 423 + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }, { x: 200, y: 0 }], 424 + start: { kind: "free" }, 425 + end: { kind: "bound", bindingId: "binding-end" }, 426 + style: { stroke: "#000", width: 2, headEnd: true }, 427 + }); 428 + 429 + const binding = BindingRecord.create(arrow.id, rect.id, "end", { kind: "center" }, "binding-end"); 430 + 431 + state = { 432 + ...state, 433 + doc: { 434 + ...state.doc, 435 + shapes: { ...state.doc.shapes, [arrow.id]: arrow, [rect.id]: rect }, 436 + bindings: { [binding.id]: binding }, 437 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [rect.id, arrow.id] } }, 438 + }, 439 + ui: { ...state.ui, selectionIds: [arrow.id] }, 440 + }; 441 + 442 + const tool = new SelectTool(); 443 + tool.onEnter(state); 444 + 445 + const resolved = resolveArrowEndpoints(state, arrow.id); 446 + expect(resolved).not.toBeNull(); 447 + 448 + const startPos = resolved!.a; 449 + const pointerDown = Action.pointerDown({ x: 0, y: 0 }, startPos, 0, { left: true, middle: false, right: false }, { 450 + ctrl: false, 451 + shift: false, 452 + alt: false, 453 + meta: false, 454 + }, 0); 455 + state = tool.onAction(state, pointerDown); 456 + 457 + const newPos = { x: startPos.x - 50, y: startPos.y }; 458 + const pointerMove = Action.pointerMove({ x: 0, y: 0 }, newPos, { left: true, middle: false, right: false }, { 459 + ctrl: false, 460 + shift: false, 461 + alt: false, 462 + meta: false, 463 + }, 100); 464 + state = tool.onAction(state, pointerMove); 465 + 466 + const pointerUp = Action.pointerUp({ x: 0, y: 0 }, newPos, 0, { left: false, middle: false, right: false }, { 467 + ctrl: false, 468 + shift: false, 469 + alt: false, 470 + meta: false, 471 + }, 200); 472 + state = tool.onAction(state, pointerUp); 473 + 474 + const updatedArrow = state.doc.shapes[arrow.id]; 475 + expect(updatedArrow.type).toBe("arrow"); 476 + if (updatedArrow.type === "arrow") { 477 + expect(updatedArrow.props.points.length).toBe(3); 478 + 479 + expect(updatedArrow.props.points[1]).toBeDefined(); 480 + } 481 + }); 482 + }); 483 + });