web based infinite canvas
2
fork

Configure Feed

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

feat: arrow model extension

+800 -37
+24 -10
packages/core/src/export.ts
··· 204 204 } 205 205 206 206 function arrowToSVG(shape: ArrowShape, transform: string, _state: EditorState): string { 207 - const { a, b, stroke, width } = shape.props; 207 + let startPoint, endPoint, strokeColor, strokeWidth; 208 + 209 + if (shape.props.a && shape.props.b) { 210 + startPoint = shape.props.a; 211 + endPoint = shape.props.b; 212 + strokeColor = shape.props.stroke || "#000"; 213 + strokeWidth = shape.props.width || 2; 214 + } else if (shape.props.points && shape.props.points.length >= 2) { 215 + startPoint = shape.props.points[0]; 216 + endPoint = shape.props.points[shape.props.points.length - 1]; 217 + strokeColor = shape.props.style?.stroke || "#000"; 218 + strokeWidth = shape.props.style?.width || 2; 219 + } else { 220 + return `<g transform="${transform}"></g>`; 221 + } 208 222 209 - const angle = Math.atan2(b.y - a.y, b.x - a.x); 223 + const angle = Math.atan2(endPoint.y - startPoint.y, endPoint.x - startPoint.x); 210 224 const arrowLength = 15; 211 225 const arrowAngle = Math.PI / 6; 212 226 213 227 const arrowPoint1 = { 214 - x: b.x - arrowLength * Math.cos(angle - arrowAngle), 215 - y: b.y - arrowLength * Math.sin(angle - arrowAngle), 228 + x: endPoint.x - arrowLength * Math.cos(angle - arrowAngle), 229 + y: endPoint.y - arrowLength * Math.sin(angle - arrowAngle), 216 230 }; 217 231 218 232 const arrowPoint2 = { 219 - x: b.x - arrowLength * Math.cos(angle + arrowAngle), 220 - y: b.y - arrowLength * Math.sin(angle + arrowAngle), 233 + x: endPoint.x - arrowLength * Math.cos(angle + arrowAngle), 234 + y: endPoint.y - arrowLength * Math.sin(angle + arrowAngle), 221 235 }; 222 236 223 - const strokeAttribute = `stroke="${escapeXML(stroke)}" stroke-width="${width}"`; 237 + const strokeAttribute = `stroke="${escapeXML(strokeColor)}" stroke-width="${strokeWidth}"`; 224 238 225 239 return [ 226 240 `<g transform="${transform}">`, 227 - ` <line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" ${strokeAttribute}/>`, 228 - ` <line x1="${b.x}" y1="${b.y}" x2="${arrowPoint1.x}" y2="${arrowPoint1.y}" ${strokeAttribute}/>`, 229 - ` <line x1="${b.x}" y1="${b.y}" x2="${arrowPoint2.x}" y2="${arrowPoint2.y}" ${strokeAttribute}/>`, 241 + ` <line x1="${startPoint.x}" y1="${startPoint.y}" x2="${endPoint.x}" y2="${endPoint.y}" ${strokeAttribute}/>`, 242 + ` <line x1="${endPoint.x}" y1="${endPoint.y}" x2="${arrowPoint1.x}" y2="${arrowPoint1.y}" ${strokeAttribute}/>`, 243 + ` <line x1="${endPoint.x}" y1="${endPoint.y}" x2="${arrowPoint2.x}" y2="${arrowPoint2.y}" ${strokeAttribute}/>`, 230 244 `</g>`, 231 245 ].join("\n"); 232 246 }
+37 -8
packages/core/src/geom.ts
··· 100 100 return Box2Ops.fromPoints(translatedPoints); 101 101 } 102 102 103 - /** 104 - * Get bounds for an arrow shape 105 - */ 106 103 function arrowBounds(shape: ArrowShape): Box2 { 107 - const { a, b } = shape.props; 108 104 const { x, y, rot } = shape; 109 105 110 - const points = [a, b]; 106 + let points: Vec2[]; 107 + if (shape.props.a && shape.props.b) { 108 + points = [shape.props.a, shape.props.b]; 109 + } else if (shape.props.points && shape.props.points.length >= 2) { 110 + points = shape.props.points; 111 + } else { 112 + return { min: { x, y }, max: { x, y } }; 113 + } 111 114 112 115 if (rot === 0) { 113 116 const translatedPoints = points.map((p) => ({ x: p.x + x, y: p.y + y })); ··· 286 289 */ 287 290 export function pointNearLine(p: Vec2, shape: LineShape | ArrowShape, tolerance = 5): boolean { 288 291 const { x, y, rot } = shape; 289 - const { a, b } = shape.props; 292 + 293 + let a: Vec2, b: Vec2; 294 + if (shape.type === "line") { 295 + a = shape.props.a; 296 + b = shape.props.b; 297 + } else { 298 + if (shape.props.a && shape.props.b) { 299 + a = shape.props.a; 300 + b = shape.props.b; 301 + } else if (shape.props.points && shape.props.points.length >= 2) { 302 + a = shape.props.points[0]; 303 + b = shape.props.points[shape.props.points.length - 1]; 304 + } else { 305 + return false; 306 + } 307 + } 308 + 290 309 const localP = worldToLocal(p, x, y, rot); 291 310 return pointNearSegment(localP, a, b, tolerance); 292 311 } ··· 456 475 const arrow = state.doc.shapes[arrowId]; 457 476 if (!arrow || arrow.type !== "arrow") return null; 458 477 459 - let a: Vec2 = { x: arrow.x + arrow.props.a.x, y: arrow.y + arrow.props.a.y }; 460 - let b: Vec2 = { x: arrow.x + arrow.props.b.x, y: arrow.y + arrow.props.b.y }; 478 + let a: Vec2, b: Vec2; 479 + if (arrow.props.a && arrow.props.b) { 480 + a = { x: arrow.x + arrow.props.a.x, y: arrow.y + arrow.props.a.y }; 481 + b = { x: arrow.x + arrow.props.b.x, y: arrow.y + arrow.props.b.y }; 482 + } else if (arrow.props.points && arrow.props.points.length >= 2) { 483 + const firstPoint = arrow.props.points[0]; 484 + const lastPoint = arrow.props.points[arrow.props.points.length - 1]; 485 + a = { x: arrow.x + firstPoint.x, y: arrow.y + firstPoint.y }; 486 + b = { x: arrow.x + lastPoint.x, y: arrow.y + lastPoint.y }; 487 + } else { 488 + return null; 489 + } 461 490 462 491 for (const binding of Object.values(state.doc.bindings)) { 463 492 if (binding.fromShapeId !== arrowId) continue;
+116 -6
packages/core/src/model.ts
··· 31 31 export type RectProps = { w: number; h: number; fill: string; stroke: string; radius: number }; 32 32 export type EllipseProps = { w: number; h: number; fill: string; stroke: string }; 33 33 export type LineProps = { a: Vec2; b: Vec2; stroke: string; width: number }; 34 - export type ArrowProps = { a: Vec2; b: Vec2; stroke: string; width: number }; 34 + 35 + /** 36 + * Arrow endpoint binding metadata 37 + */ 38 + export type ArrowEndpoint = { kind: "free" | "bound"; bindingId?: string }; 39 + 40 + /** 41 + * Arrow style configuration 42 + */ 43 + export type ArrowStyle = { stroke: string; width: number; headStart?: boolean; headEnd?: boolean; dash?: number[] }; 44 + 45 + /** 46 + * Arrow routing configuration 47 + */ 48 + export type ArrowRouting = { kind: "straight" | "orthogonal"; cornerRadius?: number }; 49 + 50 + /** 51 + * Arrow label configuration 52 + */ 53 + export type ArrowLabel = { text: string; align: "center" | "start" | "end"; offset: number }; 54 + 55 + /** 56 + * Arrow properties supporting both legacy (a, b) and modern (points) formats 57 + * Legacy format: { a, b, stroke, width } 58 + * Modern format: { points, start, end, style, routing?, label? } 59 + */ 60 + export type ArrowProps = { 61 + // TODO: do away with legacy format (for backward compatibility 62 + a?: Vec2; 63 + b?: Vec2; 64 + stroke?: string; 65 + width?: number; 66 + 67 + points?: Vec2[]; 68 + start?: ArrowEndpoint; 69 + end?: ArrowEndpoint; 70 + style?: ArrowStyle; 71 + routing?: ArrowRouting; 72 + label?: ArrowLabel; 73 + }; 74 + 35 75 export type TextProps = { text: string; fontSize: number; fontFamily: string; color: string; w?: number }; 36 76 37 77 /** ··· 134 174 }, 135 175 }; 136 176 } 177 + if (shape.type === "arrow") { 178 + return { 179 + ...shape, 180 + props: { 181 + ...shape.props, 182 + 183 + a: shape.props.a ? { ...shape.props.a } : undefined, 184 + b: shape.props.b ? { ...shape.props.b } : undefined, 185 + 186 + points: shape.props.points ? shape.props.points.map((p) => ({ ...p })) : undefined, 187 + start: shape.props.start ? { ...shape.props.start } : undefined, 188 + end: shape.props.end ? { ...shape.props.end } : undefined, 189 + style: shape.props.style 190 + ? { ...shape.props.style, dash: shape.props.style.dash ? [...shape.props.style.dash] : undefined } 191 + : undefined, 192 + routing: shape.props.routing ? { ...shape.props.routing } : undefined, 193 + label: shape.props.label ? { ...shape.props.label } : undefined, 194 + }, 195 + }; 196 + } 137 197 return { ...shape, props: { ...shape.props } } as ShapeRecord; 138 198 }, 139 199 }; ··· 141 201 export type BindingType = "arrow-end"; 142 202 export type BindingHandle = "start" | "end"; 143 203 144 - // TODO: 'edge', 'corner', etc. 145 - export type BindingAnchor = { kind: "center" }; 204 + /** 205 + * Binding anchor configuration 206 + * - center: bind to shape center 207 + * - edge: bind to shape edge with normalized coordinates (nx, ny in [-1, 1]) 208 + */ 209 + export type BindingAnchor = { kind: "center" } | { kind: "edge"; nx: number; ny: number }; 146 210 147 211 export type BindingRecord = { 148 212 id: string; ··· 174 238 * Clone a binding record 175 239 */ 176 240 clone(binding: BindingRecord): BindingRecord { 177 - return { ...binding, anchor: { ...binding.anchor } }; 241 + return { ...binding, anchor: binding.anchor.kind === "edge" ? { ...binding.anchor } : { kind: "center" } }; 178 242 }, 179 243 }; 180 244 ··· 248 312 249 313 break; 250 314 } 251 - case "line": 315 + case "line": { 316 + if (shape.props.width < 0) errors.push(`Line shape '${shapeId}' has negative width`); 317 + 318 + break; 319 + } 252 320 case "arrow": { 253 - if (shape.props.width < 0) errors.push(`${shape.type} shape '${shapeId}' has negative width`); 321 + const props = shape.props; 322 + const isLegacy = props.a !== undefined && props.b !== undefined; 323 + const isModern = props.points !== undefined; 324 + 325 + if (!isLegacy && !isModern) { 326 + errors.push(`Arrow shape '${shapeId}' missing both legacy (a, b) and modern (points) format`); 327 + } 328 + 329 + if (isLegacy) { 330 + if (props.width !== undefined && props.width < 0) { 331 + errors.push(`Arrow shape '${shapeId}' has negative width in legacy format`); 332 + } 333 + } 334 + 335 + if (isModern) { 336 + if (!props.points || props.points.length < 2) { 337 + errors.push(`Arrow shape '${shapeId}' points array must have at least 2 points`); 338 + } 339 + if (props.style) { 340 + if (props.style.width < 0) { 341 + errors.push(`Arrow shape '${shapeId}' has negative width in style`); 342 + } 343 + } 344 + if (props.routing) { 345 + if (props.routing.cornerRadius !== undefined && props.routing.cornerRadius < 0) { 346 + errors.push(`Arrow shape '${shapeId}' has negative cornerRadius`); 347 + } 348 + } 349 + if (props.label) { 350 + if (!["center", "start", "end"].includes(props.label.align)) { 351 + errors.push(`Arrow shape '${shapeId}' has invalid label alignment`); 352 + } 353 + } 354 + } 254 355 255 356 break; 256 357 } ··· 313 414 314 415 if (binding.handle !== "start" && binding.handle !== "end") { 315 416 errors.push(`Binding '${bindingId}' has invalid handle '${binding.handle}'`); 417 + } 418 + 419 + if (binding.anchor.kind === "edge") { 420 + if (binding.anchor.nx < -1 || binding.anchor.nx > 1) { 421 + errors.push(`Binding '${bindingId}' has invalid nx '${binding.anchor.nx}' (must be in [-1, 1])`); 422 + } 423 + if (binding.anchor.ny < -1 || binding.anchor.ny > 1) { 424 + errors.push(`Binding '${bindingId}' has invalid ny '${binding.anchor.ny}' (must be in [-1, 1])`); 425 + } 316 426 } 317 427 } 318 428
+50 -5
packages/core/src/tools/select.ts
··· 462 462 { id: "w", position: { x: minX, y: centerY } }, 463 463 { id: "rotate", position: { x: centerX, y: minY - ROTATE_HANDLE_OFFSET } }, 464 464 ); 465 - } else if (shape.type === "line" || shape.type === "arrow") { 465 + } else if (shape.type === "line") { 466 466 const start = this.localToWorld(shape, shape.props.a); 467 467 const end = this.localToWorld(shape, shape.props.b); 468 468 handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); 469 + } else if (shape.type === "arrow") { 470 + // TODO: do away with legacy format 471 + if (shape.props.a && shape.props.b) { 472 + const start = this.localToWorld(shape, shape.props.a); 473 + const end = this.localToWorld(shape, shape.props.b); 474 + handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); 475 + } else if (shape.props.points && shape.props.points.length >= 2) { 476 + const firstPoint = shape.props.points[0]; 477 + const lastPoint = shape.props.points[shape.props.points.length - 1]; 478 + const start = this.localToWorld(shape, firstPoint); 479 + const end = this.localToWorld(shape, lastPoint); 480 + handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); 481 + } 469 482 } 470 483 return handles; 471 484 } ··· 541 554 if (initial.type !== "line" && initial.type !== "arrow") { 542 555 return null; 543 556 } 544 - const startWorld = this.localToWorld(initial, initial.props.a); 545 - const endWorld = this.localToWorld(initial, initial.props.b); 557 + 558 + let startPoint: Vec2, endPoint: Vec2; 559 + 560 + if (initial.type === "line") { 561 + startPoint = initial.props.a; 562 + endPoint = initial.props.b; 563 + } else { 564 + if (initial.props.a && initial.props.b) { 565 + startPoint = initial.props.a; 566 + endPoint = initial.props.b; 567 + } else if (initial.props.points && initial.props.points.length >= 2) { 568 + startPoint = initial.props.points[0]; 569 + endPoint = initial.props.points[initial.props.points.length - 1]; 570 + } else { 571 + return null; 572 + } 573 + } 574 + 575 + const startWorld = this.localToWorld(initial, startPoint); 576 + const endWorld = this.localToWorld(initial, endPoint); 546 577 const newStart = handle === "line-start" ? pointer : startWorld; 547 578 const newEnd = handle === "line-end" ? pointer : endWorld; 548 - const newProps = { ...initial.props, a: { x: 0, y: 0 }, b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y } }; 549 - return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; 579 + 580 + if (initial.type === "line") { 581 + const newProps = { 582 + ...initial.props, 583 + a: { x: 0, y: 0 }, 584 + b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }, 585 + }; 586 + return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; 587 + } else { 588 + const newProps = { 589 + ...initial.props, 590 + a: { x: 0, y: 0 }, 591 + b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }, 592 + }; 593 + return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; 594 + } 550 595 } 551 596 552 597 private rotateShape(initial: ShapeRecord, pointer: Vec2): ShapeRecord | null {
+23 -3
packages/core/src/tools/shape.ts
··· 605 605 606 606 let newState = state; 607 607 608 - const arrowLength = Vec2.len(shape.props.b); 608 + let endPoint: Vec2; 609 + if (shape.props.b) { 610 + endPoint = shape.props.b; 611 + } else if (shape.props.points && shape.props.points.length >= 2) { 612 + endPoint = shape.props.points[shape.props.points.length - 1]; 613 + } else { 614 + endPoint = { x: 0, y: 0 }; 615 + } 616 + 617 + const arrowLength = Vec2.len(endPoint); 609 618 if (arrowLength < MIN_SHAPE_SIZE) { 610 619 newState = this.cancelShapeCreation(state); 611 620 } else { ··· 623 632 const arrow = state.doc.shapes[arrowId]; 624 633 if (!arrow || arrow.type !== "arrow") return state; 625 634 626 - const startWorld = { x: arrow.x + arrow.props.a.x, y: arrow.y + arrow.props.a.y }; 627 - const endWorld = { x: arrow.x + arrow.props.b.x, y: arrow.y + arrow.props.b.y }; 635 + let startPoint: Vec2, endPoint: Vec2; 636 + if (arrow.props.a && arrow.props.b) { 637 + startPoint = arrow.props.a; 638 + endPoint = arrow.props.b; 639 + } else if (arrow.props.points && arrow.props.points.length >= 2) { 640 + startPoint = arrow.props.points[0]; 641 + endPoint = arrow.props.points[arrow.props.points.length - 1]; 642 + } else { 643 + return state; 644 + } 645 + 646 + const startWorld = { x: arrow.x + startPoint.x, y: arrow.y + startPoint.y }; 647 + const endWorld = { x: arrow.x + endPoint.x, y: arrow.y + endPoint.y }; 628 648 629 649 const newBindings = { ...state.doc.bindings }; 630 650
+550 -5
packages/core/tests/model.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 2 import { 3 3 type ArrowProps, 4 + type ArrowStyle, 4 5 BindingRecord, 5 6 createId, 6 7 Document, ··· 16 17 describe("createId", () => { 17 18 it("should generate a valid UUID without prefix", () => { 18 19 const id = createId(); 19 - 20 20 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}$/); 21 21 }); 22 22 ··· 173 173 }); 174 174 175 175 describe("createArrow", () => { 176 - it("should create an arrow shape", () => { 176 + it("should create an arrow shape with legacy format", () => { 177 177 const props: ArrowProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; 178 178 const shape = ShapeRecord.createArrow(pageId, 10, 20, props); 179 179 ··· 181 181 expect(shape.type).toBe("arrow"); 182 182 expect(shape.props).toEqual(props); 183 183 }); 184 + 185 + it("should create an arrow with modern format (points only)", () => { 186 + const props: ArrowProps = { 187 + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], 188 + start: { kind: "free" }, 189 + end: { kind: "free" }, 190 + style: { stroke: "#000", width: 2 }, 191 + }; 192 + const shape = ShapeRecord.createArrow(pageId, 10, 20, props); 193 + 194 + expect(shape.id).toMatch(/^shape:/); 195 + expect(shape.type).toBe("arrow"); 196 + expect(shape.props.points).toEqual(props.points); 197 + expect(shape.props.start).toEqual({ kind: "free" }); 198 + expect(shape.props.end).toEqual({ kind: "free" }); 199 + expect(shape.props.style).toEqual({ stroke: "#000", width: 2 }); 200 + }); 201 + 202 + it("should create an arrow with polyline (3+ points)", () => { 203 + const props: ArrowProps = { 204 + points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }], 205 + start: { kind: "free" }, 206 + end: { kind: "free" }, 207 + style: { stroke: "#ff0000", width: 3 }, 208 + }; 209 + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); 210 + 211 + expect(shape.props.points?.length).toBe(3); 212 + expect(shape.props.points).toEqual(props.points); 213 + }); 214 + 215 + it("should create an arrow with bound endpoints", () => { 216 + const props: ArrowProps = { 217 + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], 218 + start: { kind: "bound", bindingId: "binding:1" }, 219 + end: { kind: "bound", bindingId: "binding:2" }, 220 + style: { stroke: "#000", width: 2 }, 221 + }; 222 + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); 223 + 224 + expect(shape.props.start).toEqual({ kind: "bound", bindingId: "binding:1" }); 225 + expect(shape.props.end).toEqual({ kind: "bound", bindingId: "binding:2" }); 226 + }); 227 + 228 + it("should create an arrow with arrowheads", () => { 229 + const style: ArrowStyle = { stroke: "#000", width: 2, headStart: true, headEnd: true }; 230 + const props: ArrowProps = { 231 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 232 + start: { kind: "free" }, 233 + end: { kind: "free" }, 234 + style, 235 + }; 236 + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); 237 + 238 + expect(shape.props.style?.headStart).toBe(true); 239 + expect(shape.props.style?.headEnd).toBe(true); 240 + }); 241 + 242 + it("should create an arrow with dash pattern", () => { 243 + const style: ArrowStyle = { stroke: "#000", width: 2, dash: [5, 3] }; 244 + const props: ArrowProps = { 245 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 246 + start: { kind: "free" }, 247 + end: { kind: "free" }, 248 + style, 249 + }; 250 + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); 251 + 252 + expect(shape.props.style?.dash).toEqual([5, 3]); 253 + }); 254 + 255 + it("should create an arrow with orthogonal routing", () => { 256 + const props: ArrowProps = { 257 + points: [{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 50, y: 50 }, { x: 100, y: 50 }], 258 + start: { kind: "free" }, 259 + end: { kind: "free" }, 260 + style: { stroke: "#000", width: 2 }, 261 + routing: { kind: "orthogonal", cornerRadius: 5 }, 262 + }; 263 + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); 264 + 265 + expect(shape.props.routing).toEqual({ kind: "orthogonal", cornerRadius: 5 }); 266 + }); 267 + 268 + it("should create an arrow with label", () => { 269 + const props: ArrowProps = { 270 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 271 + start: { kind: "free" }, 272 + end: { kind: "free" }, 273 + style: { stroke: "#000", width: 2 }, 274 + label: { text: "Connection", align: "center", offset: 0 }, 275 + }; 276 + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); 277 + 278 + expect(shape.props.label).toEqual({ text: "Connection", align: "center", offset: 0 }); 279 + }); 280 + 281 + it.each([{ align: "center" as const, offset: 0 }, { align: "start" as const, offset: 10 }, { 282 + align: "end" as const, 283 + offset: -10, 284 + }])("should create arrow with label alignment: $align", ({ align, offset }) => { 285 + const props: ArrowProps = { 286 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 287 + start: { kind: "free" }, 288 + end: { kind: "free" }, 289 + style: { stroke: "#000", width: 2 }, 290 + label: { text: "Test", align, offset }, 291 + }; 292 + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); 293 + 294 + expect(shape.props.label?.align).toBe(align); 295 + expect(shape.props.label?.offset).toBe(offset); 296 + }); 184 297 }); 185 298 186 299 describe("createText", () => { ··· 249 362 expect(cloned).toEqual(shape); 250 363 expect(cloned.props).not.toBe(shape.props); 251 364 }); 365 + 366 + it("should clone legacy arrow shape", () => { 367 + const props: ArrowProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; 368 + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); 369 + 370 + const cloned = ShapeRecord.clone(shape); 371 + 372 + expect(cloned).toEqual(shape); 373 + expect(cloned.props).not.toBe(shape.props); 374 + if (cloned.type === "arrow" && shape.type === "arrow") { 375 + expect(cloned.props.a).not.toBe(shape.props.a); 376 + expect(cloned.props.b).not.toBe(shape.props.b); 377 + } 378 + }); 379 + 380 + it("should clone modern arrow shape with points", () => { 381 + const props: ArrowProps = { 382 + points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }], 383 + start: { kind: "free" }, 384 + end: { kind: "bound", bindingId: "binding:1" }, 385 + style: { stroke: "#000", width: 2, dash: [5, 3] }, 386 + routing: { kind: "orthogonal", cornerRadius: 5 }, 387 + label: { text: "Test", align: "center", offset: 0 }, 388 + }; 389 + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); 390 + 391 + const cloned = ShapeRecord.clone(shape); 392 + 393 + expect(cloned).toEqual(shape); 394 + expect(cloned.props).not.toBe(shape.props); 395 + if (cloned.type === "arrow" && shape.type === "arrow") { 396 + expect(cloned.props.points).not.toBe(shape.props.points); 397 + expect(cloned.props.start).not.toBe(shape.props.start); 398 + expect(cloned.props.end).not.toBe(shape.props.end); 399 + expect(cloned.props.style).not.toBe(shape.props.style); 400 + expect(cloned.props.routing).not.toBe(shape.props.routing); 401 + expect(cloned.props.label).not.toBe(shape.props.label); 402 + } 403 + }); 404 + 405 + it("should deep clone arrow points array", () => { 406 + const props: ArrowProps = { 407 + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], 408 + start: { kind: "free" }, 409 + end: { kind: "free" }, 410 + style: { stroke: "#000", width: 2 }, 411 + }; 412 + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); 413 + 414 + const cloned = ShapeRecord.clone(shape); 415 + 416 + if (cloned.type === "arrow" && shape.type === "arrow" && cloned.props.points && shape.props.points) { 417 + cloned.props.points[0].x = 999; 418 + expect(shape.props.points[0].x).toBe(0); 419 + } 420 + }); 421 + 422 + it("should deep clone arrow style dash array", () => { 423 + const props: ArrowProps = { 424 + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], 425 + start: { kind: "free" }, 426 + end: { kind: "free" }, 427 + style: { stroke: "#000", width: 2, dash: [5, 3] }, 428 + }; 429 + const shape = ShapeRecord.createArrow(pageId, 0, 0, props); 430 + 431 + const cloned = ShapeRecord.clone(shape); 432 + 433 + if (cloned.type === "arrow" && shape.type === "arrow" && cloned.props.style?.dash && shape.props.style?.dash) { 434 + cloned.props.style.dash[0] = 999; 435 + expect(shape.props.style.dash[0]).toBe(5); 436 + } 437 + }); 252 438 }); 253 439 254 440 describe("position and rotation", () => { ··· 312 498 }); 313 499 314 500 describe("clone", () => { 315 - it("should create a copy of the binding", () => { 501 + it("should create a copy of the binding with center anchor", () => { 316 502 const binding = BindingRecord.create("arrow1", "shape1", "start"); 317 503 318 504 const cloned = BindingRecord.clone(binding); ··· 322 508 expect(cloned.anchor).not.toBe(binding.anchor); 323 509 }); 324 510 325 - it("should deep clone anchor", () => { 511 + it("should deep clone center anchor", () => { 326 512 const binding = BindingRecord.create("arrow1", "shape1", "start"); 327 513 328 514 const cloned = BindingRecord.clone(binding); 329 515 330 516 expect(cloned.anchor).toEqual(binding.anchor); 331 517 expect(cloned.anchor).not.toBe(binding.anchor); 518 + }); 519 + 520 + it("should clone binding with edge anchor", () => { 521 + const binding = BindingRecord.create("arrow1", "shape1", "end", { kind: "edge", nx: 0.5, ny: -0.5 }); 522 + 523 + const cloned = BindingRecord.clone(binding); 524 + 525 + expect(cloned).toEqual(binding); 526 + expect(cloned).not.toBe(binding); 527 + expect(cloned.anchor).not.toBe(binding.anchor); 528 + }); 529 + 530 + it("should deep clone edge anchor", () => { 531 + const binding = BindingRecord.create("arrow1", "shape1", "start", { kind: "edge", nx: 1, ny: 0 }); 532 + 533 + const cloned = BindingRecord.clone(binding); 534 + 535 + expect(cloned.anchor).toEqual({ kind: "edge", nx: 1, ny: 0 }); 536 + expect(cloned.anchor).not.toBe(binding.anchor); 537 + }); 538 + }); 539 + 540 + describe("edge anchors", () => { 541 + it("should create binding with edge anchor at right edge", () => { 542 + const anchor = { kind: "edge" as const, nx: 1, ny: 0 }; 543 + const binding = BindingRecord.create("arrow1", "shape1", "start", anchor); 544 + 545 + expect(binding.anchor).toEqual({ kind: "edge", nx: 1, ny: 0 }); 546 + }); 547 + 548 + it("should create binding with edge anchor at top-left corner", () => { 549 + const anchor = { kind: "edge" as const, nx: -1, ny: -1 }; 550 + const binding = BindingRecord.create("arrow1", "shape1", "end", anchor); 551 + 552 + expect(binding.anchor).toEqual({ kind: "edge", nx: -1, ny: -1 }); 553 + }); 554 + 555 + it.each([ 556 + { nx: 0, ny: 0, desc: "center" }, 557 + { nx: 1, ny: 0, desc: "right edge" }, 558 + { nx: -1, ny: 0, desc: "left edge" }, 559 + { nx: 0, ny: 1, desc: "bottom edge" }, 560 + { nx: 0, ny: -1, desc: "top edge" }, 561 + { nx: 0.5, ny: 0.5, desc: "bottom-right quadrant" }, 562 + { nx: -0.5, ny: -0.5, desc: "top-left quadrant" }, 563 + ])("should create binding with edge anchor at $desc", ({ nx, ny }) => { 564 + const anchor = { kind: "edge" as const, nx, ny }; 565 + const binding = BindingRecord.create("arrow1", "shape1", "start", anchor); 566 + 567 + expect(binding.anchor).toEqual({ kind: "edge", nx, ny }); 332 568 }); 333 569 }); 334 570 }); ··· 790 1026 791 1027 expect(result.ok).toBe(false); 792 1028 if (!result.ok) { 793 - expect(result.errors).toContain("line shape 'shape1' has negative width"); 1029 + expect(result.errors).toContain("Line shape 'shape1' has negative width"); 794 1030 } 795 1031 }); 796 1032 ··· 868 1104 expect(result.errors.length).toBeGreaterThan(1); 869 1105 } 870 1106 }); 1107 + 1108 + it("should reject arrow with neither legacy nor modern format", () => { 1109 + const doc = Document.create(); 1110 + const page = PageRecord.create("Page 1", "page1"); 1111 + const shape = ShapeRecord.createArrow("page1", 0, 0, {}, "arrow1"); 1112 + 1113 + page.shapeIds = ["arrow1"]; 1114 + doc.pages = { page1: page }; 1115 + doc.shapes = { arrow1: shape }; 1116 + 1117 + const result = validateDoc(doc); 1118 + 1119 + expect(result.ok).toBe(false); 1120 + if (!result.ok) { 1121 + expect(result.errors).toContain("Arrow shape 'arrow1' missing both legacy (a, b) and modern (points) format"); 1122 + } 1123 + }); 1124 + 1125 + it("should reject arrow with too few points in modern format", () => { 1126 + const doc = Document.create(); 1127 + const page = PageRecord.create("Page 1", "page1"); 1128 + const shape = ShapeRecord.createArrow("page1", 0, 0, { 1129 + points: [{ x: 0, y: 0 }], 1130 + start: { kind: "free" }, 1131 + end: { kind: "free" }, 1132 + style: { stroke: "#000", width: 2 }, 1133 + }, "arrow1"); 1134 + 1135 + page.shapeIds = ["arrow1"]; 1136 + doc.pages = { page1: page }; 1137 + doc.shapes = { arrow1: shape }; 1138 + 1139 + const result = validateDoc(doc); 1140 + 1141 + expect(result.ok).toBe(false); 1142 + if (!result.ok) { 1143 + expect(result.errors).toContain("Arrow shape 'arrow1' points array must have at least 2 points"); 1144 + } 1145 + }); 1146 + 1147 + it("should reject arrow with negative width in modern format", () => { 1148 + const doc = Document.create(); 1149 + const page = PageRecord.create("Page 1", "page1"); 1150 + const shape = ShapeRecord.createArrow("page1", 0, 0, { 1151 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 1152 + start: { kind: "free" }, 1153 + end: { kind: "free" }, 1154 + style: { stroke: "#000", width: -2 }, 1155 + }, "arrow1"); 1156 + 1157 + page.shapeIds = ["arrow1"]; 1158 + doc.pages = { page1: page }; 1159 + doc.shapes = { arrow1: shape }; 1160 + 1161 + const result = validateDoc(doc); 1162 + 1163 + expect(result.ok).toBe(false); 1164 + if (!result.ok) { 1165 + expect(result.errors).toContain("Arrow shape 'arrow1' has negative width in style"); 1166 + } 1167 + }); 1168 + 1169 + it("should reject arrow with negative cornerRadius", () => { 1170 + const doc = Document.create(); 1171 + const page = PageRecord.create("Page 1", "page1"); 1172 + const shape = ShapeRecord.createArrow("page1", 0, 0, { 1173 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 1174 + start: { kind: "free" }, 1175 + end: { kind: "free" }, 1176 + style: { stroke: "#000", width: 2 }, 1177 + routing: { kind: "orthogonal", cornerRadius: -5 }, 1178 + }, "arrow1"); 1179 + 1180 + page.shapeIds = ["arrow1"]; 1181 + doc.pages = { page1: page }; 1182 + doc.shapes = { arrow1: shape }; 1183 + 1184 + const result = validateDoc(doc); 1185 + 1186 + expect(result.ok).toBe(false); 1187 + if (!result.ok) { 1188 + expect(result.errors).toContain("Arrow shape 'arrow1' has negative cornerRadius"); 1189 + } 1190 + }); 1191 + 1192 + it("should reject arrow with invalid label alignment", () => { 1193 + const doc = Document.create(); 1194 + const page = PageRecord.create("Page 1", "page1"); 1195 + const shape = ShapeRecord.createArrow("page1", 0, 0, { 1196 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 1197 + start: { kind: "free" }, 1198 + end: { kind: "free" }, 1199 + style: { stroke: "#000", width: 2 }, 1200 + label: { text: "Test", align: "invalid" as any, offset: 0 }, 1201 + }, "arrow1"); 1202 + 1203 + page.shapeIds = ["arrow1"]; 1204 + doc.pages = { page1: page }; 1205 + doc.shapes = { arrow1: shape }; 1206 + 1207 + const result = validateDoc(doc); 1208 + 1209 + expect(result.ok).toBe(false); 1210 + if (!result.ok) { 1211 + expect(result.errors).toContain("Arrow shape 'arrow1' has invalid label alignment"); 1212 + } 1213 + }); 1214 + 1215 + it("should reject binding with edge anchor nx out of range", () => { 1216 + const doc = Document.create(); 1217 + const page = PageRecord.create("Page 1", "page1"); 1218 + const arrow = ShapeRecord.createArrow("page1", 0, 0, { 1219 + a: { x: 0, y: 0 }, 1220 + b: { x: 100, y: 0 }, 1221 + stroke: "#000", 1222 + width: 2, 1223 + }, "arrow1"); 1224 + const rect = ShapeRecord.createRect( 1225 + "page1", 1226 + 100, 1227 + 0, 1228 + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 1229 + "rect1", 1230 + ); 1231 + const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "edge", nx: 1.5, ny: 0 }, "binding1"); 1232 + 1233 + page.shapeIds = ["arrow1", "rect1"]; 1234 + doc.pages = { page1: page }; 1235 + doc.shapes = { arrow1: arrow, rect1: rect }; 1236 + doc.bindings = { binding1: binding }; 1237 + 1238 + const result = validateDoc(doc); 1239 + 1240 + expect(result.ok).toBe(false); 1241 + if (!result.ok) { 1242 + expect(result.errors).toContain("Binding 'binding1' has invalid nx '1.5' (must be in [-1, 1])"); 1243 + } 1244 + }); 1245 + 1246 + it("should reject binding with edge anchor ny out of range", () => { 1247 + const doc = Document.create(); 1248 + const page = PageRecord.create("Page 1", "page1"); 1249 + const arrow = ShapeRecord.createArrow("page1", 0, 0, { 1250 + a: { x: 0, y: 0 }, 1251 + b: { x: 100, y: 0 }, 1252 + stroke: "#000", 1253 + width: 2, 1254 + }, "arrow1"); 1255 + const rect = ShapeRecord.createRect( 1256 + "page1", 1257 + 100, 1258 + 0, 1259 + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 1260 + "rect1", 1261 + ); 1262 + const binding = BindingRecord.create("arrow1", "rect1", "start", { kind: "edge", nx: 0, ny: -2 }, "binding1"); 1263 + 1264 + page.shapeIds = ["arrow1", "rect1"]; 1265 + doc.pages = { page1: page }; 1266 + doc.shapes = { arrow1: arrow, rect1: rect }; 1267 + doc.bindings = { binding1: binding }; 1268 + 1269 + const result = validateDoc(doc); 1270 + 1271 + expect(result.ok).toBe(false); 1272 + if (!result.ok) { 1273 + expect(result.errors).toContain("Binding 'binding1' has invalid ny '-2' (must be in [-1, 1])"); 1274 + } 1275 + }); 1276 + 1277 + it("should accept valid modern arrow format", () => { 1278 + const doc = Document.create(); 1279 + const page = PageRecord.create("Page 1", "page1"); 1280 + const arrow = ShapeRecord.createArrow("page1", 0, 0, { 1281 + points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }], 1282 + start: { kind: "free" }, 1283 + end: { kind: "free" }, 1284 + style: { stroke: "#000", width: 2, headStart: false, headEnd: true, dash: [5, 3] }, 1285 + routing: { kind: "orthogonal", cornerRadius: 5 }, 1286 + label: { text: "Connection", align: "center", offset: 0 }, 1287 + }, "arrow1"); 1288 + 1289 + page.shapeIds = ["arrow1"]; 1290 + doc.pages = { page1: page }; 1291 + doc.shapes = { arrow1: arrow }; 1292 + 1293 + const result = validateDoc(doc); 1294 + 1295 + expect(result.ok).toBe(true); 1296 + }); 1297 + 1298 + it("should accept binding with valid edge anchor", () => { 1299 + const doc = Document.create(); 1300 + const page = PageRecord.create("Page 1", "page1"); 1301 + const arrow = ShapeRecord.createArrow("page1", 0, 0, { 1302 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 1303 + start: { kind: "free" }, 1304 + end: { kind: "bound", bindingId: "binding1" }, 1305 + style: { stroke: "#000", width: 2 }, 1306 + }, "arrow1"); 1307 + const rect = ShapeRecord.createRect( 1308 + "page1", 1309 + 100, 1310 + 0, 1311 + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 1312 + "rect1", 1313 + ); 1314 + const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "edge", nx: 0.5, ny: -0.5 }, "binding1"); 1315 + 1316 + page.shapeIds = ["arrow1", "rect1"]; 1317 + doc.pages = { page1: page }; 1318 + doc.shapes = { arrow1: arrow, rect1: rect }; 1319 + doc.bindings = { binding1: binding }; 1320 + 1321 + const result = validateDoc(doc); 1322 + 1323 + expect(result.ok).toBe(true); 1324 + }); 871 1325 }); 872 1326 873 1327 describe("edge cases", () => { ··· 1073 1527 1074 1528 doc.pages = { page1, page2 }; 1075 1529 doc.shapes = { shape1, shape2, shape3, shape4 }; 1530 + doc.bindings = { binding1: binding }; 1531 + 1532 + const json = JSON.stringify(doc); 1533 + const parsed = JSON.parse(json); 1534 + 1535 + expect(parsed).toEqual(doc); 1536 + expect(validateDoc(parsed).ok).toBe(true); 1537 + }); 1538 + 1539 + it("should round-trip arrow with modern format", () => { 1540 + const doc = Document.create(); 1541 + const page = PageRecord.create("Page 1", "page1"); 1542 + const arrow = ShapeRecord.createArrow("page1", 0, 0, { 1543 + points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }], 1544 + start: { kind: "free" }, 1545 + end: { kind: "free" }, 1546 + style: { stroke: "#ff0000", width: 3, headStart: true, headEnd: true, dash: [5, 3] }, 1547 + routing: { kind: "orthogonal", cornerRadius: 5 }, 1548 + label: { text: "Connection", align: "center", offset: 0 }, 1549 + }, "arrow1"); 1550 + 1551 + page.shapeIds = ["arrow1"]; 1552 + doc.pages = { page1: page }; 1553 + doc.shapes = { arrow1: arrow }; 1554 + 1555 + const json = JSON.stringify(doc); 1556 + const parsed = JSON.parse(json); 1557 + 1558 + expect(parsed).toEqual(doc); 1559 + expect(validateDoc(parsed).ok).toBe(true); 1560 + }); 1561 + 1562 + it("should round-trip arrow with bound endpoints", () => { 1563 + const doc = Document.create(); 1564 + const page = PageRecord.create("Page 1", "page1"); 1565 + const arrow = ShapeRecord.createArrow("page1", 0, 0, { 1566 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 1567 + start: { kind: "bound", bindingId: "binding1" }, 1568 + end: { kind: "bound", bindingId: "binding2" }, 1569 + style: { stroke: "#000", width: 2 }, 1570 + }, "arrow1"); 1571 + const rect1 = ShapeRecord.createRect( 1572 + "page1", 1573 + -50, 1574 + -25, 1575 + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 1576 + "rect1", 1577 + ); 1578 + const rect2 = ShapeRecord.createRect( 1579 + "page1", 1580 + 100, 1581 + -25, 1582 + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 1583 + "rect2", 1584 + ); 1585 + const binding1 = BindingRecord.create("arrow1", "rect1", "start", { kind: "edge", nx: 1, ny: 0 }, "binding1"); 1586 + const binding2 = BindingRecord.create("arrow1", "rect2", "end", { kind: "edge", nx: -1, ny: 0 }, "binding2"); 1587 + 1588 + page.shapeIds = ["arrow1", "rect1", "rect2"]; 1589 + doc.pages = { page1: page }; 1590 + doc.shapes = { arrow1: arrow, rect1: rect1, rect2: rect2 }; 1591 + doc.bindings = { binding1, binding2 }; 1592 + 1593 + const json = JSON.stringify(doc); 1594 + const parsed = JSON.parse(json); 1595 + 1596 + expect(parsed).toEqual(doc); 1597 + expect(validateDoc(parsed).ok).toBe(true); 1598 + }); 1599 + 1600 + it("should round-trip binding with edge anchor", () => { 1601 + const doc = Document.create(); 1602 + const page = PageRecord.create("Page 1", "page1"); 1603 + const arrow = ShapeRecord.createArrow("page1", 0, 0, { 1604 + a: { x: 0, y: 0 }, 1605 + b: { x: 100, y: 0 }, 1606 + stroke: "#000", 1607 + width: 2, 1608 + }, "arrow1"); 1609 + const rect = ShapeRecord.createRect( 1610 + "page1", 1611 + 100, 1612 + 0, 1613 + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 1614 + "rect1", 1615 + ); 1616 + const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "edge", nx: -0.5, ny: 0.5 }, "binding1"); 1617 + 1618 + page.shapeIds = ["arrow1", "rect1"]; 1619 + doc.pages = { page1: page }; 1620 + doc.shapes = { arrow1: arrow, rect1: rect }; 1076 1621 doc.bindings = { binding1: binding }; 1077 1622 1078 1623 const json = JSON.stringify(doc);