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.
Files changed (64) hide show
  1. package/README.md +64 -30
  2. package/lib/module/TreeView.js +130 -24
  3. package/lib/module/TreeView.js.map +1 -1
  4. package/lib/module/components/DragOverlay.js +19 -2
  5. package/lib/module/components/DragOverlay.js.map +1 -1
  6. package/lib/module/components/NodeList.js +83 -31
  7. package/lib/module/components/NodeList.js.map +1 -1
  8. package/lib/module/constants/treeView.constants.js +5 -0
  9. package/lib/module/constants/treeView.constants.js.map +1 -1
  10. package/lib/module/helpers/moveTreeNode.helper.js +175 -47
  11. package/lib/module/helpers/moveTreeNode.helper.js.map +1 -1
  12. package/lib/module/helpers/toggleCheckbox.helper.js +6 -13
  13. package/lib/module/helpers/toggleCheckbox.helper.js.map +1 -1
  14. package/lib/module/helpers/treeNode.helper.js +49 -0
  15. package/lib/module/helpers/treeNode.helper.js.map +1 -1
  16. package/lib/module/hooks/useDragDrop.js +486 -216
  17. package/lib/module/hooks/useDragDrop.js.map +1 -1
  18. package/lib/module/hooks/useScrollToNode.js +18 -1
  19. package/lib/module/hooks/useScrollToNode.js.map +1 -1
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/store/treeView.store.js +7 -0
  22. package/lib/module/store/treeView.store.js.map +1 -1
  23. package/lib/module/types/dragDrop.types.js +0 -2
  24. package/lib/typescript/src/TreeView.d.ts.map +1 -1
  25. package/lib/typescript/src/components/DragOverlay.d.ts.map +1 -1
  26. package/lib/typescript/src/components/NodeList.d.ts.map +1 -1
  27. package/lib/typescript/src/constants/treeView.constants.d.ts +4 -0
  28. package/lib/typescript/src/constants/treeView.constants.d.ts.map +1 -1
  29. package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts +32 -0
  30. package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts.map +1 -1
  31. package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts.map +1 -1
  32. package/lib/typescript/src/helpers/treeNode.helper.d.ts +15 -0
  33. package/lib/typescript/src/helpers/treeNode.helper.d.ts.map +1 -1
  34. package/lib/typescript/src/hooks/useDragDrop.d.ts +30 -7
  35. package/lib/typescript/src/hooks/useDragDrop.d.ts.map +1 -1
  36. package/lib/typescript/src/hooks/useScrollToNode.d.ts +10 -0
  37. package/lib/typescript/src/hooks/useScrollToNode.d.ts.map +1 -1
  38. package/lib/typescript/src/index.d.ts +3 -3
  39. package/lib/typescript/src/index.d.ts.map +1 -1
  40. package/lib/typescript/src/store/treeView.store.d.ts +6 -0
  41. package/lib/typescript/src/store/treeView.store.d.ts.map +1 -1
  42. package/lib/typescript/src/types/dragDrop.types.d.ts +24 -12
  43. package/lib/typescript/src/types/dragDrop.types.d.ts.map +1 -1
  44. package/lib/typescript/src/types/treeView.types.d.ts +78 -12
  45. package/lib/typescript/src/types/treeView.types.d.ts.map +1 -1
  46. package/package.json +2 -2
  47. package/src/TreeView.tsx +158 -26
  48. package/src/components/DragOverlay.tsx +32 -3
  49. package/src/components/NodeList.tsx +84 -29
  50. package/src/constants/treeView.constants.ts +6 -1
  51. package/src/helpers/moveTreeNode.helper.ts +160 -43
  52. package/src/helpers/toggleCheckbox.helper.ts +6 -13
  53. package/src/helpers/treeNode.helper.ts +52 -1
  54. package/src/hooks/useDragDrop.ts +597 -250
  55. package/src/hooks/useScrollToNode.ts +22 -1
  56. package/src/index.tsx +5 -1
  57. package/src/store/treeView.store.ts +6 -0
  58. package/src/types/dragDrop.types.ts +25 -13
  59. package/src/types/treeView.types.ts +82 -11
  60. package/lib/module/components/DropIndicator.js +0 -79
  61. package/lib/module/components/DropIndicator.js.map +0 -1
  62. package/lib/typescript/src/components/DropIndicator.d.ts +0 -12
  63. package/lib/typescript/src/components/DropIndicator.d.ts.map +0 -1
  64. 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 = -4,
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
- // Users can still toggle it off with enabled: false at runtime.
88
- const dragEnabled = dragAndDrop ? (_dragEnabled ?? true) : false;
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
- const handleItemLayout = useCallback((height: number) => {
112
- if (measuredItemHeightRef.current === 0 && height > 0) {
113
- measuredItemHeightRef.current = height;
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
- cancelLongPressTimer,
162
- scrollOffsetRef,
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
- // Combined onScroll handler
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
- scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
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
- }, [scrollOffsetRef, cancelLongPressTimer, treeFlashListProps]);
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 our own combined handler)
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={36}
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={effectiveDropLevel}
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: any) => {
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 it receives
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: any) => {
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
- for (const node of nodes) {
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 && isDescendant(node.children, ancestorId, candidateDescendantId)) {
52
- return true;
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
- /** Check if a node with the given ID exists anywhere in the subtree. */
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
- for (const node of nodes) {
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 && containsNode(node.children, nodeId)) return true;
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
- /** Deep clone a tree structure so mutations don't affect the original. */
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
- return nodes.map(node => ({
70
- ...node,
71
- children: node.children ? deepCloneTree(node.children) : undefined,
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
- for (let i = 0; i < nodes.length; i++) {
84
- if (nodes[i]!.id === nodeId) {
85
- const [removed] = nodes.splice(i, 1);
86
- return removed!;
87
- }
88
- const children = nodes[i]!.children;
89
- if (children) {
90
- const removed = removeNodeById(children, nodeId);
91
- if (removed) {
92
- // Clean up empty children arrays
93
- if (children.length === 0) {
94
- nodes[i] = { ...nodes[i]!, children: undefined };
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
- for (let i = 0; i < nodes.length; i++) {
114
- if (nodes[i]!.id === targetId) {
115
- if (position === "above") {
116
- nodes.splice(i, 0, nodeToInsert);
117
- } else if (position === "below") {
118
- nodes.splice(i + 1, 0, nodeToInsert);
119
- } else {
120
- // "inside" - add as first child
121
- const target = nodes[i]!;
122
- if (target.children) {
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
- nodes[i] = { ...target, children: [nodeToInsert] };
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
- if (nodeDepths.has(nodeId)) return nodeDepths.get(nodeId)!;
259
- let depth = 0;
260
- let currentId: ID | undefined = nodeId;
261
- while (currentId) {
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 unreachable unless nodeMap mutates between loops */
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
+ }