orcasvn-react-diagrams 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +11 -3
  3. package/dist/cjs/examples.js +1768 -161
  4. package/dist/cjs/index.js +786 -120
  5. package/dist/cjs/types/api/createDiagramEditor.d.ts +19 -1
  6. package/dist/cjs/types/api/index.d.ts +1 -1
  7. package/dist/cjs/types/api/types.d.ts +32 -0
  8. package/dist/cjs/types/displaybox/DisplayBoxControls.d.ts +5 -1
  9. package/dist/cjs/types/displaybox/demos/AsymmetricPortMultiAnchorDemoTab.d.ts +3 -0
  10. package/dist/cjs/types/displaybox/demos/LayoutLabelReservedSpaceDemoTab.d.ts +3 -0
  11. package/dist/cjs/types/displaybox/demos/VertexControlLinkSessionDemoTab.d.ts +3 -0
  12. package/dist/cjs/types/displaybox/demos/asymmetricPortMultiAnchorDemo.d.ts +31 -0
  13. package/dist/cjs/types/displaybox/demos/layoutLabelReservedSpaceDemo.d.ts +11 -0
  14. package/dist/cjs/types/displaybox/demos/vertexControlLinkSessionDemo.d.ts +12 -0
  15. package/dist/cjs/types/displaybox/useDemoControls.d.ts +4 -0
  16. package/dist/cjs/types/engine/AutoLayoutService.d.ts +2 -0
  17. package/dist/cjs/types/engine/DiagramEngine.d.ts +5 -0
  18. package/dist/cjs/types/engine/LinkRoutingService.d.ts +9 -1
  19. package/dist/cjs/types/models/PortModel.d.ts +5 -0
  20. package/dist/cjs/types/renderer/RenderTypes.d.ts +3 -1
  21. package/dist/cjs/types/renderer/konva/KonvaInteraction.d.ts +14 -0
  22. package/dist/cjs/types/renderer/konva/KonvaNodeFactory.d.ts +1 -0
  23. package/dist/cjs/types/renderer/konva/KonvaRenderer.d.ts +0 -1
  24. package/dist/cjs/types/shapes/BuiltInShapes.d.ts +3 -1
  25. package/dist/cjs/types/utils/__tests__/portGeometry.test.d.ts +1 -0
  26. package/dist/cjs/types/utils/portGeometry.d.ts +44 -0
  27. package/dist/esm/examples.js +1769 -162
  28. package/dist/esm/examples.js.map +1 -1
  29. package/dist/esm/index.js +786 -120
  30. package/dist/esm/index.js.map +1 -1
  31. package/dist/esm/types/api/createDiagramEditor.d.ts +19 -1
  32. package/dist/esm/types/api/index.d.ts +1 -1
  33. package/dist/esm/types/api/types.d.ts +32 -0
  34. package/dist/esm/types/displaybox/DisplayBoxControls.d.ts +5 -1
  35. package/dist/esm/types/displaybox/demos/AsymmetricPortMultiAnchorDemoTab.d.ts +3 -0
  36. package/dist/esm/types/displaybox/demos/LayoutLabelReservedSpaceDemoTab.d.ts +3 -0
  37. package/dist/esm/types/displaybox/demos/VertexControlLinkSessionDemoTab.d.ts +3 -0
  38. package/dist/esm/types/displaybox/demos/asymmetricPortMultiAnchorDemo.d.ts +31 -0
  39. package/dist/esm/types/displaybox/demos/layoutLabelReservedSpaceDemo.d.ts +11 -0
  40. package/dist/esm/types/displaybox/demos/vertexControlLinkSessionDemo.d.ts +12 -0
  41. package/dist/esm/types/displaybox/useDemoControls.d.ts +4 -0
  42. package/dist/esm/types/engine/AutoLayoutService.d.ts +2 -0
  43. package/dist/esm/types/engine/DiagramEngine.d.ts +5 -0
  44. package/dist/esm/types/engine/LinkRoutingService.d.ts +9 -1
  45. package/dist/esm/types/models/PortModel.d.ts +5 -0
  46. package/dist/esm/types/renderer/RenderTypes.d.ts +3 -1
  47. package/dist/esm/types/renderer/konva/KonvaInteraction.d.ts +14 -0
  48. package/dist/esm/types/renderer/konva/KonvaNodeFactory.d.ts +1 -0
  49. package/dist/esm/types/renderer/konva/KonvaRenderer.d.ts +0 -1
  50. package/dist/esm/types/shapes/BuiltInShapes.d.ts +3 -1
  51. package/dist/esm/types/utils/__tests__/portGeometry.test.d.ts +1 -0
  52. package/dist/esm/types/utils/portGeometry.d.ts +44 -0
  53. package/dist/examples.d.ts +50 -0
  54. package/dist/index.d.ts +58 -1
  55. package/package.json +11 -10
  56. package/src/displaybox/demos/AsymmetricPortMultiAnchorDemoTab.tsx +269 -0
  57. package/src/displaybox/demos/AutoLayoutDemoTab.tsx +113 -11
  58. package/src/displaybox/demos/DeletionEventsDemoTab.tsx +6 -1
  59. package/src/displaybox/demos/EngineEventsDemoTab.tsx +5 -0
  60. package/src/displaybox/demos/EventHandlersDemoTab.tsx +5 -0
  61. package/src/displaybox/demos/ExternalDragDropDemoTab.tsx +5 -0
  62. package/src/displaybox/demos/LayoutLabelReservedSpaceDemoTab.tsx +291 -0
  63. package/src/displaybox/demos/LinkCancelDemoTab.tsx +5 -0
  64. package/src/displaybox/demos/ShapeHoverControlsDemoTab.tsx +6 -1
  65. package/src/displaybox/demos/SimpleDemo.tsx +5 -0
  66. package/src/displaybox/demos/SvgPathDemoTab.tsx +5 -0
  67. package/src/displaybox/demos/TextLayoutDemoTab.tsx +6 -1
  68. package/src/displaybox/demos/VertexControlLinkSessionDemoTab.tsx +302 -0
  69. package/src/displaybox/demos/asymmetricPortMultiAnchorDemo.ts +357 -0
  70. package/src/displaybox/demos/autoLayoutDemo.ts +23 -5
  71. package/src/displaybox/demos/index.tsx +91 -75
  72. package/src/displaybox/demos/layoutLabelReservedSpaceDemo.ts +121 -0
  73. package/src/displaybox/demos/vertexControlLinkSessionDemo.ts +145 -0
package/dist/index.d.ts CHANGED
@@ -22,6 +22,7 @@ type DiagramContainer = {
22
22
  type MoveConstraint = 'free' | 'inside' | 'border';
23
23
  type BorderSide$1 = 'left' | 'right' | 'top' | 'bottom';
24
24
  type HostAnchorPreset = 'vertices' | 'cardinal';
25
+ type PortLinkAttachMode = 'external' | 'internal';
25
26
  type PortAnchor = {
26
27
  id: string;
27
28
  position: Point;
@@ -29,6 +30,20 @@ type PortAnchor = {
29
30
  normal?: Point;
30
31
  meta?: Record<string, unknown>;
31
32
  };
33
+ type PortBorderTransformContext = {
34
+ side: BorderSide$1;
35
+ normal: Point;
36
+ hostRect: Rect;
37
+ attachMode: PortLinkAttachMode;
38
+ effectiveLinkAttachPoint: Point;
39
+ placementPoint: Point;
40
+ rotationPivot: Point;
41
+ portSize?: Size;
42
+ };
43
+ type PortBorderTransformResult = {
44
+ rotation?: number;
45
+ offset?: Point;
46
+ };
32
47
  type PortAnchorConstraint = {
33
48
  preset: HostAnchorPreset;
34
49
  fallback?: 'nearest';
@@ -153,6 +168,15 @@ type ElementLayoutMode = 'manual' | 'horizontal' | 'vertical';
153
168
  type ElementLayoutAlign = 'start' | 'center' | 'end';
154
169
  type ElementLayoutChildFitMainAxis = 'none' | 'distribute';
155
170
  type ElementLayoutChildFitCrossAxis = 'none' | 'stretch';
171
+ type ElementLayoutLabelReservedSpaceMode = 'none' | 'fixed' | 'flexible';
172
+ type ElementLayoutLabelReservedSpacePlacement = 'top';
173
+ type ElementLayoutLabelReservedSpace = {
174
+ mode?: ElementLayoutLabelReservedSpaceMode;
175
+ placement?: ElementLayoutLabelReservedSpacePlacement;
176
+ size?: number;
177
+ minSize?: number;
178
+ maxSize?: number;
179
+ };
156
180
  type TextLayoutBoundsMode = 'owner-width' | 'owner-box' | 'fixed';
157
181
  type TextLayoutWrapMode = 'none' | 'word' | 'char';
158
182
  type TextLayoutOverflowMode = 'clip' | 'ellipsis-end' | 'ellipsis-middle' | 'ellipsis-start';
@@ -168,6 +192,7 @@ type ElementLayout = {
168
192
  childFitCrossAxis?: ElementLayoutChildFitCrossAxis;
169
193
  childFitMinSize?: Partial<Size>;
170
194
  childFitMaxSize?: Partial<Size>;
195
+ labelReservedSpace?: ElementLayoutLabelReservedSpace;
171
196
  };
172
197
  type TextLayout = {
173
198
  boundsMode?: TextLayoutBoundsMode;
@@ -222,6 +247,11 @@ type PortData = {
222
247
  moveMode?: MoveConstraint;
223
248
  anchorCenter?: boolean;
224
249
  orientToHostBorder?: boolean;
250
+ placementPoint?: Point;
251
+ linkAttachPoint?: Point;
252
+ externalLinkAttachPoint?: Point;
253
+ internalLinkAttachPoint?: Point;
254
+ rotationPivot?: Point;
225
255
  currentAnchorId?: string;
226
256
  };
227
257
  type ShapeDrawContext = {
@@ -441,7 +471,9 @@ type DiagramEngineHandle = {
441
471
  svgSize?: Size;
442
472
  projectToBorder?: (point: Point, rect: Rect) => Point;
443
473
  resolveBorderSide?: (point: Point, rect: Rect) => BorderSide$1;
474
+ resolveBorderNormal?: (point: Point, rect: Rect) => Point;
444
475
  resolvePortAnchors?: (rect: Rect, options: ResolvePortAnchorsOptions) => PortAnchor[];
476
+ resolvePortBorderTransform?: (context: PortBorderTransformContext) => PortBorderTransformResult | undefined;
445
477
  resolveHoverGeometry?: (rect: Rect) => ShapeHoverGeometry | undefined;
446
478
  resolveEllipseMidPoints?: (rect: Rect) => ShapeEllipseMidPointTarget[] | undefined;
447
479
  }) => void;
@@ -506,6 +538,11 @@ declare class PortModel {
506
538
  moveMode?: MoveConstraint;
507
539
  anchorCenter?: boolean;
508
540
  orientToHostBorder: boolean;
541
+ placementPoint?: Point;
542
+ linkAttachPoint?: Point;
543
+ externalLinkAttachPoint?: Point;
544
+ internalLinkAttachPoint?: Point;
545
+ rotationPivot?: Point;
509
546
  currentAnchorId?: string;
510
547
  constructor(data: PortData);
511
548
  setPosition(position: Point): void;
@@ -657,7 +694,9 @@ type ShapeDefinition = {
657
694
  };
658
695
  projectToBorder?: (point: Point, rect: Rect) => Point;
659
696
  resolveBorderSide?: (point: Point, rect: Rect) => BorderSide;
697
+ resolveBorderNormal?: (point: Point, rect: Rect) => Point;
660
698
  resolvePortAnchors?: (rect: Rect, options: ResolvePortAnchorsOptions) => PortAnchor[];
699
+ resolvePortBorderTransform?: (context: PortBorderTransformContext) => PortBorderTransformResult | undefined;
661
700
  resolveHoverGeometry?: (rect: Rect) => ShapeHoverGeometry | undefined;
662
701
  resolveEllipseMidPoints?: (rect: Rect) => ShapeEllipseMidPointTarget[] | undefined;
663
702
  };
@@ -703,11 +742,29 @@ type DiagramEditorConfig = {
703
742
  onChange?: (event: EngineChangeEvent) => void;
704
743
  onSelection?: (event: EngineSelectionEvent) => void;
705
744
  };
745
+ type DiagramImageExportOptions = {
746
+ mimeType?: string;
747
+ quality?: number;
748
+ pixelRatio?: number;
749
+ x?: number;
750
+ y?: number;
751
+ width?: number;
752
+ height?: number;
753
+ fitToContent?: boolean | {
754
+ padding?: number;
755
+ };
756
+ };
706
757
  type DiagramEditorHandle = DiagramEngineHandle & {
758
+ startLinkFromPort: (sourcePortId: string, pointer?: Point) => void;
759
+ updateLinkPreview: (pointer: Point) => void;
760
+ completeLinkToPort: (targetPortId: string) => void;
761
+ completeLinkToElement: (targetElementId: string, pointer: Point) => void;
762
+ cancelLink: () => void;
763
+ exportImage: (options?: DiagramImageExportOptions) => string;
707
764
  resize: (width: number, height: number) => void;
708
765
  setElementShapeHoverControls: (controls?: ElementShapeHoverControls) => void;
709
766
  destroy: () => void;
710
767
  };
711
768
  declare const createDiagramEditor: (config: DiagramEditorConfig) => DiagramEditorHandle;
712
769
 
713
- export { type AnchorReference, type BorderSide$1 as BorderSide, type ClientRectLike, type DiagramContainer, type DiagramEditorConfig, type DiagramEditorHandle, type DiagramEngineHandle, type DiagramPatch, type DiagramState, type EdgeHoverControl, type ElementData, type ElementDeletedEvent, type ElementDropEvent, type ElementLayout, type ElementLayoutAlign, type ElementLayoutChildFitCrossAxis, type ElementLayoutChildFitMainAxis, type ElementLayoutMode, type ElementLinkConnectingEvent, type ElementLinkEndedEvent, type ElementLinkStartedEvent, type ElementMovedEvent, type ElementPointerEvent, type ElementPortMovementPolicy, type ElementResizedEvent, type ElementSelectedEvent, type ElementShapeControlDragEvent, type ElementShapeControlEventType, type ElementShapeHoverControlActivationEvent, type ElementShapeHoverControlInteractionEvent, type ElementShapeHoverControls, EllipseMidPoint, type EngineChangeEvent, type EngineConfigEvent, type EngineEventMap, type EnginePointerInfo, type EngineSelectionEvent, type HostAnchorPreset, type HoverControlIcon, type LinkData, type LinkDeletedEvent, type LinkRoutingMode, type MoveConstraint, type OverlayShapeConfig, type OverlayShapeHandle, type PaperClickEvent, type Point, type PortAnchor, type PortAnchorConstraint, type PortData, type PortDeletedEvent, type PortMouseEvent, type PortMovedEvent, type PortSelectedEvent, type Rect, type RerouteLinksOptions, type ResolvePortAnchorsOptions, type ShapeControlDefinition, type ShapeControlTargetKind, type ShapeControlVisibilityTrigger, type ShapeDrawContext, type ShapeEdgeTarget, type ShapeEllipseMidPointTarget, type ShapeHoverGeometry, type ShapeVertexTarget, type SimpleShape, type Size, type TextData, type TextDeletedEvent, type TextLayout, type TextLayoutBoundsMode, type TextLayoutOverflowMode, type TextLayoutWrapMode, type TextSelectedEvent, type TextUpdatedEvent, type VertexHoverControl, createDiagramEditor, createDiagramEngine };
770
+ export { type AnchorReference, type BorderSide$1 as BorderSide, type ClientRectLike, type DiagramContainer, type DiagramEditorConfig, type DiagramEditorHandle, type DiagramEngineHandle, type DiagramImageExportOptions, type DiagramPatch, type DiagramState, type EdgeHoverControl, type ElementData, type ElementDeletedEvent, type ElementDropEvent, type ElementLayout, type ElementLayoutAlign, type ElementLayoutChildFitCrossAxis, type ElementLayoutChildFitMainAxis, type ElementLayoutLabelReservedSpace, type ElementLayoutLabelReservedSpaceMode, type ElementLayoutLabelReservedSpacePlacement, type ElementLayoutMode, type ElementLinkConnectingEvent, type ElementLinkEndedEvent, type ElementLinkStartedEvent, type ElementMovedEvent, type ElementPointerEvent, type ElementPortMovementPolicy, type ElementResizedEvent, type ElementSelectedEvent, type ElementShapeControlDragEvent, type ElementShapeControlEventType, type ElementShapeHoverControlActivationEvent, type ElementShapeHoverControlInteractionEvent, type ElementShapeHoverControls, EllipseMidPoint, type EngineChangeEvent, type EngineConfigEvent, type EngineEventMap, type EnginePointerInfo, type EngineSelectionEvent, type HostAnchorPreset, type HoverControlIcon, type LinkData, type LinkDeletedEvent, type LinkRoutingMode, type MoveConstraint, type OverlayShapeConfig, type OverlayShapeHandle, type PaperClickEvent, type Point, type PortAnchor, type PortAnchorConstraint, type PortBorderTransformContext, type PortBorderTransformResult, type PortData, type PortDeletedEvent, type PortLinkAttachMode, type PortMouseEvent, type PortMovedEvent, type PortSelectedEvent, type Rect, type RerouteLinksOptions, type ResolvePortAnchorsOptions, type ShapeControlDefinition, type ShapeControlTargetKind, type ShapeControlVisibilityTrigger, type ShapeDrawContext, type ShapeEdgeTarget, type ShapeEllipseMidPointTarget, type ShapeHoverGeometry, type ShapeVertexTarget, type SimpleShape, type Size, type TextData, type TextDeletedEvent, type TextLayout, type TextLayoutBoundsMode, type TextLayoutOverflowMode, type TextLayoutWrapMode, type TextSelectedEvent, type TextUpdatedEvent, type VertexHoverControl, createDiagramEditor, createDiagramEngine };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orcasvn-react-diagrams",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "dependencies": {
5
5
  "eventemitter3": "^5.0.1",
6
6
  "flatten-js": "^0.6.9",
@@ -57,15 +57,16 @@
57
57
  "./ai-manifest": "./ai/manifest.json",
58
58
  "./package.json": "./package.json"
59
59
  },
60
- "files": [
61
- "dist",
62
- "src/examples",
63
- "src/displaybox/demos",
64
- "src/displaybox/types.ts",
65
- "README.md",
66
- "docs",
67
- "ai"
68
- ],
60
+ "files": [
61
+ "dist",
62
+ "src/examples",
63
+ "src/displaybox/demos",
64
+ "src/displaybox/types.ts",
65
+ "README.md",
66
+ "CHANGELOG.md",
67
+ "docs",
68
+ "ai"
69
+ ],
69
70
  "types": "dist/index.d.ts",
70
71
  "devDependencies": {
71
72
  "@rollup/plugin-commonjs": "^26.0.1",
@@ -0,0 +1,269 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { OverlayShapeHandle, PortData } from '../../api';
3
+ import DisplayBoxControls from '../DisplayBoxControls';
4
+ import DisplayBoxStage from '../DisplayBoxStage';
5
+ import useDemoControls from '../useDemoControls';
6
+ import useDemoEditor from '../useDemoEditor';
7
+ import useOffsetSequence from '../useOffsetSequence';
8
+ import type { DemoActionHelpers } from '../types';
9
+ import {
10
+ asymmetricPortDefaultVariantId,
11
+ asymmetricPortMultiAnchorDemoConfig,
12
+ asymmetricPortShapeVariants,
13
+ createAsymmetricPortMultiAnchorState,
14
+ multiAnchorExternalPortId,
15
+ multiAnchorHostId,
16
+ resolveAsymmetricPortShapeVariant,
17
+ } from './asymmetricPortMultiAnchorDemo';
18
+
19
+ type DemoSide = 'top' | 'right' | 'bottom' | 'left';
20
+
21
+ const sidePositions: Record<DemoSide, { x: number; y: number }> = {
22
+ top: { x: 112, y: 0 },
23
+ right: { x: 280, y: 68 },
24
+ bottom: { x: 182, y: 180 },
25
+ left: { x: 0, y: 130 },
26
+ };
27
+
28
+ const sideLabels: DemoSide[] = ['top', 'right', 'bottom', 'left'];
29
+ const resolvePortSide = (port: PortData, host: { size: { width: number; height: number } }): DemoSide | null => {
30
+ if (Math.abs(port.position.x) <= 0.001) return 'left';
31
+ if (Math.abs(port.position.x - host.size.width) <= 0.001) return 'right';
32
+ if (Math.abs(port.position.y) <= 0.001) return 'top';
33
+ if (Math.abs(port.position.y - host.size.height) <= 0.001) return 'bottom';
34
+ return null;
35
+ };
36
+
37
+ const sideToVector = (side: DemoSide): { x: number; y: number } => {
38
+ if (side === 'left') return { x: -1, y: 0 };
39
+ if (side === 'right') return { x: 1, y: 0 };
40
+ if (side === 'top') return { x: 0, y: -1 };
41
+ return { x: 0, y: 1 };
42
+ };
43
+
44
+ const AsymmetricPortMultiAnchorDemo = () => {
45
+ const demo = asymmetricPortMultiAnchorDemoConfig;
46
+ const [showLegacyComparison, setShowLegacyComparison] = useState(true);
47
+ const [showExpectedAnchors, setShowExpectedAnchors] = useState(false);
48
+ const [selectedVariantId, setSelectedVariantId] = useState(asymmetricPortDefaultVariantId);
49
+ const overlayHandlesRef = useRef<OverlayShapeHandle[]>([]);
50
+ const selectedVariant = useMemo(
51
+ () => resolveAsymmetricPortShapeVariant(selectedVariantId),
52
+ [selectedVariantId],
53
+ );
54
+ const attachDistance = selectedVariant.externalLinkAttachPoint.x - selectedVariant.placementPoint.x;
55
+ const createState = useCallback(
56
+ () => createAsymmetricPortMultiAnchorState(showLegacyComparison, selectedVariantId),
57
+ [showLegacyComparison, selectedVariantId],
58
+ );
59
+
60
+ const { containerRef, editorRef, diagramState, selection, snapEnabled, setSnapEnabled } = useDemoEditor({
61
+ createState,
62
+ elementShapes: demo.elementShapes,
63
+ portShapes: demo.portShapes,
64
+ });
65
+
66
+ const nextOffset = useOffsetSequence();
67
+ const actionHelpers: DemoActionHelpers = useMemo(() => ({ nextOffset }), [nextOffset]);
68
+ const controls = useDemoControls({
69
+ demo,
70
+ editorRef,
71
+ diagramState,
72
+ selection,
73
+ snapEnabled,
74
+ setSnapEnabled,
75
+ actionHelpers,
76
+ });
77
+
78
+ useEffect(() => {
79
+ const editor = editorRef.current;
80
+ if (!editor) return;
81
+ asymmetricPortShapeVariants.forEach((variant) => {
82
+ editor.registerShape({
83
+ id: variant.shapeId,
84
+ baseRotation: 90,
85
+ svgPath: variant.svgPath,
86
+ svgSize: variant.svgSize,
87
+ });
88
+ });
89
+ editor.rerouteAllLinks();
90
+ }, [editorRef, showLegacyComparison, selectedVariantId]);
91
+
92
+ useEffect(() => {
93
+ const editor = editorRef.current;
94
+ const state = diagramState;
95
+
96
+ overlayHandlesRef.current.forEach((handle) => handle.destroy());
97
+ overlayHandlesRef.current = [];
98
+
99
+ if (!showExpectedAnchors || !editor || !state) return;
100
+
101
+ const hosts = new Map(state.elements.map((element) => [element.id, element]));
102
+ const debugPorts = state.ports.filter((port) => port.id.startsWith('multi-anchor-port-'));
103
+
104
+ debugPorts.forEach((port) => {
105
+ const host = hosts.get(port.elementId);
106
+ if (!host) return;
107
+ const side = resolvePortSide(port, host);
108
+ if (!side) return;
109
+ const vector = sideToVector(side);
110
+ const placementWorld = {
111
+ x: host.position.x + port.position.x,
112
+ y: host.position.y + port.position.y,
113
+ };
114
+ overlayHandlesRef.current.push(
115
+ editor.createOverlayShape({
116
+ shapeId: 'default',
117
+ position: placementWorld,
118
+ size: { width: 8, height: 8 },
119
+ anchorCenter: true,
120
+ style: {
121
+ fill: '#ffffff',
122
+ stroke: '#d97706',
123
+ strokeWidth: 2,
124
+ cornerRadius: 1,
125
+ opacity: 0.9,
126
+ },
127
+ }),
128
+ );
129
+
130
+ if (port.id === multiAnchorExternalPortId || port.id.endsWith('-port-top') || port.id.endsWith('-port-bottom')) {
131
+ overlayHandlesRef.current.push(
132
+ editor.createOverlayShape({
133
+ shapeId: 'port-dark',
134
+ position: {
135
+ x: placementWorld.x + vector.x * attachDistance,
136
+ y: placementWorld.y + vector.y * attachDistance,
137
+ },
138
+ size: { width: 8, height: 8 },
139
+ anchorCenter: true,
140
+ style: {
141
+ fill: '#dc2626',
142
+ stroke: '#dc2626',
143
+ opacity: 0.85,
144
+ },
145
+ }),
146
+ );
147
+ }
148
+ });
149
+
150
+ return () => {
151
+ overlayHandlesRef.current.forEach((handle) => handle.destroy());
152
+ overlayHandlesRef.current = [];
153
+ };
154
+ }, [diagramState, editorRef, showExpectedAnchors]);
155
+
156
+ const moveExternalPortToSide = (side: DemoSide) => {
157
+ const editor = editorRef.current;
158
+ const host = diagramState?.elements.find((element) => element.id === multiAnchorHostId);
159
+ if (!editor || !host) return;
160
+ const local = sidePositions[side];
161
+ editor.movePortTo(multiAnchorExternalPortId, host.position.x + local.x, host.position.y + local.y);
162
+ editor.setSelection([multiAnchorExternalPortId]);
163
+ };
164
+
165
+ return (
166
+ <section>
167
+ <div style={{ marginBottom: 12 }}>
168
+ <h2 style={{ marginTop: 0, marginBottom: 4 }}>{demo.title}</h2>
169
+ <p style={{ marginTop: 0, marginBottom: 8 }}>{demo.description}</p>
170
+ <p style={{ marginTop: 0, marginBottom: 0, fontSize: 13, color: '#475569' }}>
171
+ The custom `svgPath` glyph is intentionally non-centered. The multi-anchor row keeps the square pivot pinned to the
172
+ border while links use the circle for external targets and the square pivot for parent-child internal links.
173
+ </p>
174
+ <p style={{ marginTop: 8, marginBottom: 0, fontSize: 13, color: '#475569' }}>
175
+ Active glyph variant: <strong>{selectedVariant.label}</strong> ({selectedVariant.description})
176
+ </p>
177
+ </div>
178
+ <DisplayBoxControls
179
+ actions={demo.actions}
180
+ snapEnabled={controls.snapEnabled}
181
+ selectedLinkRouting={controls.selectedLinkRouting}
182
+ canToggleLinkRouting={controls.canToggleLinkRouting}
183
+ onReload={controls.handleReload}
184
+ onZoomIn={controls.handleZoomIn}
185
+ onZoomOut={controls.handleZoomOut}
186
+ onResetViewport={controls.handleResetViewport}
187
+ onToggleSnap={controls.handleToggleSnap}
188
+ onManualRender={controls.handleManualRender}
189
+ onToggleLinkRouting={controls.handleToggleLinkRouting}
190
+ onAction={controls.handleAction}
191
+ onExportImage={controls.handleExportImage}
192
+ onClearExportPreview={controls.handleClearExportPreview}
193
+ exportPreviewDataUrl={controls.exportPreviewDataUrl}
194
+ exportError={controls.exportError}
195
+ />
196
+ <div
197
+ style={{
198
+ display: 'grid',
199
+ gridTemplateColumns: 'minmax(0, 1fr) auto',
200
+ gap: 12,
201
+ alignItems: 'start',
202
+ marginBottom: 12,
203
+ padding: 12,
204
+ border: '1px solid #d9e2f0',
205
+ borderRadius: 8,
206
+ background: '#f8fbff',
207
+ }}
208
+ >
209
+ <div style={{ display: 'grid', gap: 8 }}>
210
+ <label htmlFor="legacy-comparison-toggle" style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600 }}>
211
+ <input
212
+ id="legacy-comparison-toggle"
213
+ type="checkbox"
214
+ checked={showLegacyComparison}
215
+ onChange={(event) => setShowLegacyComparison(event.target.checked)}
216
+ />
217
+ Show legacy/default comparison
218
+ </label>
219
+ <label htmlFor="expected-anchor-toggle" style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600 }}>
220
+ <input
221
+ id="expected-anchor-toggle"
222
+ type="checkbox"
223
+ checked={showExpectedAnchors}
224
+ onChange={(event) => setShowExpectedAnchors(event.target.checked)}
225
+ />
226
+ Show expected anchor markers
227
+ </label>
228
+ <label htmlFor="asymmetric-variant-select" style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600 }}>
229
+ Port glyph variant
230
+ <select
231
+ id="asymmetric-variant-select"
232
+ value={selectedVariantId}
233
+ onChange={(event) => setSelectedVariantId(event.target.value)}
234
+ >
235
+ {asymmetricPortShapeVariants.map((variant) => (
236
+ <option key={variant.id} value={variant.id}>
237
+ {variant.label}
238
+ </option>
239
+ ))}
240
+ </select>
241
+ </label>
242
+ <div style={{ fontSize: 13, color: '#334155' }}>
243
+ Drag any asymmetric port around the host border or use the side controls to verify top/right/bottom/left orientation.
244
+ </div>
245
+ <div style={{ fontSize: 13, color: '#334155' }}>
246
+ Quick visual check: the multi-anchor row should keep the square touching the border; the legacy row may shift because
247
+ it has no explicit placement or pivot anchors.
248
+ </div>
249
+ <div style={{ fontSize: 13, color: '#334155' }}>
250
+ Debug markers: amber square = expected border placement point, red dot = expected external attach point.
251
+ </div>
252
+ </div>
253
+ <div style={{ display: 'grid', gap: 8 }}>
254
+ <div style={{ fontSize: 12, fontWeight: 600, color: '#1e293b' }}>Move the external demo port</div>
255
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' }}>
256
+ {sideLabels.map((side) => (
257
+ <button key={side} type="button" onClick={() => moveExternalPortToSide(side)} style={{ padding: '6px 10px' }}>
258
+ External → {side[0].toUpperCase() + side.slice(1)}
259
+ </button>
260
+ ))}
261
+ </div>
262
+ </div>
263
+ </div>
264
+ <DisplayBoxStage containerRef={containerRef} />
265
+ </section>
266
+ );
267
+ };
268
+
269
+ export default AsymmetricPortMultiAnchorDemo;
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useMemo, useState } from 'react';
2
- import type { ElementLayout, ElementLayoutMode } from '../../api';
2
+ import type { ElementLayout, ElementLayoutLabelReservedSpaceMode, ElementLayoutMode } from '../../api';
3
3
  import { createId } from '../../utils/ids';
4
4
  import DisplayBoxControls from '../DisplayBoxControls';
5
5
  import DisplayBoxStage from '../DisplayBoxStage';
@@ -10,12 +10,15 @@ import type { DemoActionHelpers } from '../types';
10
10
  import { autoLayoutDemoConfig } from './autoLayoutDemo';
11
11
  import { gridStageStyle } from './shared';
12
12
 
13
- const parentOptions = [
14
- { id: 'layout-row', label: 'Horizontal layout' },
15
- { id: 'layout-column', label: 'Vertical layout' },
16
- { id: 'layout-nested', label: 'Nested layout' },
17
- { id: 'layout-manual', label: 'Manual (compare)' },
18
- ];
13
+ const parentOptions = [
14
+ { id: 'layout-row', label: 'Horizontal layout' },
15
+ { id: 'layout-column', label: 'Vertical layout' },
16
+ { id: 'layout-nested', label: 'Nested layout' },
17
+ { id: 'layout-manual', label: 'Manual (compare)' },
18
+ ];
19
+ const shortLabel = 'Parent label lane demo';
20
+ const longLabel =
21
+ 'Parent label lane demo with longer content to increase flexible reserved space and push children downward.';
19
22
 
20
23
  const AutoLayoutDemo = () => {
21
24
  const demo = autoLayoutDemoConfig;
@@ -49,6 +52,10 @@ const AutoLayoutDemo = () => {
49
52
  const [childFitMinHeight, setChildFitMinHeight] = useState<number | ''>('');
50
53
  const [childFitMaxWidth, setChildFitMaxWidth] = useState<number | ''>('');
51
54
  const [childFitMaxHeight, setChildFitMaxHeight] = useState<number | ''>('');
55
+ const [labelReservedMode, setLabelReservedMode] = useState<ElementLayoutLabelReservedSpaceMode>('none');
56
+ const [labelReservedFixedSize, setLabelReservedFixedSize] = useState<number>(32);
57
+ const [labelReservedMinSize, setLabelReservedMinSize] = useState<number | ''>('');
58
+ const [labelReservedMaxSize, setLabelReservedMaxSize] = useState<number | ''>('');
52
59
  const [lastTrigger, setLastTrigger] = useState<string>('None yet');
53
60
 
54
61
  useEffect(() => {
@@ -133,6 +140,10 @@ const AutoLayoutDemo = () => {
133
140
  setChildFitMinHeight(layout?.childFitMinSize?.height ?? '');
134
141
  setChildFitMaxWidth(layout?.childFitMaxSize?.width ?? '');
135
142
  setChildFitMaxHeight(layout?.childFitMaxSize?.height ?? '');
143
+ setLabelReservedMode(layout?.labelReservedSpace?.mode ?? 'none');
144
+ setLabelReservedFixedSize(layout?.labelReservedSpace?.size ?? 32);
145
+ setLabelReservedMinSize(layout?.labelReservedSpace?.minSize ?? '');
146
+ setLabelReservedMaxSize(layout?.labelReservedSpace?.maxSize ?? '');
136
147
  }, [diagramState, targetId]);
137
148
 
138
149
  const childOrder = useMemo(() => {
@@ -161,7 +172,7 @@ const AutoLayoutDemo = () => {
161
172
  });
162
173
  }, [diagramState]);
163
174
 
164
- const handleApplyLayout = () => {
175
+ const handleApplyLayout = () => {
165
176
  const editor = editorRef.current;
166
177
  if (!editor) return;
167
178
  if (!targetElement) return;
@@ -179,6 +190,16 @@ const AutoLayoutDemo = () => {
179
190
  ...(childFitMaxWidth === '' ? {} : { width: childFitMaxWidth }),
180
191
  ...(childFitMaxHeight === '' ? {} : { height: childFitMaxHeight }),
181
192
  };
193
+ const labelReservedSpace =
194
+ mode === 'manual'
195
+ ? undefined
196
+ : {
197
+ mode: labelReservedMode,
198
+ placement: 'top' as const,
199
+ ...(labelReservedMode === 'fixed' ? { size: labelReservedFixedSize } : {}),
200
+ ...(labelReservedMinSize === '' ? {} : { minSize: labelReservedMinSize }),
201
+ ...(labelReservedMaxSize === '' ? {} : { maxSize: labelReservedMaxSize }),
202
+ };
182
203
  const layout =
183
204
  mode === 'manual'
184
205
  ? { mode: 'manual' as const }
@@ -191,10 +212,20 @@ const AutoLayoutDemo = () => {
191
212
  childFitCrossAxis,
192
213
  childFitMinSize,
193
214
  childFitMaxSize,
215
+ labelReservedSpace,
194
216
  };
195
217
  editor.setElementLayout(targetElement.id, layout);
196
218
  setLastTrigger(`layout applied (${mode})`);
197
219
  };
220
+
221
+ const handleSetLabelContent = (content: string) => {
222
+ const editor = editorRef.current;
223
+ if (!editor || !diagramState || !targetElement) return;
224
+ const label = diagramState.texts.find((text) => text.ownerId === targetElement.id);
225
+ if (!label) return;
226
+ editor.updateText(label.id, content);
227
+ setLastTrigger('label updated');
228
+ };
198
229
 
199
230
  const handleAddChild = () => {
200
231
  const editor = editorRef.current;
@@ -250,6 +281,10 @@ const AutoLayoutDemo = () => {
250
281
  onManualRender={controls.handleManualRender}
251
282
  onToggleLinkRouting={controls.handleToggleLinkRouting}
252
283
  onAction={controls.handleAction}
284
+ onExportImage={controls.handleExportImage}
285
+ onClearExportPreview={controls.handleClearExportPreview}
286
+ exportPreviewDataUrl={controls.exportPreviewDataUrl}
287
+ exportError={controls.exportError}
253
288
  />
254
289
 
255
290
  <div style={{ display: 'grid', gap: 12, marginBottom: 12 }}>
@@ -400,6 +435,55 @@ const AutoLayoutDemo = () => {
400
435
  />
401
436
  </div>
402
437
 
438
+ <label htmlFor="label-reserved-mode-select" style={{ fontWeight: 600 }}>
439
+ Label lane
440
+ </label>
441
+ <select
442
+ id="label-reserved-mode-select"
443
+ value={labelReservedMode}
444
+ onChange={(event) => setLabelReservedMode(event.target.value as ElementLayoutLabelReservedSpaceMode)}
445
+ style={{ padding: '6px 10px', minWidth: 120 }}
446
+ disabled={mode === 'manual'}
447
+ >
448
+ <option value="none">None</option>
449
+ <option value="fixed">Fixed</option>
450
+ <option value="flexible">Flexible</option>
451
+ </select>
452
+
453
+ <label htmlFor="label-reserved-fixed-input" style={{ fontWeight: 600 }}>
454
+ Lane fixed/min/max
455
+ </label>
456
+ <div style={{ display: 'inline-flex', gap: 6 }}>
457
+ <input
458
+ id="label-reserved-fixed-input"
459
+ type="number"
460
+ value={labelReservedFixedSize}
461
+ onChange={(event) => setLabelReservedFixedSize(Math.max(0, Number(event.target.value) || 0))}
462
+ style={{ width: 70, padding: '6px 8px' }}
463
+ min={0}
464
+ placeholder="fixed"
465
+ disabled={mode === 'manual'}
466
+ />
467
+ <input
468
+ type="number"
469
+ value={labelReservedMinSize}
470
+ onChange={(event) => setLabelReservedMinSize(event.target.value === '' ? '' : Math.max(0, Number(event.target.value) || 0))}
471
+ style={{ width: 64, padding: '6px 8px' }}
472
+ min={0}
473
+ placeholder="min"
474
+ disabled={mode === 'manual'}
475
+ />
476
+ <input
477
+ type="number"
478
+ value={labelReservedMaxSize}
479
+ onChange={(event) => setLabelReservedMaxSize(event.target.value === '' ? '' : Math.max(0, Number(event.target.value) || 0))}
480
+ style={{ width: 64, padding: '6px 8px' }}
481
+ min={0}
482
+ placeholder="max"
483
+ disabled={mode === 'manual'}
484
+ />
485
+ </div>
486
+
403
487
  <button type="button" onClick={handleApplyLayout} style={{ padding: '6px 12px', fontWeight: 600 }}>
404
488
  Apply layout
405
489
  </button>
@@ -420,11 +504,27 @@ const AutoLayoutDemo = () => {
420
504
  alignItems: 'center',
421
505
  }}
422
506
  >
423
- <span style={{ fontWeight: 600 }}>Target:</span> {targetElement ? targetElement.id : '—'}
507
+ <span style={{ fontWeight: 600 }}>Target:</span> {targetElement ? targetElement.id : '—'}
424
508
  <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#1f4d99', display: 'inline-block' }} />
425
509
  Last trigger: <strong>{lastTrigger}</strong>
426
- </span>
427
- </div>
510
+ </span>
511
+ <button
512
+ type="button"
513
+ onClick={() => handleSetLabelContent(shortLabel)}
514
+ style={{ padding: '6px 10px' }}
515
+ disabled={!targetElement || mode === 'manual'}
516
+ >
517
+ Label short
518
+ </button>
519
+ <button
520
+ type="button"
521
+ onClick={() => handleSetLabelContent(longLabel)}
522
+ style={{ padding: '6px 10px' }}
523
+ disabled={!targetElement || mode === 'manual'}
524
+ >
525
+ Label long
526
+ </button>
527
+ </div>
428
528
 
429
529
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
430
530
  <div>
@@ -447,6 +547,7 @@ const AutoLayoutDemo = () => {
447
547
  <ul style={{ marginTop: 0, paddingLeft: 18, fontSize: 13 }}>
448
548
  <li>Horizontal: try center vs bottom alignment and larger padding.</li>
449
549
  <li>Vertical: see parent grow taller as children stack.</li>
550
+ <li>Label lane: compare none/fixed/flexible and observe children start below the reserved lane.</li>
450
551
  <li>Fit main-axis distribute fills inner layout space across siblings.</li>
451
552
  <li>Fit cross-axis stretch extends children across inner cross axis.</li>
452
553
  <li>Min/max fit guards cap distributed or stretched child sizes.</li>
@@ -499,3 +600,4 @@ const AutoLayoutDemo = () => {
499
600
  };
500
601
 
501
602
  export default AutoLayoutDemo;
603
+
@@ -103,7 +103,11 @@ const DeletionEventsDemo = () => {
103
103
  onToggleSnap={controls.handleToggleSnap}
104
104
  onManualRender={controls.handleManualRender}
105
105
  onToggleLinkRouting={controls.handleToggleLinkRouting}
106
- onAction={controls.handleAction}
106
+ onAction={controls.handleAction}
107
+ onExportImage={controls.handleExportImage}
108
+ onClearExportPreview={controls.handleClearExportPreview}
109
+ exportPreviewDataUrl={controls.exportPreviewDataUrl}
110
+ exportError={controls.exportError}
107
111
  />
108
112
  <div style={{ display: 'grid', gap: 12, marginBottom: 12 }}>
109
113
  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
@@ -145,3 +149,4 @@ const DeletionEventsDemo = () => {
145
149
  };
146
150
 
147
151
  export default DeletionEventsDemo;
152
+
@@ -95,6 +95,10 @@ const EngineEventsDemo = () => {
95
95
  onManualRender={controls.handleManualRender}
96
96
  onToggleLinkRouting={controls.handleToggleLinkRouting}
97
97
  onAction={controls.handleAction}
98
+ onExportImage={controls.handleExportImage}
99
+ onClearExportPreview={controls.handleClearExportPreview}
100
+ exportPreviewDataUrl={controls.exportPreviewDataUrl}
101
+ exportError={controls.exportError}
98
102
  />
99
103
  <div style={{ display: 'grid', gap: 12, marginBottom: 12 }}>
100
104
  <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@@ -149,3 +153,4 @@ const EngineEventsDemo = () => {
149
153
  };
150
154
 
151
155
  export default EngineEventsDemo;
156
+