@canmingir/link 1.2.10 → 1.2.12

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.
@@ -0,0 +1,362 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+
3
+ import React, { useCallback } from "react";
4
+ import {
5
+ addToNext,
6
+ cleanupReferences,
7
+ collectSubtree,
8
+ getRootsToConnect,
9
+ getSelectedInStructure,
10
+ removeFromNext,
11
+ splitFloatingStructure,
12
+ toNextArray,
13
+ } from "../utils/flowUtils";
14
+
15
+ export const useGraphOperations = ({
16
+ nodesById,
17
+ roots,
18
+ onChange,
19
+ floatingNodes,
20
+ setFloatingNodes,
21
+ editable,
22
+ }) => {
23
+ const findFloatingStructure = useCallback(
24
+ (nodeId) => {
25
+ const index = floatingNodes.findIndex(
26
+ (s) => s?.nodes && nodeId in s.nodes
27
+ );
28
+ return index >= 0 ? { structure: floatingNodes[index], index } : null;
29
+ },
30
+ [floatingNodes]
31
+ );
32
+
33
+ const handleCut = useCallback(
34
+ (nodeIds) => {
35
+ if (!editable || !onChange || !nodesById) return;
36
+
37
+ const cutSet = new Set(nodeIds);
38
+
39
+ const isInMainTree = nodeIds.some((id) => nodesById[id]);
40
+ const isInFloating = nodeIds.some((id) =>
41
+ floatingNodes.some((s) => s.nodes?.[id])
42
+ );
43
+
44
+ if (isInMainTree) {
45
+ const updatedNodes = { ...nodesById };
46
+ let updatedRoots = [...(roots || [])];
47
+
48
+ nodeIds.forEach((cutId) => {
49
+ const cutNode = updatedNodes[cutId];
50
+ if (!cutNode) return;
51
+
52
+ const parentId = cutNode.previous;
53
+ const childIds = toNextArray(cutNode.next);
54
+ const orphanIds = childIds.filter((id) => !cutSet.has(id));
55
+
56
+ orphanIds.forEach((orphanId) => {
57
+ const orphan = updatedNodes[orphanId];
58
+ if (!orphan) return;
59
+
60
+ const hasValidParent =
61
+ parentId && updatedNodes[parentId] && !cutSet.has(parentId);
62
+
63
+ if (hasValidParent) {
64
+ orphan.previous = parentId;
65
+ addToNext(updatedNodes[parentId], [orphanId]);
66
+ } else {
67
+ delete orphan.previous;
68
+ if (!updatedRoots.includes(orphanId)) {
69
+ updatedRoots.push(orphanId);
70
+ }
71
+ }
72
+ });
73
+ });
74
+
75
+ nodeIds.forEach((id) => delete updatedNodes[id]);
76
+ updatedRoots = updatedRoots.filter((r) => !cutSet.has(r));
77
+
78
+ cleanupReferences(updatedNodes, nodeIds);
79
+
80
+ onChange({ nodes: updatedNodes, roots: [...new Set(updatedRoots)] });
81
+ }
82
+
83
+ if (isInFloating) {
84
+ setFloatingNodes((prev) =>
85
+ prev
86
+ .map((structure) => {
87
+ const hasAffectedNodes = nodeIds.some(
88
+ (id) => structure.nodes?.[id]
89
+ );
90
+ if (!hasAffectedNodes) return structure;
91
+
92
+ const newNodes = { ...structure.nodes };
93
+ const newRoots = [...(structure.roots || [])];
94
+
95
+ nodeIds.forEach((id) => {
96
+ if (newNodes[id]) {
97
+ const node = newNodes[id];
98
+ const childIds = toNextArray(node.next);
99
+ const orphanIds = childIds.filter((cid) => !cutSet.has(cid));
100
+
101
+ orphanIds.forEach((orphanId) => {
102
+ if (newNodes[orphanId]) {
103
+ const parent = node.previous;
104
+ if (parent && newNodes[parent] && !cutSet.has(parent)) {
105
+ newNodes[orphanId].previous = parent;
106
+ addToNext(newNodes[parent], [orphanId]);
107
+ } else {
108
+ delete newNodes[orphanId].previous;
109
+ if (!newRoots.includes(orphanId)) {
110
+ newRoots.push(orphanId);
111
+ }
112
+ }
113
+ }
114
+ });
115
+
116
+ delete newNodes[id];
117
+ }
118
+ });
119
+
120
+ const filteredRoots = newRoots.filter(
121
+ (r) => !cutSet.has(r) && newNodes[r]
122
+ );
123
+
124
+ cleanupReferences(newNodes, nodeIds);
125
+
126
+ if (Object.keys(newNodes).length === 0) return null;
127
+
128
+ return {
129
+ ...structure,
130
+ nodes: newNodes,
131
+ roots: [...new Set(filteredRoots)],
132
+ };
133
+ })
134
+ .filter(Boolean)
135
+ );
136
+ }
137
+ },
138
+ [editable, onChange, nodesById, roots, floatingNodes, setFloatingNodes]
139
+ );
140
+
141
+ const handlePaste = useCallback(
142
+ (structure) => {
143
+ if (!editable || !structure?.nodes || !structure?.roots?.length) return;
144
+
145
+ const mainNodeIds = new Set(Object.keys(nodesById || {}));
146
+
147
+ const validNodes = {};
148
+ const validNodeIds = Object.keys(structure.nodes).filter(
149
+ (id) => !mainNodeIds.has(id)
150
+ );
151
+
152
+ if (validNodeIds.length === 0) return;
153
+
154
+ validNodeIds.forEach((id) => {
155
+ validNodes[id] = structure.nodes[id];
156
+ });
157
+
158
+ const internalChildIds = new Set();
159
+
160
+ validNodeIds.forEach((id) => {
161
+ const node = validNodes[id];
162
+
163
+ const nextIds = toNextArray(node.next || node.raw?.next);
164
+ nextIds.forEach((childId) => internalChildIds.add(childId));
165
+ });
166
+
167
+ const validRoots = structure.roots.filter(
168
+ (id) => validNodes[id] && !internalChildIds.has(id)
169
+ );
170
+
171
+ if (validRoots.length === 0) return;
172
+
173
+ const newPasteItem = {
174
+ ...structure,
175
+ nodes: validNodes,
176
+ roots: [...new Set(validRoots)],
177
+ _pasteId: uuidv4(),
178
+ };
179
+
180
+ setFloatingNodes((prev) => {
181
+ const existingFloatingIds = new Set(
182
+ prev.flatMap((item) => Object.keys(item.nodes))
183
+ );
184
+
185
+ const hasOverlap = validNodeIds.some((id) =>
186
+ existingFloatingIds.has(id)
187
+ );
188
+
189
+ if (hasOverlap) {
190
+ console.log("Paste blocked: Nodes already exist in floating layer.");
191
+ return prev;
192
+ }
193
+
194
+ return [...prev, newPasteItem];
195
+ });
196
+ },
197
+ [editable, nodesById, setFloatingNodes]
198
+ );
199
+
200
+ const handleConnect = useCallback(
201
+ (targetNodeId, selectedIds) => {
202
+ if (!editable || !onChange || !selectedIds?.length) return;
203
+
204
+ const targetInTree = nodesById?.[targetNodeId];
205
+ const targetFloating = findFloatingStructure(targetNodeId);
206
+ const sourceInTree = selectedIds.some((id) => nodesById?.[id]);
207
+ const sourceFloating = selectedIds
208
+ .map((id) => findFloatingStructure(id))
209
+ .find(Boolean);
210
+
211
+ if (sourceFloating && targetInTree) {
212
+ const { structure, index } = sourceFloating;
213
+ const selectedInStructure = getSelectedInStructure(
214
+ structure,
215
+ selectedIds
216
+ );
217
+ if (!selectedInStructure.length) return;
218
+
219
+ const rootsToConnect = getRootsToConnect(
220
+ structure,
221
+ selectedInStructure
222
+ );
223
+ const connectedNodeIds = collectSubtree(
224
+ structure,
225
+ rootsToConnect,
226
+ selectedInStructure
227
+ );
228
+
229
+ const updatedNodes = { ...nodesById };
230
+
231
+ connectedNodeIds.forEach((id) => {
232
+ updatedNodes[id] = { ...structure.nodes[id] };
233
+ });
234
+
235
+ rootsToConnect.forEach((rootId) => {
236
+ updatedNodes[rootId].previous = targetNodeId;
237
+ });
238
+
239
+ addToNext(updatedNodes[targetNodeId], rootsToConnect);
240
+
241
+ onChange({ nodes: updatedNodes, roots });
242
+
243
+ setFloatingNodes((prev) => {
244
+ if (connectedNodeIds.size === Object.keys(structure.nodes).length) {
245
+ return prev.filter((_, i) => i !== index);
246
+ }
247
+
248
+ const updated = [...prev];
249
+ updated[index] = splitFloatingStructure(structure, connectedNodeIds);
250
+ return updated.filter((s) => Object.keys(s.nodes).length > 0);
251
+ });
252
+ return;
253
+ }
254
+
255
+ if (sourceInTree && targetFloating) {
256
+ const { structure, index } = targetFloating;
257
+ const updatedNodes = { ...nodesById };
258
+ let updatedRoots = [...(roots || [])];
259
+
260
+ selectedIds.forEach((id) => {
261
+ const node = updatedNodes[id];
262
+ if (!node) return;
263
+
264
+ if (node.previous) {
265
+ removeFromNext(updatedNodes[node.previous], [id]);
266
+ }
267
+
268
+ updatedRoots = updatedRoots.filter((r) => r !== id);
269
+ delete updatedNodes[id];
270
+ });
271
+
272
+ onChange({ nodes: updatedNodes, roots: updatedRoots });
273
+
274
+ setFloatingNodes((prev) => {
275
+ const updated = [...prev];
276
+ const newStructure = {
277
+ ...structure,
278
+ nodes: { ...structure.nodes },
279
+ };
280
+
281
+ selectedIds.forEach((id) => {
282
+ if (nodesById[id]) {
283
+ newStructure.nodes[id] = {
284
+ ...nodesById[id],
285
+ previous: targetNodeId,
286
+ next: undefined,
287
+ };
288
+ }
289
+ });
290
+
291
+ addToNext(newStructure.nodes[targetNodeId], selectedIds);
292
+ updated[index] = newStructure;
293
+ return updated;
294
+ });
295
+ return;
296
+ }
297
+
298
+ if (sourceFloating && targetFloating) {
299
+ const { structure: sourceStruct, index: sourceIndex } = sourceFloating;
300
+ const { index: targetIndex } = targetFloating;
301
+ if (sourceIndex === targetIndex) return;
302
+
303
+ const selectedInStructure = getSelectedInStructure(
304
+ sourceStruct,
305
+ selectedIds
306
+ );
307
+ if (!selectedInStructure.length) return;
308
+
309
+ const rootsToConnect = getRootsToConnect(
310
+ sourceStruct,
311
+ selectedInStructure
312
+ );
313
+ const connectedNodeIds = collectSubtree(
314
+ sourceStruct,
315
+ rootsToConnect,
316
+ selectedInStructure
317
+ );
318
+
319
+ setFloatingNodes((prev) => {
320
+ const updated = prev.filter((_, i) => i !== sourceIndex);
321
+ const adjustedIndex =
322
+ targetIndex > sourceIndex ? targetIndex - 1 : targetIndex;
323
+
324
+ const targetStruct = updated[adjustedIndex];
325
+ const merged = {
326
+ ...targetStruct,
327
+ nodes: { ...targetStruct.nodes },
328
+ };
329
+
330
+ connectedNodeIds.forEach((id) => {
331
+ merged.nodes[id] = { ...sourceStruct.nodes[id] };
332
+ });
333
+
334
+ rootsToConnect.forEach((rootId) => {
335
+ merged.nodes[rootId].previous = targetNodeId;
336
+ });
337
+
338
+ addToNext(merged.nodes[targetNodeId], rootsToConnect);
339
+ updated[adjustedIndex] = merged;
340
+
341
+ if (connectedNodeIds.size < Object.keys(sourceStruct.nodes).length) {
342
+ updated.push(
343
+ splitFloatingStructure(sourceStruct, connectedNodeIds)
344
+ );
345
+ }
346
+
347
+ return updated.filter((s) => Object.keys(s.nodes).length > 0);
348
+ });
349
+ }
350
+ },
351
+ [
352
+ editable,
353
+ onChange,
354
+ nodesById,
355
+ roots,
356
+ findFloatingStructure,
357
+ setFloatingNodes,
358
+ ]
359
+ );
360
+
361
+ return { handleCut, handlePaste, handleConnect };
362
+ };
@@ -0,0 +1,56 @@
1
+ import { useMemo } from "react";
2
+
3
+ import {
4
+ applySemanticTokens,
5
+ getBaseStyleForVariant,
6
+ getDecisionNodeStyle,
7
+ } from "../styles";
8
+
9
+ export const useNodeStyle = ({ node, type, variant, style, plugin }) => {
10
+ return useMemo(() => {
11
+ const baseStyle = getBaseStyleForVariant(variant);
12
+
13
+ const variantTokens =
14
+ variant === "decision" ? getDecisionNodeStyle(node?.type) : {};
15
+
16
+ let styleTokens = {};
17
+ if (typeof style === "function") {
18
+ styleTokens = style(node) || {};
19
+ } else if (style && typeof style === "object") {
20
+ styleTokens = style;
21
+ }
22
+
23
+ let resolvedPlugin = null;
24
+ if (plugin) {
25
+ if (typeof plugin === "function") {
26
+ resolvedPlugin = plugin(type, node) || null;
27
+ } else if (typeof plugin === "object") {
28
+ resolvedPlugin = plugin;
29
+ }
30
+ }
31
+
32
+ let pluginTokens = {};
33
+ if (resolvedPlugin && typeof resolvedPlugin.style === "function") {
34
+ pluginTokens =
35
+ resolvedPlugin.style({
36
+ node,
37
+ style: styleTokens,
38
+ }) || {};
39
+ }
40
+
41
+ const rawNodeStyle = {
42
+ ...baseStyle,
43
+ ...variantTokens,
44
+ ...styleTokens,
45
+ ...pluginTokens,
46
+ };
47
+
48
+ const nodeStyle = applySemanticTokens(rawNodeStyle, baseStyle);
49
+
50
+ return {
51
+ baseStyle,
52
+ nodeStyle,
53
+ plugin: resolvedPlugin,
54
+ };
55
+ }, [node, type, variant, style, plugin]);
56
+ };
@@ -1 +1 @@
1
- export { default as Flow } from "./Flow";
1
+ export { default as Flow } from "./core/Flow";
@@ -0,0 +1,107 @@
1
+ import React from "react";
2
+
3
+ import { Box, Card, Typography } from "@mui/material";
4
+
5
+ const DefaultNodeCard = ({
6
+ title,
7
+ subtitle,
8
+ metaEntries,
9
+ nodeStyle,
10
+ baseStyle,
11
+ variant,
12
+ borderWidth,
13
+ borderColor,
14
+ cardWidth,
15
+ shape,
16
+ shadowLevel,
17
+ minHeight,
18
+ nodeSx = {},
19
+ }) => {
20
+ const effectiveWidth = cardWidth || 220;
21
+ const effectiveBorderWidth = borderWidth || 1;
22
+ const effectiveRadius =
23
+ typeof shape === "number" ? shape : baseStyle.shape || 4;
24
+
25
+ const effectiveShadow =
26
+ typeof shadowLevel === "number" ? shadowLevel : variant === "card" ? 2 : 1;
27
+
28
+ const effectiveMinHeight = minHeight || 80;
29
+
30
+ return (
31
+ <Card
32
+ sx={{
33
+ p: 2,
34
+ width: effectiveWidth,
35
+ minHeight: effectiveMinHeight,
36
+ display: "flex",
37
+ flexDirection: "row",
38
+ alignItems: "center",
39
+ justifyContent: "center",
40
+ gap: 1,
41
+ position: "relative",
42
+ borderRadius: effectiveRadius,
43
+ bgcolor: nodeStyle.bg || "background.paper",
44
+ border: `${effectiveBorderWidth}px solid ${
45
+ borderColor || "transparent"
46
+ }`,
47
+ boxShadow: effectiveShadow,
48
+ transition: "background-color 0.3s ease, box-shadow 0.3s ease",
49
+ "&:hover": {
50
+ bgcolor: nodeStyle.hoverBg || nodeStyle.bg || "grey.100",
51
+ boxShadow: effectiveShadow + 1,
52
+ cursor: "pointer",
53
+ },
54
+ ...nodeSx,
55
+ }}
56
+ >
57
+ <Box sx={{ textAlign: "left", width: "100%" }}>
58
+ <Typography
59
+ variant="subtitle2"
60
+ sx={{
61
+ textAlign: "center",
62
+ fontWeight: 600,
63
+ fontSize: 13,
64
+ mb: subtitle ? 0.5 : 0,
65
+ }}
66
+ >
67
+ {title}
68
+ </Typography>
69
+
70
+ {subtitle && (
71
+ <Typography
72
+ variant="body2"
73
+ color="text.secondary"
74
+ sx={{
75
+ textAlign: "center",
76
+ fontSize: 11,
77
+ mb: metaEntries.length ? 0.5 : 0,
78
+ }}
79
+ >
80
+ {subtitle}
81
+ </Typography>
82
+ )}
83
+
84
+ {metaEntries.length > 0 && (
85
+ <Box sx={{ mt: 0.25 }}>
86
+ {metaEntries.map(([key, value]) => (
87
+ <Typography
88
+ key={key}
89
+ variant="caption"
90
+ color="text.secondary"
91
+ sx={{
92
+ textAlign: "center",
93
+ display: "block",
94
+ fontSize: 10,
95
+ }}
96
+ >
97
+ {key}: {String(value)}
98
+ </Typography>
99
+ ))}
100
+ </Box>
101
+ )}
102
+ </Box>
103
+ </Card>
104
+ );
105
+ };
106
+
107
+ export default DefaultNodeCard;
@@ -0,0 +1,162 @@
1
+ import { Box } from "@mui/material";
2
+ import { useSelection } from "../selection/SelectionContext";
3
+
4
+ import React, { useCallback, useEffect, useRef, useState } from "react";
5
+
6
+ const DraggableNode = ({
7
+ children,
8
+ registerRef,
9
+ onDrag,
10
+ nodeId,
11
+ selectionColor = "#64748b",
12
+ initialPosition,
13
+ onConnect,
14
+ }) => {
15
+ const [offset, setOffset] = useState(() =>
16
+ initialPosition ? { ...initialPosition } : { x: 0, y: 0 }
17
+ );
18
+
19
+ useEffect(() => {
20
+ if (initialPosition) {
21
+ setOffset({ ...initialPosition });
22
+ } else {
23
+ setOffset({ x: 0, y: 0 });
24
+ }
25
+ }, [initialPosition]);
26
+
27
+ const localRef = useRef(null);
28
+ const lastDeltaRef = useRef({ x: 0, y: 0 });
29
+ const onDragRef = useRef(onDrag);
30
+
31
+ const {
32
+ isSelected,
33
+ selectNode,
34
+ toggleSelection,
35
+ clearSelection,
36
+ registerNodeHandlers,
37
+ moveSelectedNodes,
38
+ selectedIds,
39
+ } = useSelection();
40
+
41
+ const selected = isSelected(nodeId);
42
+
43
+ useEffect(() => {
44
+ onDragRef.current = onDrag;
45
+ }, [onDrag]);
46
+
47
+ useEffect(() => {
48
+ if (!nodeId) return;
49
+
50
+ return registerNodeHandlers(nodeId, {
51
+ setOffset,
52
+ onDrag: () => onDragRef.current?.(),
53
+ });
54
+ }, [nodeId, registerNodeHandlers]);
55
+
56
+ const setRef = useCallback(
57
+ (el) => {
58
+ localRef.current = el;
59
+ registerRef?.(el);
60
+ },
61
+ [registerRef]
62
+ );
63
+
64
+ const handleMouseDown = useCallback(
65
+ (e) => {
66
+ if (e.button !== 0) return;
67
+ e.stopPropagation();
68
+
69
+ if (onConnect && e.altKey) {
70
+ e.preventDefault();
71
+ onConnect(nodeId, [...selectedIds]);
72
+ return;
73
+ }
74
+
75
+ if (e.shiftKey || e.ctrlKey || e.metaKey) {
76
+ toggleSelection(nodeId);
77
+ return;
78
+ }
79
+
80
+ if (!selected) {
81
+ clearSelection();
82
+ selectNode(nodeId);
83
+ }
84
+
85
+ const startX = e.clientX;
86
+ const startY = e.clientY;
87
+ const startOffset = { ...offset };
88
+ lastDeltaRef.current = { x: 0, y: 0 };
89
+
90
+ const handleMove = (ev) => {
91
+ const dx = ev.clientX - startX;
92
+ const dy = ev.clientY - startY;
93
+
94
+ const deltaDx = dx - lastDeltaRef.current.x;
95
+ const deltaDy = dy - lastDeltaRef.current.y;
96
+ lastDeltaRef.current = { x: dx, y: dy };
97
+
98
+ setOffset({
99
+ x: startOffset.x + dx,
100
+ y: startOffset.y + dy,
101
+ });
102
+
103
+ if (selectedIds.size > 1) {
104
+ moveSelectedNodes(deltaDx, deltaDy, nodeId);
105
+ }
106
+
107
+ onDragRef.current?.();
108
+ };
109
+
110
+ const handleUp = () => {
111
+ window.removeEventListener("mousemove", handleMove);
112
+ window.removeEventListener("mouseup", handleUp);
113
+ };
114
+
115
+ window.addEventListener("mousemove", handleMove);
116
+ window.addEventListener("mouseup", handleUp);
117
+ },
118
+ [
119
+ nodeId,
120
+ offset,
121
+ selected,
122
+ selectedIds,
123
+ selectNode,
124
+ toggleSelection,
125
+ clearSelection,
126
+ moveSelectedNodes,
127
+ onConnect,
128
+ ]
129
+ );
130
+
131
+ return (
132
+ <Box
133
+ ref={setRef}
134
+ data-node-id={nodeId}
135
+ onMouseDown={handleMouseDown}
136
+ sx={{
137
+ display: "inline-flex",
138
+ flexDirection: "column",
139
+ alignItems: "center",
140
+ position: "relative",
141
+ transform: `translate(${offset.x}px, ${offset.y}px)`,
142
+ cursor: "grab",
143
+ "&:active": { cursor: "grabbing" },
144
+ ...(selected && {
145
+ "&::after": {
146
+ content: '""',
147
+ position: "absolute",
148
+ inset: -6,
149
+ border: `2px solid ${selectionColor}`,
150
+ borderRadius: "12px",
151
+ pointerEvents: "none",
152
+ boxShadow: `0 0 8px ${selectionColor}66`,
153
+ },
154
+ }),
155
+ }}
156
+ >
157
+ {children}
158
+ </Box>
159
+ );
160
+ };
161
+
162
+ export default DraggableNode;