@canmingir/link 1.2.10 → 1.2.11

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.10",
3
+ "version": "1.2.11",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.js",
@@ -0,0 +1,79 @@
1
+ import FlowNode from "./FlowNode";
2
+ import { useGraphOperations } from "../hooks/useGraphOperations";
3
+
4
+ import React, { useMemo, useState } from "react";
5
+ import { assertLinkedGraph, buildTreeFromLinked } from "../utils/flowUtils";
6
+
7
+ export const Flow = ({
8
+ data,
9
+ variant = "simple",
10
+ style,
11
+ plugin,
12
+ editable = false,
13
+ onChange,
14
+ }) => {
15
+ const [floatingNodes, setFloatingNodes] = useState([]);
16
+
17
+ const { nodesById, roots } = useMemo(() => assertLinkedGraph(data), [data]);
18
+
19
+ const { handleCut, handlePaste, handleConnect } = useGraphOperations({
20
+ nodesById,
21
+ roots,
22
+ onChange,
23
+ floatingNodes,
24
+ setFloatingNodes,
25
+ editable,
26
+ });
27
+
28
+ const allNodesById = useMemo(() => {
29
+ if (!floatingNodes.length) return nodesById;
30
+
31
+ const merged = { ...nodesById };
32
+
33
+ for (const structure of floatingNodes) {
34
+ if (structure?.nodes) {
35
+ Object.assign(merged, structure.nodes);
36
+ }
37
+ }
38
+
39
+ return merged;
40
+ }, [nodesById, floatingNodes]);
41
+
42
+ const treeData = useMemo(() => {
43
+ if (!roots?.length) return null;
44
+
45
+ if (roots.length === 1) {
46
+ return (
47
+ buildTreeFromLinked(roots[0], nodesById) || {
48
+ id: roots[0],
49
+ children: [],
50
+ }
51
+ );
52
+ }
53
+
54
+ const children = roots
55
+ .map((r) => buildTreeFromLinked(r, nodesById))
56
+ .filter(Boolean);
57
+
58
+ return children.length > 0
59
+ ? { id: "__root__", label: "Start", children }
60
+ : null;
61
+ }, [nodesById, roots]);
62
+
63
+ return (
64
+ <FlowNode
65
+ node={treeData}
66
+ variant={variant}
67
+ style={style}
68
+ plugin={plugin}
69
+ isRoot={true}
70
+ nodesById={allNodesById}
71
+ onPaste={editable ? handlePaste : undefined}
72
+ onCut={editable ? handleCut : undefined}
73
+ onConnect={editable ? handleConnect : undefined}
74
+ floatingNodes={floatingNodes}
75
+ />
76
+ );
77
+ };
78
+
79
+ export default Flow;
@@ -0,0 +1,68 @@
1
+ import FlowNodeView from "../nodes/FlowNodeView";
2
+ import FlowViewport from "./FlowViewport";
3
+ import React from "react";
4
+ import { SelectionProvider } from "../selection/SelectionContext";
5
+ import { getBaseStyleForVariant } from "../styles";
6
+
7
+ const FlowNode = ({
8
+ isRoot = false,
9
+ onAddNode,
10
+ variant,
11
+ nodesById,
12
+ onPaste,
13
+ onCut,
14
+ onConnect,
15
+ floatingNodes,
16
+ style,
17
+ plugin,
18
+ node,
19
+ ...props
20
+ }) => {
21
+ if (!isRoot) {
22
+ if (!node) return null;
23
+ return (
24
+ <FlowNodeView
25
+ node={node}
26
+ onAddNode={onAddNode}
27
+ variant={variant}
28
+ style={style}
29
+ plugin={plugin}
30
+ onConnect={onConnect}
31
+ {...props}
32
+ />
33
+ );
34
+ }
35
+
36
+ const baseStyle = getBaseStyleForVariant(variant);
37
+ const selectionColor = baseStyle.selectionColor ?? "#64748b";
38
+
39
+ return (
40
+ <SelectionProvider>
41
+ <FlowViewport
42
+ selectionColor={selectionColor}
43
+ nodesById={nodesById}
44
+ onPaste={onPaste}
45
+ onCut={onCut}
46
+ onConnect={onConnect}
47
+ floatingNodes={floatingNodes}
48
+ variant={variant}
49
+ style={style}
50
+ plugin={plugin}
51
+ >
52
+ {node && (
53
+ <FlowNodeView
54
+ node={node}
55
+ onAddNode={onAddNode}
56
+ variant={variant}
57
+ style={style}
58
+ plugin={plugin}
59
+ onConnect={onConnect}
60
+ {...props}
61
+ />
62
+ )}
63
+ </FlowViewport>
64
+ </SelectionProvider>
65
+ );
66
+ };
67
+
68
+ export default FlowNode;
@@ -0,0 +1,259 @@
1
+ import { Box } from "@mui/material";
2
+ import FloatingGraph from "../graph/FloatingGraph";
3
+ import SelectionOverlay from "../selection/SelectionOverlay";
4
+ import { useSelection } from "../selection/SelectionContext";
5
+
6
+ import React, { useEffect, useRef, useState } from "react";
7
+
8
+ const FlowViewport = ({
9
+ children,
10
+ selectionColor = "#64748b",
11
+ nodesById,
12
+ onPaste,
13
+ onCut,
14
+ onConnect,
15
+ floatingNodes = [],
16
+ variant,
17
+ style,
18
+ plugin,
19
+ }) => {
20
+ const clampZoom = (zoom) => Math.min(2.5, Math.max(0.25, zoom));
21
+
22
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
23
+ const [zoom, setZoom] = useState(1);
24
+ const [isDragging, setIsDragging] = useState(false);
25
+ const [selectionBox, setSelectionBox] = useState(null);
26
+
27
+ const containerRef = useRef(null);
28
+ const selectionBoxRef = useRef(null);
29
+ const mousePositionRef = useRef({ x: 0, y: 0 });
30
+
31
+ const {
32
+ clearSelection,
33
+ selectMultiple,
34
+ addToSelection,
35
+ cutSelectedNodes,
36
+ pasteNodes,
37
+ selectedIds,
38
+ } = useSelection();
39
+
40
+ useEffect(() => {
41
+ const handleMouseMove = (e) => {
42
+ mousePositionRef.current = { x: e.clientX, y: e.clientY };
43
+ };
44
+
45
+ window.addEventListener("mousemove", handleMouseMove);
46
+
47
+ return () => {
48
+ window.removeEventListener("mousemove", handleMouseMove);
49
+ };
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ const onWheel = (e) => {
54
+ const wantsZoom = e.ctrlKey || e.metaKey;
55
+ if (!wantsZoom) return;
56
+ e.preventDefault();
57
+ const direction = e.deltaY > 0 ? -1 : 1;
58
+ const factor = direction > 0 ? 1.1 : 1 / 1.1;
59
+ setZoom((z) => clampZoom(z * factor));
60
+ };
61
+ window.addEventListener("wheel", onWheel, { passive: false });
62
+ return () => window.removeEventListener("wheel", onWheel);
63
+ }, []);
64
+
65
+ useEffect(() => {
66
+ const handleKeyDown = (e) => {
67
+ const isMod = e.ctrlKey || e.metaKey;
68
+ if (!isMod) return;
69
+
70
+ if (e.key === "x" && selectedIds.size > 0 && nodesById) {
71
+ e.preventDefault();
72
+ cutSelectedNodes(nodesById, onCut);
73
+ }
74
+
75
+ if (e.key === "v" && onPaste) {
76
+ e.preventDefault();
77
+ const containerRect = containerRef.current?.getBoundingClientRect();
78
+ const mousePos = mousePositionRef.current;
79
+
80
+ const canvasX = containerRect
81
+ ? (mousePos.x -
82
+ containerRect.left -
83
+ containerRect.width / 2 -
84
+ offset.x) /
85
+ zoom
86
+ : 0;
87
+ const canvasY = containerRect
88
+ ? (mousePos.y -
89
+ containerRect.top -
90
+ containerRect.height / 2 -
91
+ offset.y) /
92
+ zoom
93
+ : 0;
94
+
95
+ pasteNodes(onPaste, canvasX, canvasY);
96
+ }
97
+ };
98
+
99
+ window.addEventListener("keydown", handleKeyDown);
100
+ return () => window.removeEventListener("keydown", handleKeyDown);
101
+ }, [
102
+ cutSelectedNodes,
103
+ pasteNodes,
104
+ selectedIds,
105
+ nodesById,
106
+ onPaste,
107
+ onCut,
108
+ offset,
109
+ zoom,
110
+ ]);
111
+
112
+ const handleViewportMouseDown = (e) => {
113
+ if (e.target?.closest?.(".MuiCard-root") || e.target?.closest?.("button"))
114
+ return;
115
+
116
+ if (e.button !== 0) return;
117
+
118
+ const startX = e.clientX;
119
+ const startY = e.clientY;
120
+
121
+ if (e.shiftKey || e.ctrlKey || e.metaKey) {
122
+ setSelectionBox({ startX, startY, currentX: startX, currentY: startY });
123
+ selectionBoxRef.current = {
124
+ startX,
125
+ startY,
126
+ currentX: startX,
127
+ currentY: startY,
128
+ };
129
+
130
+ const onMove = (ev) => {
131
+ const newBox = {
132
+ startX,
133
+ startY,
134
+ currentX: ev.clientX,
135
+ currentY: ev.clientY,
136
+ };
137
+ setSelectionBox(newBox);
138
+ selectionBoxRef.current = newBox;
139
+ };
140
+
141
+ const onUp = () => {
142
+ if (containerRef.current && selectionBoxRef.current) {
143
+ const box = selectionBoxRef.current;
144
+ const nodes = containerRef.current.querySelectorAll("[data-node-id]");
145
+ const selectedNodeIds = [];
146
+
147
+ const boxLeft = Math.min(box.startX, box.currentX);
148
+ const boxRight = Math.max(box.startX, box.currentX);
149
+ const boxTop = Math.min(box.startY, box.currentY);
150
+ const boxBottom = Math.max(box.startY, box.currentY);
151
+
152
+ nodes.forEach((node) => {
153
+ const rect = node.getBoundingClientRect();
154
+
155
+ if (
156
+ rect.left < boxRight &&
157
+ rect.right > boxLeft &&
158
+ rect.top < boxBottom &&
159
+ rect.bottom > boxTop
160
+ ) {
161
+ const nodeId = node.getAttribute("data-node-id");
162
+ if (nodeId) selectedNodeIds.push(nodeId);
163
+ }
164
+ });
165
+
166
+ if (selectedNodeIds.length > 0) {
167
+ if (e.shiftKey) {
168
+ addToSelection(selectedNodeIds);
169
+ } else {
170
+ selectMultiple(selectedNodeIds);
171
+ }
172
+ }
173
+ }
174
+
175
+ setSelectionBox(null);
176
+ selectionBoxRef.current = null;
177
+ window.removeEventListener("mousemove", onMove);
178
+ window.removeEventListener("mouseup", onUp);
179
+ };
180
+
181
+ window.addEventListener("mousemove", onMove);
182
+ window.addEventListener("mouseup", onUp);
183
+ return;
184
+ }
185
+
186
+ clearSelection();
187
+
188
+ setIsDragging(true);
189
+ const startOffset = { ...offset };
190
+
191
+ const onMove = (ev) => {
192
+ setOffset({
193
+ x: startOffset.x + (ev.clientX - startX),
194
+ y: startOffset.y + (ev.clientY - startY),
195
+ });
196
+ };
197
+
198
+ const onUp = () => {
199
+ setIsDragging(false);
200
+ window.removeEventListener("mousemove", onMove);
201
+ window.removeEventListener("mouseup", onUp);
202
+ };
203
+
204
+ window.addEventListener("mousemove", onMove);
205
+ window.addEventListener("mouseup", onUp);
206
+ };
207
+
208
+ return (
209
+ <Box
210
+ ref={containerRef}
211
+ onMouseDown={handleViewportMouseDown}
212
+ sx={{
213
+ width: "100vw",
214
+ height: "100vh",
215
+ overflow: "hidden",
216
+ bgcolor: "none",
217
+ cursor: isDragging ? "grabbing" : "default",
218
+ userSelect: "none",
219
+ position: "relative",
220
+ }}
221
+ >
222
+ <SelectionOverlay box={selectionBox} selectionColor={selectionColor} />
223
+ <Box
224
+ sx={{
225
+ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
226
+ transformOrigin: "center center",
227
+ width: "100%",
228
+ height: "100%",
229
+ display: "flex",
230
+ alignItems: "center",
231
+ justifyContent: "center",
232
+ transition: isDragging ? "none" : "transform 0.1s ease-out",
233
+ pointerEvents: "auto",
234
+ position: "relative",
235
+ }}
236
+ >
237
+ {children}
238
+ {floatingNodes.map((structure, index) => {
239
+ const structureKey =
240
+ (structure && (structure.id || structure.key)) ??
241
+ `floating-${index}`;
242
+ return (
243
+ <FloatingGraph
244
+ key={structureKey}
245
+ structure={structure}
246
+ variant={variant}
247
+ style={style}
248
+ plugin={plugin}
249
+ selectionColor={selectionColor}
250
+ onConnect={onConnect}
251
+ />
252
+ );
253
+ })}
254
+ </Box>
255
+ </Box>
256
+ );
257
+ };
258
+
259
+ export default FlowViewport;
@@ -0,0 +1,44 @@
1
+ import { Box } from "@mui/material";
2
+ import FlowNodeView from "../nodes/FlowNodeView";
3
+ import { buildDetachedTree } from "../utils/flowUtils";
4
+
5
+ import React, { useMemo } from "react";
6
+
7
+ const FloatingGraph = ({ structure, variant, style, plugin, onConnect }) => {
8
+ const position = structure?._pastePosition || { x: 0, y: 0 };
9
+
10
+ const treesData = useMemo(() => {
11
+ if (!structure?.roots?.length || !structure?.nodes) return [];
12
+ return structure.roots
13
+ .map((rootId) => buildDetachedTree(rootId, structure.nodes))
14
+ .filter(Boolean);
15
+ }, [structure]);
16
+
17
+ if (!treesData.length) return null;
18
+
19
+ return (
20
+ <Box
21
+ sx={{
22
+ position: "absolute",
23
+ left: "50%",
24
+ top: "50%",
25
+ transform: `translate(${position.x}px, ${position.y}px)`,
26
+ display: "flex",
27
+ gap: 2,
28
+ }}
29
+ >
30
+ {treesData.map((tree, idx) => (
31
+ <FlowNodeView
32
+ key={tree.id || `floating-${idx}`}
33
+ node={tree}
34
+ variant={variant}
35
+ style={style}
36
+ plugin={plugin}
37
+ onConnect={onConnect}
38
+ />
39
+ ))}
40
+ </Box>
41
+ );
42
+ };
43
+
44
+ export default FloatingGraph;