@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canmingir/link",
3
- "version": "1.2.60",
3
+ "version": "1.2.62",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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}
@@ -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
- data,
10
- variant = "simple",
11
- style,
12
- plugin,
13
- editable = false,
14
- onChange,
15
- height,
16
- initialZoom,
17
- centered = false,
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
- flows = [],
10
- variant = "simple",
11
- style,
12
- plugin,
13
- initialZoom = 1,
14
- height = "100vh",
15
- gap = 480,
16
- onFlowPositionChange,
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
- isRoot = false,
9
- onAddNode,
10
- variant,
11
- nodesById,
12
- onPaste,
13
- onCut,
14
- onConnect,
15
- floatingNodes,
16
- style,
17
- plugin,
18
- node,
19
- height,
20
- initialZoom,
21
- centered,
22
- ...props
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 React, { useEffect, useRef, useState } from "react";
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
- children,
10
- selectionColor = "#64748b",
11
- nodesById,
12
- onPaste,
13
- onCut,
14
- onConnect,
15
- floatingNodes = [],
16
- variant,
17
- style,
18
- plugin,
19
- height = "100vh",
20
- initialZoom = 1,
21
- centered = false,
22
- sx = {},
23
- ...rest
24
- }) => {
25
- const clampZoom = (zoom) => Math.min(2.5, Math.max(0.25, zoom));
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: centered || shouldCenter ? "center" : "flex-start",
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: centered ? 0 : variant === "horizontal" ? 4 : 0,
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
+ };