@canmingir/link 1.2.3 → 1.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canmingir/link",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.js",
@@ -0,0 +1,62 @@
1
+ import { Box } from "@mui/material";
2
+
3
+ import React, { useRef, useState } from "react";
4
+
5
+ const DraggableNode = ({ children, registerRef, onDrag }) => {
6
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
7
+ const localRef = useRef(null);
8
+
9
+ const setRef = (el) => {
10
+ localRef.current = el;
11
+ if (registerRef) registerRef(el);
12
+ };
13
+
14
+ const handleMouseDown = (e) => {
15
+ if (e.button !== 0) return;
16
+ e.stopPropagation();
17
+
18
+ const startX = e.clientX;
19
+ const startY = e.clientY;
20
+ const startOffset = { ...offset };
21
+
22
+ const onMove = (ev) => {
23
+ const dx = ev.clientX - startX;
24
+ const dy = ev.clientY - startY;
25
+ setOffset({
26
+ x: startOffset.x + dx,
27
+ y: startOffset.y + dy,
28
+ });
29
+ if (onDrag) onDrag();
30
+ };
31
+
32
+ const onUp = () => {
33
+ window.removeEventListener("mousemove", onMove);
34
+ window.removeEventListener("mouseup", onUp);
35
+ };
36
+
37
+ window.addEventListener("mousemove", onMove);
38
+ window.addEventListener("mouseup", onUp);
39
+ };
40
+
41
+ return (
42
+ <Box
43
+ ref={setRef}
44
+ onMouseDown={handleMouseDown}
45
+ sx={{
46
+ display: "inline-flex",
47
+ flexDirection: "column",
48
+ alignItems: "center",
49
+ position: "relative",
50
+ transform: `translate(${offset.x}px, ${offset.y}px)`,
51
+ cursor: "grab",
52
+ "&:active": {
53
+ cursor: "grabbing",
54
+ },
55
+ }}
56
+ >
57
+ {children}
58
+ </Box>
59
+ );
60
+ };
61
+
62
+ export default DraggableNode;
@@ -0,0 +1,172 @@
1
+ import React, { useLayoutEffect, useState } from "react";
2
+
3
+ const DynamicConnector = ({
4
+ containerEl,
5
+ parentEl,
6
+ childEls,
7
+ stroke,
8
+ strokeWidth,
9
+ lineStyle,
10
+ connectorType = "default",
11
+ tick = 0,
12
+ }) => {
13
+ const [dims, setDims] = useState(null);
14
+ const [points, setPoints] = useState({
15
+ parent: null,
16
+ children: [],
17
+ yMid: null,
18
+ });
19
+
20
+ useLayoutEffect(() => {
21
+ if (!containerEl || !parentEl || !childEls?.length) return;
22
+
23
+ const update = () => {
24
+ const cRect = containerEl.getBoundingClientRect();
25
+ const pRect = parentEl.getBoundingClientRect();
26
+
27
+ const parent = {
28
+ x: pRect.left + pRect.width / 2 - cRect.left,
29
+ y: pRect.bottom - cRect.top,
30
+ };
31
+
32
+ const children = childEls.map((el) => {
33
+ const r = el.getBoundingClientRect();
34
+ return {
35
+ x: r.left + r.width / 2 - cRect.left,
36
+ y: r.top - cRect.top,
37
+ };
38
+ });
39
+
40
+ const firstTop = Math.min(...children.map((c) => c.y));
41
+ const yMid =
42
+ parent.y + Math.max(12, Math.min(24, (firstTop - parent.y) * 0.4));
43
+
44
+ setPoints({ parent, children, yMid });
45
+ setDims({ w: cRect.width, h: cRect.height });
46
+ };
47
+
48
+ const ro = new ResizeObserver(update);
49
+ ro.observe(containerEl);
50
+ ro.observe(parentEl);
51
+ childEls.forEach((el) => el && ro.observe(el));
52
+
53
+ update();
54
+ return () => ro.disconnect();
55
+ }, [containerEl, parentEl, childEls, tick]);
56
+
57
+ if (!dims || !points.parent || !points.children.length) return null;
58
+
59
+ const dash =
60
+ lineStyle === "dashed"
61
+ ? `${strokeWidth * 3},${strokeWidth * 2}`
62
+ : lineStyle === "dotted"
63
+ ? `${strokeWidth},${strokeWidth * 1.5}`
64
+ : undefined;
65
+
66
+ const onlyOne = points.children.length === 1;
67
+
68
+ if (connectorType === "curved" || connectorType === "n8n") {
69
+ const createN8nPath = (from, to) => {
70
+ const v = Math.max(32, Math.abs(to.y - from.y) * 0.35);
71
+ const h = Math.max(24, Math.abs(to.x - from.x) * 0.35);
72
+
73
+ const c1 = {
74
+ x: to.x > from.x ? from.x + h : from.x - h,
75
+ y: from.y + v,
76
+ };
77
+ const c2 = {
78
+ x: to.x > from.x ? to.x - h : to.x + h,
79
+ y: to.y - v,
80
+ };
81
+
82
+ return `M ${from.x} ${from.y} C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${to.x} ${to.y}`;
83
+ };
84
+
85
+ return (
86
+ <svg
87
+ style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
88
+ width="100%"
89
+ height="100%"
90
+ viewBox={`0 0 ${dims.w} ${dims.h}`}
91
+ >
92
+ {points.children.map((child, i) => (
93
+ <path
94
+ key={i}
95
+ d={createN8nPath(points.parent, child)}
96
+ fill="none"
97
+ stroke={stroke}
98
+ strokeWidth={strokeWidth}
99
+ strokeDasharray={dash}
100
+ strokeLinecap="round"
101
+ strokeLinejoin="round"
102
+ />
103
+ ))}
104
+ </svg>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <svg
110
+ style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
111
+ width="100%"
112
+ height="100%"
113
+ viewBox={`0 0 ${dims.w} ${dims.h}`}
114
+ >
115
+ {onlyOne ? (
116
+ <line
117
+ x1={points.parent.x}
118
+ y1={points.parent.y}
119
+ x2={points.children[0].x}
120
+ y2={points.children[0].y}
121
+ stroke={stroke}
122
+ strokeWidth={strokeWidth}
123
+ strokeDasharray={dash}
124
+ />
125
+ ) : (
126
+ <>
127
+ <line
128
+ x1={points.parent.x}
129
+ y1={points.parent.y}
130
+ x2={points.parent.x}
131
+ y2={points.yMid}
132
+ stroke={stroke}
133
+ strokeWidth={strokeWidth}
134
+ strokeDasharray={dash}
135
+ />
136
+
137
+ {(() => {
138
+ const xs = points.children.map((c) => c.x);
139
+ const xMin = Math.min(...xs);
140
+ const xMax = Math.max(...xs);
141
+ return (
142
+ <line
143
+ x1={xMin}
144
+ y1={points.yMid}
145
+ x2={xMax}
146
+ y2={points.yMid}
147
+ stroke={stroke}
148
+ strokeWidth={strokeWidth}
149
+ strokeDasharray={dash}
150
+ />
151
+ );
152
+ })()}
153
+
154
+ {points.children.map((c, i) => (
155
+ <line
156
+ key={i}
157
+ x1={c.x}
158
+ y1={points.yMid}
159
+ x2={c.x}
160
+ y2={c.y}
161
+ stroke={stroke}
162
+ strokeWidth={strokeWidth}
163
+ strokeDasharray={dash}
164
+ />
165
+ ))}
166
+ </>
167
+ )}
168
+ </svg>
169
+ );
170
+ };
171
+
172
+ export default DynamicConnector;
@@ -0,0 +1,34 @@
1
+ import FlowNode from "./FlowNode";
2
+
3
+ import React, { useMemo } from "react";
4
+ import { assertLinkedGraph, buildTreeFromLinked } from "./flowUtils";
5
+
6
+ export const Flow = ({ data, variant = "simple", style, plugin }) => {
7
+ const { nodesById, roots } = useMemo(() => assertLinkedGraph(data), [data]);
8
+
9
+ const treeData = useMemo(() => {
10
+ if (!roots?.length)
11
+ return { id: "__empty__", label: "(empty)", children: [] };
12
+
13
+ if (roots.length === 1) {
14
+ return (
15
+ buildTreeFromLinked(roots[0], nodesById) || {
16
+ id: roots[0],
17
+ children: [],
18
+ }
19
+ );
20
+ }
21
+
22
+ const children = roots
23
+ .map((r) => buildTreeFromLinked(r, nodesById))
24
+ .filter(Boolean);
25
+
26
+ return { id: "__root__", label: "Root", children };
27
+ }, [nodesById, roots]);
28
+
29
+ return (
30
+ <FlowNode node={treeData} variant={variant} style={style} plugin={plugin} />
31
+ );
32
+ };
33
+
34
+ export default Flow;
@@ -0,0 +1,270 @@
1
+ import DraggableNode from "./DraggableNode";
2
+ import DynamicConnector from "./DynamicConnector";
3
+ import { getContentParts } from "./flowUtils";
4
+
5
+ import { Box, Card, Typography } from "@mui/material";
6
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
7
+ import {
8
+ applySemanticTokens,
9
+ getBaseStyleForVariant,
10
+ getDecisionNodeStyle,
11
+ toPxNumber,
12
+ } from "./styles";
13
+
14
+ const FlowNode = ({ node, type, variant, style, plugin }) => {
15
+ const baseStyle = getBaseStyleForVariant(variant);
16
+ const hasChildren = Array.isArray(node.children) && node.children.length > 0;
17
+
18
+ const variantTokens =
19
+ variant === "decision" ? getDecisionNodeStyle(node.type) : {};
20
+
21
+ let styleTokens = {};
22
+ if (typeof style === "function") {
23
+ styleTokens = style(node) || {};
24
+ } else if (style && typeof style === "object") {
25
+ styleTokens = style;
26
+ }
27
+
28
+ let plugins = null;
29
+ if (plugin) {
30
+ if (typeof plugin === "function") {
31
+ plugin = plugin(type, node);
32
+ } else if (
33
+ typeof plugin === "object" &&
34
+ (typeof plugin.renderNode === "function" ||
35
+ typeof plugin.resolveStyle === "function")
36
+ ) {
37
+ plugins = plugin;
38
+ }
39
+ }
40
+
41
+ let pluginTokens = {};
42
+ if (plugins && typeof plugins.resolveStyle === "function") {
43
+ pluginTokens =
44
+ plugins.resolveStyle({
45
+ node,
46
+ style: styleTokens,
47
+ }) || {};
48
+ }
49
+
50
+ const rawNodeStyle = {
51
+ ...baseStyle,
52
+ ...variantTokens,
53
+ ...styleTokens,
54
+ ...pluginTokens,
55
+ };
56
+
57
+ const nodeStyle = applySemanticTokens(rawNodeStyle, baseStyle);
58
+
59
+ const {
60
+ lineColor = baseStyle.lineColor,
61
+ lineWidth = baseStyle.lineWidth,
62
+ lineStyle = baseStyle.lineStyle,
63
+ gap = baseStyle.gap,
64
+ levelGap = baseStyle.levelGap ?? 2.5,
65
+ nodeSx = {},
66
+ borderWidth,
67
+ borderColor = baseStyle.borderColor,
68
+ cardWidth,
69
+ shape,
70
+ shadowLevel,
71
+ minHeight,
72
+ connectorType = baseStyle.connectorType ?? "default",
73
+ } = nodeStyle;
74
+
75
+ const strokeWidth = toPxNumber(lineWidth, 1.5);
76
+ const dashStyle =
77
+ lineStyle === "dashed" || lineStyle === "dotted" ? lineStyle : "solid";
78
+
79
+ const containerRef = useRef(null);
80
+ const parentRef = useRef(null);
81
+ const childRefs = useRef({});
82
+ const [childElList, setChildElList] = useState([]);
83
+
84
+ const [connectorTick, setConnectorTick] = useState(0);
85
+ const notifyDrag = () => setConnectorTick((t) => t + 1);
86
+
87
+ useLayoutEffect(() => {
88
+ const els = (node.children || [])
89
+ .map((c) => childRefs.current[c.id])
90
+ .filter(Boolean);
91
+ setChildElList(els);
92
+ }, [node.children]);
93
+
94
+ useEffect(() => {
95
+ const t = setTimeout(() => {
96
+ const els = (node.children || [])
97
+ .map((c) => childRefs.current[c.id])
98
+ .filter(Boolean);
99
+ setChildElList(els);
100
+ }, 0);
101
+ return () => clearTimeout(t);
102
+ }, [node.children]);
103
+
104
+ const { title, subtitle, metaEntries } = getContentParts(node);
105
+
106
+ const renderDefaultCard = () => {
107
+ const effectiveWidth = cardWidth || 220;
108
+ const effectiveBorderWidth = borderWidth || 1;
109
+ const effectiveRadius =
110
+ typeof shape === "number" ? shape : baseStyle.shape || 4;
111
+ const effectiveShadow =
112
+ typeof shadowLevel === "number"
113
+ ? shadowLevel
114
+ : variant === "card"
115
+ ? 2
116
+ : 1;
117
+ const effectiveMinHeight = minHeight || 80;
118
+
119
+ return (
120
+ <Card
121
+ sx={{
122
+ p: 2,
123
+ width: effectiveWidth,
124
+ minHeight: effectiveMinHeight,
125
+ display: "flex",
126
+ flexDirection: "row",
127
+ alignItems: "center",
128
+ justifyContent: "center",
129
+ gap: 1,
130
+ position: "relative",
131
+ borderRadius: effectiveRadius,
132
+ bgcolor: nodeStyle.bg || "background.paper",
133
+ border: `${effectiveBorderWidth}px solid ${
134
+ borderColor || "transparent"
135
+ }`,
136
+ boxShadow: effectiveShadow,
137
+ transition: "background-color 0.3s ease, box-shadow 0.3s ease",
138
+ "&:hover": {
139
+ bgcolor: nodeStyle.hoverBg || nodeStyle.bg || "grey.100",
140
+ boxShadow: effectiveShadow + 1,
141
+ cursor: "pointer",
142
+ },
143
+ ...nodeSx,
144
+ }}
145
+ >
146
+ <Box sx={{ textAlign: "left", width: "100%" }}>
147
+ <Typography
148
+ variant="subtitle2"
149
+ sx={{
150
+ textAlign: "center",
151
+ fontWeight: 600,
152
+ fontSize: 13,
153
+ mb: subtitle ? 0.5 : 0,
154
+ }}
155
+ >
156
+ {title}
157
+ </Typography>
158
+
159
+ {subtitle && (
160
+ <Typography
161
+ variant="body2"
162
+ color="text.secondary"
163
+ sx={{
164
+ textAlign: "center",
165
+ fontSize: 11,
166
+ mb: metaEntries.length ? 0.5 : 0,
167
+ }}
168
+ >
169
+ {subtitle}
170
+ </Typography>
171
+ )}
172
+
173
+ {metaEntries.length > 0 && (
174
+ <Box sx={{ mt: 0.25 }}>
175
+ {metaEntries.map(([key, value]) => (
176
+ <Typography
177
+ key={key}
178
+ variant="caption"
179
+ color="text.secondary"
180
+ sx={{
181
+ textAlign: "center",
182
+ display: "block",
183
+ fontSize: 10,
184
+ }}
185
+ >
186
+ {key}: {String(value)}
187
+ </Typography>
188
+ ))}
189
+ </Box>
190
+ )}
191
+ </Box>
192
+ </Card>
193
+ );
194
+ };
195
+
196
+ const renderContent = () => {
197
+ if (plugin && typeof plugin.renderNode === "function") {
198
+ return plugin.renderNode({
199
+ node,
200
+ title,
201
+ subtitle,
202
+ metaEntries,
203
+ nodeStyle,
204
+ baseStyle,
205
+ });
206
+ }
207
+ return renderDefaultCard();
208
+ };
209
+
210
+ return (
211
+ <Box
212
+ ref={containerRef}
213
+ sx={{
214
+ display: "inline-flex",
215
+ flexDirection: "column",
216
+ alignItems: "center",
217
+ position: "relative",
218
+ }}
219
+ >
220
+ <Box ref={parentRef} sx={{ position: "relative" }}>
221
+ {renderContent()}
222
+ </Box>
223
+
224
+ {hasChildren && (
225
+ <>
226
+ <DynamicConnector
227
+ containerEl={containerRef.current}
228
+ parentEl={parentRef.current}
229
+ childEls={childElList}
230
+ stroke={lineColor}
231
+ strokeWidth={strokeWidth}
232
+ lineStyle={dashStyle}
233
+ connectorType={connectorType}
234
+ tick={connectorTick}
235
+ />
236
+
237
+ <Box
238
+ sx={{
239
+ display: "flex",
240
+ flexDirection: "row",
241
+ columnGap: gap,
242
+ marginTop: levelGap,
243
+ position: "relative",
244
+ alignItems: "flex-start",
245
+ justifyContent: "center",
246
+ }}
247
+ >
248
+ {node.children.map((child) => (
249
+ <DraggableNode
250
+ key={child.id}
251
+ registerRef={(el) => (childRefs.current[child.id] = el)}
252
+ onDrag={notifyDrag}
253
+ >
254
+ <FlowNode
255
+ node={child}
256
+ type={type}
257
+ variant={variant}
258
+ style={style}
259
+ plugin={plugin}
260
+ />
261
+ </DraggableNode>
262
+ ))}
263
+ </Box>
264
+ </>
265
+ )}
266
+ </Box>
267
+ );
268
+ };
269
+
270
+ export default FlowNode;
@@ -0,0 +1,111 @@
1
+ export function assertLinkedGraph(data) {
2
+ if (!data || typeof data !== "object") {
3
+ throw new Error(
4
+ "FlowChart expected a linked graph object: { nodes, roots? }."
5
+ );
6
+ }
7
+ const { nodes, roots } = data;
8
+ if (!nodes || typeof nodes !== "object") {
9
+ throw new Error("FlowChart expected data.nodes to be an object.");
10
+ }
11
+
12
+ let useRoots = Array.isArray(roots) ? [...roots] : null;
13
+ if (!useRoots || useRoots.length === 0) {
14
+ useRoots = Object.keys(nodes).filter((id) => !nodes[id]?.previous);
15
+ }
16
+ if (useRoots.length === 0) {
17
+ const first = Object.keys(nodes)[0];
18
+ if (first) useRoots = [first];
19
+ }
20
+
21
+ for (const r of useRoots) {
22
+ if (!nodes[r]) throw new Error(`Root id "${r}" not found in data.nodes.`);
23
+ }
24
+
25
+ return { nodesById: nodes, roots: useRoots };
26
+ }
27
+
28
+ export function buildTreeFromLinked(rootId, nodesById) {
29
+ if (!rootId || !nodesById?.[rootId]) return null;
30
+ const seen = new Set();
31
+
32
+ const cloneNode = (node) => {
33
+ if (!node) return null;
34
+ const { next, previous, children, ...rest } = node;
35
+ return { ...rest, id: node.id, previous, next, children: [] };
36
+ };
37
+
38
+ const dfs = (id) => {
39
+ if (!id || seen.has(id) || !nodesById[id]) return null;
40
+ seen.add(id);
41
+
42
+ const node = nodesById[id];
43
+ const out = cloneNode(node);
44
+
45
+ const nextArr = Array.isArray(node.next)
46
+ ? node.next
47
+ : node.next != null
48
+ ? [node.next]
49
+ : [];
50
+
51
+ for (const nxt of nextArr) {
52
+ const nextId = typeof nxt === "string" ? nxt : nxt?.id;
53
+ if (!nextId || !nodesById[nextId]) continue;
54
+
55
+ const target = nodesById[nextId];
56
+ if (target.previous == null || target.previous === id) {
57
+ const built = dfs(nextId);
58
+ if (built) out.children.push(built);
59
+ }
60
+ }
61
+ return out;
62
+ };
63
+
64
+ return dfs(rootId);
65
+ }
66
+
67
+ export const getContentParts = (n) => {
68
+ const entries = Object.entries(n).filter(
69
+ ([key]) =>
70
+ key !== "children" && key !== "id" && key !== "previous" && key !== "next"
71
+ );
72
+
73
+ if (entries.length === 0) {
74
+ return {
75
+ title: "(empty)",
76
+ subtitle: null,
77
+ metaEntries: [],
78
+ };
79
+ }
80
+
81
+ const preferredTitleKeys = ["label", "title", "name"];
82
+ const titleEntry =
83
+ entries.find(([key]) => preferredTitleKeys.includes(key)) || entries[0];
84
+ const [titleKey, rawTitle] = titleEntry;
85
+ const title = String(rawTitle);
86
+ let remaining = entries.filter(([key]) => key !== titleKey);
87
+
88
+ const preferredSubtitleKeys = ["description", "role", "type", "status"];
89
+ const subtitleEntry =
90
+ remaining.find(([key]) => preferredSubtitleKeys.includes(key)) || null;
91
+
92
+ let subtitle = null;
93
+ if (subtitleEntry) {
94
+ const [subtitleKey, raw] = subtitleEntry;
95
+ subtitle = String(raw);
96
+ remaining = remaining.filter(([key]) => key !== subtitleKey);
97
+ }
98
+
99
+ const metaEntries = remaining
100
+ .filter(([, value]) => {
101
+ const t = typeof value;
102
+ return (
103
+ (t === "string" || t === "number" || t === "boolean") &&
104
+ value !== "" &&
105
+ value !== null
106
+ );
107
+ })
108
+ .map(([k, v]) => [k, v]);
109
+
110
+ return { title, subtitle, metaEntries };
111
+ };
@@ -0,0 +1 @@
1
+ export { default as Flow } from "./Flow";