@graph-artifact/core 0.1.7 → 0.1.10
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.
- package/dist/ThemeContext.d.ts +5 -1
- package/dist/ThemeContext.js +17 -2
- package/dist/components/DiagramCanvas.d.ts +3 -1
- package/dist/components/DiagramCanvas.js +2 -2
- package/dist/components/GraphCanvas.d.ts +10 -0
- package/dist/components/GraphCanvas.js +219 -22
- package/dist/components/nodes/FlowNode.js +9 -6
- package/dist/components/nodes/SequenceNodes.js +2 -2
- package/dist/components/nodes/StateNode.js +9 -6
- package/dist/config.js +5 -2
- package/dist/layout/dagre/index.d.ts +4 -1
- package/dist/layout/dagre/index.js +3 -2
- package/dist/layout/edges/buildEdges.js +9 -7
- package/dist/layout/index.d.ts +4 -1
- package/dist/layout/index.js +11 -2
- package/dist/layout/sequenceLayout.js +43 -8
- package/dist/layout/subgraph.js +29 -20
- package/dist/parsers/flowchart.js +17 -8
- package/dist/theme/dark.js +2 -2
- package/dist/theme/light.js +3 -2
- package/dist/theme/types.d.ts +2 -0
- package/dist/types.d.ts +12 -0
- package/package.json +1 -1
package/dist/ThemeContext.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { ReactNode } from 'react';
|
|
|
2
2
|
import type { ThemeOverride } from './types.js';
|
|
3
3
|
import type { ThemeTokens } from './theme/index.js';
|
|
4
4
|
export interface Theme {
|
|
5
|
+
/** Theme mode used for host integration (React Flow colorMode, etc.). */
|
|
6
|
+
mode: 'dark' | 'light';
|
|
5
7
|
color: Record<string, string>;
|
|
6
8
|
font: {
|
|
7
9
|
family: string;
|
|
@@ -39,9 +41,11 @@ export interface Theme {
|
|
|
39
41
|
}
|
|
40
42
|
export declare const defaultTheme: Theme;
|
|
41
43
|
interface ThemeProviderProps {
|
|
44
|
+
/** Override the base theme used ('dark' | 'light' | custom registered theme). */
|
|
45
|
+
baseTheme?: string;
|
|
42
46
|
overrides?: ThemeOverride;
|
|
43
47
|
children: ReactNode;
|
|
44
48
|
}
|
|
45
|
-
export declare function ThemeProvider({ overrides, children }: ThemeProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
49
|
+
export declare function ThemeProvider({ baseTheme, overrides, children }: ThemeProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
46
50
|
export declare function useTheme(): Theme;
|
|
47
51
|
export {};
|
package/dist/ThemeContext.js
CHANGED
|
@@ -5,6 +5,8 @@ import { getThemeTokens } from './theme/index.js';
|
|
|
5
5
|
// ─── Build Theme from Tokens ──────────────────────────────────────────────────
|
|
6
6
|
function buildTheme(tokens) {
|
|
7
7
|
return {
|
|
8
|
+
// Most consumers only ship dark + light. Treat unknown themes as dark by default.
|
|
9
|
+
mode: tokens.color.darkBg2 === '#ffffff' ? 'light' : 'dark',
|
|
8
10
|
color: { ...tokens.color },
|
|
9
11
|
font: {
|
|
10
12
|
family: tokens.font.family,
|
|
@@ -72,8 +74,21 @@ function resolveTheme(instanceOverrides) {
|
|
|
72
74
|
}
|
|
73
75
|
// ─── Context & Provider ───────────────────────────────────────────────────────
|
|
74
76
|
const ThemeContext = createContext(defaultTheme);
|
|
75
|
-
export function ThemeProvider({ overrides, children }) {
|
|
76
|
-
const theme = useMemo(() =>
|
|
77
|
+
export function ThemeProvider({ baseTheme, overrides, children }) {
|
|
78
|
+
const theme = useMemo(() => {
|
|
79
|
+
if (!baseTheme)
|
|
80
|
+
return resolveTheme(overrides);
|
|
81
|
+
const tokens = getThemeTokens(baseTheme);
|
|
82
|
+
let resolved = buildTheme(tokens);
|
|
83
|
+
const { theme: configOverrides } = getConfig();
|
|
84
|
+
if (configOverrides && Object.keys(configOverrides).length > 0) {
|
|
85
|
+
resolved = deepMerge(resolved, configOverrides);
|
|
86
|
+
}
|
|
87
|
+
if (overrides && Object.keys(overrides).length > 0) {
|
|
88
|
+
resolved = deepMerge(resolved, overrides);
|
|
89
|
+
}
|
|
90
|
+
return resolved;
|
|
91
|
+
}, [baseTheme, overrides]);
|
|
77
92
|
return _jsx(ThemeContext.Provider, { value: theme, children: children });
|
|
78
93
|
}
|
|
79
94
|
export function useTheme() {
|
|
@@ -6,6 +6,8 @@ interface DiagramCanvasProps {
|
|
|
6
6
|
onNodeClick: (nodeId: string, nodeLabel: string) => void;
|
|
7
7
|
canvasOverrides?: Partial<GraphConfig['canvas']>;
|
|
8
8
|
hideAttribution?: boolean;
|
|
9
|
+
relayoutOnMeasure?: boolean;
|
|
10
|
+
onLayoutStable?: () => void;
|
|
9
11
|
}
|
|
10
|
-
export declare function DiagramCanvas({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution }: DiagramCanvasProps): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
export declare function DiagramCanvas({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution, relayoutOnMeasure, onLayoutStable }: DiagramCanvasProps): import("react/jsx-runtime").JSX.Element;
|
|
11
13
|
export {};
|
|
@@ -11,9 +11,9 @@ function UnsupportedCustomDiagram({ parsed }) {
|
|
|
11
11
|
fontSize: 14,
|
|
12
12
|
}, children: `Diagram type '${parsed.diagramType}' is detected but custom rendering is not implemented yet.` }));
|
|
13
13
|
}
|
|
14
|
-
export function DiagramCanvas({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution }) {
|
|
14
|
+
export function DiagramCanvas({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution, relayoutOnMeasure, onLayoutStable }) {
|
|
15
15
|
if (parsed.kind === 'graph') {
|
|
16
|
-
return (_jsx(GraphCanvas, { parsed: parsed, metadata: metadata, onNodeClick: onNodeClick, canvasOverrides: canvasOverrides, hideAttribution: hideAttribution }));
|
|
16
|
+
return (_jsx(GraphCanvas, { parsed: parsed, metadata: metadata, onNodeClick: onNodeClick, canvasOverrides: canvasOverrides, hideAttribution: hideAttribution, relayoutOnMeasure: relayoutOnMeasure, onLayoutStable: onLayoutStable }));
|
|
17
17
|
}
|
|
18
18
|
return _jsx(UnsupportedCustomDiagram, { parsed: parsed });
|
|
19
19
|
}
|
|
@@ -7,6 +7,16 @@ interface GraphCanvasProps {
|
|
|
7
7
|
onNodeClick: (nodeId: string, nodeLabel: string) => void;
|
|
8
8
|
canvasOverrides?: Partial<GraphConfig['canvas']>;
|
|
9
9
|
hideAttribution?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* If true, reruns dagre after React Flow measures node DOM sizes so
|
|
12
|
+
* layout reflects actual rendered content (auto-sized text/markdown).
|
|
13
|
+
*/
|
|
14
|
+
relayoutOnMeasure?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Called once when layout is stable and fitView has been applied (if enabled).
|
|
17
|
+
* Useful for host apps that need to know when the diagram is "ready".
|
|
18
|
+
*/
|
|
19
|
+
onLayoutStable?: () => void;
|
|
10
20
|
}
|
|
11
21
|
export declare function GraphCanvas(props: GraphCanvasProps): import("react/jsx-runtime").JSX.Element;
|
|
12
22
|
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useMemo, useCallback, useState, useEffect, useRef } from 'react';
|
|
3
|
-
import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, useNodesInitialized, useStore, useStoreApi, } from '@xyflow/react';
|
|
3
|
+
import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, useReactFlow, useNodesInitialized, useStore, useStoreApi, } from '@xyflow/react';
|
|
4
4
|
import '@xyflow/react/dist/style.css';
|
|
5
5
|
import { layoutNodes, buildLayoutEdges } from '../layout/index.js';
|
|
6
6
|
import { getConfig, getNodeTypes } from '../config.js';
|
|
@@ -8,12 +8,13 @@ import { useTheme } from '../ThemeContext.js';
|
|
|
8
8
|
import { RoutedEdge } from './edges/RoutedEdge.js';
|
|
9
9
|
// Stable reference — must be outside the component to avoid infinite re-renders
|
|
10
10
|
const edgeTypes = { routed: RoutedEdge };
|
|
11
|
-
function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution }) {
|
|
11
|
+
function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution, relayoutOnMeasure, onLayoutStable, }) {
|
|
12
12
|
const theme = useTheme();
|
|
13
13
|
const { canvas: baseCanvas } = getConfig();
|
|
14
14
|
const canvas = useMemo(() => ({ ...baseCanvas, ...(canvasOverrides ?? {}) }), [baseCanvas, canvasOverrides]);
|
|
15
15
|
const resolvedNodeTypes = getNodeTypes();
|
|
16
16
|
const storeApi = useStoreApi();
|
|
17
|
+
const reactFlow = useReactFlow();
|
|
17
18
|
const nodesInitialized = useNodesInitialized();
|
|
18
19
|
const measuredSignature = useStore((s) => Array.from(s.nodeLookup.values()).reduce((hash, n) => {
|
|
19
20
|
const w = Math.round(n.measured?.width ?? n.width ?? 0);
|
|
@@ -26,8 +27,15 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
26
27
|
hash = ((hash << 5) - hash + h) | 0;
|
|
27
28
|
return hash;
|
|
28
29
|
}, 0));
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
const defaultRelayout = parsed.diagramType === 'flowchart' ||
|
|
31
|
+
parsed.diagramType === 'er' ||
|
|
32
|
+
parsed.diagramType === 'class' ||
|
|
33
|
+
parsed.diagramType === 'gitGraph';
|
|
34
|
+
const shouldRelayoutOnMeasure = Boolean(relayoutOnMeasure ?? defaultRelayout) &&
|
|
35
|
+
parsed.diagramType !== 'sequence' &&
|
|
36
|
+
parsed.diagramType !== 'mindmap';
|
|
37
|
+
// Initial layout (dagre estimates).
|
|
38
|
+
const initialLayoutState = useMemo(() => {
|
|
31
39
|
try {
|
|
32
40
|
return {
|
|
33
41
|
result: layoutNodes(parsed, { theme, metadata }),
|
|
@@ -39,21 +47,134 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
39
47
|
return { result: null, error: message };
|
|
40
48
|
}
|
|
41
49
|
}, [parsed, theme, metadata]);
|
|
42
|
-
const
|
|
43
|
-
const nodes =
|
|
44
|
-
const context =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
const [layoutError, setLayoutError] = useState(initialLayoutState.error);
|
|
51
|
+
const [nodes, setNodes] = useState(initialLayoutState.result?.nodes ?? []);
|
|
52
|
+
const [context, setContext] = useState(initialLayoutState.result?.context ?? undefined);
|
|
53
|
+
const [edges, setEdges] = useState(() => {
|
|
54
|
+
const r = initialLayoutState.result;
|
|
55
|
+
const c = r?.context ?? undefined;
|
|
56
|
+
return c
|
|
57
|
+
? buildLayoutEdges(c, theme)
|
|
58
|
+
: (r?.edges ?? []);
|
|
59
|
+
});
|
|
51
60
|
const contextRef = useRef(context);
|
|
52
61
|
contextRef.current = context;
|
|
53
|
-
//
|
|
62
|
+
// Apply fitView once per diagram after node measurement stabilizes.
|
|
63
|
+
const didInitialFit = useRef(false);
|
|
64
|
+
const didStableCallback = useRef(false);
|
|
54
65
|
useEffect(() => {
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
didInitialFit.current = false;
|
|
67
|
+
didStableCallback.current = false;
|
|
68
|
+
}, [parsed]);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!nodesInitialized)
|
|
71
|
+
return;
|
|
72
|
+
if (layoutError)
|
|
73
|
+
return;
|
|
74
|
+
if (nodes.length === 0)
|
|
75
|
+
return;
|
|
76
|
+
// Wait until after relayout pass completes (if enabled).
|
|
77
|
+
if (shouldRelayoutOnMeasure && relayoutPass.current > 0 && relayoutPass.current < 2) {
|
|
78
|
+
// allow another pass to settle
|
|
79
|
+
}
|
|
80
|
+
// Always try to fitView once if enabled; React Flow's declarative fitView
|
|
81
|
+
// runs early and can miss when sizes change post-measurement.
|
|
82
|
+
if (canvas.fitView && !didInitialFit.current) {
|
|
83
|
+
didInitialFit.current = true;
|
|
84
|
+
// Double rAF to ensure DOM + measured sizes are committed.
|
|
85
|
+
requestAnimationFrame(() => {
|
|
86
|
+
requestAnimationFrame(() => {
|
|
87
|
+
try {
|
|
88
|
+
reactFlow.fitView({ padding: 0.2, maxZoom: 1, duration: 250 });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// ignore fitView errors (unmounted / not ready)
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (onLayoutStable && !didStableCallback.current) {
|
|
97
|
+
didStableCallback.current = true;
|
|
98
|
+
onLayoutStable();
|
|
99
|
+
}
|
|
100
|
+
}, [
|
|
101
|
+
nodesInitialized,
|
|
102
|
+
measuredSignature,
|
|
103
|
+
layoutError,
|
|
104
|
+
nodes.length,
|
|
105
|
+
canvas.fitView,
|
|
106
|
+
reactFlow,
|
|
107
|
+
onLayoutStable,
|
|
108
|
+
shouldRelayoutOnMeasure,
|
|
109
|
+
]);
|
|
110
|
+
// Reset to initial layout when input diagram changes.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
setLayoutError(initialLayoutState.error);
|
|
113
|
+
if (!initialLayoutState.result) {
|
|
114
|
+
setNodes([]);
|
|
115
|
+
setContext(undefined);
|
|
116
|
+
setEdges([]);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const r = initialLayoutState.result;
|
|
120
|
+
const c = r.context ?? undefined;
|
|
121
|
+
setNodes(r.nodes);
|
|
122
|
+
setContext(c);
|
|
123
|
+
setEdges(c ? buildLayoutEdges(c, theme) : (r.edges ?? []));
|
|
124
|
+
}, [initialLayoutState, theme]);
|
|
125
|
+
// If we enable measured relayout, allow a couple of passes until stable.
|
|
126
|
+
const relayoutPass = useRef(0);
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
relayoutPass.current = 0;
|
|
129
|
+
}, [parsed, theme, metadata, shouldRelayoutOnMeasure]);
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!shouldRelayoutOnMeasure)
|
|
132
|
+
return;
|
|
133
|
+
if (!nodesInitialized || !contextRef.current)
|
|
134
|
+
return;
|
|
135
|
+
if (relayoutPass.current >= 2)
|
|
136
|
+
return;
|
|
137
|
+
const nodeLookup = storeApi.getState().nodeLookup;
|
|
138
|
+
const overrides = new Map();
|
|
139
|
+
let anyDiff = false;
|
|
140
|
+
for (const internalNode of nodeLookup.values()) {
|
|
141
|
+
const estimated = contextRef.current.estimatedPositions.get(internalNode.id);
|
|
142
|
+
if (!estimated)
|
|
143
|
+
continue; // ignore subgraph containers and unknown nodes
|
|
144
|
+
const measuredW = internalNode.measured?.width ?? internalNode.width ?? estimated.width;
|
|
145
|
+
const measuredH = internalNode.measured?.height ?? internalNode.height ?? estimated.height;
|
|
146
|
+
if (!Number.isFinite(measuredW) || !Number.isFinite(measuredH) || measuredW <= 0 || measuredH <= 0)
|
|
147
|
+
continue;
|
|
148
|
+
overrides.set(internalNode.id, { width: measuredW, height: measuredH });
|
|
149
|
+
if (Math.abs(measuredW - estimated.width) > 2 || Math.abs(measuredH - estimated.height) > 2) {
|
|
150
|
+
anyDiff = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!anyDiff)
|
|
154
|
+
return;
|
|
155
|
+
try {
|
|
156
|
+
const next = layoutNodes(parsed, { theme, metadata }, overrides);
|
|
157
|
+
if (!next.context)
|
|
158
|
+
return; // sequence/custom returns edges directly
|
|
159
|
+
relayoutPass.current += 1;
|
|
160
|
+
setNodes(next.nodes);
|
|
161
|
+
setContext(next.context);
|
|
162
|
+
setEdges(buildLayoutEdges(next.context, theme));
|
|
163
|
+
setLayoutError(null);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
167
|
+
setLayoutError(message);
|
|
168
|
+
}
|
|
169
|
+
}, [
|
|
170
|
+
shouldRelayoutOnMeasure,
|
|
171
|
+
nodesInitialized,
|
|
172
|
+
measuredSignature,
|
|
173
|
+
parsed,
|
|
174
|
+
theme,
|
|
175
|
+
metadata,
|
|
176
|
+
storeApi,
|
|
177
|
+
]);
|
|
57
178
|
// Phase 2: Recompute when React Flow measurements change so intersections
|
|
58
179
|
// always use actual rendered node dimensions.
|
|
59
180
|
useEffect(() => {
|
|
@@ -61,12 +182,13 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
61
182
|
return;
|
|
62
183
|
const nodeLookup = storeApi.getState().nodeLookup;
|
|
63
184
|
const measuredPositions = new Map();
|
|
185
|
+
const subgraphIds = new Set((parsed.subgraphs ?? []).map((s) => s.id));
|
|
64
186
|
for (const internalNode of nodeLookup.values()) {
|
|
65
187
|
const estimated = contextRef.current.estimatedPositions.get(internalNode.id);
|
|
66
|
-
if (!estimated)
|
|
188
|
+
if (!estimated && !subgraphIds.has(internalNode.id))
|
|
67
189
|
continue;
|
|
68
|
-
const measuredW = internalNode.measured?.width ?? internalNode.width ?? estimated
|
|
69
|
-
const measuredH = internalNode.measured?.height ?? internalNode.height ?? estimated
|
|
190
|
+
const measuredW = internalNode.measured?.width ?? internalNode.width ?? estimated?.width ?? 0;
|
|
191
|
+
const measuredH = internalNode.measured?.height ?? internalNode.height ?? estimated?.height ?? 0;
|
|
70
192
|
const abs = internalNode.internals.positionAbsolute;
|
|
71
193
|
measuredPositions.set(internalNode.id, {
|
|
72
194
|
x: abs.x,
|
|
@@ -77,6 +199,64 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
77
199
|
}
|
|
78
200
|
setEdges(buildLayoutEdges(contextRef.current, theme, measuredPositions));
|
|
79
201
|
}, [nodesInitialized, measuredSignature, theme, storeApi]);
|
|
202
|
+
// Subgraph boxes are visual annotations; compute their bounds from measured
|
|
203
|
+
// child node DOM dimensions so they never drift due to sizing changes.
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!nodesInitialized)
|
|
206
|
+
return;
|
|
207
|
+
if (!parsed.subgraphs || parsed.subgraphs.length === 0)
|
|
208
|
+
return;
|
|
209
|
+
const { layout } = getConfig();
|
|
210
|
+
const nodeLookup = storeApi.getState().nodeLookup;
|
|
211
|
+
// Include extra pad so node shadows/rounded corners don't peek outside.
|
|
212
|
+
// We intentionally over-pad here; subgraphs are visual groupings, not tight boxes.
|
|
213
|
+
const pad = layout.subgraphPadding + 16;
|
|
214
|
+
const labelOffset = theme.nodeStyles.subgraph.labelOffset;
|
|
215
|
+
const boundsById = new Map();
|
|
216
|
+
for (const sg of parsed.subgraphs) {
|
|
217
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
218
|
+
for (const nid of sg.nodeIds) {
|
|
219
|
+
const internal = nodeLookup.get(nid);
|
|
220
|
+
if (!internal)
|
|
221
|
+
continue;
|
|
222
|
+
const abs = internal.internals.positionAbsolute;
|
|
223
|
+
const w = internal.measured?.width ?? internal.width ?? 0;
|
|
224
|
+
const h = internal.measured?.height ?? internal.height ?? 0;
|
|
225
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0)
|
|
226
|
+
continue;
|
|
227
|
+
minX = Math.min(minX, abs.x);
|
|
228
|
+
minY = Math.min(minY, abs.y);
|
|
229
|
+
maxX = Math.max(maxX, abs.x + w);
|
|
230
|
+
maxY = Math.max(maxY, abs.y + h);
|
|
231
|
+
}
|
|
232
|
+
if (!isFinite(minX))
|
|
233
|
+
continue;
|
|
234
|
+
const next = {
|
|
235
|
+
x: minX - pad,
|
|
236
|
+
y: minY - pad - labelOffset,
|
|
237
|
+
width: maxX - minX + pad * 2,
|
|
238
|
+
height: maxY - minY + pad * 2 + labelOffset,
|
|
239
|
+
};
|
|
240
|
+
boundsById.set(sg.id, next);
|
|
241
|
+
}
|
|
242
|
+
if (boundsById.size === 0)
|
|
243
|
+
return;
|
|
244
|
+
setNodes((prev) => prev.map((n) => {
|
|
245
|
+
const b = boundsById.get(n.id);
|
|
246
|
+
if (b) {
|
|
247
|
+
return {
|
|
248
|
+
...n,
|
|
249
|
+
position: { x: b.x, y: b.y },
|
|
250
|
+
style: {
|
|
251
|
+
...(n.style ?? {}),
|
|
252
|
+
width: b.width,
|
|
253
|
+
height: b.height,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return n;
|
|
258
|
+
}));
|
|
259
|
+
}, [nodesInitialized, measuredSignature, parsed.subgraphs, storeApi, theme.nodeStyles.subgraph.labelOffset]);
|
|
80
260
|
const handleNodeClick = useCallback((_event, node) => {
|
|
81
261
|
const label = String(node.data?.label ?? node.id);
|
|
82
262
|
onNodeClick(node.id, label);
|
|
@@ -84,7 +264,7 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
84
264
|
const defaultEdgeOptions = useMemo(() => ({ animated: false, style: theme.edgeDefaults.style }), [theme.edgeDefaults.style]);
|
|
85
265
|
const miniMapNodeColor = useCallback(() => theme.color.orange1, [theme.color.orange1]);
|
|
86
266
|
const miniMapMaskColor = useMemo(() => `${theme.color.gray1}cc`, [theme.color.gray1]);
|
|
87
|
-
if (
|
|
267
|
+
if (layoutError) {
|
|
88
268
|
return (_jsx("div", { style: {
|
|
89
269
|
width: '100%',
|
|
90
270
|
height: '100%',
|
|
@@ -96,9 +276,26 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
96
276
|
fontFamily: theme.font.family,
|
|
97
277
|
fontSize: 13,
|
|
98
278
|
textAlign: 'center',
|
|
99
|
-
}, children: `Diagram syntax/layout error: ${
|
|
279
|
+
}, children: `Diagram syntax/layout error: ${layoutError}` }));
|
|
100
280
|
}
|
|
101
|
-
return (
|
|
281
|
+
return (_jsxs("div", { style: { width: '100%', height: '100%' }, children: [_jsx("style", { children: `
|
|
282
|
+
/* Theme React Flow chrome (controls/minimap) to match our tokens. */
|
|
283
|
+
.react-flow__controls-button {
|
|
284
|
+
background: ${theme.mode === 'dark' ? theme.color.darkBg3 : theme.color.white};
|
|
285
|
+
color: ${theme.mode === 'dark' ? theme.color.gray4 : theme.color.gray4};
|
|
286
|
+
border: 1px solid ${theme.color.gray2};
|
|
287
|
+
}
|
|
288
|
+
.react-flow__controls-button:hover {
|
|
289
|
+
background: ${theme.mode === 'dark' ? theme.color.gray1 : theme.color.gray1};
|
|
290
|
+
}
|
|
291
|
+
.react-flow__controls-button svg {
|
|
292
|
+
fill: currentColor;
|
|
293
|
+
}
|
|
294
|
+
.react-flow__minimap {
|
|
295
|
+
background: ${theme.mode === 'dark' ? theme.color.darkBg2 : theme.color.white};
|
|
296
|
+
border: 1px solid ${theme.color.gray2};
|
|
297
|
+
}
|
|
298
|
+
` }), _jsxs(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: resolvedNodeTypes, edgeTypes: edgeTypes, onNodeClick: handleNodeClick, fitView: canvas.fitView, minZoom: canvas.minZoom, maxZoom: canvas.maxZoom, defaultEdgeOptions: defaultEdgeOptions, zIndexMode: "manual", colorMode: theme.mode, proOptions: hideAttribution ? { hideAttribution: true } : undefined, children: [canvas.showBackground && (_jsx(Background, { color: theme.color.gray2, gap: canvas.backgroundGap, size: canvas.backgroundSize })), canvas.showControls && _jsx(Controls, {}), canvas.showMiniMap && (_jsx(MiniMap, { nodeColor: miniMapNodeColor, maskColor: miniMapMaskColor }))] })] }));
|
|
102
299
|
}
|
|
103
300
|
export function GraphCanvas(props) {
|
|
104
301
|
return (_jsx(ReactFlowProvider, { children: _jsx(GraphCanvasInner, { ...props }) }));
|
|
@@ -37,12 +37,9 @@ function htmlSubsetToMarkdown(src) {
|
|
|
37
37
|
}
|
|
38
38
|
function normalizeLabelMarkdown(label) {
|
|
39
39
|
// Mermaid treats "\n" inside labels as a hard line break.
|
|
40
|
-
// react-markdown doesn't always preserve raw newlines, so force
|
|
41
|
-
// CommonMark hard-breaks by adding two trailing spaces.
|
|
42
40
|
return htmlSubsetToMarkdown(label)
|
|
43
41
|
.replace(/\\n/g, '\n')
|
|
44
|
-
.trim()
|
|
45
|
-
.replace(/\n/g, ' \n');
|
|
42
|
+
.trim();
|
|
46
43
|
}
|
|
47
44
|
/**
|
|
48
45
|
* Generates evenly-spaced handle positions across a face.
|
|
@@ -161,13 +158,19 @@ export const FlowNode = memo(function FlowNode({ data }) {
|
|
|
161
158
|
return (_jsxs("div", { style: {
|
|
162
159
|
backgroundColor: nodeBgColor, borderRadius: br,
|
|
163
160
|
padding: `${theme.space[3]} ${theme.space[5]}`,
|
|
164
|
-
width: layoutWidth,
|
|
161
|
+
width: layoutWidth,
|
|
162
|
+
// Allow content-driven height. Dagre supplies an initial estimate;
|
|
163
|
+
// GraphCanvas can relayout using measured dimensions.
|
|
164
|
+
minHeight: layoutHeight,
|
|
165
165
|
boxSizing: 'border-box',
|
|
166
166
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
167
167
|
fontFamily: theme.font.family, cursor: 'pointer',
|
|
168
168
|
}, children: [_jsx("div", { style: {
|
|
169
169
|
color: nodeTextColor, fontSize: theme.font.size.lg, fontWeight: theme.font.weight.bold,
|
|
170
170
|
textAlign: 'center', lineHeight: theme.lineHeight.normal,
|
|
171
|
-
whiteSpace: 'normal',
|
|
171
|
+
whiteSpace: 'normal',
|
|
172
|
+
overflowWrap: 'anywhere',
|
|
173
|
+
wordBreak: 'break-word',
|
|
174
|
+
width: '100%',
|
|
172
175
|
}, children: _jsx(ReactMarkdown, { components: markdownComponents, children: labelMarkdown }) }), ChildComponent && (_jsx(ChildComponent, { nodeId: String(data.id ?? ''), label: label, componentProps: meta?.componentProps })), _jsx(NodeHandles, { fwIn: fwIn, fwOut: fwOut })] }));
|
|
173
176
|
});
|
|
@@ -80,7 +80,7 @@ export const SequenceNoteNode = memo(function SequenceNoteNode({ data }) {
|
|
|
80
80
|
const ns = theme.nodeStyles.sequence;
|
|
81
81
|
return (_jsx("div", { style: {
|
|
82
82
|
width: w,
|
|
83
|
-
backgroundColor:
|
|
83
|
+
backgroundColor: ns.note.backgroundColor,
|
|
84
84
|
border: `${ns.note.borderWidth} solid ${theme.color.gray2}`,
|
|
85
85
|
borderRadius: theme.radius.sm,
|
|
86
86
|
display: 'flex',
|
|
@@ -90,7 +90,7 @@ export const SequenceNoteNode = memo(function SequenceNoteNode({ data }) {
|
|
|
90
90
|
pointerEvents: 'none',
|
|
91
91
|
boxSizing: 'border-box',
|
|
92
92
|
}, children: _jsx("span", { style: {
|
|
93
|
-
color:
|
|
93
|
+
color: ns.note.textColor,
|
|
94
94
|
fontSize: theme.font.size.sm,
|
|
95
95
|
fontFamily: theme.font.family,
|
|
96
96
|
textAlign: 'center',
|
|
@@ -38,7 +38,7 @@ export const StateNode = memo(function StateNode({ data }) {
|
|
|
38
38
|
if (shape === 'circle') {
|
|
39
39
|
return (_jsx("div", { style: {
|
|
40
40
|
width: layoutWidth, height: layoutHeight,
|
|
41
|
-
backgroundColor: theme.color.
|
|
41
|
+
backgroundColor: theme.color.orange1, borderRadius: theme.radius.full,
|
|
42
42
|
}, children: _jsx(StateHandles, { fwIn: fwIn, fwOut: fwOut }) }));
|
|
43
43
|
}
|
|
44
44
|
// End state: bullseye (outer ring + inner filled dot).
|
|
@@ -49,23 +49,26 @@ export const StateNode = memo(function StateNode({ data }) {
|
|
|
49
49
|
width: layoutWidth, height: layoutHeight,
|
|
50
50
|
boxSizing: 'border-box',
|
|
51
51
|
borderRadius: theme.radius.full,
|
|
52
|
-
border: `${theme.borderWidth.md} solid ${theme.color.
|
|
52
|
+
border: `${theme.borderWidth.md} solid ${theme.color.orange1}`,
|
|
53
53
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
54
54
|
}, children: [_jsx("div", { style: {
|
|
55
55
|
width: innerSize, height: innerSize,
|
|
56
|
-
|
|
56
|
+
// Inner fill: dark in dark mode, dark gray in light mode.
|
|
57
|
+
backgroundColor: theme.mode === 'dark' ? theme.color.darkBg2 : theme.color.gray5,
|
|
58
|
+
borderRadius: theme.radius.full,
|
|
57
59
|
} }), _jsx(StateHandles, { fwIn: fwIn, fwOut: fwOut })] }));
|
|
58
60
|
}
|
|
59
61
|
return (_jsxs("div", { style: {
|
|
60
|
-
backgroundColor: theme.color.
|
|
61
|
-
|
|
62
|
+
backgroundColor: theme.color.orange1,
|
|
63
|
+
border: `${theme.borderWidth.sm} solid ${theme.color.orange3}`,
|
|
64
|
+
borderRadius: theme.radius.pill,
|
|
62
65
|
padding: `${theme.space[3]} ${theme.space[6]}`,
|
|
63
66
|
width: layoutWidth, height: layoutHeight,
|
|
64
67
|
boxSizing: 'border-box',
|
|
65
68
|
fontFamily: theme.font.family, cursor: 'pointer',
|
|
66
69
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
67
70
|
}, children: [_jsx("div", { style: {
|
|
68
|
-
color: theme.color.
|
|
71
|
+
color: theme.color.white, fontSize: theme.font.size.lg, fontWeight: theme.font.weight.semibold,
|
|
69
72
|
textAlign: 'center', whiteSpace: 'nowrap',
|
|
70
73
|
}, children: label }), _jsx(StateHandles, { fwIn: fwIn, fwOut: fwOut })] }));
|
|
71
74
|
});
|
package/dist/config.js
CHANGED
|
@@ -63,12 +63,15 @@ function createDefaultConfig() {
|
|
|
63
63
|
},
|
|
64
64
|
},
|
|
65
65
|
canvas: {
|
|
66
|
-
|
|
66
|
+
// Allow fitView to zoom out far enough for large diagrams.
|
|
67
|
+
minZoom: 0.02,
|
|
67
68
|
maxZoom: 2,
|
|
68
69
|
fitView: true,
|
|
69
70
|
showBackground: true,
|
|
70
71
|
showControls: true,
|
|
71
|
-
|
|
72
|
+
// MiniMap is useful in dedicated viewers but noisy in embedded contexts (chat, docs).
|
|
73
|
+
// Host apps can enable it via `canvasOverrides`.
|
|
74
|
+
showMiniMap: false,
|
|
72
75
|
backgroundGap: 20,
|
|
73
76
|
backgroundSize: 1,
|
|
74
77
|
},
|
|
@@ -18,7 +18,10 @@ export type { NodePosition, HandlePositions, EdgeWaypoints } from '../types.js';
|
|
|
18
18
|
* Back-edges route to side handles to avoid passing through intermediate nodes.
|
|
19
19
|
*/
|
|
20
20
|
export declare function directionToHandles(direction: ParsedGraph['direction']): HandlePositions;
|
|
21
|
-
export declare function createDagreGraph(parsed: ParsedGraph, layoutConfig: GraphConfig['layout']
|
|
21
|
+
export declare function createDagreGraph(parsed: ParsedGraph, layoutConfig: GraphConfig['layout'], nodeSizeOverrides?: Map<string, {
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
}>): dagre.graphlib.Graph;
|
|
22
25
|
/**
|
|
23
26
|
* Extracts top-left positions from dagre's center-based coordinates.
|
|
24
27
|
* Returns a Map of nodeId → { x, y, width, height }.
|
|
@@ -50,7 +50,7 @@ export function directionToHandles(direction) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
// ─── Dagre Graph Construction ───────────────────────────────────────────────
|
|
53
|
-
export function createDagreGraph(parsed, layoutConfig) {
|
|
53
|
+
export function createDagreGraph(parsed, layoutConfig, nodeSizeOverrides) {
|
|
54
54
|
validateParsedGraph(parsed);
|
|
55
55
|
const g = new dagre.graphlib.Graph({ multigraph: true, compound: true });
|
|
56
56
|
g.setDefaultEdgeLabel(() => ({}));
|
|
@@ -81,7 +81,8 @@ export function createDagreGraph(parsed, layoutConfig) {
|
|
|
81
81
|
ranker: 'network-simplex',
|
|
82
82
|
});
|
|
83
83
|
for (const node of parsed.nodes) {
|
|
84
|
-
const
|
|
84
|
+
const override = nodeSizeOverrides?.get(node.id);
|
|
85
|
+
const size = override ?? nodeSize(node, parsed.diagramType, layoutConfig.nodeSizing);
|
|
85
86
|
g.setNode(node.id, { width: size.width, height: size.height });
|
|
86
87
|
}
|
|
87
88
|
// Defensive: if a parser emits an edge endpoint that is missing from
|
|
@@ -118,8 +118,8 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
118
118
|
const forwardBySource = new Map();
|
|
119
119
|
const forwardByTarget = new Map();
|
|
120
120
|
parsedEdges.forEach((edge, i) => {
|
|
121
|
-
const sp = positions.get(edge.source);
|
|
122
|
-
const tp = positions.get(edge.target);
|
|
121
|
+
const sp = positions.get(edge.sourceSubgraphId ?? edge.source);
|
|
122
|
+
const tp = positions.get(edge.targetSubgraphId ?? edge.target);
|
|
123
123
|
if (!sp || !tp)
|
|
124
124
|
return;
|
|
125
125
|
const edgeClass = classifyEdge(sp, tp, direction);
|
|
@@ -149,14 +149,14 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
149
149
|
const backSourceCounters = new Map();
|
|
150
150
|
const backTargetCounters = new Map();
|
|
151
151
|
return parsedEdges.map((edge, i) => {
|
|
152
|
-
const sourcePos = positions.get(edge.source);
|
|
153
|
-
const targetPos = positions.get(edge.target);
|
|
152
|
+
const sourcePos = positions.get(edge.sourceSubgraphId ?? edge.source);
|
|
153
|
+
const targetPos = positions.get(edge.targetSubgraphId ?? edge.target);
|
|
154
154
|
const edgeClass = sourcePos && targetPos
|
|
155
155
|
? classifyEdge(sourcePos, targetPos, direction)
|
|
156
156
|
: 'forward';
|
|
157
157
|
const waypoints = edgeWaypoints?.[i];
|
|
158
|
-
const sourceShape = nodeShapes?.get(edge.source) ?? 'rect';
|
|
159
|
-
const targetShape = nodeShapes?.get(edge.target) ?? 'rect';
|
|
158
|
+
const sourceShape = edge.sourceSubgraphId ? 'rect' : (nodeShapes?.get(edge.source) ?? 'rect');
|
|
159
|
+
const targetShape = edge.targetSubgraphId ? 'rect' : (nodeShapes?.get(edge.target) ?? 'rect');
|
|
160
160
|
// ── Compute path from dagre waypoints ──
|
|
161
161
|
let svgPath = '';
|
|
162
162
|
let labelPos = { x: 0, y: 0 };
|
|
@@ -371,7 +371,9 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
371
371
|
labelBgBorderRadius: theme.edgeDefaults.labelBgBorderRadius,
|
|
372
372
|
sourceHandle,
|
|
373
373
|
targetHandle,
|
|
374
|
-
|
|
374
|
+
// Keep edges above subgraph backgrounds but below regular nodes.
|
|
375
|
+
// (Regular nodes set zIndex=10; subgraphs set zIndex=0.)
|
|
376
|
+
zIndex: 1,
|
|
375
377
|
data: {
|
|
376
378
|
svgPath,
|
|
377
379
|
labelX: labelPos.x,
|
package/dist/layout/index.d.ts
CHANGED
|
@@ -43,7 +43,10 @@ export interface CustomLayoutResult {
|
|
|
43
43
|
data: ParsedCustomDiagram['data'];
|
|
44
44
|
}
|
|
45
45
|
export type LayoutResult = GraphLayoutResult | CustomLayoutResult;
|
|
46
|
-
export declare function layoutNodes(parsed: ParsedGraph, options: LayoutOptions
|
|
46
|
+
export declare function layoutNodes(parsed: ParsedGraph, options: LayoutOptions, nodeSizeOverrides?: Map<string, {
|
|
47
|
+
width: number;
|
|
48
|
+
height: number;
|
|
49
|
+
}>): {
|
|
47
50
|
nodes: Node[];
|
|
48
51
|
context: LayoutContext;
|
|
49
52
|
} | {
|
package/dist/layout/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import { computeSubgraphBounds, buildSubgraphNode } from './subgraph.js';
|
|
|
21
21
|
import { layoutSequenceDiagram } from './sequenceLayout.js';
|
|
22
22
|
import { layoutMindmapGraph } from './mindmapLayout.js';
|
|
23
23
|
// ─── Phase 1: Layout Nodes ──────────────────────────────────────────────────
|
|
24
|
-
export function layoutNodes(parsed, options) {
|
|
24
|
+
export function layoutNodes(parsed, options, nodeSizeOverrides) {
|
|
25
25
|
const { theme, metadata } = options;
|
|
26
26
|
// Mindmap uses dedicated radial layout (not dagre).
|
|
27
27
|
if (parsed.diagramType === 'mindmap') {
|
|
@@ -46,7 +46,7 @@ export function layoutNodes(parsed, options) {
|
|
|
46
46
|
}
|
|
47
47
|
const { layout } = getConfig();
|
|
48
48
|
// 1. Build dagre graph and run layout
|
|
49
|
-
const graph = createDagreGraph(parsed, layout);
|
|
49
|
+
const graph = createDagreGraph(parsed, layout, nodeSizeOverrides);
|
|
50
50
|
// 2. Extract positioned coordinates and edge waypoints
|
|
51
51
|
const positions = extractPositions(graph, parsed.nodes);
|
|
52
52
|
const edgeWaypoints = extractEdgeWaypoints(graph, parsed);
|
|
@@ -62,6 +62,7 @@ export function layoutNodes(parsed, options) {
|
|
|
62
62
|
return {
|
|
63
63
|
id: node.id,
|
|
64
64
|
position: { x: pos.x, y: pos.y },
|
|
65
|
+
zIndex: 10,
|
|
65
66
|
data: {
|
|
66
67
|
label: node.label,
|
|
67
68
|
shape: node.shape,
|
|
@@ -85,6 +86,14 @@ export function layoutNodes(parsed, options) {
|
|
|
85
86
|
const bounds = computeSubgraphBounds(subgraph, positions, layout.subgraphPadding, theme.nodeStyles.subgraph.labelOffset, graph);
|
|
86
87
|
if (!bounds)
|
|
87
88
|
continue;
|
|
89
|
+
// Make subgraph bounds available to the edge builder so edges that
|
|
90
|
+
// reference a subgraph id can attach to the subgraph boundary.
|
|
91
|
+
positions.set(subgraph.id, {
|
|
92
|
+
x: bounds.x,
|
|
93
|
+
y: bounds.y,
|
|
94
|
+
width: bounds.width,
|
|
95
|
+
height: bounds.height,
|
|
96
|
+
});
|
|
88
97
|
nodes.unshift(buildSubgraphNode(subgraph, bounds, theme));
|
|
89
98
|
}
|
|
90
99
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { MarkerType } from '@xyflow/react';
|
|
19
19
|
import { getConfig } from '../config.js';
|
|
20
|
+
import { generateRoundedPath } from './edges/paths.js';
|
|
20
21
|
// ─── Layout Constants ──────────────────────────────────────────────────────
|
|
21
22
|
const PARTICIPANT_WIDTH = 150;
|
|
22
23
|
const PARTICIPANT_HEIGHT = 40;
|
|
@@ -291,12 +292,11 @@ export function layoutSequenceDiagram(sequence, theme, metadata) {
|
|
|
291
292
|
selectable: false,
|
|
292
293
|
});
|
|
293
294
|
if (isSelf) {
|
|
294
|
-
const
|
|
295
|
+
const loopWidth = 44;
|
|
296
|
+
const loopRightX = participantCenterX(fromIdx, seq.leftPadding, seq.participantWidth, seq.participantGap) + loopWidth;
|
|
295
297
|
const loopBottomY = y + seq.selfMessageExtra + 10;
|
|
296
|
-
const cornerAnchorId = `__anchor_${msg.from}_${mi}_corner`;
|
|
297
298
|
const returnAnchorId = `__anchor_${msg.from}_${mi}_return`;
|
|
298
|
-
|
|
299
|
-
nodes.push({ id: cornerAnchorId, position: { x: loopRightX - 1, y: y - 1 }, data: { anchor: true }, type: 'sequenceAnchor', draggable: false, selectable: false }, {
|
|
299
|
+
nodes.push({
|
|
300
300
|
id: returnAnchorId,
|
|
301
301
|
position: {
|
|
302
302
|
x: participantCenterX(fromIdx, seq.leftPadding, seq.participantWidth, seq.participantGap) - 1,
|
|
@@ -306,8 +306,16 @@ export function layoutSequenceDiagram(sequence, theme, metadata) {
|
|
|
306
306
|
type: 'sequenceAnchor',
|
|
307
307
|
draggable: false,
|
|
308
308
|
selectable: false,
|
|
309
|
-
}
|
|
310
|
-
|
|
309
|
+
});
|
|
310
|
+
// Self-message: draw a single rounded loop-back path (closer to Mermaid).
|
|
311
|
+
const cx = participantCenterX(fromIdx, seq.leftPadding, seq.participantWidth, seq.participantGap);
|
|
312
|
+
const routePoints = [
|
|
313
|
+
{ x: cx, y },
|
|
314
|
+
{ x: loopRightX, y },
|
|
315
|
+
{ x: loopRightX, y: loopBottomY },
|
|
316
|
+
{ x: cx, y: loopBottomY },
|
|
317
|
+
];
|
|
318
|
+
edges.push(buildSequenceRoutedEdge(`e-self-${mi}`, sourceAnchorId, returnAnchorId, msg.arrowType, theme, true, routePoints));
|
|
311
319
|
if (label) {
|
|
312
320
|
nodes.push({
|
|
313
321
|
id: `__msglabel_${mi}`,
|
|
@@ -392,6 +400,7 @@ export function layoutSequenceDiagram(sequence, theme, metadata) {
|
|
|
392
400
|
function buildSequenceEdge(id, source, target, arrowType, theme, showArrow) {
|
|
393
401
|
const isDashed = arrowType === 'dashed' || arrowType === 'dashed_open';
|
|
394
402
|
const isOpen = arrowType === 'solid_open' || arrowType === 'dashed_open';
|
|
403
|
+
const markerSize = theme.edgeDefaults.markerSize;
|
|
395
404
|
return {
|
|
396
405
|
id,
|
|
397
406
|
source,
|
|
@@ -406,8 +415,34 @@ function buildSequenceEdge(id, source, target, arrowType, theme, showArrow) {
|
|
|
406
415
|
markerEnd: {
|
|
407
416
|
type: MarkerType.ArrowClosed,
|
|
408
417
|
color: theme.color.gray3,
|
|
409
|
-
width:
|
|
410
|
-
height:
|
|
418
|
+
width: markerSize,
|
|
419
|
+
height: markerSize,
|
|
420
|
+
},
|
|
421
|
+
} : {}),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
function buildSequenceRoutedEdge(id, source, target, arrowType, theme, showArrow, points) {
|
|
425
|
+
const isDashed = arrowType === 'dashed' || arrowType === 'dashed_open';
|
|
426
|
+
const isOpen = arrowType === 'solid_open' || arrowType === 'dashed_open';
|
|
427
|
+
const svgPath = generateRoundedPath(points, 14);
|
|
428
|
+
const markerSize = theme.edgeDefaults.markerSize;
|
|
429
|
+
return {
|
|
430
|
+
id,
|
|
431
|
+
source,
|
|
432
|
+
target,
|
|
433
|
+
type: 'routed',
|
|
434
|
+
style: {
|
|
435
|
+
stroke: theme.color.gray3,
|
|
436
|
+
strokeWidth: 1.5,
|
|
437
|
+
...(isDashed ? { strokeDasharray: '6,4' } : {}),
|
|
438
|
+
},
|
|
439
|
+
data: { svgPath },
|
|
440
|
+
...(showArrow && !isOpen ? {
|
|
441
|
+
markerEnd: {
|
|
442
|
+
type: MarkerType.ArrowClosed,
|
|
443
|
+
color: theme.color.gray3,
|
|
444
|
+
width: markerSize,
|
|
445
|
+
height: markerSize,
|
|
411
446
|
},
|
|
412
447
|
} : {}),
|
|
413
448
|
};
|
package/dist/layout/subgraph.js
CHANGED
|
@@ -11,7 +11,31 @@
|
|
|
11
11
|
* Falls back to manual bounding box if dagre cluster data isn't available.
|
|
12
12
|
*/
|
|
13
13
|
export function computeSubgraphBounds(subgraph, nodePositions, padding, labelOffset, dagreGraph) {
|
|
14
|
-
//
|
|
14
|
+
// Prefer manual bounds derived from child node positions. In practice this
|
|
15
|
+
// is more reliable than dagre's compound cluster dimensions when node
|
|
16
|
+
// sizes are overridden post-measurement.
|
|
17
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
18
|
+
for (const nodeId of subgraph.nodeIds) {
|
|
19
|
+
const pos = nodePositions.get(nodeId);
|
|
20
|
+
if (!pos)
|
|
21
|
+
continue;
|
|
22
|
+
minX = Math.min(minX, pos.x);
|
|
23
|
+
minY = Math.min(minY, pos.y);
|
|
24
|
+
maxX = Math.max(maxX, pos.x + pos.width);
|
|
25
|
+
maxY = Math.max(maxY, pos.y + pos.height);
|
|
26
|
+
}
|
|
27
|
+
if (isFinite(minX)) {
|
|
28
|
+
// Account for node shadows/rounding so children don't "peek" outside.
|
|
29
|
+
const shadowPad = 4;
|
|
30
|
+
const pad = padding + shadowPad;
|
|
31
|
+
return {
|
|
32
|
+
x: minX - pad,
|
|
33
|
+
y: minY - pad - labelOffset,
|
|
34
|
+
width: maxX - minX + pad * 2,
|
|
35
|
+
height: maxY - minY + pad * 2 + labelOffset,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Fallback: use dagre's computed cluster bounds if available.
|
|
15
39
|
if (dagreGraph) {
|
|
16
40
|
const clusterNode = dagreGraph.node(subgraph.id);
|
|
17
41
|
if (clusterNode && clusterNode.width && clusterNode.height) {
|
|
@@ -23,25 +47,7 @@ export function computeSubgraphBounds(subgraph, nodePositions, padding, labelOff
|
|
|
23
47
|
};
|
|
24
48
|
}
|
|
25
49
|
}
|
|
26
|
-
|
|
27
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
28
|
-
for (const nodeId of subgraph.nodeIds) {
|
|
29
|
-
const pos = nodePositions.get(nodeId);
|
|
30
|
-
if (!pos)
|
|
31
|
-
continue;
|
|
32
|
-
minX = Math.min(minX, pos.x);
|
|
33
|
-
minY = Math.min(minY, pos.y);
|
|
34
|
-
maxX = Math.max(maxX, pos.x + pos.width);
|
|
35
|
-
maxY = Math.max(maxY, pos.y + pos.height);
|
|
36
|
-
}
|
|
37
|
-
if (!isFinite(minX))
|
|
38
|
-
return null;
|
|
39
|
-
return {
|
|
40
|
-
x: minX - padding,
|
|
41
|
-
y: minY - padding - labelOffset,
|
|
42
|
-
width: maxX - minX + padding * 2,
|
|
43
|
-
height: maxY - minY + padding * 2 + labelOffset,
|
|
44
|
-
};
|
|
50
|
+
return null;
|
|
45
51
|
}
|
|
46
52
|
// ─── Subgraph Node Builder ──────────────────────────────────────────────────
|
|
47
53
|
export function buildSubgraphNode(subgraph, bounds, theme) {
|
|
@@ -51,13 +57,16 @@ export function buildSubgraphNode(subgraph, bounds, theme) {
|
|
|
51
57
|
position: { x: bounds.x, y: bounds.y },
|
|
52
58
|
data: { label: subgraph.label },
|
|
53
59
|
type: 'subgraph',
|
|
60
|
+
zIndex: 0,
|
|
54
61
|
style: {
|
|
62
|
+
boxSizing: 'border-box',
|
|
55
63
|
width: bounds.width,
|
|
56
64
|
height: bounds.height,
|
|
57
65
|
backgroundColor: `${theme.color.darkBg3}${subgraphStyles.bgOpacity}`,
|
|
58
66
|
border: `${subgraphStyles.borderStyle} ${theme.color.gray3}`,
|
|
59
67
|
borderRadius: theme.radius.lg,
|
|
60
68
|
padding: theme.space[2],
|
|
69
|
+
overflow: 'visible',
|
|
61
70
|
},
|
|
62
71
|
};
|
|
63
72
|
}
|
|
@@ -78,11 +78,17 @@ export function parseFlowchart(syntax) {
|
|
|
78
78
|
}
|
|
79
79
|
const resolvedEdges = [];
|
|
80
80
|
for (const edge of edges) {
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
if (!
|
|
81
|
+
const sourceResolved = resolveFlowEndpoint(edge.source, 'source', nodeMap, subgraphMap);
|
|
82
|
+
const targetResolved = resolveFlowEndpoint(edge.target, 'target', nodeMap, subgraphMap);
|
|
83
|
+
if (!sourceResolved || !targetResolved)
|
|
84
84
|
continue;
|
|
85
|
-
resolvedEdges.push({
|
|
85
|
+
resolvedEdges.push({
|
|
86
|
+
...edge,
|
|
87
|
+
source: sourceResolved.nodeId,
|
|
88
|
+
target: targetResolved.nodeId,
|
|
89
|
+
sourceSubgraphId: sourceResolved.subgraphId,
|
|
90
|
+
targetSubgraphId: targetResolved.subgraphId,
|
|
91
|
+
});
|
|
86
92
|
}
|
|
87
93
|
return {
|
|
88
94
|
kind: 'graph',
|
|
@@ -99,12 +105,15 @@ function resolveFlowEndpoint(endpoint, role, nodeMap, subgraphMap) {
|
|
|
99
105
|
// Heuristic anchor selection:
|
|
100
106
|
// - source endpoint (subgraph -> node): use the last member encountered
|
|
101
107
|
// - target endpoint (node -> subgraph): use the first member encountered
|
|
102
|
-
return
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
return {
|
|
109
|
+
nodeId: role === 'source'
|
|
110
|
+
? sg.nodeIds[sg.nodeIds.length - 1]
|
|
111
|
+
: sg.nodeIds[0],
|
|
112
|
+
subgraphId: endpoint,
|
|
113
|
+
};
|
|
105
114
|
}
|
|
106
115
|
if (nodeMap.has(endpoint))
|
|
107
|
-
return endpoint;
|
|
116
|
+
return { nodeId: endpoint };
|
|
108
117
|
return null;
|
|
109
118
|
}
|
|
110
119
|
// ─── Arrow Classification ─────────────────────────────────────────────────────
|
package/dist/theme/dark.js
CHANGED
|
@@ -107,7 +107,7 @@ const edgeDefaults = {
|
|
|
107
107
|
labelBgBorderRadius: 4,
|
|
108
108
|
dashedPattern: '5,5',
|
|
109
109
|
thickWidth: 3,
|
|
110
|
-
markerSize:
|
|
110
|
+
markerSize: 14,
|
|
111
111
|
};
|
|
112
112
|
// ─── Shared Node Base Styles ────────────────────────────────────────────────
|
|
113
113
|
const nodeBase = {
|
|
@@ -160,7 +160,7 @@ const nodeStyles = {
|
|
|
160
160
|
sequence: {
|
|
161
161
|
number: { size: 22, fontSize: font.size.xs, borderWidth: '1.5px' },
|
|
162
162
|
block: { borderWidth: borderWidth.sm, dividerWidth: borderWidth.sm, labelCharWidth: 8, labelOffsetBase: 24 },
|
|
163
|
-
note: { borderWidth: borderWidth.sm, paddingY: space[1] },
|
|
163
|
+
note: { borderWidth: borderWidth.sm, paddingY: space[1], backgroundColor: color.darkBg3, textColor: color.gray4 },
|
|
164
164
|
},
|
|
165
165
|
subgraph: {
|
|
166
166
|
pointerEvents: 'none',
|
package/dist/theme/light.js
CHANGED
|
@@ -107,7 +107,7 @@ const edgeDefaults = {
|
|
|
107
107
|
labelBgBorderRadius: 4,
|
|
108
108
|
dashedPattern: '5,5',
|
|
109
109
|
thickWidth: 3,
|
|
110
|
-
markerSize:
|
|
110
|
+
markerSize: 14,
|
|
111
111
|
};
|
|
112
112
|
// ─── Shared Node Base Styles ────────────────────────────────────────────────
|
|
113
113
|
const nodeBase = {
|
|
@@ -160,7 +160,8 @@ const nodeStyles = {
|
|
|
160
160
|
sequence: {
|
|
161
161
|
number: { size: 22, fontSize: font.size.xs, borderWidth: '1.5px' },
|
|
162
162
|
block: { borderWidth: borderWidth.sm, dividerWidth: borderWidth.sm, labelCharWidth: 8, labelOffsetBase: 24 },
|
|
163
|
-
|
|
163
|
+
// Light mode notes: use a darker neutral so they don't disappear on white backgrounds.
|
|
164
|
+
note: { borderWidth: borderWidth.sm, paddingY: space[1], backgroundColor: color.gray2, textColor: color.gray5 },
|
|
164
165
|
},
|
|
165
166
|
subgraph: {
|
|
166
167
|
pointerEvents: 'none',
|
package/dist/theme/types.d.ts
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -141,6 +141,18 @@ export type Cardinality = 'ONLY_ONE' | 'ZERO_OR_ONE' | 'ZERO_OR_MORE' | 'ONE_OR_
|
|
|
141
141
|
export interface ParsedEdge {
|
|
142
142
|
source: string;
|
|
143
143
|
target: string;
|
|
144
|
+
/**
|
|
145
|
+
* If the original Mermaid syntax referenced a subgraph id as an endpoint,
|
|
146
|
+
* we resolve it to a representative member node for layout, but keep the
|
|
147
|
+
* subgraph id here so edge rendering can attach to the subgraph boundary.
|
|
148
|
+
*/
|
|
149
|
+
sourceSubgraphId?: string;
|
|
150
|
+
/**
|
|
151
|
+
* If the original Mermaid syntax referenced a subgraph id as an endpoint,
|
|
152
|
+
* we resolve it to a representative member node for layout, but keep the
|
|
153
|
+
* subgraph id here so edge rendering can attach to the subgraph boundary.
|
|
154
|
+
*/
|
|
155
|
+
targetSubgraphId?: string;
|
|
144
156
|
label?: string;
|
|
145
157
|
edgeType?: EdgeType;
|
|
146
158
|
/** Render edge line without arrowhead marker (used by mindmap-like links). */
|
package/package.json
CHANGED