From 140ac1e9a79f3a118fb557ddbe1906ebd1f4b059 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Wed, 24 Dec 2025 15:53:19 -0600 Subject: [PATCH] feat: arrow model extension --- packages/core/src/export.ts | 34 +- packages/core/src/geom.ts | 45 ++- packages/core/src/model.ts | 122 ++++++- packages/core/src/tools/select.ts | 55 ++- packages/core/src/tools/shape.ts | 26 +- packages/core/tests/model.test.ts | 555 +++++++++++++++++++++++++++++- 6 files changed, 800 insertions(+), 37 deletions(-) diff --git a/packages/core/src/export.ts b/packages/core/src/export.ts index 1ab1501..c4d0ec9 100644 --- a/packages/core/src/export.ts +++ b/packages/core/src/export.ts @@ -204,29 +204,43 @@ function lineToSVG(shape: LineShape, transform: string): string { } function arrowToSVG(shape: ArrowShape, transform: string, _state: EditorState): string { - const { a, b, stroke, width } = shape.props; + let startPoint, endPoint, strokeColor, strokeWidth; + + if (shape.props.a && shape.props.b) { + startPoint = shape.props.a; + endPoint = shape.props.b; + strokeColor = shape.props.stroke || "#000"; + strokeWidth = shape.props.width || 2; + } else if (shape.props.points && shape.props.points.length >= 2) { + startPoint = shape.props.points[0]; + endPoint = shape.props.points[shape.props.points.length - 1]; + strokeColor = shape.props.style?.stroke || "#000"; + strokeWidth = shape.props.style?.width || 2; + } else { + return ``; + } - const angle = Math.atan2(b.y - a.y, b.x - a.x); + const angle = Math.atan2(endPoint.y - startPoint.y, endPoint.x - startPoint.x); const arrowLength = 15; const arrowAngle = Math.PI / 6; const arrowPoint1 = { - x: b.x - arrowLength * Math.cos(angle - arrowAngle), - y: b.y - arrowLength * Math.sin(angle - arrowAngle), + x: endPoint.x - arrowLength * Math.cos(angle - arrowAngle), + y: endPoint.y - arrowLength * Math.sin(angle - arrowAngle), }; const arrowPoint2 = { - x: b.x - arrowLength * Math.cos(angle + arrowAngle), - y: b.y - arrowLength * Math.sin(angle + arrowAngle), + x: endPoint.x - arrowLength * Math.cos(angle + arrowAngle), + y: endPoint.y - arrowLength * Math.sin(angle + arrowAngle), }; - const strokeAttribute = `stroke="${escapeXML(stroke)}" stroke-width="${width}"`; + const strokeAttribute = `stroke="${escapeXML(strokeColor)}" stroke-width="${strokeWidth}"`; return [ ``, - ` `, - ` `, - ` `, + ` `, + ` `, + ` `, ``, ].join("\n"); } diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts index 6c16817..4b2621c 100644 --- a/packages/core/src/geom.ts +++ b/packages/core/src/geom.ts @@ -100,14 +100,17 @@ function lineBounds(shape: LineShape): Box2 { return Box2Ops.fromPoints(translatedPoints); } -/** - * Get bounds for an arrow shape - */ function arrowBounds(shape: ArrowShape): Box2 { - const { a, b } = shape.props; const { x, y, rot } = shape; - const points = [a, b]; + let points: Vec2[]; + if (shape.props.a && shape.props.b) { + points = [shape.props.a, shape.props.b]; + } else if (shape.props.points && shape.props.points.length >= 2) { + points = shape.props.points; + } else { + return { min: { x, y }, max: { x, y } }; + } if (rot === 0) { const translatedPoints = points.map((p) => ({ x: p.x + x, y: p.y + y })); @@ -286,7 +289,23 @@ export function pointNearSegment(p: Vec2, a: Vec2, b: Vec2, tolerance: number): */ export function pointNearLine(p: Vec2, shape: LineShape | ArrowShape, tolerance = 5): boolean { const { x, y, rot } = shape; - const { a, b } = shape.props; + + let a: Vec2, b: Vec2; + if (shape.type === "line") { + a = shape.props.a; + b = shape.props.b; + } else { + if (shape.props.a && shape.props.b) { + a = shape.props.a; + b = shape.props.b; + } else if (shape.props.points && shape.props.points.length >= 2) { + a = shape.props.points[0]; + b = shape.props.points[shape.props.points.length - 1]; + } else { + return false; + } + } + const localP = worldToLocal(p, x, y, rot); return pointNearSegment(localP, a, b, tolerance); } @@ -456,8 +475,18 @@ export function resolveArrowEndpoints(state: EditorState, arrowId: string): { a: const arrow = state.doc.shapes[arrowId]; if (!arrow || arrow.type !== "arrow") return null; - let a: Vec2 = { x: arrow.x + arrow.props.a.x, y: arrow.y + arrow.props.a.y }; - let b: Vec2 = { x: arrow.x + arrow.props.b.x, y: arrow.y + arrow.props.b.y }; + let a: Vec2, b: Vec2; + if (arrow.props.a && arrow.props.b) { + a = { x: arrow.x + arrow.props.a.x, y: arrow.y + arrow.props.a.y }; + b = { x: arrow.x + arrow.props.b.x, y: arrow.y + arrow.props.b.y }; + } else if (arrow.props.points && arrow.props.points.length >= 2) { + const firstPoint = arrow.props.points[0]; + const lastPoint = arrow.props.points[arrow.props.points.length - 1]; + a = { x: arrow.x + firstPoint.x, y: arrow.y + firstPoint.y }; + b = { x: arrow.x + lastPoint.x, y: arrow.y + lastPoint.y }; + } else { + return null; + } for (const binding of Object.values(state.doc.bindings)) { if (binding.fromShapeId !== arrowId) continue; diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 0ec0635..0624549 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -31,7 +31,47 @@ export const PageRecord = { export type RectProps = { w: number; h: number; fill: string; stroke: string; radius: number }; export type EllipseProps = { w: number; h: number; fill: string; stroke: string }; export type LineProps = { a: Vec2; b: Vec2; stroke: string; width: number }; -export type ArrowProps = { a: Vec2; b: Vec2; stroke: string; width: number }; + +/** + * Arrow endpoint binding metadata + */ +export type ArrowEndpoint = { kind: "free" | "bound"; bindingId?: string }; + +/** + * Arrow style configuration + */ +export type ArrowStyle = { stroke: string; width: number; headStart?: boolean; headEnd?: boolean; dash?: number[] }; + +/** + * Arrow routing configuration + */ +export type ArrowRouting = { kind: "straight" | "orthogonal"; cornerRadius?: number }; + +/** + * Arrow label configuration + */ +export type ArrowLabel = { text: string; align: "center" | "start" | "end"; offset: number }; + +/** + * Arrow properties supporting both legacy (a, b) and modern (points) formats + * Legacy format: { a, b, stroke, width } + * Modern format: { points, start, end, style, routing?, label? } + */ +export type ArrowProps = { + // TODO: do away with legacy format (for backward compatibility + a?: Vec2; + b?: Vec2; + stroke?: string; + width?: number; + + points?: Vec2[]; + start?: ArrowEndpoint; + end?: ArrowEndpoint; + style?: ArrowStyle; + routing?: ArrowRouting; + label?: ArrowLabel; +}; + export type TextProps = { text: string; fontSize: number; fontFamily: string; color: string; w?: number }; /** @@ -134,6 +174,26 @@ export const ShapeRecord = { }, }; } + if (shape.type === "arrow") { + return { + ...shape, + props: { + ...shape.props, + + a: shape.props.a ? { ...shape.props.a } : undefined, + b: shape.props.b ? { ...shape.props.b } : undefined, + + points: shape.props.points ? shape.props.points.map((p) => ({ ...p })) : undefined, + start: shape.props.start ? { ...shape.props.start } : undefined, + end: shape.props.end ? { ...shape.props.end } : undefined, + style: shape.props.style + ? { ...shape.props.style, dash: shape.props.style.dash ? [...shape.props.style.dash] : undefined } + : undefined, + routing: shape.props.routing ? { ...shape.props.routing } : undefined, + label: shape.props.label ? { ...shape.props.label } : undefined, + }, + }; + } return { ...shape, props: { ...shape.props } } as ShapeRecord; }, }; @@ -141,8 +201,12 @@ export const ShapeRecord = { export type BindingType = "arrow-end"; export type BindingHandle = "start" | "end"; -// TODO: 'edge', 'corner', etc. -export type BindingAnchor = { kind: "center" }; +/** + * Binding anchor configuration + * - center: bind to shape center + * - edge: bind to shape edge with normalized coordinates (nx, ny in [-1, 1]) + */ +export type BindingAnchor = { kind: "center" } | { kind: "edge"; nx: number; ny: number }; export type BindingRecord = { id: string; @@ -174,7 +238,7 @@ export const BindingRecord = { * Clone a binding record */ clone(binding: BindingRecord): BindingRecord { - return { ...binding, anchor: { ...binding.anchor } }; + return { ...binding, anchor: binding.anchor.kind === "edge" ? { ...binding.anchor } : { kind: "center" } }; }, }; @@ -248,9 +312,46 @@ export function validateDoc(document: Document): ValidationResult { break; } - case "line": + case "line": { + if (shape.props.width < 0) errors.push(`Line shape '${shapeId}' has negative width`); + + break; + } case "arrow": { - if (shape.props.width < 0) errors.push(`${shape.type} shape '${shapeId}' has negative width`); + const props = shape.props; + const isLegacy = props.a !== undefined && props.b !== undefined; + const isModern = props.points !== undefined; + + if (!isLegacy && !isModern) { + errors.push(`Arrow shape '${shapeId}' missing both legacy (a, b) and modern (points) format`); + } + + if (isLegacy) { + if (props.width !== undefined && props.width < 0) { + errors.push(`Arrow shape '${shapeId}' has negative width in legacy format`); + } + } + + if (isModern) { + if (!props.points || props.points.length < 2) { + errors.push(`Arrow shape '${shapeId}' points array must have at least 2 points`); + } + if (props.style) { + if (props.style.width < 0) { + errors.push(`Arrow shape '${shapeId}' has negative width in style`); + } + } + if (props.routing) { + if (props.routing.cornerRadius !== undefined && props.routing.cornerRadius < 0) { + errors.push(`Arrow shape '${shapeId}' has negative cornerRadius`); + } + } + if (props.label) { + if (!["center", "start", "end"].includes(props.label.align)) { + errors.push(`Arrow shape '${shapeId}' has invalid label alignment`); + } + } + } break; } @@ -314,6 +415,15 @@ export function validateDoc(document: Document): ValidationResult { if (binding.handle !== "start" && binding.handle !== "end") { errors.push(`Binding '${bindingId}' has invalid handle '${binding.handle}'`); } + + if (binding.anchor.kind === "edge") { + if (binding.anchor.nx < -1 || binding.anchor.nx > 1) { + errors.push(`Binding '${bindingId}' has invalid nx '${binding.anchor.nx}' (must be in [-1, 1])`); + } + if (binding.anchor.ny < -1 || binding.anchor.ny > 1) { + errors.push(`Binding '${bindingId}' has invalid ny '${binding.anchor.ny}' (must be in [-1, 1])`); + } + } } if (errors.length > 0) { diff --git a/packages/core/src/tools/select.ts b/packages/core/src/tools/select.ts index 2a84004..6eb549e 100644 --- a/packages/core/src/tools/select.ts +++ b/packages/core/src/tools/select.ts @@ -462,10 +462,23 @@ export class SelectTool implements Tool { { id: "w", position: { x: minX, y: centerY } }, { id: "rotate", position: { x: centerX, y: minY - ROTATE_HANDLE_OFFSET } }, ); - } else if (shape.type === "line" || shape.type === "arrow") { + } else if (shape.type === "line") { const start = this.localToWorld(shape, shape.props.a); const end = this.localToWorld(shape, shape.props.b); handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); + } else if (shape.type === "arrow") { + // TODO: do away with legacy format + if (shape.props.a && shape.props.b) { + const start = this.localToWorld(shape, shape.props.a); + const end = this.localToWorld(shape, shape.props.b); + handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); + } else if (shape.props.points && shape.props.points.length >= 2) { + const firstPoint = shape.props.points[0]; + const lastPoint = shape.props.points[shape.props.points.length - 1]; + const start = this.localToWorld(shape, firstPoint); + const end = this.localToWorld(shape, lastPoint); + handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); + } } return handles; } @@ -541,12 +554,44 @@ export class SelectTool implements Tool { if (initial.type !== "line" && initial.type !== "arrow") { return null; } - const startWorld = this.localToWorld(initial, initial.props.a); - const endWorld = this.localToWorld(initial, initial.props.b); + + let startPoint: Vec2, endPoint: Vec2; + + if (initial.type === "line") { + startPoint = initial.props.a; + endPoint = initial.props.b; + } else { + if (initial.props.a && initial.props.b) { + startPoint = initial.props.a; + endPoint = initial.props.b; + } else if (initial.props.points && initial.props.points.length >= 2) { + startPoint = initial.props.points[0]; + endPoint = initial.props.points[initial.props.points.length - 1]; + } else { + return null; + } + } + + const startWorld = this.localToWorld(initial, startPoint); + const endWorld = this.localToWorld(initial, endPoint); const newStart = handle === "line-start" ? pointer : startWorld; const newEnd = handle === "line-end" ? pointer : endWorld; - const newProps = { ...initial.props, a: { x: 0, y: 0 }, b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y } }; - return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; + + if (initial.type === "line") { + const newProps = { + ...initial.props, + a: { x: 0, y: 0 }, + b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }, + }; + return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; + } else { + const newProps = { + ...initial.props, + a: { x: 0, y: 0 }, + b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }, + }; + return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; + } } private rotateShape(initial: ShapeRecord, pointer: Vec2): ShapeRecord | null { diff --git a/packages/core/src/tools/shape.ts b/packages/core/src/tools/shape.ts index 2441fba..93c43a3 100644 --- a/packages/core/src/tools/shape.ts +++ b/packages/core/src/tools/shape.ts @@ -605,7 +605,16 @@ export class ArrowTool implements Tool { let newState = state; - const arrowLength = Vec2.len(shape.props.b); + let endPoint: Vec2; + if (shape.props.b) { + endPoint = shape.props.b; + } else if (shape.props.points && shape.props.points.length >= 2) { + endPoint = shape.props.points[shape.props.points.length - 1]; + } else { + endPoint = { x: 0, y: 0 }; + } + + const arrowLength = Vec2.len(endPoint); if (arrowLength < MIN_SHAPE_SIZE) { newState = this.cancelShapeCreation(state); } else { @@ -623,8 +632,19 @@ export class ArrowTool implements Tool { const arrow = state.doc.shapes[arrowId]; if (!arrow || arrow.type !== "arrow") return state; - const startWorld = { x: arrow.x + arrow.props.a.x, y: arrow.y + arrow.props.a.y }; - const endWorld = { x: arrow.x + arrow.props.b.x, y: arrow.y + arrow.props.b.y }; + let startPoint: Vec2, endPoint: Vec2; + if (arrow.props.a && arrow.props.b) { + startPoint = arrow.props.a; + endPoint = arrow.props.b; + } else if (arrow.props.points && arrow.props.points.length >= 2) { + startPoint = arrow.props.points[0]; + endPoint = arrow.props.points[arrow.props.points.length - 1]; + } else { + return state; + } + + const startWorld = { x: arrow.x + startPoint.x, y: arrow.y + startPoint.y }; + const endWorld = { x: arrow.x + endPoint.x, y: arrow.y + endPoint.y }; const newBindings = { ...state.doc.bindings }; diff --git a/packages/core/tests/model.test.ts b/packages/core/tests/model.test.ts index 0f8004a..7312601 100644 --- a/packages/core/tests/model.test.ts +++ b/packages/core/tests/model.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { type ArrowProps, + type ArrowStyle, BindingRecord, createId, Document, @@ -16,7 +17,6 @@ import { describe("createId", () => { it("should generate a valid UUID without prefix", () => { const id = createId(); - expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); }); @@ -173,7 +173,7 @@ describe("ShapeRecord", () => { }); describe("createArrow", () => { - it("should create an arrow shape", () => { + it("should create an arrow shape with legacy format", () => { const props: ArrowProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; const shape = ShapeRecord.createArrow(pageId, 10, 20, props); @@ -181,6 +181,119 @@ describe("ShapeRecord", () => { expect(shape.type).toBe("arrow"); expect(shape.props).toEqual(props); }); + + it("should create an arrow with modern format (points only)", () => { + const props: ArrowProps = { + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, + }; + const shape = ShapeRecord.createArrow(pageId, 10, 20, props); + + expect(shape.id).toMatch(/^shape:/); + expect(shape.type).toBe("arrow"); + expect(shape.props.points).toEqual(props.points); + expect(shape.props.start).toEqual({ kind: "free" }); + expect(shape.props.end).toEqual({ kind: "free" }); + expect(shape.props.style).toEqual({ stroke: "#000", width: 2 }); + }); + + it("should create an arrow with polyline (3+ points)", () => { + const props: ArrowProps = { + points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#ff0000", width: 3 }, + }; + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); + + expect(shape.props.points?.length).toBe(3); + expect(shape.props.points).toEqual(props.points); + }); + + it("should create an arrow with bound endpoints", () => { + const props: ArrowProps = { + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], + start: { kind: "bound", bindingId: "binding:1" }, + end: { kind: "bound", bindingId: "binding:2" }, + style: { stroke: "#000", width: 2 }, + }; + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); + + expect(shape.props.start).toEqual({ kind: "bound", bindingId: "binding:1" }); + expect(shape.props.end).toEqual({ kind: "bound", bindingId: "binding:2" }); + }); + + it("should create an arrow with arrowheads", () => { + const style: ArrowStyle = { stroke: "#000", width: 2, headStart: true, headEnd: true }; + const props: ArrowProps = { + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style, + }; + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); + + expect(shape.props.style?.headStart).toBe(true); + expect(shape.props.style?.headEnd).toBe(true); + }); + + it("should create an arrow with dash pattern", () => { + const style: ArrowStyle = { stroke: "#000", width: 2, dash: [5, 3] }; + const props: ArrowProps = { + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style, + }; + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); + + expect(shape.props.style?.dash).toEqual([5, 3]); + }); + + it("should create an arrow with orthogonal routing", () => { + const props: ArrowProps = { + points: [{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 50, y: 50 }, { x: 100, y: 50 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, + routing: { kind: "orthogonal", cornerRadius: 5 }, + }; + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); + + expect(shape.props.routing).toEqual({ kind: "orthogonal", cornerRadius: 5 }); + }); + + it("should create an arrow with label", () => { + const props: ArrowProps = { + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, + label: { text: "Connection", align: "center", offset: 0 }, + }; + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); + + expect(shape.props.label).toEqual({ text: "Connection", align: "center", offset: 0 }); + }); + + it.each([{ align: "center" as const, offset: 0 }, { align: "start" as const, offset: 10 }, { + align: "end" as const, + offset: -10, + }])("should create arrow with label alignment: $align", ({ align, offset }) => { + const props: ArrowProps = { + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, + label: { text: "Test", align, offset }, + }; + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); + + expect(shape.props.label?.align).toBe(align); + expect(shape.props.label?.offset).toBe(offset); + }); }); describe("createText", () => { @@ -249,6 +362,79 @@ describe("ShapeRecord", () => { expect(cloned).toEqual(shape); expect(cloned.props).not.toBe(shape.props); }); + + it("should clone legacy arrow shape", () => { + const props: ArrowProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); + + const cloned = ShapeRecord.clone(shape); + + expect(cloned).toEqual(shape); + expect(cloned.props).not.toBe(shape.props); + if (cloned.type === "arrow" && shape.type === "arrow") { + expect(cloned.props.a).not.toBe(shape.props.a); + expect(cloned.props.b).not.toBe(shape.props.b); + } + }); + + it("should clone modern arrow shape with points", () => { + const props: ArrowProps = { + points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }], + start: { kind: "free" }, + end: { kind: "bound", bindingId: "binding:1" }, + style: { stroke: "#000", width: 2, dash: [5, 3] }, + routing: { kind: "orthogonal", cornerRadius: 5 }, + label: { text: "Test", align: "center", offset: 0 }, + }; + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); + + const cloned = ShapeRecord.clone(shape); + + expect(cloned).toEqual(shape); + expect(cloned.props).not.toBe(shape.props); + if (cloned.type === "arrow" && shape.type === "arrow") { + expect(cloned.props.points).not.toBe(shape.props.points); + expect(cloned.props.start).not.toBe(shape.props.start); + expect(cloned.props.end).not.toBe(shape.props.end); + expect(cloned.props.style).not.toBe(shape.props.style); + expect(cloned.props.routing).not.toBe(shape.props.routing); + expect(cloned.props.label).not.toBe(shape.props.label); + } + }); + + it("should deep clone arrow points array", () => { + const props: ArrowProps = { + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, + }; + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); + + const cloned = ShapeRecord.clone(shape); + + if (cloned.type === "arrow" && shape.type === "arrow" && cloned.props.points && shape.props.points) { + cloned.props.points[0].x = 999; + expect(shape.props.points[0].x).toBe(0); + } + }); + + it("should deep clone arrow style dash array", () => { + const props: ArrowProps = { + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2, dash: [5, 3] }, + }; + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); + + const cloned = ShapeRecord.clone(shape); + + if (cloned.type === "arrow" && shape.type === "arrow" && cloned.props.style?.dash && shape.props.style?.dash) { + cloned.props.style.dash[0] = 999; + expect(shape.props.style.dash[0]).toBe(5); + } + }); }); describe("position and rotation", () => { @@ -312,7 +498,7 @@ describe("BindingRecord", () => { }); describe("clone", () => { - it("should create a copy of the binding", () => { + it("should create a copy of the binding with center anchor", () => { const binding = BindingRecord.create("arrow1", "shape1", "start"); const cloned = BindingRecord.clone(binding); @@ -322,7 +508,7 @@ describe("BindingRecord", () => { expect(cloned.anchor).not.toBe(binding.anchor); }); - it("should deep clone anchor", () => { + it("should deep clone center anchor", () => { const binding = BindingRecord.create("arrow1", "shape1", "start"); const cloned = BindingRecord.clone(binding); @@ -330,6 +516,56 @@ describe("BindingRecord", () => { expect(cloned.anchor).toEqual(binding.anchor); expect(cloned.anchor).not.toBe(binding.anchor); }); + + it("should clone binding with edge anchor", () => { + const binding = BindingRecord.create("arrow1", "shape1", "end", { kind: "edge", nx: 0.5, ny: -0.5 }); + + const cloned = BindingRecord.clone(binding); + + expect(cloned).toEqual(binding); + expect(cloned).not.toBe(binding); + expect(cloned.anchor).not.toBe(binding.anchor); + }); + + it("should deep clone edge anchor", () => { + const binding = BindingRecord.create("arrow1", "shape1", "start", { kind: "edge", nx: 1, ny: 0 }); + + const cloned = BindingRecord.clone(binding); + + expect(cloned.anchor).toEqual({ kind: "edge", nx: 1, ny: 0 }); + expect(cloned.anchor).not.toBe(binding.anchor); + }); + }); + + describe("edge anchors", () => { + it("should create binding with edge anchor at right edge", () => { + const anchor = { kind: "edge" as const, nx: 1, ny: 0 }; + const binding = BindingRecord.create("arrow1", "shape1", "start", anchor); + + expect(binding.anchor).toEqual({ kind: "edge", nx: 1, ny: 0 }); + }); + + it("should create binding with edge anchor at top-left corner", () => { + const anchor = { kind: "edge" as const, nx: -1, ny: -1 }; + const binding = BindingRecord.create("arrow1", "shape1", "end", anchor); + + expect(binding.anchor).toEqual({ kind: "edge", nx: -1, ny: -1 }); + }); + + it.each([ + { nx: 0, ny: 0, desc: "center" }, + { nx: 1, ny: 0, desc: "right edge" }, + { nx: -1, ny: 0, desc: "left edge" }, + { nx: 0, ny: 1, desc: "bottom edge" }, + { nx: 0, ny: -1, desc: "top edge" }, + { nx: 0.5, ny: 0.5, desc: "bottom-right quadrant" }, + { nx: -0.5, ny: -0.5, desc: "top-left quadrant" }, + ])("should create binding with edge anchor at $desc", ({ nx, ny }) => { + const anchor = { kind: "edge" as const, nx, ny }; + const binding = BindingRecord.create("arrow1", "shape1", "start", anchor); + + expect(binding.anchor).toEqual({ kind: "edge", nx, ny }); + }); }); }); @@ -790,7 +1026,7 @@ describe("validateDoc", () => { expect(result.ok).toBe(false); if (!result.ok) { - expect(result.errors).toContain("line shape 'shape1' has negative width"); + expect(result.errors).toContain("Line shape 'shape1' has negative width"); } }); @@ -868,6 +1104,224 @@ describe("validateDoc", () => { expect(result.errors.length).toBeGreaterThan(1); } }); + + it("should reject arrow with neither legacy nor modern format", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createArrow("page1", 0, 0, {}, "arrow1"); + + page.shapeIds = ["arrow1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Arrow shape 'arrow1' missing both legacy (a, b) and modern (points) format"); + } + }); + + it("should reject arrow with too few points in modern format", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createArrow("page1", 0, 0, { + points: [{ x: 0, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, + }, "arrow1"); + + page.shapeIds = ["arrow1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Arrow shape 'arrow1' points array must have at least 2 points"); + } + }); + + it("should reject arrow with negative width in modern format", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createArrow("page1", 0, 0, { + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: -2 }, + }, "arrow1"); + + page.shapeIds = ["arrow1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Arrow shape 'arrow1' has negative width in style"); + } + }); + + it("should reject arrow with negative cornerRadius", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createArrow("page1", 0, 0, { + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, + routing: { kind: "orthogonal", cornerRadius: -5 }, + }, "arrow1"); + + page.shapeIds = ["arrow1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Arrow shape 'arrow1' has negative cornerRadius"); + } + }); + + it("should reject arrow with invalid label alignment", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createArrow("page1", 0, 0, { + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, + label: { text: "Test", align: "invalid" as any, offset: 0 }, + }, "arrow1"); + + page.shapeIds = ["arrow1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Arrow shape 'arrow1' has invalid label alignment"); + } + }); + + it("should reject binding with edge anchor nx out of range", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const arrow = ShapeRecord.createArrow("page1", 0, 0, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 0 }, + stroke: "#000", + width: 2, + }, "arrow1"); + const rect = ShapeRecord.createRect( + "page1", + 100, + 0, + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "rect1", + ); + const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "edge", nx: 1.5, ny: 0 }, "binding1"); + + page.shapeIds = ["arrow1", "rect1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: arrow, rect1: rect }; + doc.bindings = { binding1: binding }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Binding 'binding1' has invalid nx '1.5' (must be in [-1, 1])"); + } + }); + + it("should reject binding with edge anchor ny out of range", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const arrow = ShapeRecord.createArrow("page1", 0, 0, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 0 }, + stroke: "#000", + width: 2, + }, "arrow1"); + const rect = ShapeRecord.createRect( + "page1", + 100, + 0, + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "rect1", + ); + const binding = BindingRecord.create("arrow1", "rect1", "start", { kind: "edge", nx: 0, ny: -2 }, "binding1"); + + page.shapeIds = ["arrow1", "rect1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: arrow, rect1: rect }; + doc.bindings = { binding1: binding }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Binding 'binding1' has invalid ny '-2' (must be in [-1, 1])"); + } + }); + + it("should accept valid modern arrow format", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const arrow = ShapeRecord.createArrow("page1", 0, 0, { + points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2, headStart: false, headEnd: true, dash: [5, 3] }, + routing: { kind: "orthogonal", cornerRadius: 5 }, + label: { text: "Connection", align: "center", offset: 0 }, + }, "arrow1"); + + page.shapeIds = ["arrow1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: arrow }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(true); + }); + + it("should accept binding with valid edge anchor", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const arrow = ShapeRecord.createArrow("page1", 0, 0, { + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "bound", bindingId: "binding1" }, + style: { stroke: "#000", width: 2 }, + }, "arrow1"); + const rect = ShapeRecord.createRect( + "page1", + 100, + 0, + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "rect1", + ); + const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "edge", nx: 0.5, ny: -0.5 }, "binding1"); + + page.shapeIds = ["arrow1", "rect1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: arrow, rect1: rect }; + doc.bindings = { binding1: binding }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(true); + }); }); describe("edge cases", () => { @@ -1081,4 +1535,95 @@ describe("JSON serialization", () => { expect(parsed).toEqual(doc); expect(validateDoc(parsed).ok).toBe(true); }); + + it("should round-trip arrow with modern format", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const arrow = ShapeRecord.createArrow("page1", 0, 0, { + points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#ff0000", width: 3, headStart: true, headEnd: true, dash: [5, 3] }, + routing: { kind: "orthogonal", cornerRadius: 5 }, + label: { text: "Connection", align: "center", offset: 0 }, + }, "arrow1"); + + page.shapeIds = ["arrow1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: arrow }; + + const json = JSON.stringify(doc); + const parsed = JSON.parse(json); + + expect(parsed).toEqual(doc); + expect(validateDoc(parsed).ok).toBe(true); + }); + + it("should round-trip arrow with bound endpoints", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const arrow = ShapeRecord.createArrow("page1", 0, 0, { + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "bound", bindingId: "binding1" }, + end: { kind: "bound", bindingId: "binding2" }, + style: { stroke: "#000", width: 2 }, + }, "arrow1"); + const rect1 = ShapeRecord.createRect( + "page1", + -50, + -25, + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "rect1", + ); + const rect2 = ShapeRecord.createRect( + "page1", + 100, + -25, + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "rect2", + ); + const binding1 = BindingRecord.create("arrow1", "rect1", "start", { kind: "edge", nx: 1, ny: 0 }, "binding1"); + const binding2 = BindingRecord.create("arrow1", "rect2", "end", { kind: "edge", nx: -1, ny: 0 }, "binding2"); + + page.shapeIds = ["arrow1", "rect1", "rect2"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: arrow, rect1: rect1, rect2: rect2 }; + doc.bindings = { binding1, binding2 }; + + const json = JSON.stringify(doc); + const parsed = JSON.parse(json); + + expect(parsed).toEqual(doc); + expect(validateDoc(parsed).ok).toBe(true); + }); + + it("should round-trip binding with edge anchor", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const arrow = ShapeRecord.createArrow("page1", 0, 0, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 0 }, + stroke: "#000", + width: 2, + }, "arrow1"); + const rect = ShapeRecord.createRect( + "page1", + 100, + 0, + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "rect1", + ); + const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "edge", nx: -0.5, ny: 0.5 }, "binding1"); + + page.shapeIds = ["arrow1", "rect1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: arrow, rect1: rect }; + doc.bindings = { binding1: binding }; + + const json = JSON.stringify(doc); + const parsed = JSON.parse(json); + + expect(parsed).toEqual(doc); + expect(validateDoc(parsed).ok).toBe(true); + }); }); -- 2.51.2