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.
Files changed (64) hide show
  1. package/README.md +57 -26
  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 +82 -29
  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 +5 -12
  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 +470 -186
  17. package/lib/module/hooks/useDragDrop.js.map +1 -1
  18. package/lib/module/hooks/useScrollToNode.js +17 -0
  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 +24 -6
  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 +2 -2
  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 +68 -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 +82 -28
  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 +5 -12
  53. package/src/helpers/treeNode.helper.ts +52 -1
  54. package/src/hooks/useDragDrop.ts +573 -214
  55. package/src/hooks/useScrollToNode.ts +21 -0
  56. package/src/index.tsx +3 -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 +71 -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";
@@ -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
- // Users can still toggle it off with enabled: false at runtime.
89
- 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;
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
- const handleItemLayout = useCallback((height: number) => {
113
- if (measuredItemHeightRef.current === 0 && height > 0) {
114
- 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
+ }
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
- cancelLongPressTimer,
163
- scrollOffsetRef,
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
- // 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.
190
220
  const handleScroll = useCallback((
191
221
  event: NativeSyntheticEvent<NativeScrollEvent>
192
222
  ) => {
193
- scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
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
- }, [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]);
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
- nodeIndex={index}
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 our own combined handler)
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={36}
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={effectiveDropLevel}
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: any) => {
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: any) => {
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
- 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
 
@@ -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
+ }