react-native-tree-multi-select 3.0.0-beta.4 → 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 +64 -30
- 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 +83 -31
- 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 +6 -13
- 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 +486 -216
- package/lib/module/hooks/useDragDrop.js.map +1 -1
- package/lib/module/hooks/useScrollToNode.js +18 -1
- 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 +30 -7
- 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 +3 -3
- 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 +78 -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 +84 -29
- package/src/constants/treeView.constants.ts +6 -1
- package/src/helpers/moveTreeNode.helper.ts +160 -43
- package/src/helpers/toggleCheckbox.helper.ts +6 -13
- package/src/helpers/treeNode.helper.ts +52 -1
- package/src/hooks/useDragDrop.ts +597 -250
- package/src/hooks/useScrollToNode.ts +22 -1
- package/src/index.tsx +5 -1
- package/src/store/treeView.store.ts +6 -0
- package/src/types/dragDrop.types.ts +25 -13
- package/src/types/treeView.types.ts +82 -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";
|
|
@@ -74,18 +77,25 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
74
77
|
longPressDuration = 400,
|
|
75
78
|
autoScrollThreshold = 60,
|
|
76
79
|
autoScrollSpeed = 1.0,
|
|
77
|
-
dragOverlayOffset = -
|
|
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,
|
|
82
88
|
canNodeHaveChildren,
|
|
83
89
|
canDrag,
|
|
90
|
+
autoScrollToDroppedNode,
|
|
84
91
|
} = dragAndDrop ?? {};
|
|
85
92
|
|
|
86
|
-
// When the dragAndDrop prop is provided, drag is enabled by default
|
|
87
|
-
//
|
|
88
|
-
|
|
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;
|
|
89
99
|
|
|
90
100
|
const {
|
|
91
101
|
expanded,
|
|
@@ -105,15 +115,32 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
105
115
|
|
|
106
116
|
const flashListRef = useRef<FlashList<__FlattenedTreeNode__<ID>> | null>(null);
|
|
107
117
|
const containerRef = useRef<View>(null);
|
|
108
|
-
const internalDataRef = useRef<TreeNode<ID>[] | null>(null);
|
|
109
118
|
const measuredItemHeightRef = useRef(0);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|
|
114
135
|
}
|
|
115
136
|
}, []);
|
|
116
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
|
+
|
|
117
144
|
const [initialScrollIndex, setInitialScrollIndex] = useState<number>(-1);
|
|
118
145
|
|
|
119
146
|
// First we filter the tree as per the search term and keys
|
|
@@ -155,11 +182,10 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
155
182
|
overlayX,
|
|
156
183
|
isDragging,
|
|
157
184
|
draggedNode,
|
|
158
|
-
effectiveDropLevel,
|
|
159
185
|
handleNodeTouchStart,
|
|
160
186
|
handleNodeTouchEnd,
|
|
161
|
-
|
|
162
|
-
|
|
187
|
+
handleScroll: dragHandleScroll,
|
|
188
|
+
containerHeightRef,
|
|
163
189
|
} = useDragDrop<ID>({
|
|
164
190
|
storeId,
|
|
165
191
|
flattenedNodes: flattenedFilteredNodes,
|
|
@@ -172,27 +198,44 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
172
198
|
longPressDuration,
|
|
173
199
|
autoScrollThreshold,
|
|
174
200
|
autoScrollSpeed,
|
|
175
|
-
internalDataRef,
|
|
176
201
|
measuredItemHeightRef,
|
|
202
|
+
contentHeightRef,
|
|
203
|
+
itemHeightsRef,
|
|
177
204
|
dragOverlayOffset,
|
|
205
|
+
overlayYCorrection,
|
|
178
206
|
autoExpandDelay,
|
|
207
|
+
autoExpand,
|
|
208
|
+
magneticSnap,
|
|
179
209
|
indentationMultiplier: effectiveIndentationMultiplier,
|
|
180
210
|
canDrop: canDropCallback,
|
|
181
211
|
maxDepth,
|
|
182
212
|
canNodeHaveChildren,
|
|
183
213
|
canDrag,
|
|
214
|
+
scrollToNodeHandlerRef,
|
|
215
|
+
autoScrollToDroppedNode,
|
|
184
216
|
});
|
|
185
217
|
|
|
186
|
-
//
|
|
218
|
+
// The hook owns scroll-offset bookkeeping (single-writer during drag,
|
|
219
|
+
// long-press cancellation); this wrapper just forwards to the user's onScroll.
|
|
187
220
|
const handleScroll = useCallback((
|
|
188
221
|
event: NativeSyntheticEvent<NativeScrollEvent>
|
|
189
222
|
) => {
|
|
190
|
-
|
|
191
|
-
// Cancel long press timer if user is scrolling
|
|
192
|
-
cancelLongPressTimer();
|
|
193
|
-
// Forward to user's onScroll
|
|
223
|
+
dragHandleScroll(event);
|
|
194
224
|
treeFlashListProps?.onScroll?.(event as any);
|
|
195
|
-
}, [
|
|
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]);
|
|
196
239
|
|
|
197
240
|
const nodeRenderer = useCallback((
|
|
198
241
|
{ item, index }: { item: __FlattenedTreeNode__<ID>; index: number; }
|
|
@@ -237,14 +280,17 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
237
280
|
handleItemLayout,
|
|
238
281
|
]);
|
|
239
282
|
|
|
240
|
-
// 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)
|
|
241
285
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
242
|
-
const { onScroll: _userOnScroll, ...restFlashListProps } = treeFlashListProps ?? {};
|
|
286
|
+
const { onScroll: _userOnScroll, onContentSizeChange: _userOnContentSizeChange, ...restFlashListProps } = treeFlashListProps ?? {};
|
|
243
287
|
|
|
244
288
|
const flashListElement = (
|
|
245
289
|
<FlashList
|
|
246
290
|
ref={flashListRef}
|
|
247
|
-
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}
|
|
248
294
|
initialScrollIndex={initialScrollIndex}
|
|
249
295
|
removeClippedSubviews={true}
|
|
250
296
|
keyboardShouldPersistTaps="handled"
|
|
@@ -253,6 +299,7 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
253
299
|
ListFooterComponent={<HeaderFooterView />}
|
|
254
300
|
{...restFlashListProps}
|
|
255
301
|
onScroll={handleScroll}
|
|
302
|
+
onContentSizeChange={handleContentSizeChange}
|
|
256
303
|
scrollEnabled={isDragging ? false : (restFlashListProps?.scrollEnabled ?? true)}
|
|
257
304
|
data={flattenedFilteredNodes}
|
|
258
305
|
renderItem={nodeRenderer}
|
|
@@ -265,6 +312,7 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
265
312
|
<View
|
|
266
313
|
ref={containerRef}
|
|
267
314
|
style={styles.dragContainer}
|
|
315
|
+
onLayout={handleContainerLayout}
|
|
268
316
|
{...panResponder.panHandlers}
|
|
269
317
|
>
|
|
270
318
|
{flashListElement}
|
|
@@ -274,7 +322,10 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
274
322
|
overlayY={overlayY}
|
|
275
323
|
overlayX={overlayX}
|
|
276
324
|
node={draggedNode}
|
|
277
|
-
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}
|
|
278
329
|
indentationMultiplier={effectiveIndentationMultiplier}
|
|
279
330
|
CheckboxComponent={CheckboxComponent}
|
|
280
331
|
ExpandCollapseIconComponent={ExpandCollapseIconComponent}
|
|
@@ -372,7 +423,7 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
372
423
|
toggleCheckboxes(storeId, [node.id]);
|
|
373
424
|
}, [storeId, node.id]);
|
|
374
425
|
|
|
375
|
-
const handleTouchStart = useCallback((e:
|
|
426
|
+
const handleTouchStart = useCallback((e: GestureResponderEvent) => {
|
|
376
427
|
wasDraggedRef.current = false;
|
|
377
428
|
if (!onNodeTouchStart) return;
|
|
378
429
|
const { pageY, locationY } = e.nativeEvent;
|
|
@@ -385,7 +436,7 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
385
436
|
|
|
386
437
|
// Determine opacity for drag state (separate values for dragged node vs invalid targets).
|
|
387
438
|
// When CustomNodeRowComponent is used, hand off all visual control
|
|
388
|
-
// (including drag opacity) to the custom component
|
|
439
|
+
// (including drag opacity) to the custom component - it receives
|
|
389
440
|
// isDraggedNode / isInvalidDropTarget / isDragging props.
|
|
390
441
|
const draggedOpacity = dragDropCustomizations?.draggedNodeOpacity ?? 0.3;
|
|
391
442
|
const invalidOpacity = dragDropCustomizations?.invalidTargetOpacity ?? 0.3;
|
|
@@ -395,9 +446,9 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
395
446
|
? (isBeingDragged ? draggedOpacity : isDragInvalid ? invalidOpacity : 1.0)
|
|
396
447
|
: 1.0;
|
|
397
448
|
|
|
398
|
-
const handleLayout = useCallback((e:
|
|
399
|
-
onItemLayout?.(e.nativeEvent.layout.height);
|
|
400
|
-
}, [onItemLayout]);
|
|
449
|
+
const handleLayout = useCallback((e: LayoutChangeEvent) => {
|
|
450
|
+
onItemLayout?.(node.id, e.nativeEvent.layout.height);
|
|
451
|
+
}, [onItemLayout, node.id]);
|
|
401
452
|
|
|
402
453
|
const touchHandlers = dragEnabled ? {
|
|
403
454
|
onTouchStart: handleTouchStart,
|
|
@@ -486,6 +537,8 @@ function NodeDropIndicator({ position, level, indentationMultiplier, styleProps
|
|
|
486
537
|
const circleSize = styleProps?.circleSize ?? 10;
|
|
487
538
|
const highlightColor = styleProps?.highlightColor ?? "rgba(0, 120, 255, 0.15)";
|
|
488
539
|
const highlightBorderColor = styleProps?.highlightBorderColor ?? "rgba(0, 120, 255, 0.5)";
|
|
540
|
+
const highlightBorderWidth = styleProps?.highlightBorderWidth ?? 2;
|
|
541
|
+
const highlightBorderRadius = styleProps?.highlightBorderRadius ?? 4;
|
|
489
542
|
|
|
490
543
|
// Indent the line to match the node's nesting level so users can
|
|
491
544
|
// visually distinguish drops at different tree depths.
|
|
@@ -501,6 +554,8 @@ function NodeDropIndicator({ position, level, indentationMultiplier, styleProps
|
|
|
501
554
|
left: leftOffset,
|
|
502
555
|
backgroundColor: highlightColor,
|
|
503
556
|
borderColor: highlightBorderColor,
|
|
557
|
+
borderWidth: highlightBorderWidth,
|
|
558
|
+
borderRadius: highlightBorderRadius,
|
|
504
559
|
},
|
|
505
560
|
]}
|
|
506
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
|
|
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
|
|
|
@@ -276,7 +269,7 @@ export function recalculateCheckedStates<ID>(storeId: string) {
|
|
|
276
269
|
// Update each parent based on its children's current state
|
|
277
270
|
for (const parentId of parentNodes) {
|
|
278
271
|
const node = nodeMap.get(parentId);
|
|
279
|
-
/* istanbul ignore next -- parentNodes is built from nodeMap entries with children above; same nodeMap, same iteration
|
|
272
|
+
/* istanbul ignore next -- parentNodes is built from nodeMap entries with children above; same nodeMap, same iteration - unreachable unless nodeMap mutates between loops */
|
|
280
273
|
if (!node?.children?.length) continue;
|
|
281
274
|
updateParentCheckState(parentId, node.children, tempChecked, tempIndeterminate);
|
|
282
275
|
}
|
|
@@ -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
|
+
}
|