react-native-tree-multi-select 3.0.0-beta.5 → 3.0.0-beta.6
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 +45 -21
- 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 +76 -27
- 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 +79 -27
- 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; }
|
|
@@ -240,14 +280,17 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
240
280
|
handleItemLayout,
|
|
241
281
|
]);
|
|
242
282
|
|
|
243
|
-
// Extract FlashList props but exclude onScroll (we provide
|
|
283
|
+
// Extract FlashList props but exclude onScroll / onContentSizeChange (we provide
|
|
284
|
+
// our own combined handlers that still forward to the user's callbacks)
|
|
244
285
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
245
|
-
const { onScroll: _userOnScroll, ...restFlashListProps } = treeFlashListProps ?? {};
|
|
286
|
+
const { onScroll: _userOnScroll, onContentSizeChange: _userOnContentSizeChange, ...restFlashListProps } = treeFlashListProps ?? {};
|
|
246
287
|
|
|
247
288
|
const flashListElement = (
|
|
248
289
|
<FlashList
|
|
249
290
|
ref={flashListRef}
|
|
250
|
-
estimatedItemSize
|
|
291
|
+
// estimatedItemSize is used by FlashList v1; v2 auto-measures and ignores
|
|
292
|
+
// it. Consumers can override via treeFlashListProps (spread below).
|
|
293
|
+
estimatedItemSize={defaultItemHeight}
|
|
251
294
|
initialScrollIndex={initialScrollIndex}
|
|
252
295
|
removeClippedSubviews={true}
|
|
253
296
|
keyboardShouldPersistTaps="handled"
|
|
@@ -256,6 +299,7 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
256
299
|
ListFooterComponent={<HeaderFooterView />}
|
|
257
300
|
{...restFlashListProps}
|
|
258
301
|
onScroll={handleScroll}
|
|
302
|
+
onContentSizeChange={handleContentSizeChange}
|
|
259
303
|
scrollEnabled={isDragging ? false : (restFlashListProps?.scrollEnabled ?? true)}
|
|
260
304
|
data={flattenedFilteredNodes}
|
|
261
305
|
renderItem={nodeRenderer}
|
|
@@ -268,6 +312,7 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
268
312
|
<View
|
|
269
313
|
ref={containerRef}
|
|
270
314
|
style={styles.dragContainer}
|
|
315
|
+
onLayout={handleContainerLayout}
|
|
271
316
|
{...panResponder.panHandlers}
|
|
272
317
|
>
|
|
273
318
|
{flashListElement}
|
|
@@ -277,7 +322,10 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
277
322
|
overlayY={overlayY}
|
|
278
323
|
overlayX={overlayX}
|
|
279
324
|
node={draggedNode}
|
|
280
|
-
level
|
|
325
|
+
/* Constant for the whole drag: the level shift toward the
|
|
326
|
+
drop target is expressed via the overlayX translate, not
|
|
327
|
+
a re-render (which caused visible indent flicker). */
|
|
328
|
+
level={draggedNode.level ?? 0}
|
|
281
329
|
indentationMultiplier={effectiveIndentationMultiplier}
|
|
282
330
|
CheckboxComponent={CheckboxComponent}
|
|
283
331
|
ExpandCollapseIconComponent={ExpandCollapseIconComponent}
|
|
@@ -375,7 +423,7 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
375
423
|
toggleCheckboxes(storeId, [node.id]);
|
|
376
424
|
}, [storeId, node.id]);
|
|
377
425
|
|
|
378
|
-
const handleTouchStart = useCallback((e:
|
|
426
|
+
const handleTouchStart = useCallback((e: GestureResponderEvent) => {
|
|
379
427
|
wasDraggedRef.current = false;
|
|
380
428
|
if (!onNodeTouchStart) return;
|
|
381
429
|
const { pageY, locationY } = e.nativeEvent;
|
|
@@ -398,9 +446,9 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
398
446
|
? (isBeingDragged ? draggedOpacity : isDragInvalid ? invalidOpacity : 1.0)
|
|
399
447
|
: 1.0;
|
|
400
448
|
|
|
401
|
-
const handleLayout = useCallback((e:
|
|
402
|
-
onItemLayout?.(e.nativeEvent.layout.height);
|
|
403
|
-
}, [onItemLayout]);
|
|
449
|
+
const handleLayout = useCallback((e: LayoutChangeEvent) => {
|
|
450
|
+
onItemLayout?.(node.id, e.nativeEvent.layout.height);
|
|
451
|
+
}, [onItemLayout, node.id]);
|
|
404
452
|
|
|
405
453
|
const touchHandlers = dragEnabled ? {
|
|
406
454
|
onTouchStart: handleTouchStart,
|
|
@@ -489,6 +537,8 @@ function NodeDropIndicator({ position, level, indentationMultiplier, styleProps
|
|
|
489
537
|
const circleSize = styleProps?.circleSize ?? 10;
|
|
490
538
|
const highlightColor = styleProps?.highlightColor ?? "rgba(0, 120, 255, 0.15)";
|
|
491
539
|
const highlightBorderColor = styleProps?.highlightBorderColor ?? "rgba(0, 120, 255, 0.5)";
|
|
540
|
+
const highlightBorderWidth = styleProps?.highlightBorderWidth ?? 2;
|
|
541
|
+
const highlightBorderRadius = styleProps?.highlightBorderRadius ?? 4;
|
|
492
542
|
|
|
493
543
|
// Indent the line to match the node's nesting level so users can
|
|
494
544
|
// visually distinguish drops at different tree depths.
|
|
@@ -504,6 +554,8 @@ function NodeDropIndicator({ position, level, indentationMultiplier, styleProps
|
|
|
504
554
|
left: leftOffset,
|
|
505
555
|
backgroundColor: highlightColor,
|
|
506
556
|
borderColor: highlightBorderColor,
|
|
557
|
+
borderWidth: highlightBorderWidth,
|
|
558
|
+
borderRadius: highlightBorderRadius,
|
|
507
559
|
},
|
|
508
560
|
]}
|
|
509
561
|
/>
|
|
@@ -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
|
+
}
|