react-native-tree-multi-select 3.0.0-beta.5 → 3.0.0-beta.7
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/README.md +57 -26
- package/lib/module/TreeView.js +130 -24
- package/lib/module/TreeView.js.map +1 -1
- package/lib/module/components/DragOverlay.js +19 -2
- package/lib/module/components/DragOverlay.js.map +1 -1
- package/lib/module/components/NodeList.js +82 -29
- package/lib/module/components/NodeList.js.map +1 -1
- package/lib/module/constants/treeView.constants.js +5 -0
- package/lib/module/constants/treeView.constants.js.map +1 -1
- package/lib/module/helpers/moveTreeNode.helper.js +175 -47
- package/lib/module/helpers/moveTreeNode.helper.js.map +1 -1
- package/lib/module/helpers/toggleCheckbox.helper.js +5 -12
- package/lib/module/helpers/toggleCheckbox.helper.js.map +1 -1
- package/lib/module/helpers/treeNode.helper.js +49 -0
- package/lib/module/helpers/treeNode.helper.js.map +1 -1
- package/lib/module/hooks/useDragDrop.js +470 -186
- package/lib/module/hooks/useDragDrop.js.map +1 -1
- package/lib/module/hooks/useScrollToNode.js +17 -0
- package/lib/module/hooks/useScrollToNode.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/store/treeView.store.js +7 -0
- package/lib/module/store/treeView.store.js.map +1 -1
- package/lib/module/types/dragDrop.types.js +0 -2
- package/lib/typescript/src/TreeView.d.ts.map +1 -1
- package/lib/typescript/src/components/DragOverlay.d.ts.map +1 -1
- package/lib/typescript/src/components/NodeList.d.ts.map +1 -1
- package/lib/typescript/src/constants/treeView.constants.d.ts +4 -0
- package/lib/typescript/src/constants/treeView.constants.d.ts.map +1 -1
- package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts +32 -0
- package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts.map +1 -1
- package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts.map +1 -1
- package/lib/typescript/src/helpers/treeNode.helper.d.ts +15 -0
- package/lib/typescript/src/helpers/treeNode.helper.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useDragDrop.d.ts +24 -6
- package/lib/typescript/src/hooks/useDragDrop.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useScrollToNode.d.ts +10 -0
- package/lib/typescript/src/hooks/useScrollToNode.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/store/treeView.store.d.ts +6 -0
- package/lib/typescript/src/store/treeView.store.d.ts.map +1 -1
- package/lib/typescript/src/types/dragDrop.types.d.ts +24 -12
- package/lib/typescript/src/types/dragDrop.types.d.ts.map +1 -1
- package/lib/typescript/src/types/treeView.types.d.ts +68 -12
- package/lib/typescript/src/types/treeView.types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/TreeView.tsx +158 -26
- package/src/components/DragOverlay.tsx +32 -3
- package/src/components/NodeList.tsx +82 -28
- package/src/constants/treeView.constants.ts +6 -1
- package/src/helpers/moveTreeNode.helper.ts +160 -43
- package/src/helpers/toggleCheckbox.helper.ts +5 -12
- package/src/helpers/treeNode.helper.ts +52 -1
- package/src/hooks/useDragDrop.ts +573 -214
- package/src/hooks/useScrollToNode.ts +21 -0
- package/src/index.tsx +3 -1
- package/src/store/treeView.store.ts +6 -0
- package/src/types/dragDrop.types.ts +25 -13
- package/src/types/treeView.types.ts +71 -11
- package/lib/module/components/DropIndicator.js +0 -79
- package/lib/module/components/DropIndicator.js.map +0 -1
- package/lib/typescript/src/components/DropIndicator.d.ts +0 -12
- package/lib/typescript/src/components/DropIndicator.d.ts.map +0 -1
- package/src/components/DropIndicator.tsx +0 -95
|
@@ -9,8 +9,11 @@ import {
|
|
|
9
9
|
View,
|
|
10
10
|
StyleSheet,
|
|
11
11
|
TouchableOpacity,
|
|
12
|
+
Platform,
|
|
12
13
|
type NativeSyntheticEvent,
|
|
13
14
|
type NativeScrollEvent,
|
|
15
|
+
type GestureResponderEvent,
|
|
16
|
+
type LayoutChangeEvent,
|
|
14
17
|
} from "react-native";
|
|
15
18
|
import { FlashList } from "@shopify/flash-list";
|
|
16
19
|
|
|
@@ -19,7 +22,6 @@ import type {
|
|
|
19
22
|
DropIndicatorStyleProps,
|
|
20
23
|
NodeListProps,
|
|
21
24
|
NodeProps,
|
|
22
|
-
TreeNode,
|
|
23
25
|
} from "../types/treeView.types";
|
|
24
26
|
|
|
25
27
|
import { useTreeViewStore } from "../store/treeView.store";
|
|
@@ -37,6 +39,7 @@ import { DragOverlay } from "./DragOverlay";
|
|
|
37
39
|
import type { DropPosition } from "../types/dragDrop.types";
|
|
38
40
|
import {
|
|
39
41
|
defaultIndentationMultiplier,
|
|
42
|
+
defaultItemHeight,
|
|
40
43
|
listHeaderFooterPadding
|
|
41
44
|
} from "../constants/treeView.constants";
|
|
42
45
|
import { useShallow } from "zustand/react/shallow";
|
|
@@ -75,7 +78,10 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
75
78
|
autoScrollThreshold = 60,
|
|
76
79
|
autoScrollSpeed = 1.0,
|
|
77
80
|
dragOverlayOffset = -2,
|
|
81
|
+
overlayYCorrection,
|
|
78
82
|
autoExpandDelay = 800,
|
|
83
|
+
autoExpand = true,
|
|
84
|
+
magneticSnap = true,
|
|
79
85
|
customizations: dragDropCustomizations,
|
|
80
86
|
canDrop: canDropCallback,
|
|
81
87
|
maxDepth,
|
|
@@ -84,9 +90,12 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
84
90
|
autoScrollToDroppedNode,
|
|
85
91
|
} = dragAndDrop ?? {};
|
|
86
92
|
|
|
87
|
-
// When the dragAndDrop prop is provided, drag is enabled by default
|
|
88
|
-
//
|
|
89
|
-
|
|
93
|
+
// When the dragAndDrop prop is provided, drag is enabled by default on native
|
|
94
|
+
// (iOS/Android). On web it defaults OFF because the PanResponder-based drag is
|
|
95
|
+
// still a work in progress there - consumers can opt in with `enabled: true`.
|
|
96
|
+
const dragEnabled = dragAndDrop
|
|
97
|
+
? (_dragEnabled ?? Platform.OS !== "web")
|
|
98
|
+
: false;
|
|
90
99
|
|
|
91
100
|
const {
|
|
92
101
|
expanded,
|
|
@@ -106,15 +115,32 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
106
115
|
|
|
107
116
|
const flashListRef = useRef<FlashList<__FlattenedTreeNode__<ID>> | null>(null);
|
|
108
117
|
const containerRef = useRef<View>(null);
|
|
109
|
-
const internalDataRef = useRef<TreeNode<ID>[] | null>(null);
|
|
110
118
|
const measuredItemHeightRef = useRef(0);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
const contentHeightRef = useRef(0);
|
|
120
|
+
// Measured row heights keyed by stable node id (NOT flattened index, which is
|
|
121
|
+
// reused by different nodes across expand/collapse/filter/reorder). Used for
|
|
122
|
+
// accurate drop targeting with variable-height rows when the whole list is
|
|
123
|
+
// rendered. Keying by id means a stale entry can never satisfy the
|
|
124
|
+
// "all current rows measured" gate in useDragDrop.
|
|
125
|
+
const itemHeightsRef = useRef<Map<ID, number>>(new Map());
|
|
126
|
+
|
|
127
|
+
const handleItemLayout = useCallback((id: ID, height: number) => {
|
|
128
|
+
if (height > 0) {
|
|
129
|
+
itemHeightsRef.current.set(id, height);
|
|
130
|
+
// First measured height seeds the uniform fallback used while
|
|
131
|
+
// virtualization keeps some rows unmeasured.
|
|
132
|
+
if (measuredItemHeightRef.current === 0) {
|
|
133
|
+
measuredItemHeightRef.current = height;
|
|
134
|
+
}
|
|
115
135
|
}
|
|
116
136
|
}, []);
|
|
117
137
|
|
|
138
|
+
// Measured heights of removed nodes would otherwise accumulate forever on
|
|
139
|
+
// dynamic trees; clear on structural change and let rows re-measure on layout.
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
itemHeightsRef.current.clear();
|
|
142
|
+
}, [initialTreeViewData]);
|
|
143
|
+
|
|
118
144
|
const [initialScrollIndex, setInitialScrollIndex] = useState<number>(-1);
|
|
119
145
|
|
|
120
146
|
// First we filter the tree as per the search term and keys
|
|
@@ -156,11 +182,10 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
156
182
|
overlayX,
|
|
157
183
|
isDragging,
|
|
158
184
|
draggedNode,
|
|
159
|
-
effectiveDropLevel,
|
|
160
185
|
handleNodeTouchStart,
|
|
161
186
|
handleNodeTouchEnd,
|
|
162
|
-
|
|
163
|
-
|
|
187
|
+
handleScroll: dragHandleScroll,
|
|
188
|
+
containerHeightRef,
|
|
164
189
|
} = useDragDrop<ID>({
|
|
165
190
|
storeId,
|
|
166
191
|
flattenedNodes: flattenedFilteredNodes,
|
|
@@ -173,10 +198,14 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
173
198
|
longPressDuration,
|
|
174
199
|
autoScrollThreshold,
|
|
175
200
|
autoScrollSpeed,
|
|
176
|
-
internalDataRef,
|
|
177
201
|
measuredItemHeightRef,
|
|
202
|
+
contentHeightRef,
|
|
203
|
+
itemHeightsRef,
|
|
178
204
|
dragOverlayOffset,
|
|
205
|
+
overlayYCorrection,
|
|
179
206
|
autoExpandDelay,
|
|
207
|
+
autoExpand,
|
|
208
|
+
magneticSnap,
|
|
180
209
|
indentationMultiplier: effectiveIndentationMultiplier,
|
|
181
210
|
canDrop: canDropCallback,
|
|
182
211
|
maxDepth,
|
|
@@ -186,16 +215,27 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
186
215
|
autoScrollToDroppedNode,
|
|
187
216
|
});
|
|
188
217
|
|
|
189
|
-
//
|
|
218
|
+
// The hook owns scroll-offset bookkeeping (single-writer during drag,
|
|
219
|
+
// long-press cancellation); this wrapper just forwards to the user's onScroll.
|
|
190
220
|
const handleScroll = useCallback((
|
|
191
221
|
event: NativeSyntheticEvent<NativeScrollEvent>
|
|
192
222
|
) => {
|
|
193
|
-
|
|
194
|
-
// Cancel long press timer if user is scrolling
|
|
195
|
-
cancelLongPressTimer();
|
|
196
|
-
// Forward to user's onScroll
|
|
223
|
+
dragHandleScroll(event);
|
|
197
224
|
treeFlashListProps?.onScroll?.(event as any);
|
|
198
|
-
}, [
|
|
225
|
+
}, [dragHandleScroll, treeFlashListProps]);
|
|
226
|
+
|
|
227
|
+
// Track total content height so auto-scroll during drag can clamp to the
|
|
228
|
+
// scrollable range.
|
|
229
|
+
const handleContentSizeChange = useCallback((width: number, height: number) => {
|
|
230
|
+
contentHeightRef.current = height;
|
|
231
|
+
treeFlashListProps?.onContentSizeChange?.(width, height);
|
|
232
|
+
}, [contentHeightRef, treeFlashListProps]);
|
|
233
|
+
|
|
234
|
+
// Keep the container height fresh on resize (orientation change, keyboard, split
|
|
235
|
+
// view) so auto-scroll edge detection and clamping don't go stale mid-session.
|
|
236
|
+
const handleContainerLayout = useCallback((e: LayoutChangeEvent) => {
|
|
237
|
+
containerHeightRef.current = e.nativeEvent.layout.height;
|
|
238
|
+
}, [containerHeightRef]);
|
|
199
239
|
|
|
200
240
|
const nodeRenderer = useCallback((
|
|
201
241
|
{ item, index }: { item: __FlattenedTreeNode__<ID>; index: number; }
|
|
@@ -215,7 +255,9 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
215
255
|
ExpandCollapseTouchableComponent={ExpandCollapseTouchableComponent}
|
|
216
256
|
CustomNodeRowComponent={CustomNodeRowComponent}
|
|
217
257
|
|
|
218
|
-
|
|
258
|
+
// Index only matters for drag touch bookkeeping; keep it stable
|
|
259
|
+
// when drag is off so memoized nodes skip index-shift re-renders
|
|
260
|
+
nodeIndex={dragEnabled ? index : 0}
|
|
219
261
|
dragEnabled={dragEnabled}
|
|
220
262
|
isDragging={isDragging}
|
|
221
263
|
onNodeTouchStart={dragEnabled ? handleNodeTouchStart : undefined}
|
|
@@ -240,14 +282,17 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
240
282
|
handleItemLayout,
|
|
241
283
|
]);
|
|
242
284
|
|
|
243
|
-
// Extract FlashList props but exclude onScroll (we provide
|
|
285
|
+
// Extract FlashList props but exclude onScroll / onContentSizeChange (we provide
|
|
286
|
+
// our own combined handlers that still forward to the user's callbacks)
|
|
244
287
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
245
|
-
const { onScroll: _userOnScroll, ...restFlashListProps } = treeFlashListProps ?? {};
|
|
288
|
+
const { onScroll: _userOnScroll, onContentSizeChange: _userOnContentSizeChange, ...restFlashListProps } = treeFlashListProps ?? {};
|
|
246
289
|
|
|
247
290
|
const flashListElement = (
|
|
248
291
|
<FlashList
|
|
249
292
|
ref={flashListRef}
|
|
250
|
-
estimatedItemSize
|
|
293
|
+
// estimatedItemSize is used by FlashList v1; v2 auto-measures and ignores
|
|
294
|
+
// it. Consumers can override via treeFlashListProps (spread below).
|
|
295
|
+
estimatedItemSize={defaultItemHeight}
|
|
251
296
|
initialScrollIndex={initialScrollIndex}
|
|
252
297
|
removeClippedSubviews={true}
|
|
253
298
|
keyboardShouldPersistTaps="handled"
|
|
@@ -256,6 +301,7 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
256
301
|
ListFooterComponent={<HeaderFooterView />}
|
|
257
302
|
{...restFlashListProps}
|
|
258
303
|
onScroll={handleScroll}
|
|
304
|
+
onContentSizeChange={handleContentSizeChange}
|
|
259
305
|
scrollEnabled={isDragging ? false : (restFlashListProps?.scrollEnabled ?? true)}
|
|
260
306
|
data={flattenedFilteredNodes}
|
|
261
307
|
renderItem={nodeRenderer}
|
|
@@ -268,6 +314,7 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
268
314
|
<View
|
|
269
315
|
ref={containerRef}
|
|
270
316
|
style={styles.dragContainer}
|
|
317
|
+
onLayout={handleContainerLayout}
|
|
271
318
|
{...panResponder.panHandlers}
|
|
272
319
|
>
|
|
273
320
|
{flashListElement}
|
|
@@ -277,7 +324,10 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
277
324
|
overlayY={overlayY}
|
|
278
325
|
overlayX={overlayX}
|
|
279
326
|
node={draggedNode}
|
|
280
|
-
level
|
|
327
|
+
/* Constant for the whole drag: the level shift toward the
|
|
328
|
+
drop target is expressed via the overlayX translate, not
|
|
329
|
+
a re-render (which caused visible indent flicker). */
|
|
330
|
+
level={draggedNode.level ?? 0}
|
|
281
331
|
indentationMultiplier={effectiveIndentationMultiplier}
|
|
282
332
|
CheckboxComponent={CheckboxComponent}
|
|
283
333
|
ExpandCollapseIconComponent={ExpandCollapseIconComponent}
|
|
@@ -375,7 +425,7 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
375
425
|
toggleCheckboxes(storeId, [node.id]);
|
|
376
426
|
}, [storeId, node.id]);
|
|
377
427
|
|
|
378
|
-
const handleTouchStart = useCallback((e:
|
|
428
|
+
const handleTouchStart = useCallback((e: GestureResponderEvent) => {
|
|
379
429
|
wasDraggedRef.current = false;
|
|
380
430
|
if (!onNodeTouchStart) return;
|
|
381
431
|
const { pageY, locationY } = e.nativeEvent;
|
|
@@ -398,9 +448,9 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
398
448
|
? (isBeingDragged ? draggedOpacity : isDragInvalid ? invalidOpacity : 1.0)
|
|
399
449
|
: 1.0;
|
|
400
450
|
|
|
401
|
-
const handleLayout = useCallback((e:
|
|
402
|
-
onItemLayout?.(e.nativeEvent.layout.height);
|
|
403
|
-
}, [onItemLayout]);
|
|
451
|
+
const handleLayout = useCallback((e: LayoutChangeEvent) => {
|
|
452
|
+
onItemLayout?.(node.id, e.nativeEvent.layout.height);
|
|
453
|
+
}, [onItemLayout, node.id]);
|
|
404
454
|
|
|
405
455
|
const touchHandlers = dragEnabled ? {
|
|
406
456
|
onTouchStart: handleTouchStart,
|
|
@@ -489,6 +539,8 @@ function NodeDropIndicator({ position, level, indentationMultiplier, styleProps
|
|
|
489
539
|
const circleSize = styleProps?.circleSize ?? 10;
|
|
490
540
|
const highlightColor = styleProps?.highlightColor ?? "rgba(0, 120, 255, 0.15)";
|
|
491
541
|
const highlightBorderColor = styleProps?.highlightBorderColor ?? "rgba(0, 120, 255, 0.5)";
|
|
542
|
+
const highlightBorderWidth = styleProps?.highlightBorderWidth ?? 2;
|
|
543
|
+
const highlightBorderRadius = styleProps?.highlightBorderRadius ?? 4;
|
|
492
544
|
|
|
493
545
|
// Indent the line to match the node's nesting level so users can
|
|
494
546
|
// visually distinguish drops at different tree depths.
|
|
@@ -504,6 +556,8 @@ function NodeDropIndicator({ position, level, indentationMultiplier, styleProps
|
|
|
504
556
|
left: leftOffset,
|
|
505
557
|
backgroundColor: highlightColor,
|
|
506
558
|
borderColor: highlightBorderColor,
|
|
559
|
+
borderWidth: highlightBorderWidth,
|
|
560
|
+
borderRadius: highlightBorderRadius,
|
|
507
561
|
},
|
|
508
562
|
]}
|
|
509
563
|
/>
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export const defaultIndentationMultiplier = 15;
|
|
2
2
|
|
|
3
3
|
/** Padding used by the FlashList header/footer component. Total header height = 2 * this value. */
|
|
4
|
-
export const listHeaderFooterPadding = 5;
|
|
4
|
+
export const listHeaderFooterPadding = 5;
|
|
5
|
+
|
|
6
|
+
/** Estimated/default row height in px. Used as FlashList's estimatedItemSize and as
|
|
7
|
+
* the drag-and-drop fallback height before a row has been measured (kept in one
|
|
8
|
+
* place so the two never silently diverge). */
|
|
9
|
+
export const defaultItemHeight = 36;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { TreeNode } from "../types/treeView.types";
|
|
2
2
|
import type { DropPosition } from "../types/dragDrop.types";
|
|
3
|
+
import { getTreeViewStore } from "../store/treeView.store";
|
|
4
|
+
import { initializeNodeMaps } from "./treeNode.helper";
|
|
5
|
+
import { recalculateCheckedStates } from "./toggleCheckbox.helper";
|
|
6
|
+
import { expandNodes } from "./expandCollapse.helper";
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Move a node within a tree structure. Returns a new tree (no mutation).
|
|
@@ -35,65 +39,176 @@ export function moveTreeNode<ID>(
|
|
|
35
39
|
return cloned;
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Commit the result of a `moveTreeNode` to the store: swap in the new tree,
|
|
44
|
+
* rebuild the node maps, recalculate parent checked/indeterminate states, and
|
|
45
|
+
* expand whatever is needed to make the moved node visible ("inside" drops
|
|
46
|
+
* expand the target; ancestors of the moved node are always expanded).
|
|
47
|
+
*
|
|
48
|
+
* Shared by the interactive drag commit (useDragDrop.handleDragEnd) and the
|
|
49
|
+
* programmatic `TreeViewRef.moveNode` so the two paths cannot drift.
|
|
50
|
+
*/
|
|
51
|
+
export function applyMoveToStore<ID>(
|
|
52
|
+
storeId: string,
|
|
53
|
+
newData: TreeNode<ID>[],
|
|
54
|
+
movedNodeId: ID,
|
|
55
|
+
targetNodeId: ID,
|
|
56
|
+
position: DropPosition
|
|
57
|
+
): void {
|
|
58
|
+
const store = getTreeViewStore<ID>(storeId);
|
|
59
|
+
store.getState().updateInitialTreeViewData(newData);
|
|
60
|
+
initializeNodeMaps(storeId, newData);
|
|
61
|
+
recalculateCheckedStates<ID>(storeId);
|
|
62
|
+
if (position === "inside") {
|
|
63
|
+
expandNodes(storeId, [targetNodeId]);
|
|
64
|
+
}
|
|
65
|
+
expandNodes(storeId, [movedNodeId], true);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Locate a node within a tree, returning its parent id (null at root) and its
|
|
70
|
+
* index within that parent's children (or the root array). Returns null if the
|
|
71
|
+
* node is not found. Iterative (stack-based) DFS.
|
|
72
|
+
*
|
|
73
|
+
* Used to build the lightweight `MoveResult` delta (previous/new parent + index)
|
|
74
|
+
* without exposing a full tree copy.
|
|
75
|
+
*/
|
|
76
|
+
export function findNodePosition<ID>(
|
|
77
|
+
data: TreeNode<ID>[],
|
|
78
|
+
nodeId: ID
|
|
79
|
+
): { parentId: ID | null; index: number; } | null {
|
|
80
|
+
const stack: Array<{ nodes: TreeNode<ID>[]; parentId: ID | null; }> = [
|
|
81
|
+
{ nodes: data, parentId: null }
|
|
82
|
+
];
|
|
83
|
+
while (stack.length > 0) {
|
|
84
|
+
const { nodes, parentId } = stack.pop()!;
|
|
85
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
86
|
+
const node = nodes[i]!;
|
|
87
|
+
if (node.id === nodeId) return { parentId, index: i };
|
|
88
|
+
if (node.children?.length) {
|
|
89
|
+
stack.push({ nodes: node.children, parentId: node.id });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `findNodePosition` for a node that is still in the store's CURRENT tree:
|
|
98
|
+
* uses the already-built childToParentMap/nodeMap for O(depth + siblings)
|
|
99
|
+
* instead of a full-tree DFS. Only valid while the maps match `data` (i.e.
|
|
100
|
+
* before the move mutates the tree).
|
|
101
|
+
*/
|
|
102
|
+
export function findNodePositionFromMaps<ID>(
|
|
103
|
+
data: TreeNode<ID>[],
|
|
104
|
+
nodeMap: Map<ID, TreeNode<ID>>,
|
|
105
|
+
childToParentMap: Map<ID, ID>,
|
|
106
|
+
nodeId: ID
|
|
107
|
+
): { parentId: ID | null; index: number; } | null {
|
|
108
|
+
const parentId = childToParentMap.get(nodeId);
|
|
109
|
+
const siblings = parentId !== undefined
|
|
110
|
+
? nodeMap.get(parentId)?.children
|
|
111
|
+
: data;
|
|
112
|
+
if (!siblings) return null;
|
|
113
|
+
const index = siblings.findIndex((n) => n.id === nodeId);
|
|
114
|
+
return index === -1 ? null : { parentId: parentId ?? null, index };
|
|
115
|
+
}
|
|
116
|
+
|
|
38
117
|
/**
|
|
39
118
|
* Check if `candidateDescendantId` is a descendant of `ancestorId` in the tree.
|
|
119
|
+
* Iterative (stack-based) DFS to avoid call-stack limits on deep trees.
|
|
40
120
|
*/
|
|
41
121
|
function isDescendant<ID>(
|
|
42
122
|
nodes: TreeNode<ID>[],
|
|
43
123
|
ancestorId: ID,
|
|
44
124
|
candidateDescendantId: ID,
|
|
45
125
|
): boolean {
|
|
46
|
-
|
|
126
|
+
const stack: TreeNode<ID>[] = [...nodes];
|
|
127
|
+
while (stack.length > 0) {
|
|
128
|
+
const node = stack.pop()!;
|
|
47
129
|
if (node.id === ancestorId) {
|
|
48
|
-
// Found the ancestor - search its subtree for the candidate
|
|
130
|
+
// Found the ancestor - search its subtree for the candidate.
|
|
131
|
+
// IDs are unique, so the first match is the only ancestor to check.
|
|
49
132
|
return containsNode(node.children ?? [], candidateDescendantId);
|
|
50
133
|
}
|
|
51
|
-
if (node.children
|
|
52
|
-
|
|
134
|
+
if (node.children) {
|
|
135
|
+
for (const child of node.children) stack.push(child);
|
|
53
136
|
}
|
|
54
137
|
}
|
|
55
138
|
return false;
|
|
56
139
|
}
|
|
57
140
|
|
|
58
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* Check if a node with the given ID exists anywhere in the subtree.
|
|
143
|
+
* Iterative (stack-based) DFS to avoid call-stack limits on deep trees.
|
|
144
|
+
*/
|
|
59
145
|
function containsNode<ID>(nodes: TreeNode<ID>[], nodeId: ID): boolean {
|
|
60
|
-
|
|
146
|
+
const stack: TreeNode<ID>[] = [...nodes];
|
|
147
|
+
while (stack.length > 0) {
|
|
148
|
+
const node = stack.pop()!;
|
|
61
149
|
if (node.id === nodeId) return true;
|
|
62
|
-
if (node.children
|
|
150
|
+
if (node.children) {
|
|
151
|
+
for (const child of node.children) stack.push(child);
|
|
152
|
+
}
|
|
63
153
|
}
|
|
64
154
|
return false;
|
|
65
155
|
}
|
|
66
156
|
|
|
67
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Deep clone a tree structure so mutations don't affect the original.
|
|
159
|
+
* Iterative (stack-based) clone to avoid call-stack limits on deep trees.
|
|
160
|
+
* Preserves the original shape: every node carries a `children` key
|
|
161
|
+
* (`undefined` for leaves, a cloned array - possibly empty - otherwise).
|
|
162
|
+
*/
|
|
68
163
|
function deepCloneTree<ID>(nodes: TreeNode<ID>[]): TreeNode<ID>[] {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
164
|
+
const root: TreeNode<ID>[] = nodes.map(node => ({ ...node, children: undefined }));
|
|
165
|
+
const stack: Array<{ src: TreeNode<ID>[]; dst: TreeNode<ID>[]; }> = [
|
|
166
|
+
{ src: nodes, dst: root }
|
|
167
|
+
];
|
|
168
|
+
while (stack.length > 0) {
|
|
169
|
+
const { src, dst } = stack.pop()!;
|
|
170
|
+
for (let i = 0; i < src.length; i++) {
|
|
171
|
+
const children = src[i]!.children;
|
|
172
|
+
if (children) {
|
|
173
|
+
const clonedChildren: TreeNode<ID>[] =
|
|
174
|
+
children.map(child => ({ ...child, children: undefined }));
|
|
175
|
+
dst[i]!.children = clonedChildren;
|
|
176
|
+
stack.push({ src: children, dst: clonedChildren });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return root;
|
|
73
181
|
}
|
|
74
182
|
|
|
75
183
|
/**
|
|
76
184
|
* Remove a node by ID from the tree. Mutates the cloned tree in-place.
|
|
77
185
|
* Returns the removed node, or null if not found.
|
|
186
|
+
* Iterative (stack-based) DFS to avoid call-stack limits on deep trees.
|
|
78
187
|
*/
|
|
79
188
|
function removeNodeById<ID>(
|
|
80
189
|
nodes: TreeNode<ID>[],
|
|
81
190
|
nodeId: ID,
|
|
82
191
|
): TreeNode<ID> | null {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
192
|
+
// Each frame carries the array being scanned plus the node that owns it
|
|
193
|
+
// (null at the root) so an emptied children array can be detached.
|
|
194
|
+
const stack: Array<{ nodes: TreeNode<ID>[]; parent: TreeNode<ID> | null; }> = [
|
|
195
|
+
{ nodes, parent: null }
|
|
196
|
+
];
|
|
197
|
+
while (stack.length > 0) {
|
|
198
|
+
const { nodes: level, parent } = stack.pop()!;
|
|
199
|
+
for (let i = 0; i < level.length; i++) {
|
|
200
|
+
const node = level[i]!;
|
|
201
|
+
if (node.id === nodeId) {
|
|
202
|
+
const [removed] = level.splice(i, 1);
|
|
203
|
+
// Clean up an emptied children array on the owning parent
|
|
204
|
+
// (matches the original shape: leaves carry children: undefined).
|
|
205
|
+
if (parent && level.length === 0) {
|
|
206
|
+
parent.children = undefined;
|
|
95
207
|
}
|
|
96
|
-
return removed;
|
|
208
|
+
return removed ?? null;
|
|
209
|
+
}
|
|
210
|
+
if (node.children?.length) {
|
|
211
|
+
stack.push({ nodes: node.children, parent: node });
|
|
97
212
|
}
|
|
98
213
|
}
|
|
99
214
|
}
|
|
@@ -103,6 +218,7 @@ function removeNodeById<ID>(
|
|
|
103
218
|
/**
|
|
104
219
|
* Insert a node relative to a target node. Mutates the cloned tree in-place.
|
|
105
220
|
* Returns true if insertion was successful.
|
|
221
|
+
* Iterative (stack-based) DFS to avoid call-stack limits on deep trees.
|
|
106
222
|
*/
|
|
107
223
|
function insertNode<ID>(
|
|
108
224
|
nodes: TreeNode<ID>[],
|
|
@@ -110,28 +226,29 @@ function insertNode<ID>(
|
|
|
110
226
|
targetId: ID,
|
|
111
227
|
position: DropPosition,
|
|
112
228
|
): boolean {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
target.children.unshift(nodeToInsert);
|
|
229
|
+
const stack: TreeNode<ID>[][] = [nodes];
|
|
230
|
+
while (stack.length > 0) {
|
|
231
|
+
const level = stack.pop()!;
|
|
232
|
+
for (let i = 0; i < level.length; i++) {
|
|
233
|
+
const node = level[i]!;
|
|
234
|
+
if (node.id === targetId) {
|
|
235
|
+
if (position === "above") {
|
|
236
|
+
level.splice(i, 0, nodeToInsert);
|
|
237
|
+
} else if (position === "below") {
|
|
238
|
+
level.splice(i + 1, 0, nodeToInsert);
|
|
124
239
|
} else {
|
|
125
|
-
|
|
240
|
+
// "inside" - add as first child
|
|
241
|
+
if (node.children) {
|
|
242
|
+
node.children.unshift(nodeToInsert);
|
|
243
|
+
} else {
|
|
244
|
+
node.children = [nodeToInsert];
|
|
245
|
+
}
|
|
126
246
|
}
|
|
127
|
-
}
|
|
128
|
-
return true;
|
|
129
|
-
}
|
|
130
|
-
const children = nodes[i]!.children;
|
|
131
|
-
if (children) {
|
|
132
|
-
if (insertNode(children, nodeToInsert, targetId, position)) {
|
|
133
247
|
return true;
|
|
134
248
|
}
|
|
249
|
+
if (node.children?.length) {
|
|
250
|
+
stack.push(node.children);
|
|
251
|
+
}
|
|
135
252
|
}
|
|
136
253
|
}
|
|
137
254
|
return false;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CheckboxValueType, TreeNode } from "../types/treeView.types";
|
|
2
2
|
import { getTreeViewStore } from "../store/treeView.store";
|
|
3
|
+
import { getNodeDepthFromParentMap } from "./treeNode.helper";
|
|
3
4
|
|
|
4
5
|
/** Derive the tri-state checkbox value from checked and indeterminate booleans. */
|
|
5
6
|
export function getCheckboxValue(
|
|
@@ -255,19 +256,11 @@ export function recalculateCheckedStates<ID>(storeId: string) {
|
|
|
255
256
|
// Sort by depth descending (deepest first) for correct bottom-up propagation
|
|
256
257
|
const nodeDepths = new Map<ID, number>();
|
|
257
258
|
function getDepth(nodeId: ID): number {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const parentId = childToParentMap.get(currentId);
|
|
263
|
-
if (parentId) {
|
|
264
|
-
depth++;
|
|
265
|
-
currentId = parentId;
|
|
266
|
-
} else {
|
|
267
|
-
break;
|
|
268
|
-
}
|
|
259
|
+
let depth = nodeDepths.get(nodeId);
|
|
260
|
+
if (depth === undefined) {
|
|
261
|
+
depth = getNodeDepthFromParentMap(childToParentMap, nodeId);
|
|
262
|
+
nodeDepths.set(nodeId, depth);
|
|
269
263
|
}
|
|
270
|
-
nodeDepths.set(nodeId, depth);
|
|
271
264
|
return depth;
|
|
272
265
|
}
|
|
273
266
|
|
|
@@ -41,4 +41,55 @@ export function initializeNodeMaps<ID>(storeId: string, initialData: TreeNode<ID
|
|
|
41
41
|
// Batch update the store with both maps at once
|
|
42
42
|
updateNodeMap(nodeMap);
|
|
43
43
|
updateChildToParentMap(childToParentMap);
|
|
44
|
-
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compute the maximum depth of a node's subtree (0 for a leaf node).
|
|
48
|
+
* Uses an iterative stack-based DFS to avoid call-stack limits on deep trees.
|
|
49
|
+
*
|
|
50
|
+
* @param nodeMap - Map of node ID to node (from `initializeNodeMaps`).
|
|
51
|
+
* @param nodeId - The root of the subtree to measure.
|
|
52
|
+
*/
|
|
53
|
+
export function getSubtreeDepthFromMap<ID>(
|
|
54
|
+
nodeMap: Map<ID, TreeNode<ID>>,
|
|
55
|
+
nodeId: ID
|
|
56
|
+
): number {
|
|
57
|
+
const root = nodeMap.get(nodeId);
|
|
58
|
+
if (!root?.children?.length) return 0;
|
|
59
|
+
|
|
60
|
+
let maxDepth = 0;
|
|
61
|
+
const stack: Array<{ node: TreeNode<ID>; depth: number; }> = [
|
|
62
|
+
{ node: root, depth: 0 }
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
while (stack.length > 0) {
|
|
66
|
+
const { node, depth } = stack.pop()!;
|
|
67
|
+
if (node.children?.length) {
|
|
68
|
+
for (const child of node.children) {
|
|
69
|
+
if (depth + 1 > maxDepth) maxDepth = depth + 1;
|
|
70
|
+
stack.push({ node: child, depth: depth + 1 });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return maxDepth;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compute a node's depth (its level, 0 for root) by walking the child-to-parent map.
|
|
80
|
+
*
|
|
81
|
+
* @param childToParentMap - Map of child ID to parent ID (from `initializeNodeMaps`).
|
|
82
|
+
* @param nodeId - The node whose depth to compute.
|
|
83
|
+
*/
|
|
84
|
+
export function getNodeDepthFromParentMap<ID>(
|
|
85
|
+
childToParentMap: Map<ID, ID>,
|
|
86
|
+
nodeId: ID
|
|
87
|
+
): number {
|
|
88
|
+
let depth = 0;
|
|
89
|
+
let currentId: ID | undefined = nodeId;
|
|
90
|
+
while (currentId !== undefined && childToParentMap.has(currentId)) {
|
|
91
|
+
currentId = childToParentMap.get(currentId);
|
|
92
|
+
depth++;
|
|
93
|
+
}
|
|
94
|
+
return depth;
|
|
95
|
+
}
|