web based infinite canvas
2
fork

Configure Feed

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

feat: snappable arrow rendering

+442 -27
+79 -4
packages/core/src/geom.ts
··· 462 462 } 463 463 464 464 /** 465 + * Compute anchor point on a shape's bounds given normalized coordinates 466 + * 467 + * @param shape - Target shape 468 + * @param nx - Normalized x coordinate in [-1, 1] where -1 is left edge, 1 is right edge, 0 is center 469 + * @param ny - Normalized y coordinate in [-1, 1] where -1 is top edge, 1 is bottom edge, 0 is center 470 + * @returns World coordinates of the anchor point 471 + */ 472 + export function computeEdgeAnchor(shape: ShapeRecord, nx: number, ny: number): Vec2 { 473 + const bounds = shapeBounds(shape); 474 + const centerX = (bounds.min.x + bounds.max.x) / 2; 475 + const centerY = (bounds.min.y + bounds.max.y) / 2; 476 + const halfWidth = (bounds.max.x - bounds.min.x) / 2; 477 + const halfHeight = (bounds.max.y - bounds.min.y) / 2; 478 + 479 + return { x: centerX + nx * halfWidth, y: centerY + ny * halfHeight }; 480 + } 481 + 482 + /** 483 + * Compute normalized anchor coordinates from a world point and target shape 484 + * 485 + * @param point - World coordinates of the point to anchor 486 + * @param shape - Target shape to anchor to 487 + * @returns Normalized coordinates {nx, ny} in [-1, 1] 488 + */ 489 + export function computeNormalizedAnchor(point: Vec2, shape: ShapeRecord): { nx: number; ny: number } { 490 + const bounds = shapeBounds(shape); 491 + const centerX = (bounds.min.x + bounds.max.x) / 2; 492 + const centerY = (bounds.min.y + bounds.max.y) / 2; 493 + const halfWidth = Math.max((bounds.max.x - bounds.min.x) / 2, 1); 494 + const halfHeight = Math.max((bounds.max.y - bounds.min.y) / 2, 1); 495 + 496 + const nx = Math.max(-1, Math.min(1, (point.x - centerX) / halfWidth)); 497 + const ny = Math.max(-1, Math.min(1, (point.y - centerY) / halfHeight)); 498 + 499 + return { nx, ny }; 500 + } 501 + 502 + /** 465 503 * Resolve arrow endpoints considering bindings 466 504 * 467 505 * If an arrow endpoint is bound to a target shape, returns the bound position 468 - * (center of target shape for v0). Otherwise returns the arrow's stored endpoint. 506 + * based on the binding anchor (center or edge with normalized coordinates). 507 + * Otherwise returns the arrow's stored endpoint. 469 508 * 470 509 * @param state - Editor state 471 510 * @param arrowId - ID of the arrow shape ··· 494 533 const targetShape = state.doc.shapes[binding.toShapeId]; 495 534 if (!targetShape) continue; 496 535 497 - const targetCenter = shapeCenter(targetShape); 536 + let anchorPoint: Vec2; 537 + if (binding.anchor.kind === "center") { 538 + anchorPoint = shapeCenter(targetShape); 539 + } else { 540 + anchorPoint = computeEdgeAnchor(targetShape, binding.anchor.nx, binding.anchor.ny); 541 + } 498 542 499 543 if (binding.handle === "start") { 500 - a = targetCenter; 544 + a = anchorPoint; 501 545 } else if (binding.handle === "end") { 502 - b = targetCenter; 546 + b = anchorPoint; 503 547 } 504 548 } 505 549 506 550 return { a, b }; 507 551 } 552 + 553 + /** 554 + * Compute orthogonal (Manhattan-style) routing between two points 555 + * 556 + * Creates a path with 2-4 segments that connects start to end using only horizontal and vertical lines. 557 + * The path avoids overlapping segments and creates clean right angles. 558 + * 559 + * @param start - Starting point 560 + * @param end - Ending point 561 + * @returns Array of points forming the orthogonal path (includes start and end) 562 + */ 563 + export function computeOrthogonalPath(start: Vec2, end: Vec2): Vec2[] { 564 + const dx = end.x - start.x; 565 + const dy = end.y - start.y; 566 + 567 + if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) { 568 + return [start, end]; 569 + } 570 + 571 + if (Math.abs(dx) < 0.1) { 572 + return [start, end]; 573 + } 574 + 575 + if (Math.abs(dy) < 0.1) { 576 + return [start, end]; 577 + } 578 + 579 + const midX = start.x + dx / 2; 580 + 581 + return [start, { x: midX, y: start.y }, { x: midX, y: end.y }, end]; 582 + }
+56 -2
packages/core/src/tools/select.ts
··· 1 1 import type { Action } from "../actions"; 2 - import { hitTestPoint, shapeBounds } from "../geom"; 2 + import { computeNormalizedAnchor, hitTestPoint, shapeBounds } from "../geom"; 3 3 import { Box2, type Vec2, Vec2 as Vec2Ops } from "../math"; 4 - import { ShapeRecord } from "../model"; 4 + import { BindingRecord, ShapeRecord } from "../model"; 5 5 import { EditorState, getCurrentPage, type ToolId } from "../reactivity"; 6 6 import type { Tool } from "./base"; 7 7 ··· 301 301 302 302 if (this.toolState.marqueeStart && this.toolState.marqueeEnd) { 303 303 newState = this.completeMarqueeSelection(state); 304 + } 305 + 306 + if ( 307 + this.toolState.handleShapeId 308 + && (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") 309 + ) { 310 + newState = this.updateArrowBindings(newState, this.toolState.handleShapeId, action.world); 304 311 } 305 312 306 313 this.toolState.activeHandle = null; ··· 616 623 const cos = Math.cos(shape.rot); 617 624 const sin = Math.sin(shape.rot); 618 625 return { x: shape.x + point.x * cos - point.y * sin, y: shape.y + point.x * sin + point.y * cos }; 626 + } 627 + 628 + /** 629 + * Update arrow bindings when an endpoint is dragged 630 + * 631 + * Creates or updates bindings for arrow endpoints based on hit testing. 632 + * If the endpoint is over a shape, creates/updates an edge anchor binding. 633 + * If the endpoint is not over a shape, removes any existing binding. 634 + */ 635 + private updateArrowBindings(state: EditorState, arrowId: string, endpointWorld: Vec2): EditorState { 636 + const arrow = state.doc.shapes[arrowId]; 637 + if (!arrow || arrow.type !== "arrow") return state; 638 + 639 + const handle = this.toolState.activeHandle === "line-start" ? "start" : "end"; 640 + 641 + const stateWithoutArrow = { 642 + ...state, 643 + doc: { 644 + ...state.doc, 645 + shapes: Object.fromEntries(Object.entries(state.doc.shapes).filter(([id]) => id !== arrowId)), 646 + }, 647 + }; 648 + 649 + const hitShapeId = hitTestPoint(stateWithoutArrow, endpointWorld); 650 + 651 + const newBindings = { ...state.doc.bindings }; 652 + 653 + for (const [bindingId, binding] of Object.entries(newBindings)) { 654 + if (binding.fromShapeId === arrowId && binding.handle === handle) { 655 + delete newBindings[bindingId]; 656 + } 657 + } 658 + 659 + if (hitShapeId) { 660 + const targetShape = state.doc.shapes[hitShapeId]; 661 + if (targetShape) { 662 + const anchor = computeNormalizedAnchor(endpointWorld, targetShape); 663 + const binding = BindingRecord.create(arrowId, hitShapeId, handle, { 664 + kind: "edge", 665 + nx: anchor.nx, 666 + ny: anchor.ny, 667 + }); 668 + newBindings[binding.id] = binding; 669 + } 670 + } 671 + 672 + return { ...state, doc: { ...state.doc, bindings: newBindings } }; 619 673 } 620 674 }
+17 -5
packages/core/src/tools/shape.ts
··· 1 1 import type { Action } from "../actions"; 2 - import { hitTestPoint } from "../geom"; 2 + import { computeNormalizedAnchor, hitTestPoint } from "../geom"; 3 3 import { Vec2 } from "../math"; 4 4 import { BindingRecord, createId, ShapeRecord } from "../model"; 5 5 import type { EditorState, ToolId } from "../reactivity"; ··· 658 658 659 659 const startHitId = hitTestPoint(stateWithoutArrow, startWorld); 660 660 if (startHitId) { 661 - const binding = BindingRecord.create(arrowId, startHitId, "start"); 662 - newBindings[binding.id] = binding; 661 + const targetShape = state.doc.shapes[startHitId]; 662 + if (targetShape) { 663 + const anchor = computeNormalizedAnchor(startWorld, targetShape); 664 + const binding = BindingRecord.create(arrowId, startHitId, "start", { 665 + kind: "edge", 666 + nx: anchor.nx, 667 + ny: anchor.ny, 668 + }); 669 + newBindings[binding.id] = binding; 670 + } 663 671 } 664 672 665 673 const endHitId = hitTestPoint(stateWithoutArrow, endWorld); 666 674 if (endHitId) { 667 - const binding = BindingRecord.create(arrowId, endHitId, "end"); 668 - newBindings[binding.id] = binding; 675 + const targetShape = state.doc.shapes[endHitId]; 676 + if (targetShape) { 677 + const anchor = computeNormalizedAnchor(endWorld, targetShape); 678 + const binding = BindingRecord.create(arrowId, endHitId, "end", { kind: "edge", nx: anchor.nx, ny: anchor.ny }); 679 + newBindings[binding.id] = binding; 680 + } 669 681 } 670 682 671 683 return { ...state, doc: { ...state.doc, bindings: newBindings } };
+132
packages/core/tests/geom.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 2 import { 3 3 BindingRecord, 4 + computeEdgeAnchor, 5 + computeNormalizedAnchor, 6 + computeOrthogonalPath, 4 7 hitTestPoint, 5 8 PageRecord, 6 9 pointInEllipse, ··· 992 995 const resolved2 = resolveArrowEndpoints(state, arrow.id); 993 996 994 997 expect(resolved2?.b).toEqual({ x: 350, y: 350 }); 998 + }); 999 + 1000 + it("should resolve edge anchors correctly", () => { 1001 + const store = new Store(); 1002 + const page = PageRecord.create("Test Page", "page:1"); 1003 + const targetRect = ShapeRecord.createRect( 1004 + page.id, 1005 + 100, 1006 + 100, 1007 + { w: 100, h: 100, fill: "", stroke: "", radius: 0 }, 1008 + "rect:1", 1009 + ); 1010 + const arrow = ShapeRecord.createArrow(page.id, 0, 0, { 1011 + a: { x: 0, y: 0 }, 1012 + b: { x: 100, y: 100 }, 1013 + stroke: "#000", 1014 + width: 2, 1015 + }, "arrow:1"); 1016 + 1017 + const binding = BindingRecord.create(arrow.id, targetRect.id, "end", { kind: "edge", nx: 1, ny: 0 }); 1018 + 1019 + store.setState((state) => ({ 1020 + ...state, 1021 + doc: { 1022 + pages: { [page.id]: { ...page, shapeIds: [arrow.id, targetRect.id] } }, 1023 + shapes: { [arrow.id]: arrow, [targetRect.id]: targetRect }, 1024 + bindings: { [binding.id]: binding }, 1025 + }, 1026 + ui: { ...state.ui, currentPageId: page.id }, 1027 + })); 1028 + 1029 + const state = store.getState(); 1030 + const resolved = resolveArrowEndpoints(state, arrow.id); 1031 + 1032 + expect(resolved?.b).toEqual({ x: 200, y: 150 }); 1033 + }); 1034 + }); 1035 + 1036 + describe("computeEdgeAnchor", () => { 1037 + it("should compute center anchor correctly", () => { 1038 + const rect = ShapeRecord.createRect("page:1", 100, 100, { w: 100, h: 100, fill: "", stroke: "", radius: 0 }); 1039 + const anchor = computeEdgeAnchor(rect, 0, 0); 1040 + 1041 + expect(anchor).toEqual({ x: 150, y: 150 }); 1042 + }); 1043 + 1044 + it("should compute edge anchors correctly", () => { 1045 + const rect = ShapeRecord.createRect("page:1", 100, 100, { w: 100, h: 100, fill: "", stroke: "", radius: 0 }); 1046 + 1047 + expect(computeEdgeAnchor(rect, -1, -1)).toEqual({ x: 100, y: 100 }); 1048 + expect(computeEdgeAnchor(rect, 1, -1)).toEqual({ x: 200, y: 100 }); 1049 + expect(computeEdgeAnchor(rect, 1, 1)).toEqual({ x: 200, y: 200 }); 1050 + expect(computeEdgeAnchor(rect, -1, 1)).toEqual({ x: 100, y: 200 }); 1051 + expect(computeEdgeAnchor(rect, 1, 0)).toEqual({ x: 200, y: 150 }); 1052 + expect(computeEdgeAnchor(rect, 0, 1)).toEqual({ x: 150, y: 200 }); 1053 + }); 1054 + }); 1055 + 1056 + describe("computeNormalizedAnchor", () => { 1057 + it("should compute normalized anchor for center point", () => { 1058 + const rect = ShapeRecord.createRect("page:1", 100, 100, { w: 100, h: 100, fill: "", stroke: "", radius: 0 }); 1059 + const anchor = computeNormalizedAnchor({ x: 150, y: 150 }, rect); 1060 + 1061 + expect(anchor.nx).toBeCloseTo(0, 5); 1062 + expect(anchor.ny).toBeCloseTo(0, 5); 1063 + }); 1064 + 1065 + it("should compute normalized anchor for edge points", () => { 1066 + const rect = ShapeRecord.createRect("page:1", 100, 100, { w: 100, h: 100, fill: "", stroke: "", radius: 0 }); 1067 + 1068 + const topLeft = computeNormalizedAnchor({ x: 100, y: 100 }, rect); 1069 + expect(topLeft.nx).toBeCloseTo(-1, 5); 1070 + expect(topLeft.ny).toBeCloseTo(-1, 5); 1071 + 1072 + const bottomRight = computeNormalizedAnchor({ x: 200, y: 200 }, rect); 1073 + expect(bottomRight.nx).toBeCloseTo(1, 5); 1074 + expect(bottomRight.ny).toBeCloseTo(1, 5); 1075 + 1076 + const rightCenter = computeNormalizedAnchor({ x: 200, y: 150 }, rect); 1077 + expect(rightCenter.nx).toBeCloseTo(1, 5); 1078 + expect(rightCenter.ny).toBeCloseTo(0, 5); 1079 + }); 1080 + 1081 + it("should clamp normalized anchor values to [-1, 1]", () => { 1082 + const rect = ShapeRecord.createRect("page:1", 100, 100, { w: 100, h: 100, fill: "", stroke: "", radius: 0 }); 1083 + 1084 + const far = computeNormalizedAnchor({ x: 300, y: 300 }, rect); 1085 + expect(far.nx).toBe(1); 1086 + expect(far.ny).toBe(1); 1087 + 1088 + const farNeg = computeNormalizedAnchor({ x: 0, y: 0 }, rect); 1089 + expect(farNeg.nx).toBe(-1); 1090 + expect(farNeg.ny).toBe(-1); 1091 + }); 1092 + }); 1093 + 1094 + describe("computeOrthogonalPath", () => { 1095 + it("should create a straight path for horizontal alignment", () => { 1096 + const path = computeOrthogonalPath({ x: 0, y: 0 }, { x: 100, y: 0 }); 1097 + 1098 + expect(path).toHaveLength(2); 1099 + expect(path[0]).toEqual({ x: 0, y: 0 }); 1100 + expect(path[1]).toEqual({ x: 100, y: 0 }); 1101 + }); 1102 + 1103 + it("should create a straight path for vertical alignment", () => { 1104 + const path = computeOrthogonalPath({ x: 0, y: 0 }, { x: 0, y: 100 }); 1105 + 1106 + expect(path).toHaveLength(2); 1107 + expect(path[0]).toEqual({ x: 0, y: 0 }); 1108 + expect(path[1]).toEqual({ x: 0, y: 100 }); 1109 + }); 1110 + 1111 + it("should create a 4-point path for diagonal movement", () => { 1112 + const path = computeOrthogonalPath({ x: 0, y: 0 }, { x: 100, y: 100 }); 1113 + 1114 + expect(path).toHaveLength(4); 1115 + expect(path[0]).toEqual({ x: 0, y: 0 }); 1116 + expect(path[1]).toEqual({ x: 50, y: 0 }); 1117 + expect(path[2]).toEqual({ x: 50, y: 100 }); 1118 + expect(path[3]).toEqual({ x: 100, y: 100 }); 1119 + }); 1120 + 1121 + it("should handle same start and end points", () => { 1122 + const path = computeOrthogonalPath({ x: 100, y: 100 }, { x: 100, y: 100 }); 1123 + 1124 + expect(path).toHaveLength(2); 1125 + expect(path[0]).toEqual({ x: 100, y: 100 }); 1126 + expect(path[1]).toEqual({ x: 100, y: 100 }); 995 1127 }); 996 1128 }); 997 1129 });
+158 -16
packages/renderer/src/index.ts
··· 416 416 * Draw an arrow shape 417 417 */ 418 418 function drawArrow(context: CanvasRenderingContext2D, state: EditorState, shape: ArrowShape) { 419 - const { stroke, width } = shape.props; 419 + const legacyStroke = shape.props.stroke; 420 + const legacyWidth = shape.props.width; 421 + const modernStyle = shape.props.style; 422 + const style = modernStyle ?? { stroke: legacyStroke ?? "#000", width: legacyWidth ?? 2 }; 420 423 421 424 const resolved = resolveArrowEndpoints(state, shape.id); 422 425 if (!resolved) return; ··· 424 427 const a = { x: resolved.a.x - shape.x, y: resolved.a.y - shape.y }; 425 428 const b = { x: resolved.b.x - shape.x, y: resolved.b.y - shape.y }; 426 429 430 + let points: Vec2[]; 431 + const modernPoints = shape.props.points; 432 + if (modernPoints && modernPoints.length >= 2) { 433 + points = modernPoints.map((p: Vec2, index: number) => { 434 + if (index === 0) return a; 435 + if (index === modernPoints.length - 1) return b; 436 + return p; 437 + }); 438 + } else { 439 + points = [a, b]; 440 + } 441 + 427 442 context.beginPath(); 428 - context.moveTo(a.x, a.y); 429 - context.lineTo(b.x, b.y); 443 + context.moveTo(points[0].x, points[0].y); 444 + for (let i = 1; i < points.length; i++) { 445 + context.lineTo(points[i].x, points[i].y); 446 + } 430 447 431 - context.strokeStyle = stroke; 432 - context.lineWidth = width; 448 + context.strokeStyle = style.stroke; 449 + context.lineWidth = style.width; 450 + if (style.dash) { 451 + context.setLineDash(style.dash); 452 + } 433 453 context.stroke(); 454 + if (style.dash) { 455 + context.setLineDash([]); 456 + } 434 457 435 - const angle = Math.atan2(b.y - a.y, b.x - a.x); 458 + const lastSegment = { from: points[points.length - 2], to: points[points.length - 1] }; 459 + const angle = Math.atan2(lastSegment.to.y - lastSegment.from.y, lastSegment.to.x - lastSegment.from.x); 436 460 const arrowLength = 15; 437 461 const arrowAngle = Math.PI / 6; 438 462 439 - context.beginPath(); 440 - context.moveTo(b.x, b.y); 441 - context.lineTo(b.x - arrowLength * Math.cos(angle - arrowAngle), b.y - arrowLength * Math.sin(angle - arrowAngle)); 442 - context.moveTo(b.x, b.y); 443 - context.lineTo(b.x - arrowLength * Math.cos(angle + arrowAngle), b.y - arrowLength * Math.sin(angle + arrowAngle)); 463 + const drawHead = (at: Vec2, reverse: boolean) => { 464 + const dir = reverse ? angle + Math.PI : angle; 465 + context.beginPath(); 466 + context.moveTo(at.x, at.y); 467 + context.lineTo(at.x - arrowLength * Math.cos(dir - arrowAngle), at.y - arrowLength * Math.sin(dir - arrowAngle)); 468 + context.moveTo(at.x, at.y); 469 + context.lineTo(at.x - arrowLength * Math.cos(dir + arrowAngle), at.y - arrowLength * Math.sin(dir + arrowAngle)); 470 + context.strokeStyle = style.stroke; 471 + context.lineWidth = style.width; 472 + context.stroke(); 473 + }; 474 + 475 + if (style.headEnd !== false) { 476 + drawHead(lastSegment.to, false); 477 + } 478 + 479 + if (style.headStart) { 480 + const firstSegment = { from: points[0], to: points[1] }; 481 + const startAngle = Math.atan2(firstSegment.to.y - firstSegment.from.y, firstSegment.to.x - firstSegment.from.x); 482 + const startDir = startAngle + Math.PI; 483 + context.beginPath(); 484 + context.moveTo(firstSegment.from.x, firstSegment.from.y); 485 + context.lineTo( 486 + firstSegment.from.x - arrowLength * Math.cos(startDir - arrowAngle), 487 + firstSegment.from.y - arrowLength * Math.sin(startDir - arrowAngle), 488 + ); 489 + context.moveTo(firstSegment.from.x, firstSegment.from.y); 490 + context.lineTo( 491 + firstSegment.from.x - arrowLength * Math.cos(startDir + arrowAngle), 492 + firstSegment.from.y - arrowLength * Math.sin(startDir + arrowAngle), 493 + ); 494 + context.strokeStyle = style.stroke; 495 + context.lineWidth = style.width; 496 + context.stroke(); 497 + } 444 498 445 - context.strokeStyle = stroke; 446 - context.lineWidth = width; 447 - context.stroke(); 499 + const label = shape.props.label; 500 + if (label) { 501 + drawArrowLabel(context, state, points, label); 502 + } 503 + } 504 + 505 + /** 506 + * Draw an arrow label 507 + */ 508 + function drawArrowLabel( 509 + context: CanvasRenderingContext2D, 510 + state: EditorState, 511 + points: Vec2[], 512 + label: { text: string; align: string; offset: number }, 513 + ) { 514 + if (!label.text) return; 515 + 516 + let labelPos: Vec2; 517 + const totalLength = computePolylineLength(points); 518 + let targetDist: number; 519 + 520 + if (label.align === "start") { 521 + targetDist = label.offset; 522 + } else if (label.align === "end") { 523 + targetDist = totalLength - label.offset; 524 + } else { 525 + targetDist = totalLength / 2 + label.offset; 526 + } 527 + 528 + labelPos = getPointAtDistance(points, targetDist); 529 + 530 + context.save(); 531 + context.font = "14px sans-serif"; 532 + context.fillStyle = "#000"; 533 + context.textAlign = "center"; 534 + context.textBaseline = "bottom"; 535 + const metrics = context.measureText(label.text); 536 + const padding = 4; 537 + const bgWidth = metrics.width + padding * 2; 538 + const bgHeight = 18; 539 + 540 + context.fillStyle = "rgba(255, 255, 255, 0.9)"; 541 + context.fillRect(labelPos.x - bgWidth / 2, labelPos.y - bgHeight - 5, bgWidth, bgHeight); 542 + context.strokeStyle = "#ccc"; 543 + context.lineWidth = 1 / state.camera.zoom; 544 + context.strokeRect(labelPos.x - bgWidth / 2, labelPos.y - bgHeight - 5, bgWidth, bgHeight); 545 + 546 + context.fillStyle = "#000"; 547 + context.fillText(label.text, labelPos.x, labelPos.y - 5); 548 + context.restore(); 549 + } 550 + 551 + function computePolylineLength(points: Vec2[]): number { 552 + let length = 0; 553 + for (let i = 1; i < points.length; i++) { 554 + const dx = points[i].x - points[i - 1].x; 555 + const dy = points[i].y - points[i - 1].y; 556 + length += Math.sqrt(dx * dx + dy * dy); 557 + } 558 + return length; 559 + } 560 + 561 + function getPointAtDistance(points: Vec2[], targetDist: number): Vec2 { 562 + let accum = 0; 563 + for (let i = 1; i < points.length; i++) { 564 + const dx = points[i].x - points[i - 1].x; 565 + const dy = points[i].y - points[i - 1].y; 566 + const segLen = Math.sqrt(dx * dx + dy * dy); 567 + if (accum + segLen >= targetDist) { 568 + const t = (targetDist - accum) / segLen; 569 + return { x: points[i - 1].x + dx * t, y: points[i - 1].y + dy * t }; 570 + } 571 + accum += segLen; 572 + } 573 + return points[points.length - 1]; 448 574 } 449 575 450 576 /** ··· 560 686 context.strokeRect(0, 0, w, h); 561 687 break; 562 688 } 563 - case "line": 564 - case "arrow": { 689 + case "line": { 565 690 const { a, b } = shape.props; 566 691 const minX = Math.min(a.x, b.x); 567 692 const minY = Math.min(a.y, b.y); ··· 569 694 const maxY = Math.max(a.y, b.y); 570 695 const padding = 5; 571 696 context.strokeRect(minX - padding, minY - padding, maxX - minX + padding * 2, maxY - minY + padding * 2); 697 + break; 698 + } 699 + case "arrow": { 700 + const bounds = shapeBounds(shape); 701 + const localBounds = { 702 + minX: bounds.min.x - shape.x, 703 + minY: bounds.min.y - shape.y, 704 + maxX: bounds.max.x - shape.x, 705 + maxY: bounds.max.y - shape.y, 706 + }; 707 + const padding = 5; 708 + context.strokeRect( 709 + localBounds.minX - padding, 710 + localBounds.minY - padding, 711 + localBounds.maxX - localBounds.minX + padding * 2, 712 + localBounds.maxY - localBounds.minY + padding * 2, 713 + ); 572 714 break; 573 715 } 574 716 case "text": {