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);
+ });
});