@canmingir/link 1.2.60 → 1.2.62
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 +1 -1
- package/src/components/nav-section/mini/nav-list.jsx +1 -1
- package/src/config/schemas.js +1 -0
- package/src/lib/Flow/core/Flow.jsx +33 -13
- package/src/lib/Flow/core/FlowBoard.jsx +32 -12
- package/src/lib/Flow/core/FlowNode.jsx +39 -19
- package/src/lib/Flow/core/FlowViewport.jsx +175 -24
- package/src/lib/Flow/utils/viewportUtils.js +81 -0
- package/src/stories/FlowFitView.stories.jsx +173 -0
package/package.json
CHANGED
|
@@ -40,7 +40,7 @@ export default function NavList({ data, depth, slotProps }) {
|
|
|
40
40
|
open={openMenu}
|
|
41
41
|
onMouseEnter={handleOpenMenu}
|
|
42
42
|
onMouseLeave={handleCloseMenu}
|
|
43
|
-
title={data.title}
|
|
43
|
+
title={data.compactTitle ?? data.title}
|
|
44
44
|
path={data.path}
|
|
45
45
|
icon={data.icon}
|
|
46
46
|
info={data.info}
|
package/src/config/schemas.js
CHANGED
|
@@ -55,6 +55,7 @@ export const MenuConfigSchema = Joi.object({
|
|
|
55
55
|
.items(
|
|
56
56
|
Joi.object({
|
|
57
57
|
title: Joi.string().required(),
|
|
58
|
+
compactTitle: Joi.string().optional(),
|
|
58
59
|
icon: Joi.string().required(),
|
|
59
60
|
path: Joi.string().required(),
|
|
60
61
|
external: Joi.boolean().optional().default(false),
|
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
import { Box, alpha } from "@mui/material";
|
|
2
|
-
import React, { useMemo, useState } from "react";
|
|
2
|
+
import React, { forwardRef, useMemo, useState } from "react";
|
|
3
3
|
import { assertLinkedGraph, buildTreeFromLinked } from "../utils/flowUtils";
|
|
4
4
|
|
|
5
5
|
import FlowNode from "./FlowNode";
|
|
6
6
|
import { useGraphOperations } from "../hooks/useGraphOperations";
|
|
7
7
|
|
|
8
|
-
export const Flow = (
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
export const Flow = forwardRef(function Flow(
|
|
9
|
+
{
|
|
10
|
+
data,
|
|
11
|
+
variant = "simple",
|
|
12
|
+
style,
|
|
13
|
+
plugin,
|
|
14
|
+
editable = false,
|
|
15
|
+
onChange,
|
|
16
|
+
height,
|
|
17
|
+
initialZoom,
|
|
18
|
+
centered = false,
|
|
19
|
+
minZoom,
|
|
20
|
+
maxZoom,
|
|
21
|
+
fitViewPadding,
|
|
22
|
+
fitViewMaxZoom,
|
|
23
|
+
fitViewOnMount,
|
|
24
|
+
fitViewOnResize,
|
|
25
|
+
fitViewOnNodesChange,
|
|
26
|
+
onInit,
|
|
27
|
+
},
|
|
28
|
+
ref,
|
|
29
|
+
) {
|
|
19
30
|
const [floatingNodes, setFloatingNodes] = useState([]);
|
|
20
31
|
|
|
21
32
|
const { nodesById, roots } = useMemo(() => assertLinkedGraph(data), [data]);
|
|
@@ -79,6 +90,7 @@ export const Flow = ({
|
|
|
79
90
|
}}
|
|
80
91
|
>
|
|
81
92
|
<FlowNode
|
|
93
|
+
ref={ref}
|
|
82
94
|
node={treeData}
|
|
83
95
|
variant={variant}
|
|
84
96
|
style={style}
|
|
@@ -92,9 +104,17 @@ export const Flow = ({
|
|
|
92
104
|
height={height}
|
|
93
105
|
initialZoom={initialZoom}
|
|
94
106
|
centered={centered}
|
|
107
|
+
minZoom={minZoom}
|
|
108
|
+
maxZoom={maxZoom}
|
|
109
|
+
fitViewPadding={fitViewPadding}
|
|
110
|
+
fitViewMaxZoom={fitViewMaxZoom}
|
|
111
|
+
fitViewOnMount={fitViewOnMount}
|
|
112
|
+
fitViewOnResize={fitViewOnResize}
|
|
113
|
+
fitViewOnNodesChange={fitViewOnNodesChange}
|
|
114
|
+
onInit={onInit}
|
|
95
115
|
/>
|
|
96
116
|
</Box>
|
|
97
117
|
);
|
|
98
|
-
};
|
|
118
|
+
});
|
|
99
119
|
|
|
100
120
|
export default Flow;
|
|
@@ -1,20 +1,31 @@
|
|
|
1
|
-
import React, { useMemo } from "react";
|
|
1
|
+
import React, { forwardRef, useMemo } from "react";
|
|
2
2
|
|
|
3
3
|
import FlowBoardItem from "./FlowBoardItem";
|
|
4
4
|
import FlowViewport from "./FlowViewport";
|
|
5
5
|
import { SelectionProvider } from "../selection/SelectionContext";
|
|
6
6
|
import { getBaseStyleForVariant } from "../styles";
|
|
7
7
|
|
|
8
|
-
export const FlowBoard = (
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
8
|
+
export const FlowBoard = forwardRef(function FlowBoard(
|
|
9
|
+
{
|
|
10
|
+
flows = [],
|
|
11
|
+
variant = "simple",
|
|
12
|
+
style,
|
|
13
|
+
plugin,
|
|
14
|
+
initialZoom = 1,
|
|
15
|
+
height = "100vh",
|
|
16
|
+
gap = 480,
|
|
17
|
+
onFlowPositionChange,
|
|
18
|
+
minZoom,
|
|
19
|
+
maxZoom,
|
|
20
|
+
fitViewPadding,
|
|
21
|
+
fitViewMaxZoom,
|
|
22
|
+
fitViewOnMount,
|
|
23
|
+
fitViewOnResize,
|
|
24
|
+
fitViewOnNodesChange,
|
|
25
|
+
onInit,
|
|
26
|
+
},
|
|
27
|
+
ref,
|
|
28
|
+
) {
|
|
18
29
|
const baseStyle = getBaseStyleForVariant(variant);
|
|
19
30
|
const selectionColor = baseStyle.selectionColor ?? "#64748b";
|
|
20
31
|
|
|
@@ -44,6 +55,7 @@ export const FlowBoard = ({
|
|
|
44
55
|
return (
|
|
45
56
|
<SelectionProvider>
|
|
46
57
|
<FlowViewport
|
|
58
|
+
ref={ref}
|
|
47
59
|
selectionColor={selectionColor}
|
|
48
60
|
nodesById={mergedNodesById}
|
|
49
61
|
variant={variant}
|
|
@@ -51,6 +63,14 @@ export const FlowBoard = ({
|
|
|
51
63
|
plugin={plugin}
|
|
52
64
|
height={height}
|
|
53
65
|
initialZoom={initialZoom}
|
|
66
|
+
minZoom={minZoom}
|
|
67
|
+
maxZoom={maxZoom}
|
|
68
|
+
fitViewPadding={fitViewPadding}
|
|
69
|
+
fitViewMaxZoom={fitViewMaxZoom}
|
|
70
|
+
fitViewOnMount={fitViewOnMount}
|
|
71
|
+
fitViewOnResize={fitViewOnResize}
|
|
72
|
+
fitViewOnNodesChange={fitViewOnNodesChange}
|
|
73
|
+
onInit={onInit}
|
|
54
74
|
>
|
|
55
75
|
{positionedFlows.map(({ flow, id, position }) => (
|
|
56
76
|
<FlowBoardItem
|
|
@@ -73,6 +93,6 @@ export const FlowBoard = ({
|
|
|
73
93
|
</FlowViewport>
|
|
74
94
|
</SelectionProvider>
|
|
75
95
|
);
|
|
76
|
-
};
|
|
96
|
+
});
|
|
77
97
|
|
|
78
98
|
export default FlowBoard;
|
|
@@ -1,26 +1,37 @@
|
|
|
1
1
|
import FlowNodeView from "../nodes/FlowNodeView";
|
|
2
2
|
import FlowViewport from "./FlowViewport";
|
|
3
|
-
import React from "react";
|
|
3
|
+
import React, { forwardRef } from "react";
|
|
4
4
|
import { SelectionProvider } from "../selection/SelectionContext";
|
|
5
5
|
import { getBaseStyleForVariant } from "../styles";
|
|
6
6
|
|
|
7
|
-
const FlowNode = (
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
7
|
+
const FlowNode = forwardRef(function FlowNode(
|
|
8
|
+
{
|
|
9
|
+
isRoot = false,
|
|
10
|
+
onAddNode,
|
|
11
|
+
variant,
|
|
12
|
+
nodesById,
|
|
13
|
+
onPaste,
|
|
14
|
+
onCut,
|
|
15
|
+
onConnect,
|
|
16
|
+
floatingNodes,
|
|
17
|
+
style,
|
|
18
|
+
plugin,
|
|
19
|
+
node,
|
|
20
|
+
height,
|
|
21
|
+
initialZoom,
|
|
22
|
+
centered,
|
|
23
|
+
minZoom,
|
|
24
|
+
maxZoom,
|
|
25
|
+
fitViewPadding,
|
|
26
|
+
fitViewMaxZoom,
|
|
27
|
+
fitViewOnMount,
|
|
28
|
+
fitViewOnResize,
|
|
29
|
+
fitViewOnNodesChange,
|
|
30
|
+
onInit,
|
|
31
|
+
...props
|
|
32
|
+
},
|
|
33
|
+
ref,
|
|
34
|
+
) {
|
|
24
35
|
if (!isRoot) {
|
|
25
36
|
if (!node) return null;
|
|
26
37
|
return (
|
|
@@ -42,6 +53,7 @@ const FlowNode = ({
|
|
|
42
53
|
return (
|
|
43
54
|
<SelectionProvider>
|
|
44
55
|
<FlowViewport
|
|
56
|
+
ref={ref}
|
|
45
57
|
selectionColor={selectionColor}
|
|
46
58
|
nodesById={nodesById}
|
|
47
59
|
onPaste={onPaste}
|
|
@@ -54,6 +66,14 @@ const FlowNode = ({
|
|
|
54
66
|
height={height}
|
|
55
67
|
initialZoom={initialZoom}
|
|
56
68
|
centered={centered}
|
|
69
|
+
minZoom={minZoom}
|
|
70
|
+
maxZoom={maxZoom}
|
|
71
|
+
fitViewPadding={fitViewPadding}
|
|
72
|
+
fitViewMaxZoom={fitViewMaxZoom}
|
|
73
|
+
fitViewOnMount={fitViewOnMount}
|
|
74
|
+
fitViewOnResize={fitViewOnResize}
|
|
75
|
+
fitViewOnNodesChange={fitViewOnNodesChange}
|
|
76
|
+
onInit={onInit}
|
|
57
77
|
>
|
|
58
78
|
{node && (
|
|
59
79
|
<FlowNodeView
|
|
@@ -69,6 +89,6 @@ const FlowNode = ({
|
|
|
69
89
|
</FlowViewport>
|
|
70
90
|
</SelectionProvider>
|
|
71
91
|
);
|
|
72
|
-
};
|
|
92
|
+
});
|
|
73
93
|
|
|
74
94
|
export default FlowNode;
|
|
@@ -1,28 +1,55 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_FIT_VIEW_PADDING,
|
|
3
|
+
DEFAULT_MAX_ZOOM,
|
|
4
|
+
DEFAULT_MIN_ZOOM,
|
|
5
|
+
clampZoomValue,
|
|
6
|
+
computeFitViewState,
|
|
7
|
+
} from "../utils/viewportUtils";
|
|
8
|
+
import React, {
|
|
9
|
+
forwardRef,
|
|
10
|
+
useCallback,
|
|
11
|
+
useEffect,
|
|
12
|
+
useImperativeHandle,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
} from "react";
|
|
2
16
|
|
|
3
17
|
import { Box } from "@mui/material";
|
|
4
18
|
import FloatingGraph from "../graph/FloatingGraph";
|
|
5
19
|
import SelectionOverlay from "../selection/SelectionOverlay";
|
|
6
20
|
import { useSelection } from "../selection/SelectionContext";
|
|
7
21
|
|
|
8
|
-
const FlowViewport = (
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
const FlowViewport = forwardRef(function FlowViewport(
|
|
23
|
+
{
|
|
24
|
+
children,
|
|
25
|
+
selectionColor = "#64748b",
|
|
26
|
+
nodesById,
|
|
27
|
+
onPaste,
|
|
28
|
+
onCut,
|
|
29
|
+
onConnect,
|
|
30
|
+
floatingNodes = [],
|
|
31
|
+
variant,
|
|
32
|
+
style,
|
|
33
|
+
plugin,
|
|
34
|
+
height = "100vh",
|
|
35
|
+
initialZoom = 1,
|
|
36
|
+
centered = false,
|
|
37
|
+
minZoom = DEFAULT_MIN_ZOOM,
|
|
38
|
+
maxZoom = DEFAULT_MAX_ZOOM,
|
|
39
|
+
fitViewPadding = DEFAULT_FIT_VIEW_PADDING,
|
|
40
|
+
fitViewMaxZoom = 1,
|
|
41
|
+
fitViewOnMount = false,
|
|
42
|
+
fitViewOnResize = false,
|
|
43
|
+
fitViewOnNodesChange = false,
|
|
44
|
+
onInit,
|
|
45
|
+
sx = {},
|
|
46
|
+
...rest
|
|
47
|
+
},
|
|
48
|
+
ref,
|
|
49
|
+
) {
|
|
50
|
+
const clampZoom = (z) => clampZoomValue(z, minZoom, maxZoom);
|
|
51
|
+
|
|
52
|
+
const usesFitView = fitViewOnMount || fitViewOnResize || fitViewOnNodesChange;
|
|
26
53
|
|
|
27
54
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
|
28
55
|
const [zoom, setZoom] = useState(initialZoom);
|
|
@@ -46,7 +73,7 @@ const FlowViewport = ({
|
|
|
46
73
|
} = useSelection();
|
|
47
74
|
|
|
48
75
|
useEffect(() => {
|
|
49
|
-
if (centered) return;
|
|
76
|
+
if (centered || usesFitView) return;
|
|
50
77
|
|
|
51
78
|
const container = containerRef.current;
|
|
52
79
|
const inner = innerRef.current;
|
|
@@ -67,7 +94,123 @@ const FlowViewport = ({
|
|
|
67
94
|
observer.observe(container);
|
|
68
95
|
|
|
69
96
|
return () => observer.disconnect();
|
|
70
|
-
}, [centered]);
|
|
97
|
+
}, [centered, usesFitView]);
|
|
98
|
+
|
|
99
|
+
const runFitView = useCallback(
|
|
100
|
+
(options = {}) => {
|
|
101
|
+
const fit = computeFitViewState(containerRef.current, {
|
|
102
|
+
zoom,
|
|
103
|
+
offset,
|
|
104
|
+
padding: options.padding ?? fitViewPadding,
|
|
105
|
+
minZoom: options.minZoom ?? minZoom,
|
|
106
|
+
maxZoom: options.maxZoom ?? fitViewMaxZoom,
|
|
107
|
+
});
|
|
108
|
+
if (!fit) return;
|
|
109
|
+
setZoom(fit.zoom);
|
|
110
|
+
setOffset(fit.offset);
|
|
111
|
+
},
|
|
112
|
+
[zoom, offset, fitViewPadding, minZoom, fitViewMaxZoom],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const zoomIn = useCallback(
|
|
116
|
+
(step = 1.2) => setZoom((z) => clampZoom(z * step)),
|
|
117
|
+
[minZoom, maxZoom],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const zoomOut = useCallback(
|
|
121
|
+
(step = 1.2) => setZoom((z) => clampZoom(z / step)),
|
|
122
|
+
[minZoom, maxZoom],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const setZoomPublic = useCallback(
|
|
126
|
+
(z) => setZoom(clampZoom(z)),
|
|
127
|
+
[minZoom, maxZoom],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const setCenter = useCallback(
|
|
131
|
+
(x, y, options = {}) => {
|
|
132
|
+
const targetZoom = clampZoom(options.zoom ?? zoom);
|
|
133
|
+
setZoom(targetZoom);
|
|
134
|
+
setOffset({ x: -targetZoom * x, y: -targetZoom * y });
|
|
135
|
+
},
|
|
136
|
+
[zoom, minZoom, maxZoom],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const getZoom = useCallback(() => zoom, [zoom]);
|
|
140
|
+
|
|
141
|
+
const runFitViewRef = useRef(runFitView);
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
runFitViewRef.current = runFitView;
|
|
144
|
+
}, [runFitView]);
|
|
145
|
+
|
|
146
|
+
useImperativeHandle(
|
|
147
|
+
ref,
|
|
148
|
+
() => ({
|
|
149
|
+
fitView: runFitView,
|
|
150
|
+
zoomIn,
|
|
151
|
+
zoomOut,
|
|
152
|
+
setZoom: setZoomPublic,
|
|
153
|
+
setCenter,
|
|
154
|
+
getZoom,
|
|
155
|
+
}),
|
|
156
|
+
[runFitView, zoomIn, zoomOut, setZoomPublic, setCenter, getZoom],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!fitViewOnMount) return;
|
|
161
|
+
runFitView();
|
|
162
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!fitViewOnResize) return;
|
|
167
|
+
|
|
168
|
+
const container = containerRef.current;
|
|
169
|
+
if (!container) return;
|
|
170
|
+
|
|
171
|
+
let frame = null;
|
|
172
|
+
let isFirstCallback = true;
|
|
173
|
+
const observer = new ResizeObserver(() => {
|
|
174
|
+
if (isFirstCallback) {
|
|
175
|
+
isFirstCallback = false;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (frame) cancelAnimationFrame(frame);
|
|
179
|
+
frame = requestAnimationFrame(() => runFitViewRef.current());
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
observer.observe(container);
|
|
183
|
+
|
|
184
|
+
return () => {
|
|
185
|
+
if (frame) cancelAnimationFrame(frame);
|
|
186
|
+
observer.disconnect();
|
|
187
|
+
};
|
|
188
|
+
}, [fitViewOnResize]);
|
|
189
|
+
|
|
190
|
+
const nodeCount = nodesById ? Object.keys(nodesById).length : 0;
|
|
191
|
+
const previousNodeCountRef = useRef(nodeCount);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!fitViewOnNodesChange) return;
|
|
195
|
+
|
|
196
|
+
if (previousNodeCountRef.current !== nodeCount) {
|
|
197
|
+
previousNodeCountRef.current = nodeCount;
|
|
198
|
+
const frame = requestAnimationFrame(() => runFitViewRef.current());
|
|
199
|
+
return () => cancelAnimationFrame(frame);
|
|
200
|
+
}
|
|
201
|
+
}, [fitViewOnNodesChange, nodeCount]);
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
onInit?.({
|
|
205
|
+
fitView: runFitView,
|
|
206
|
+
zoomIn,
|
|
207
|
+
zoomOut,
|
|
208
|
+
setZoom: setZoomPublic,
|
|
209
|
+
setCenter,
|
|
210
|
+
getZoom,
|
|
211
|
+
});
|
|
212
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
213
|
+
}, []);
|
|
71
214
|
|
|
72
215
|
useEffect(() => {
|
|
73
216
|
const handleMouseMove = (e) => {
|
|
@@ -313,11 +456,19 @@ const FlowViewport = ({
|
|
|
313
456
|
height: height,
|
|
314
457
|
display: "flex",
|
|
315
458
|
alignItems: "center",
|
|
316
|
-
justifyContent:
|
|
459
|
+
justifyContent:
|
|
460
|
+
centered || (!usesFitView && shouldCenter)
|
|
461
|
+
? "center"
|
|
462
|
+
: "flex-start",
|
|
317
463
|
transition: isDragging ? "none" : "transform 0.1s ease-out",
|
|
318
464
|
pointerEvents: "auto",
|
|
319
465
|
position: "relative",
|
|
320
|
-
pl:
|
|
466
|
+
pl:
|
|
467
|
+
centered || (!usesFitView && shouldCenter)
|
|
468
|
+
? 0
|
|
469
|
+
: variant === "horizontal"
|
|
470
|
+
? 4
|
|
471
|
+
: 0,
|
|
321
472
|
}}
|
|
322
473
|
>
|
|
323
474
|
{children}
|
|
@@ -340,6 +491,6 @@ const FlowViewport = ({
|
|
|
340
491
|
</Box>
|
|
341
492
|
</Box>
|
|
342
493
|
);
|
|
343
|
-
};
|
|
494
|
+
});
|
|
344
495
|
|
|
345
496
|
export default FlowViewport;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const DEFAULT_MIN_ZOOM = 0.25;
|
|
2
|
+
export const DEFAULT_MAX_ZOOM = 2.5;
|
|
3
|
+
export const DEFAULT_FIT_VIEW_PADDING = 40;
|
|
4
|
+
|
|
5
|
+
export const clampZoomValue = (zoom, minZoom, maxZoom) =>
|
|
6
|
+
Math.min(maxZoom, Math.max(minZoom, zoom));
|
|
7
|
+
|
|
8
|
+
export const computeFitTransform = ({
|
|
9
|
+
bounds,
|
|
10
|
+
containerWidth,
|
|
11
|
+
containerHeight,
|
|
12
|
+
padding = DEFAULT_FIT_VIEW_PADDING,
|
|
13
|
+
minZoom = DEFAULT_MIN_ZOOM,
|
|
14
|
+
maxZoom = DEFAULT_MAX_ZOOM,
|
|
15
|
+
}) => {
|
|
16
|
+
const availableWidth = Math.max(0, containerWidth - 2 * padding);
|
|
17
|
+
const availableHeight = Math.max(0, containerHeight - 2 * padding);
|
|
18
|
+
|
|
19
|
+
const scaleX = bounds.width > 0 ? availableWidth / bounds.width : Infinity;
|
|
20
|
+
const scaleY = bounds.height > 0 ? availableHeight / bounds.height : Infinity;
|
|
21
|
+
const rawZoom = Math.min(scaleX, scaleY);
|
|
22
|
+
|
|
23
|
+
const zoom = clampZoomValue(rawZoom, minZoom, maxZoom);
|
|
24
|
+
|
|
25
|
+
const centerX = bounds.x + bounds.width / 2;
|
|
26
|
+
const centerY = bounds.y + bounds.height / 2;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
zoom,
|
|
30
|
+
offset: {
|
|
31
|
+
x: -zoom * centerX,
|
|
32
|
+
y: -zoom * centerY,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const computeContentBounds = (containerEl, { zoom, offset }) => {
|
|
38
|
+
if (!containerEl) return null;
|
|
39
|
+
|
|
40
|
+
const nodeEls = containerEl.querySelectorAll("[data-node-id]");
|
|
41
|
+
if (nodeEls.length === 0) return null;
|
|
42
|
+
|
|
43
|
+
const containerRect = containerEl.getBoundingClientRect();
|
|
44
|
+
const originX = containerRect.left + containerRect.width / 2;
|
|
45
|
+
const originY = containerRect.top + containerRect.height / 2;
|
|
46
|
+
|
|
47
|
+
const toContentX = (screenX) => (screenX - originX - offset.x) / zoom;
|
|
48
|
+
const toContentY = (screenY) => (screenY - originY - offset.y) / zoom;
|
|
49
|
+
|
|
50
|
+
let minX = Infinity;
|
|
51
|
+
let minY = Infinity;
|
|
52
|
+
let maxX = -Infinity;
|
|
53
|
+
let maxY = -Infinity;
|
|
54
|
+
|
|
55
|
+
nodeEls.forEach((el) => {
|
|
56
|
+
const rect = el.getBoundingClientRect();
|
|
57
|
+
minX = Math.min(minX, toContentX(rect.left));
|
|
58
|
+
maxX = Math.max(maxX, toContentX(rect.right));
|
|
59
|
+
minY = Math.min(minY, toContentY(rect.top));
|
|
60
|
+
maxY = Math.max(maxY, toContentY(rect.bottom));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const computeFitViewState = (
|
|
67
|
+
containerEl,
|
|
68
|
+
{ zoom, offset, padding, minZoom, maxZoom },
|
|
69
|
+
) => {
|
|
70
|
+
const bounds = computeContentBounds(containerEl, { zoom, offset });
|
|
71
|
+
if (!bounds) return null;
|
|
72
|
+
|
|
73
|
+
return computeFitTransform({
|
|
74
|
+
bounds,
|
|
75
|
+
containerWidth: containerEl.clientWidth,
|
|
76
|
+
containerHeight: containerEl.clientHeight,
|
|
77
|
+
padding,
|
|
78
|
+
minZoom,
|
|
79
|
+
maxZoom,
|
|
80
|
+
});
|
|
81
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Button, Stack } from "@mui/material";
|
|
2
|
+
import React, { useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { Flow } from "../lib/Flow";
|
|
5
|
+
|
|
6
|
+
const treeToLinked = (tree) => {
|
|
7
|
+
if (!tree) return { nodes: {}, roots: [] };
|
|
8
|
+
|
|
9
|
+
const nodes = {};
|
|
10
|
+
const roots = [];
|
|
11
|
+
|
|
12
|
+
const walk = (node, parentId = null) => {
|
|
13
|
+
if (!node || !node.id) return;
|
|
14
|
+
|
|
15
|
+
const { children, ...rest } = node;
|
|
16
|
+
if (!nodes[node.id]) {
|
|
17
|
+
nodes[node.id] = { ...rest, id: node.id };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const kids = Array.isArray(children) ? children : [];
|
|
21
|
+
const nextIds = kids.map((c) => c.id).filter(Boolean);
|
|
22
|
+
|
|
23
|
+
if (nextIds.length === 1) {
|
|
24
|
+
nodes[node.id].next = nextIds[0];
|
|
25
|
+
} else if (nextIds.length > 1) {
|
|
26
|
+
nodes[node.id].next = nextIds;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (parentId) {
|
|
30
|
+
nodes[node.id].previous = parentId;
|
|
31
|
+
} else if (!roots.includes(node.id)) {
|
|
32
|
+
roots.push(node.id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
kids.forEach((child) => walk(child, node.id));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
walk(tree);
|
|
39
|
+
return { nodes, roots };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
title: "Components/FlowFitView",
|
|
44
|
+
component: Flow,
|
|
45
|
+
parameters: {
|
|
46
|
+
layout: "fullscreen",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const wideTree = {
|
|
51
|
+
id: "root",
|
|
52
|
+
label: "Root",
|
|
53
|
+
children: [
|
|
54
|
+
{
|
|
55
|
+
id: "branch1",
|
|
56
|
+
label: "Branch 1",
|
|
57
|
+
children: [
|
|
58
|
+
{ id: "leaf1-1", label: "Leaf 1.1", children: [] },
|
|
59
|
+
{ id: "leaf1-2", label: "Leaf 1.2", children: [] },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "branch2",
|
|
64
|
+
label: "Branch 2",
|
|
65
|
+
children: [
|
|
66
|
+
{ id: "leaf2-1", label: "Leaf 2.1", children: [] },
|
|
67
|
+
{ id: "leaf2-2", label: "Leaf 2.2", children: [] },
|
|
68
|
+
{ id: "leaf2-3", label: "Leaf 2.3", children: [] },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "branch3",
|
|
73
|
+
label: "Branch 3",
|
|
74
|
+
children: [{ id: "leaf3-1", label: "Leaf 3.1", children: [] }],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const FitViewDemo = {
|
|
80
|
+
render: () => {
|
|
81
|
+
const flowRef = useRef(null);
|
|
82
|
+
const data = treeToLinked(wideTree);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Stack sx={{ height: "100vh" }}>
|
|
86
|
+
<Stack direction="row" spacing={1} sx={{ p: 1 }}>
|
|
87
|
+
<Button
|
|
88
|
+
variant="contained"
|
|
89
|
+
onClick={() => flowRef.current?.fitView()}
|
|
90
|
+
>
|
|
91
|
+
Fit View
|
|
92
|
+
</Button>
|
|
93
|
+
<Button variant="outlined" onClick={() => flowRef.current?.zoomIn()}>
|
|
94
|
+
Zoom In
|
|
95
|
+
</Button>
|
|
96
|
+
<Button
|
|
97
|
+
variant="outlined"
|
|
98
|
+
onClick={() => flowRef.current?.zoomOut()}
|
|
99
|
+
>
|
|
100
|
+
Zoom Out
|
|
101
|
+
</Button>
|
|
102
|
+
</Stack>
|
|
103
|
+
<Flow
|
|
104
|
+
ref={flowRef}
|
|
105
|
+
data={data}
|
|
106
|
+
variant="simple"
|
|
107
|
+
height="calc(100vh - 56px)"
|
|
108
|
+
minZoom={0.25}
|
|
109
|
+
maxZoom={2.5}
|
|
110
|
+
fitViewPadding={60}
|
|
111
|
+
fitViewMaxZoom={1}
|
|
112
|
+
fitViewOnMount
|
|
113
|
+
/>
|
|
114
|
+
</Stack>
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const FitViewOnNodesChangeDemo = {
|
|
120
|
+
render: () => {
|
|
121
|
+
const flowRef = useRef(null);
|
|
122
|
+
const [tree, setTree] = useState(wideTree);
|
|
123
|
+
const data = treeToLinked(tree);
|
|
124
|
+
|
|
125
|
+
const addLeafToBranch1 = () => {
|
|
126
|
+
setTree((prev) => {
|
|
127
|
+
const branch1 = prev.children.find((c) => c.id === "branch1");
|
|
128
|
+
const nextIndex = branch1.children.length + 1;
|
|
129
|
+
const newLeaf = {
|
|
130
|
+
id: `leaf1-${nextIndex}-${Date.now()}`,
|
|
131
|
+
label: `Leaf 1.${nextIndex}`,
|
|
132
|
+
children: [],
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
...prev,
|
|
136
|
+
children: prev.children.map((c) =>
|
|
137
|
+
c.id === "branch1"
|
|
138
|
+
? { ...c, children: [...c.children, newLeaf] }
|
|
139
|
+
: c,
|
|
140
|
+
),
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<Stack sx={{ height: "100vh" }}>
|
|
147
|
+
<Stack direction="row" spacing={1} sx={{ p: 1 }}>
|
|
148
|
+
<Button variant="contained" onClick={addLeafToBranch1}>
|
|
149
|
+
Add Node
|
|
150
|
+
</Button>
|
|
151
|
+
<Button
|
|
152
|
+
variant="outlined"
|
|
153
|
+
onClick={() => flowRef.current?.fitView()}
|
|
154
|
+
>
|
|
155
|
+
Fit View
|
|
156
|
+
</Button>
|
|
157
|
+
</Stack>
|
|
158
|
+
<Flow
|
|
159
|
+
ref={flowRef}
|
|
160
|
+
data={data}
|
|
161
|
+
variant="simple"
|
|
162
|
+
height="calc(100vh - 56px)"
|
|
163
|
+
minZoom={0.25}
|
|
164
|
+
maxZoom={2.5}
|
|
165
|
+
fitViewPadding={60}
|
|
166
|
+
fitViewMaxZoom={1}
|
|
167
|
+
fitViewOnMount
|
|
168
|
+
fitViewOnNodesChange
|
|
169
|
+
/>
|
|
170
|
+
</Stack>
|
|
171
|
+
);
|
|
172
|
+
},
|
|
173
|
+
};
|