From 1d2745fc8aa2a78fe31a2a7f4f0dd3786ecefaf9 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Wed, 24 Dec 2025 16:09:31 -0600 Subject: [PATCH] feat: snappable arrow rendering --- packages/core/src/geom.ts | 83 +++++++++++++- packages/core/src/tools/select.ts | 58 +++++++++- packages/core/src/tools/shape.ts | 22 +++- packages/core/tests/geom.test.ts | 132 +++++++++++++++++++++++ packages/renderer/src/index.ts | 174 +++++++++++++++++++++++++++--- 5 files changed, 442 insertions(+), 27 deletions(-) diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts index 4b2621c..65bfc99 100644 --- a/packages/core/src/geom.ts +++ b/packages/core/src/geom.ts @@ -461,11 +461,50 @@ export function shapeCenter(shape: ShapeRecord): Vec2 { return { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2 }; } +/** + * Compute anchor point on a shape's bounds given normalized coordinates + * + * @param shape - Target shape + * @param nx - Normalized x coordinate in [-1, 1] where -1 is left edge, 1 is right edge, 0 is center + * @param ny - Normalized y coordinate in [-1, 1] where -1 is top edge, 1 is bottom edge, 0 is center + * @returns World coordinates of the anchor point + */ +export function computeEdgeAnchor(shape: ShapeRecord, nx: number, ny: number): Vec2 { + const bounds = shapeBounds(shape); + const centerX = (bounds.min.x + bounds.max.x) / 2; + const centerY = (bounds.min.y + bounds.max.y) / 2; + const halfWidth = (bounds.max.x - bounds.min.x) / 2; + const halfHeight = (bounds.max.y - bounds.min.y) / 2; + + return { x: centerX + nx * halfWidth, y: centerY + ny * halfHeight }; +} + +/** + * Compute normalized anchor coordinates from a world point and target shape + * + * @param point - World coordinates of the point to anchor + * @param shape - Target shape to anchor to + * @returns Normalized coordinates {nx, ny} in [-1, 1] + */ +export function computeNormalizedAnchor(point: Vec2, shape: ShapeRecord): { nx: number; ny: number } { + const bounds = shapeBounds(shape); + const centerX = (bounds.min.x + bounds.max.x) / 2; + const centerY = (bounds.min.y + bounds.max.y) / 2; + const halfWidth = Math.max((bounds.max.x - bounds.min.x) / 2, 1); + const halfHeight = Math.max((bounds.max.y - bounds.min.y) / 2, 1); + + const nx = Math.max(-1, Math.min(1, (point.x - centerX) / halfWidth)); + const ny = Math.max(-1, Math.min(1, (point.y - centerY) / halfHeight)); + + return { nx, ny }; +} + /** * Resolve arrow endpoints considering bindings * * If an arrow endpoint is bound to a target shape, returns the bound position - * (center of target shape for v0). Otherwise returns the arrow's stored endpoint. + * based on the binding anchor (center or edge with normalized coordinates). + * Otherwise returns the arrow's stored endpoint. * * @param state - Editor state * @param arrowId - ID of the arrow shape @@ -494,14 +533,50 @@ export function resolveArrowEndpoints(state: EditorState, arrowId: string): { a: const targetShape = state.doc.shapes[binding.toShapeId]; if (!targetShape) continue; - const targetCenter = shapeCenter(targetShape); + let anchorPoint: Vec2; + if (binding.anchor.kind === "center") { + anchorPoint = shapeCenter(targetShape); + } else { + anchorPoint = computeEdgeAnchor(targetShape, binding.anchor.nx, binding.anchor.ny); + } if (binding.handle === "start") { - a = targetCenter; + a = anchorPoint; } else if (binding.handle === "end") { - b = targetCenter; + b = anchorPoint; } } return { a, b }; } + +/** + * Compute orthogonal (Manhattan-style) routing between two points + * + * Creates a path with 2-4 segments that connects start to end using only horizontal and vertical lines. + * The path avoids overlapping segments and creates clean right angles. + * + * @param start - Starting point + * @param end - Ending point + * @returns Array of points forming the orthogonal path (includes start and end) + */ +export function computeOrthogonalPath(start: Vec2, end: Vec2): Vec2[] { + const dx = end.x - start.x; + const dy = end.y - start.y; + + if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) { + return [start, end]; + } + + if (Math.abs(dx) < 0.1) { + return [start, end]; + } + + if (Math.abs(dy) < 0.1) { + return [start, end]; + } + + const midX = start.x + dx / 2; + + return [start, { x: midX, y: start.y }, { x: midX, y: end.y }, end]; +} diff --git a/packages/core/src/tools/select.ts b/packages/core/src/tools/select.ts index 6eb549e..94835c4 100644 --- a/packages/core/src/tools/select.ts +++ b/packages/core/src/tools/select.ts @@ -1,7 +1,7 @@ import type { Action } from "../actions"; -import { hitTestPoint, shapeBounds } from "../geom"; +import { computeNormalizedAnchor, hitTestPoint, shapeBounds } from "../geom"; import { Box2, type Vec2, Vec2 as Vec2Ops } from "../math"; -import { ShapeRecord } from "../model"; +import { BindingRecord, ShapeRecord } from "../model"; import { EditorState, getCurrentPage, type ToolId } from "../reactivity"; import type { Tool } from "./base"; @@ -303,6 +303,13 @@ export class SelectTool implements Tool { newState = this.completeMarqueeSelection(state); } + if ( + this.toolState.handleShapeId + && (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") + ) { + newState = this.updateArrowBindings(newState, this.toolState.handleShapeId, action.world); + } + this.toolState.activeHandle = null; this.toolState.handleShapeId = null; this.toolState.handleStartBounds = null; @@ -617,4 +624,51 @@ export class SelectTool implements Tool { const sin = Math.sin(shape.rot); return { x: shape.x + point.x * cos - point.y * sin, y: shape.y + point.x * sin + point.y * cos }; } + + /** + * Update arrow bindings when an endpoint is dragged + * + * Creates or updates bindings for arrow endpoints based on hit testing. + * If the endpoint is over a shape, creates/updates an edge anchor binding. + * If the endpoint is not over a shape, removes any existing binding. + */ + private updateArrowBindings(state: EditorState, arrowId: string, endpointWorld: Vec2): EditorState { + const arrow = state.doc.shapes[arrowId]; + if (!arrow || arrow.type !== "arrow") return state; + + const handle = this.toolState.activeHandle === "line-start" ? "start" : "end"; + + const stateWithoutArrow = { + ...state, + doc: { + ...state.doc, + shapes: Object.fromEntries(Object.entries(state.doc.shapes).filter(([id]) => id !== arrowId)), + }, + }; + + const hitShapeId = hitTestPoint(stateWithoutArrow, endpointWorld); + + const newBindings = { ...state.doc.bindings }; + + for (const [bindingId, binding] of Object.entries(newBindings)) { + if (binding.fromShapeId === arrowId && binding.handle === handle) { + delete newBindings[bindingId]; + } + } + + if (hitShapeId) { + const targetShape = state.doc.shapes[hitShapeId]; + if (targetShape) { + const anchor = computeNormalizedAnchor(endpointWorld, targetShape); + const binding = BindingRecord.create(arrowId, hitShapeId, handle, { + kind: "edge", + nx: anchor.nx, + ny: anchor.ny, + }); + newBindings[binding.id] = binding; + } + } + + return { ...state, doc: { ...state.doc, bindings: newBindings } }; + } } diff --git a/packages/core/src/tools/shape.ts b/packages/core/src/tools/shape.ts index 93c43a3..02f2c18 100644 --- a/packages/core/src/tools/shape.ts +++ b/packages/core/src/tools/shape.ts @@ -1,5 +1,5 @@ import type { Action } from "../actions"; -import { hitTestPoint } from "../geom"; +import { computeNormalizedAnchor, hitTestPoint } from "../geom"; import { Vec2 } from "../math"; import { BindingRecord, createId, ShapeRecord } from "../model"; import type { EditorState, ToolId } from "../reactivity"; @@ -658,14 +658,26 @@ export class ArrowTool implements Tool { const startHitId = hitTestPoint(stateWithoutArrow, startWorld); if (startHitId) { - const binding = BindingRecord.create(arrowId, startHitId, "start"); - newBindings[binding.id] = binding; + const targetShape = state.doc.shapes[startHitId]; + if (targetShape) { + const anchor = computeNormalizedAnchor(startWorld, targetShape); + const binding = BindingRecord.create(arrowId, startHitId, "start", { + kind: "edge", + nx: anchor.nx, + ny: anchor.ny, + }); + newBindings[binding.id] = binding; + } } const endHitId = hitTestPoint(stateWithoutArrow, endWorld); if (endHitId) { - const binding = BindingRecord.create(arrowId, endHitId, "end"); - newBindings[binding.id] = binding; + const targetShape = state.doc.shapes[endHitId]; + if (targetShape) { + const anchor = computeNormalizedAnchor(endWorld, targetShape); + const binding = BindingRecord.create(arrowId, endHitId, "end", { kind: "edge", nx: anchor.nx, ny: anchor.ny }); + newBindings[binding.id] = binding; + } } return { ...state, doc: { ...state.doc, bindings: newBindings } }; diff --git a/packages/core/tests/geom.test.ts b/packages/core/tests/geom.test.ts index e3b0e06..1c8ed20 100644 --- a/packages/core/tests/geom.test.ts +++ b/packages/core/tests/geom.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { BindingRecord, + computeEdgeAnchor, + computeNormalizedAnchor, + computeOrthogonalPath, hitTestPoint, PageRecord, pointInEllipse, @@ -993,5 +996,134 @@ describe("Geometry", () => { expect(resolved2?.b).toEqual({ x: 350, y: 350 }); }); + + it("should resolve edge anchors correctly", () => { + const store = new Store(); + const page = PageRecord.create("Test Page", "page:1"); + const targetRect = ShapeRecord.createRect( + page.id, + 100, + 100, + { w: 100, h: 100, fill: "", stroke: "", radius: 0 }, + "rect:1", + ); + const arrow = ShapeRecord.createArrow(page.id, 0, 0, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 100 }, + stroke: "#000", + width: 2, + }, "arrow:1"); + + const binding = BindingRecord.create(arrow.id, targetRect.id, "end", { kind: "edge", nx: 1, ny: 0 }); + + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [arrow.id, targetRect.id] } }, + shapes: { [arrow.id]: arrow, [targetRect.id]: targetRect }, + bindings: { [binding.id]: binding }, + }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + const resolved = resolveArrowEndpoints(state, arrow.id); + + expect(resolved?.b).toEqual({ x: 200, y: 150 }); + }); + }); + + describe("computeEdgeAnchor", () => { + it("should compute center anchor correctly", () => { + const rect = ShapeRecord.createRect("page:1", 100, 100, { w: 100, h: 100, fill: "", stroke: "", radius: 0 }); + const anchor = computeEdgeAnchor(rect, 0, 0); + + expect(anchor).toEqual({ x: 150, y: 150 }); + }); + + it("should compute edge anchors correctly", () => { + const rect = ShapeRecord.createRect("page:1", 100, 100, { w: 100, h: 100, fill: "", stroke: "", radius: 0 }); + + expect(computeEdgeAnchor(rect, -1, -1)).toEqual({ x: 100, y: 100 }); + expect(computeEdgeAnchor(rect, 1, -1)).toEqual({ x: 200, y: 100 }); + expect(computeEdgeAnchor(rect, 1, 1)).toEqual({ x: 200, y: 200 }); + expect(computeEdgeAnchor(rect, -1, 1)).toEqual({ x: 100, y: 200 }); + expect(computeEdgeAnchor(rect, 1, 0)).toEqual({ x: 200, y: 150 }); + expect(computeEdgeAnchor(rect, 0, 1)).toEqual({ x: 150, y: 200 }); + }); + }); + + describe("computeNormalizedAnchor", () => { + it("should compute normalized anchor for center point", () => { + const rect = ShapeRecord.createRect("page:1", 100, 100, { w: 100, h: 100, fill: "", stroke: "", radius: 0 }); + const anchor = computeNormalizedAnchor({ x: 150, y: 150 }, rect); + + expect(anchor.nx).toBeCloseTo(0, 5); + expect(anchor.ny).toBeCloseTo(0, 5); + }); + + it("should compute normalized anchor for edge points", () => { + const rect = ShapeRecord.createRect("page:1", 100, 100, { w: 100, h: 100, fill: "", stroke: "", radius: 0 }); + + const topLeft = computeNormalizedAnchor({ x: 100, y: 100 }, rect); + expect(topLeft.nx).toBeCloseTo(-1, 5); + expect(topLeft.ny).toBeCloseTo(-1, 5); + + const bottomRight = computeNormalizedAnchor({ x: 200, y: 200 }, rect); + expect(bottomRight.nx).toBeCloseTo(1, 5); + expect(bottomRight.ny).toBeCloseTo(1, 5); + + const rightCenter = computeNormalizedAnchor({ x: 200, y: 150 }, rect); + expect(rightCenter.nx).toBeCloseTo(1, 5); + expect(rightCenter.ny).toBeCloseTo(0, 5); + }); + + it("should clamp normalized anchor values to [-1, 1]", () => { + const rect = ShapeRecord.createRect("page:1", 100, 100, { w: 100, h: 100, fill: "", stroke: "", radius: 0 }); + + const far = computeNormalizedAnchor({ x: 300, y: 300 }, rect); + expect(far.nx).toBe(1); + expect(far.ny).toBe(1); + + const farNeg = computeNormalizedAnchor({ x: 0, y: 0 }, rect); + expect(farNeg.nx).toBe(-1); + expect(farNeg.ny).toBe(-1); + }); + }); + + describe("computeOrthogonalPath", () => { + it("should create a straight path for horizontal alignment", () => { + const path = computeOrthogonalPath({ x: 0, y: 0 }, { x: 100, y: 0 }); + + expect(path).toHaveLength(2); + expect(path[0]).toEqual({ x: 0, y: 0 }); + expect(path[1]).toEqual({ x: 100, y: 0 }); + }); + + it("should create a straight path for vertical alignment", () => { + const path = computeOrthogonalPath({ x: 0, y: 0 }, { x: 0, y: 100 }); + + expect(path).toHaveLength(2); + expect(path[0]).toEqual({ x: 0, y: 0 }); + expect(path[1]).toEqual({ x: 0, y: 100 }); + }); + + it("should create a 4-point path for diagonal movement", () => { + const path = computeOrthogonalPath({ x: 0, y: 0 }, { x: 100, y: 100 }); + + expect(path).toHaveLength(4); + expect(path[0]).toEqual({ x: 0, y: 0 }); + expect(path[1]).toEqual({ x: 50, y: 0 }); + expect(path[2]).toEqual({ x: 50, y: 100 }); + expect(path[3]).toEqual({ x: 100, y: 100 }); + }); + + it("should handle same start and end points", () => { + const path = computeOrthogonalPath({ x: 100, y: 100 }, { x: 100, y: 100 }); + + expect(path).toHaveLength(2); + expect(path[0]).toEqual({ x: 100, y: 100 }); + expect(path[1]).toEqual({ x: 100, y: 100 }); + }); }); }); diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index 2652697..e2b4b55 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -416,7 +416,10 @@ function drawLine(context: CanvasRenderingContext2D, shape: LineShape) { * Draw an arrow shape */ function drawArrow(context: CanvasRenderingContext2D, state: EditorState, shape: ArrowShape) { - const { stroke, width } = shape.props; + const legacyStroke = shape.props.stroke; + const legacyWidth = shape.props.width; + const modernStyle = shape.props.style; + const style = modernStyle ?? { stroke: legacyStroke ?? "#000", width: legacyWidth ?? 2 }; const resolved = resolveArrowEndpoints(state, shape.id); if (!resolved) return; @@ -424,27 +427,150 @@ function drawArrow(context: CanvasRenderingContext2D, state: EditorState, shape: const a = { x: resolved.a.x - shape.x, y: resolved.a.y - shape.y }; const b = { x: resolved.b.x - shape.x, y: resolved.b.y - shape.y }; + let points: Vec2[]; + const modernPoints = shape.props.points; + if (modernPoints && modernPoints.length >= 2) { + points = modernPoints.map((p: Vec2, index: number) => { + if (index === 0) return a; + if (index === modernPoints.length - 1) return b; + return p; + }); + } else { + points = [a, b]; + } + context.beginPath(); - context.moveTo(a.x, a.y); - context.lineTo(b.x, b.y); + context.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i++) { + context.lineTo(points[i].x, points[i].y); + } - context.strokeStyle = stroke; - context.lineWidth = width; + context.strokeStyle = style.stroke; + context.lineWidth = style.width; + if (style.dash) { + context.setLineDash(style.dash); + } context.stroke(); + if (style.dash) { + context.setLineDash([]); + } - const angle = Math.atan2(b.y - a.y, b.x - a.x); + const lastSegment = { from: points[points.length - 2], to: points[points.length - 1] }; + const angle = Math.atan2(lastSegment.to.y - lastSegment.from.y, lastSegment.to.x - lastSegment.from.x); const arrowLength = 15; const arrowAngle = Math.PI / 6; - context.beginPath(); - context.moveTo(b.x, b.y); - context.lineTo(b.x - arrowLength * Math.cos(angle - arrowAngle), b.y - arrowLength * Math.sin(angle - arrowAngle)); - context.moveTo(b.x, b.y); - context.lineTo(b.x - arrowLength * Math.cos(angle + arrowAngle), b.y - arrowLength * Math.sin(angle + arrowAngle)); + const drawHead = (at: Vec2, reverse: boolean) => { + const dir = reverse ? angle + Math.PI : angle; + context.beginPath(); + context.moveTo(at.x, at.y); + context.lineTo(at.x - arrowLength * Math.cos(dir - arrowAngle), at.y - arrowLength * Math.sin(dir - arrowAngle)); + context.moveTo(at.x, at.y); + context.lineTo(at.x - arrowLength * Math.cos(dir + arrowAngle), at.y - arrowLength * Math.sin(dir + arrowAngle)); + context.strokeStyle = style.stroke; + context.lineWidth = style.width; + context.stroke(); + }; - context.strokeStyle = stroke; - context.lineWidth = width; - context.stroke(); + if (style.headEnd !== false) { + drawHead(lastSegment.to, false); + } + + if (style.headStart) { + const firstSegment = { from: points[0], to: points[1] }; + const startAngle = Math.atan2(firstSegment.to.y - firstSegment.from.y, firstSegment.to.x - firstSegment.from.x); + const startDir = startAngle + Math.PI; + context.beginPath(); + context.moveTo(firstSegment.from.x, firstSegment.from.y); + context.lineTo( + firstSegment.from.x - arrowLength * Math.cos(startDir - arrowAngle), + firstSegment.from.y - arrowLength * Math.sin(startDir - arrowAngle), + ); + context.moveTo(firstSegment.from.x, firstSegment.from.y); + context.lineTo( + firstSegment.from.x - arrowLength * Math.cos(startDir + arrowAngle), + firstSegment.from.y - arrowLength * Math.sin(startDir + arrowAngle), + ); + context.strokeStyle = style.stroke; + context.lineWidth = style.width; + context.stroke(); + } + + const label = shape.props.label; + if (label) { + drawArrowLabel(context, state, points, label); + } +} + +/** + * Draw an arrow label + */ +function drawArrowLabel( + context: CanvasRenderingContext2D, + state: EditorState, + points: Vec2[], + label: { text: string; align: string; offset: number }, +) { + if (!label.text) return; + + let labelPos: Vec2; + const totalLength = computePolylineLength(points); + let targetDist: number; + + if (label.align === "start") { + targetDist = label.offset; + } else if (label.align === "end") { + targetDist = totalLength - label.offset; + } else { + targetDist = totalLength / 2 + label.offset; + } + + labelPos = getPointAtDistance(points, targetDist); + + context.save(); + context.font = "14px sans-serif"; + context.fillStyle = "#000"; + context.textAlign = "center"; + context.textBaseline = "bottom"; + const metrics = context.measureText(label.text); + const padding = 4; + const bgWidth = metrics.width + padding * 2; + const bgHeight = 18; + + context.fillStyle = "rgba(255, 255, 255, 0.9)"; + context.fillRect(labelPos.x - bgWidth / 2, labelPos.y - bgHeight - 5, bgWidth, bgHeight); + context.strokeStyle = "#ccc"; + context.lineWidth = 1 / state.camera.zoom; + context.strokeRect(labelPos.x - bgWidth / 2, labelPos.y - bgHeight - 5, bgWidth, bgHeight); + + context.fillStyle = "#000"; + context.fillText(label.text, labelPos.x, labelPos.y - 5); + context.restore(); +} + +function computePolylineLength(points: Vec2[]): number { + let length = 0; + for (let i = 1; i < points.length; i++) { + const dx = points[i].x - points[i - 1].x; + const dy = points[i].y - points[i - 1].y; + length += Math.sqrt(dx * dx + dy * dy); + } + return length; +} + +function getPointAtDistance(points: Vec2[], targetDist: number): Vec2 { + let accum = 0; + for (let i = 1; i < points.length; i++) { + const dx = points[i].x - points[i - 1].x; + const dy = points[i].y - points[i - 1].y; + const segLen = Math.sqrt(dx * dx + dy * dy); + if (accum + segLen >= targetDist) { + const t = (targetDist - accum) / segLen; + return { x: points[i - 1].x + dx * t, y: points[i - 1].y + dy * t }; + } + accum += segLen; + } + return points[points.length - 1]; } /** @@ -560,8 +686,7 @@ function drawSelection( context.strokeRect(0, 0, w, h); break; } - case "line": - case "arrow": { + case "line": { const { a, b } = shape.props; const minX = Math.min(a.x, b.x); const minY = Math.min(a.y, b.y); @@ -571,6 +696,23 @@ function drawSelection( context.strokeRect(minX - padding, minY - padding, maxX - minX + padding * 2, maxY - minY + padding * 2); break; } + case "arrow": { + const bounds = shapeBounds(shape); + const localBounds = { + minX: bounds.min.x - shape.x, + minY: bounds.min.y - shape.y, + maxX: bounds.max.x - shape.x, + maxY: bounds.max.y - shape.y, + }; + const padding = 5; + context.strokeRect( + localBounds.minX - padding, + localBounds.minY - padding, + localBounds.maxX - localBounds.minX + padding * 2, + localBounds.maxY - localBounds.minY + padding * 2, + ); + break; + } case "text": { const { fontSize, fontFamily, text, w } = shape.props; context.font = `${fontSize}px ${fontFamily}`; -- 2.51.2