react-native-tree-multi-select 3.0.0-beta.3 → 3.0.0-beta.5

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 (96) hide show
  1. package/README.md +100 -30
  2. package/lib/module/TreeView.js +36 -31
  3. package/lib/module/TreeView.js.map +1 -1
  4. package/lib/module/components/CheckboxView.js +8 -4
  5. package/lib/module/components/CheckboxView.js.map +1 -1
  6. package/lib/module/components/CustomExpandCollapseIcon.js +2 -2
  7. package/lib/module/components/CustomExpandCollapseIcon.js.map +1 -1
  8. package/lib/module/components/DragOverlay.js +17 -5
  9. package/lib/module/components/DragOverlay.js.map +1 -1
  10. package/lib/module/components/DropIndicator.js +2 -2
  11. package/lib/module/components/DropIndicator.js.map +1 -1
  12. package/lib/module/components/NodeList.js +78 -58
  13. package/lib/module/components/NodeList.js.map +1 -1
  14. package/lib/module/constants/treeView.constants.js +3 -0
  15. package/lib/module/constants/treeView.constants.js.map +1 -1
  16. package/lib/module/helpers/expandCollapse.helper.js.map +1 -1
  17. package/lib/module/helpers/moveTreeNode.helper.js +30 -0
  18. package/lib/module/helpers/moveTreeNode.helper.js.map +1 -1
  19. package/lib/module/helpers/selectAll.helper.js.map +1 -1
  20. package/lib/module/helpers/toggleCheckbox.helper.js +43 -60
  21. package/lib/module/helpers/toggleCheckbox.helper.js.map +1 -1
  22. package/lib/module/hooks/useDragDrop.js +146 -65
  23. package/lib/module/hooks/useDragDrop.js.map +1 -1
  24. package/lib/module/{handlers/ScrollToNodeHandler.js → hooks/useScrollToNode.js} +27 -26
  25. package/lib/module/hooks/useScrollToNode.js.map +1 -0
  26. package/lib/module/index.js +1 -0
  27. package/lib/module/index.js.map +1 -1
  28. package/lib/module/jest.setup.js +14 -1
  29. package/lib/module/jest.setup.js.map +1 -1
  30. package/lib/module/store/treeView.store.js +3 -0
  31. package/lib/module/store/treeView.store.js.map +1 -1
  32. package/lib/module/utils/typedMemo.js +3 -3
  33. package/lib/module/utils/typedMemo.js.map +1 -1
  34. package/lib/module/utils/useDeepCompareEffect.js +5 -5
  35. package/lib/module/utils/useDeepCompareEffect.js.map +1 -1
  36. package/lib/typescript/src/TreeView.d.ts +3 -3
  37. package/lib/typescript/src/TreeView.d.ts.map +1 -1
  38. package/lib/typescript/src/components/CheckboxView.d.ts +1 -2
  39. package/lib/typescript/src/components/CheckboxView.d.ts.map +1 -1
  40. package/lib/typescript/src/components/CustomExpandCollapseIcon.d.ts +1 -2
  41. package/lib/typescript/src/components/CustomExpandCollapseIcon.d.ts.map +1 -1
  42. package/lib/typescript/src/components/DragOverlay.d.ts +1 -0
  43. package/lib/typescript/src/components/DragOverlay.d.ts.map +1 -1
  44. package/lib/typescript/src/components/DropIndicator.d.ts +1 -2
  45. package/lib/typescript/src/components/DropIndicator.d.ts.map +1 -1
  46. package/lib/typescript/src/components/NodeList.d.ts.map +1 -1
  47. package/lib/typescript/src/constants/treeView.constants.d.ts +2 -0
  48. package/lib/typescript/src/constants/treeView.constants.d.ts.map +1 -1
  49. package/lib/typescript/src/helpers/expandCollapse.helper.d.ts +2 -2
  50. package/lib/typescript/src/helpers/expandCollapse.helper.d.ts.map +1 -1
  51. package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts.map +1 -1
  52. package/lib/typescript/src/helpers/selectAll.helper.d.ts +4 -4
  53. package/lib/typescript/src/helpers/selectAll.helper.d.ts.map +1 -1
  54. package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts +3 -0
  55. package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts.map +1 -1
  56. package/lib/typescript/src/hooks/useDragDrop.d.ts +24 -8
  57. package/lib/typescript/src/hooks/useDragDrop.d.ts.map +1 -1
  58. package/lib/typescript/src/{handlers/ScrollToNodeHandler.d.ts → hooks/useScrollToNode.d.ts} +13 -15
  59. package/lib/typescript/src/hooks/useScrollToNode.d.ts.map +1 -0
  60. package/lib/typescript/src/index.d.ts +4 -3
  61. package/lib/typescript/src/index.d.ts.map +1 -1
  62. package/lib/typescript/src/jest.setup.d.ts +1 -1
  63. package/lib/typescript/src/jest.setup.d.ts.map +1 -1
  64. package/lib/typescript/src/store/treeView.store.d.ts +2 -1
  65. package/lib/typescript/src/store/treeView.store.d.ts.map +1 -1
  66. package/lib/typescript/src/types/dragDrop.types.d.ts +10 -0
  67. package/lib/typescript/src/types/dragDrop.types.d.ts.map +1 -1
  68. package/lib/typescript/src/types/treeView.types.d.ts +79 -41
  69. package/lib/typescript/src/types/treeView.types.d.ts.map +1 -1
  70. package/lib/typescript/src/utils/typedMemo.d.ts +1 -1
  71. package/lib/typescript/src/utils/typedMemo.d.ts.map +1 -1
  72. package/lib/typescript/src/utils/useDeepCompareEffect.d.ts +2 -2
  73. package/lib/typescript/src/utils/useDeepCompareEffect.d.ts.map +1 -1
  74. package/package.json +32 -15
  75. package/src/TreeView.tsx +57 -35
  76. package/src/components/CheckboxView.tsx +7 -4
  77. package/src/components/CustomExpandCollapseIcon.tsx +2 -2
  78. package/src/components/DragOverlay.tsx +19 -6
  79. package/src/components/DropIndicator.tsx +2 -2
  80. package/src/components/NodeList.tsx +87 -60
  81. package/src/constants/treeView.constants.ts +4 -1
  82. package/src/helpers/expandCollapse.helper.ts +5 -5
  83. package/src/helpers/moveTreeNode.helper.ts +33 -0
  84. package/src/helpers/selectAll.helper.ts +10 -10
  85. package/src/helpers/toggleCheckbox.helper.ts +56 -68
  86. package/src/hooks/useDragDrop.ts +190 -80
  87. package/src/{handlers/ScrollToNodeHandler.tsx → hooks/useScrollToNode.ts} +48 -45
  88. package/src/index.tsx +11 -0
  89. package/src/jest.setup.ts +14 -1
  90. package/src/store/treeView.store.ts +6 -1
  91. package/src/types/dragDrop.types.ts +12 -0
  92. package/src/types/treeView.types.ts +87 -43
  93. package/src/utils/typedMemo.ts +3 -3
  94. package/src/utils/useDeepCompareEffect.ts +13 -7
  95. package/lib/module/handlers/ScrollToNodeHandler.js.map +0 -1
  96. package/lib/typescript/src/handlers/ScrollToNodeHandler.d.ts.map +0 -1
@@ -1,5 +1,56 @@
1
+ import type { CheckboxValueType, TreeNode } from "../types/treeView.types";
1
2
  import { getTreeViewStore } from "../store/treeView.store";
2
3
 
4
+ /** Derive the tri-state checkbox value from checked and indeterminate booleans. */
5
+ export function getCheckboxValue(
6
+ isChecked: boolean,
7
+ isIndeterminate: boolean
8
+ ): CheckboxValueType {
9
+ if (isIndeterminate) return "indeterminate";
10
+ return isChecked;
11
+ }
12
+
13
+ /**
14
+ * Update a parent node's checked/indeterminate state based on its children.
15
+ * Mutates tempChecked and tempIndeterminate in-place.
16
+ */
17
+ function updateParentCheckState<ID>(
18
+ parentId: ID,
19
+ children: TreeNode<ID>[],
20
+ tempChecked: Set<ID>,
21
+ tempIndeterminate: Set<ID>,
22
+ ): void {
23
+ let allChecked = true;
24
+ let anyCheckedOrIndeterminate = false;
25
+
26
+ for (const child of children) {
27
+ const isChecked = tempChecked.has(child.id);
28
+ const isIndeterminate = tempIndeterminate.has(child.id);
29
+
30
+ if (isChecked) {
31
+ anyCheckedOrIndeterminate = true;
32
+ } else if (isIndeterminate) {
33
+ anyCheckedOrIndeterminate = true;
34
+ allChecked = false;
35
+ } else {
36
+ allChecked = false;
37
+ }
38
+
39
+ if (!allChecked && anyCheckedOrIndeterminate) break;
40
+ }
41
+
42
+ if (allChecked) {
43
+ tempChecked.add(parentId);
44
+ tempIndeterminate.delete(parentId);
45
+ } else if (anyCheckedOrIndeterminate) {
46
+ tempChecked.delete(parentId);
47
+ tempIndeterminate.add(parentId);
48
+ } else {
49
+ tempChecked.delete(parentId);
50
+ tempIndeterminate.delete(parentId);
51
+ }
52
+ }
53
+
3
54
  /**
4
55
  * Function to toggle checkbox state for a tree structure.
5
56
  * It sets the checked and indeterminate state for all affected nodes in the tree after an action to check/uncheck is made.
@@ -106,7 +157,7 @@ export function toggleCheckboxes<ID>(
106
157
  while (stack.length > 0) {
107
158
  const nodeId = stack.pop()!;
108
159
  const node = nodeMap.get(nodeId);
109
- if (!node) continue; // Node does not exist; skip
160
+ if (!node) continue;
110
161
 
111
162
  if (childrenChecked) {
112
163
  tempChecked.add(nodeId);
@@ -157,43 +208,8 @@ export function toggleCheckboxes<ID>(
157
208
  */
158
209
  function updateNodeState(nodeId: ID) {
159
210
  const node = nodeMap.get(nodeId);
160
- if (!node || !node.children || node.children.length === 0) {
161
- // Leaf nodes are already updated.
162
- return;
163
- }
164
-
165
- let allChildrenChecked = true;
166
- let anyChildCheckedOrIndeterminate = false;
167
-
168
- for (const child of node.children) {
169
- const isChecked = tempChecked.has(child.id);
170
- const isIndeterminate = tempIndeterminate.has(child.id);
171
-
172
- if (isChecked) {
173
- anyChildCheckedOrIndeterminate = true;
174
- } else if (isIndeterminate) {
175
- anyChildCheckedOrIndeterminate = true;
176
- allChildrenChecked = false;
177
- } else {
178
- allChildrenChecked = false;
179
- }
180
-
181
- // If both conditions are met, we can break early.
182
- if (!allChildrenChecked && anyChildCheckedOrIndeterminate) {
183
- break;
184
- }
185
- }
186
-
187
- if (allChildrenChecked) {
188
- tempChecked.add(nodeId);
189
- tempIndeterminate.delete(nodeId);
190
- } else if (anyChildCheckedOrIndeterminate) {
191
- tempChecked.delete(nodeId);
192
- tempIndeterminate.add(nodeId);
193
- } else {
194
- tempChecked.delete(nodeId);
195
- tempIndeterminate.delete(nodeId);
196
- }
211
+ if (!node?.children?.length) return;
212
+ updateParentCheckState(nodeId, node.children, tempChecked, tempIndeterminate);
197
213
  }
198
214
 
199
215
  // Update the state object with the new checked and indeterminate sets.
@@ -260,37 +276,9 @@ export function recalculateCheckedStates<ID>(storeId: string) {
260
276
  // Update each parent based on its children's current state
261
277
  for (const parentId of parentNodes) {
262
278
  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 */
263
280
  if (!node?.children?.length) continue;
264
-
265
- let allChecked = true;
266
- let anyCheckedOrIndeterminate = false;
267
-
268
- for (const child of node.children) {
269
- const isChecked = tempChecked.has(child.id);
270
- const isIndeterminate = tempIndeterminate.has(child.id);
271
-
272
- if (isChecked) {
273
- anyCheckedOrIndeterminate = true;
274
- } else if (isIndeterminate) {
275
- anyCheckedOrIndeterminate = true;
276
- allChecked = false;
277
- } else {
278
- allChecked = false;
279
- }
280
-
281
- if (!allChecked && anyCheckedOrIndeterminate) break;
282
- }
283
-
284
- if (allChecked) {
285
- tempChecked.add(parentId);
286
- tempIndeterminate.delete(parentId);
287
- } else if (anyCheckedOrIndeterminate) {
288
- tempChecked.delete(parentId);
289
- tempIndeterminate.add(parentId);
290
- } else {
291
- tempChecked.delete(parentId);
292
- tempIndeterminate.delete(parentId);
293
- }
281
+ updateParentCheckState(parentId, node.children, tempChecked, tempIndeterminate);
294
282
  }
295
283
 
296
284
  updateChecked(tempChecked);
@@ -1,33 +1,67 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import {
2
+ MutableRefObject,
3
+ RefObject,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState
8
+ } from "react";
2
9
  import {
3
10
  Animated,
4
11
  PanResponder,
12
+ Platform,
5
13
  type PanResponderInstance,
6
14
  } from "react-native";
7
15
  import type { FlashList } from "@shopify/flash-list";
8
16
 
9
- import type { __FlattenedTreeNode__, TreeNode } from "../types/treeView.types";
10
- import type { DragEndEvent, DropTarget } from "../types/dragDrop.types";
17
+ import type { __FlattenedTreeNode__, TreeNode, DropAutoScrollOptions } from "../types/treeView.types";
18
+ import type { ScrollToNodeHandlerRef } from "./useScrollToNode";
19
+ import type { DragCancelEvent, DragEndEvent, DragStartEvent, DropTarget } from "../types/dragDrop.types";
11
20
  import { getTreeViewStore } from "../store/treeView.store";
12
- import { collapseNodes, expandNodes, handleToggleExpand, initializeNodeMaps, recalculateCheckedStates } from "../helpers";
21
+ import {
22
+ collapseNodes,
23
+ expandNodes,
24
+ handleToggleExpand,
25
+ initializeNodeMaps,
26
+ recalculateCheckedStates
27
+ } from "../helpers";
13
28
  import { moveTreeNode } from "../helpers/moveTreeNode.helper";
29
+ import { listHeaderFooterPadding } from "../constants/treeView.constants";
30
+
31
+ // Android reports locationY slightly differently, causing the overlay
32
+ // to appear ~1 item height closer to the finger than on iOS.
33
+ const PLATFORM_OVERLAY_Y_CORRECTION = Platform.OS === "android" ? -2 : 0;
14
34
 
15
35
  interface UseDragDropParams<ID> {
16
36
  storeId: string;
17
37
  flattenedNodes: __FlattenedTreeNode__<ID>[];
18
- flashListRef: React.RefObject<FlashList<__FlattenedTreeNode__<ID>> | null>;
19
- containerRef: React.RefObject<{ measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => void; } | null>;
38
+ flashListRef: RefObject<FlashList<__FlattenedTreeNode__<ID>> | null>;
39
+ containerRef: RefObject<{ measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => void; } | null>;
20
40
  dragEnabled: boolean;
41
+ onDragStart?: (event: DragStartEvent<ID>) => void;
21
42
  onDragEnd?: (event: DragEndEvent<ID>) => void;
43
+ onDragCancel?: (event: DragCancelEvent<ID>) => void;
22
44
  longPressDuration: number;
23
45
  autoScrollThreshold: number;
24
46
  autoScrollSpeed: number;
25
- internalDataRef: React.MutableRefObject<TreeNode<ID>[] | null>;
26
- measuredItemHeightRef: React.MutableRefObject<number>;
47
+ internalDataRef: MutableRefObject<TreeNode<ID>[] | null>;
48
+ measuredItemHeightRef: MutableRefObject<number>;
27
49
  dragOverlayOffset: number;
28
50
  autoExpandDelay: number;
29
51
  /** Pixels per nesting level, used for magnetic overlay shift. */
30
52
  indentationMultiplier: number;
53
+ /** Callback to determine if a drop is allowed on a specific target. */
54
+ canDrop?: (draggedNode: TreeNode<ID>, targetNode: TreeNode<ID>, position: "above" | "below" | "inside") => boolean;
55
+ /** Maximum nesting depth allowed. */
56
+ maxDepth?: number;
57
+ /** Callback to determine if a node can accept children. */
58
+ canNodeHaveChildren?: (node: TreeNode<ID>) => boolean;
59
+ /** Ref for scrolling to a node after drop. */
60
+ scrollToNodeHandlerRef: RefObject<ScrollToNodeHandlerRef<ID> | null>;
61
+ /** Auto-scroll configuration for after drop. */
62
+ autoScrollToDroppedNode?: boolean | DropAutoScrollOptions;
63
+ /** Callback to determine if a node can be dragged. */
64
+ canDrag?: (node: TreeNode<ID>) => boolean;
31
65
  }
32
66
 
33
67
  interface UseDragDropReturn<ID> {
@@ -46,8 +80,8 @@ interface UseDragDropReturn<ID> {
46
80
  ) => void;
47
81
  handleNodeTouchEnd: () => void;
48
82
  cancelLongPressTimer: () => void;
49
- scrollOffsetRef: React.MutableRefObject<number>;
50
- headerOffsetRef: React.MutableRefObject<number>;
83
+ scrollOffsetRef: MutableRefObject<number>;
84
+ headerOffsetRef: MutableRefObject<number>;
51
85
  }
52
86
 
53
87
  export function useDragDrop<ID>(
@@ -59,7 +93,9 @@ export function useDragDrop<ID>(
59
93
  flashListRef,
60
94
  containerRef,
61
95
  dragEnabled,
96
+ onDragStart,
62
97
  onDragEnd,
98
+ onDragCancel,
63
99
  longPressDuration,
64
100
  autoScrollThreshold,
65
101
  autoScrollSpeed,
@@ -68,6 +104,12 @@ export function useDragDrop<ID>(
68
104
  dragOverlayOffset,
69
105
  autoExpandDelay,
70
106
  indentationMultiplier,
107
+ canDrop: canDropCallback,
108
+ maxDepth,
109
+ canNodeHaveChildren,
110
+ canDrag,
111
+ scrollToNodeHandlerRef,
112
+ autoScrollToDroppedNode,
71
113
  } = params;
72
114
 
73
115
  // --- Refs for mutable state (no stale closures in PanResponder) ---
@@ -80,6 +122,7 @@ export function useDragDrop<ID>(
80
122
 
81
123
  const containerPageXRef = useRef(0);
82
124
  const containerPageYRef = useRef(0);
125
+ const containerWidthRef = useRef(0);
83
126
  const containerHeightRef = useRef(0);
84
127
  const grabOffsetYRef = useRef(0);
85
128
  const scrollOffsetRef = useRef(0);
@@ -109,13 +152,40 @@ export function useDragDrop<ID>(
109
152
  // Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
110
153
  const prevDropTargetRef = useRef<{ targetIndex: number; position: "above" | "below" | "inside"; } | null>(null);
111
154
 
155
+ // Depth of the dragged subtree (computed once at drag start, used for maxDepth check)
156
+ const draggedSubtreeDepthRef = useRef(0);
157
+
112
158
  // Keep flattenedNodes ref current for PanResponder closures
113
159
  const flattenedNodesRef = useRef(flattenedNodes);
114
160
  flattenedNodesRef.current = flattenedNodes;
115
161
 
116
162
  // Keep callbacks current
163
+ const onDragStartRef = useRef(onDragStart);
164
+ onDragStartRef.current = onDragStart;
117
165
  const onDragEndRef = useRef(onDragEnd);
118
166
  onDragEndRef.current = onDragEnd;
167
+ const onDragCancelRef = useRef(onDragCancel);
168
+ onDragCancelRef.current = onDragCancel;
169
+ const canDropRef = useRef(canDropCallback);
170
+ canDropRef.current = canDropCallback;
171
+ const canNodeHaveChildrenRef = useRef(canNodeHaveChildren);
172
+ canNodeHaveChildrenRef.current = canNodeHaveChildren;
173
+ const canDragRef = useRef(canDrag);
174
+ canDragRef.current = canDrag;
175
+
176
+ // Keep config values current for PanResponder closures
177
+ const dragOverlayOffsetRef = useRef(dragOverlayOffset);
178
+ dragOverlayOffsetRef.current = dragOverlayOffset;
179
+ const autoScrollThresholdRef = useRef(autoScrollThreshold);
180
+ autoScrollThresholdRef.current = autoScrollThreshold;
181
+ const autoScrollSpeedParamRef = useRef(autoScrollSpeed);
182
+ autoScrollSpeedParamRef.current = autoScrollSpeed;
183
+ const autoExpandDelayRef = useRef(autoExpandDelay);
184
+ autoExpandDelayRef.current = autoExpandDelay;
185
+ const indentationMultiplierRef = useRef(indentationMultiplier);
186
+ indentationMultiplierRef.current = indentationMultiplier;
187
+ const maxDepthRef = useRef(maxDepth);
188
+ maxDepthRef.current = maxDepth;
119
189
 
120
190
  // --- React state (triggers re-renders only at drag start/end + indicator changes) ---
121
191
  const [isDragging, setIsDragging] = useState(false);
@@ -157,6 +227,22 @@ export function useDragDrop<ID>(
157
227
  [storeId]
158
228
  );
159
229
 
230
+ // --- Get the maximum depth of a subtree (0 for leaf nodes) ---
231
+ const getSubtreeDepth = useCallback(
232
+ (nodeId: ID): number => {
233
+ const store = getTreeViewStore<ID>(storeId);
234
+ const { nodeMap } = store.getState();
235
+ const node = nodeMap.get(nodeId);
236
+ if (!node?.children?.length) return 0;
237
+ let max = 0;
238
+ for (const child of node.children) {
239
+ max = Math.max(max, 1 + getSubtreeDepth(child.id));
240
+ }
241
+ return max;
242
+ },
243
+ [storeId]
244
+ );
245
+
160
246
  // --- Initiate drag ---
161
247
  const initiateDrag = useCallback(
162
248
  (nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
@@ -165,9 +251,10 @@ export function useDragDrop<ID>(
165
251
  const container = containerRef.current;
166
252
  if (!container) return;
167
253
 
168
- container.measureInWindow((x, y, _w, h) => {
254
+ container.measureInWindow((x, y, w, h) => {
169
255
  containerPageXRef.current = x;
170
256
  containerPageYRef.current = y;
257
+ containerWidthRef.current = w;
171
258
  containerHeightRef.current = h;
172
259
 
173
260
  // Find the node in flattened list
@@ -187,12 +274,11 @@ export function useDragDrop<ID>(
187
274
  draggedNodeRef.current = node;
188
275
  draggedNodeIdRef.current = nodeId;
189
276
  draggedNodeIndexRef.current = nodeIndex;
277
+ draggedSubtreeDepthRef.current = getSubtreeDepth(nodeId);
190
278
 
191
- // Use measured item height if available, fall back to estimatedItemSize
279
+ // Use measured item height if available, fall back to default
192
280
  const measured = measuredItemHeightRef.current;
193
- const estimatedSize =
194
- (flashListRef.current as any)?.props?.estimatedItemSize ?? 36;
195
- itemHeightRef.current = measured > 0 ? measured : estimatedSize;
281
+ itemHeightRef.current = measured > 0 ? measured : 36;
196
282
 
197
283
  // Calculate headerOffset dynamically:
198
284
  // fingerLocalY = pageY - containerPageY
@@ -207,9 +293,8 @@ export function useDragDrop<ID>(
207
293
 
208
294
  // Delta-based auto-scroll: compute finger's position in the container
209
295
  // from the node's known index (avoids unreliable containerPageY).
210
- // The FlashList header (padding:5 → ~10px) + nodeIndex * itemHeight - scroll + locationY
211
296
  const iH = itemHeightRef.current;
212
- const listHeaderHeight = 10; // HeaderFooterView has padding: 5 → 10px total
297
+ const listHeaderHeight = listHeaderFooterPadding * 2;
213
298
  initialFingerPageYRef.current = pageY;
214
299
  initialFingerContainerYRef.current =
215
300
  listHeaderHeight + nodeIndex * iH - scrollOffsetRef.current + locationY;
@@ -223,7 +308,7 @@ export function useDragDrop<ID>(
223
308
  store.getState().updateInvalidDragTargetIds(descendants);
224
309
 
225
310
  // Set overlay initial position (with configurable offset)
226
- const overlayLocalY = fingerLocalY - locationY + dragOverlayOffset * itemHeightRef.current;
311
+ const overlayLocalY = fingerLocalY - locationY + (dragOverlayOffsetRef.current + PLATFORM_OVERLAY_Y_CORRECTION) * itemHeightRef.current;
227
312
  overlayY.setValue(overlayLocalY);
228
313
 
229
314
  // Reset magnetic overlay
@@ -238,6 +323,9 @@ export function useDragDrop<ID>(
238
323
  setEffectiveDropLevel(node.level ?? 0);
239
324
  setDropTarget(null);
240
325
 
326
+ // Notify consumer that drag has started
327
+ onDragStartRef.current?.({ draggedNodeId: nodeId });
328
+
241
329
  // Start auto-scroll loop
242
330
  startAutoScrollLoop();
243
331
  });
@@ -249,6 +337,7 @@ export function useDragDrop<ID>(
249
337
  containerRef,
250
338
  flashListRef,
251
339
  getDescendantIds,
340
+ getSubtreeDepth,
252
341
  overlayY,
253
342
  ]
254
343
  );
@@ -258,6 +347,12 @@ export function useDragDrop<ID>(
258
347
  (nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
259
348
  if (!dragEnabled) return;
260
349
 
350
+ // Check if this node can be dragged
351
+ if (canDragRef.current) {
352
+ const node = flattenedNodesRef.current[nodeIndex];
353
+ if (node && !canDragRef.current(node)) return;
354
+ }
355
+
261
356
  // Cancel any existing timer
262
357
  cancelLongPressTimer();
263
358
 
@@ -281,7 +376,7 @@ export function useDragDrop<ID>(
281
376
  scrollOffsetRef.current + autoScrollSpeedRef.current
282
377
  );
283
378
  scrollOffsetRef.current = newOffset;
284
- (flashListRef.current as any)?.scrollToOffset?.({
379
+ flashListRef.current?.scrollToOffset?.({
285
380
  offset: newOffset,
286
381
  animated: false,
287
382
  });
@@ -302,8 +397,8 @@ export function useDragDrop<ID>(
302
397
 
303
398
  const updateAutoScroll = useCallback(
304
399
  (fingerInContainer: number) => {
305
- const threshold = autoScrollThreshold;
306
- const maxSpeed = 8 * autoScrollSpeed;
400
+ const threshold = autoScrollThresholdRef.current;
401
+ const maxSpeed = 8 * autoScrollSpeedParamRef.current;
307
402
  const containerH = containerHeightRef.current;
308
403
 
309
404
  if (fingerInContainer < threshold) {
@@ -319,7 +414,7 @@ export function useDragDrop<ID>(
319
414
  autoScrollSpeedRef.current = 0;
320
415
  }
321
416
  },
322
- [autoScrollThreshold, autoScrollSpeed]
417
+ []
323
418
  );
324
419
 
325
420
  // --- Cancel auto-expand timer ---
@@ -357,14 +452,34 @@ export function useDragDrop<ID>(
357
452
  const positionInItem =
358
453
  (adjustedContentY - clampedIndex * iH) / iH;
359
454
  let position: "above" | "below" | "inside";
360
- if (positionInItem < 0.15) {
455
+ if (positionInItem < 0.25) {
361
456
  position = "above";
362
- } else if (positionInItem > 0.85) {
457
+ } else if (positionInItem > 0.75) {
363
458
  position = "below";
364
459
  } else {
365
460
  position = "inside";
366
461
  }
367
462
 
463
+ // --- Determine if "inside" drop is allowed for this target ---
464
+ const canDropInsideTarget = (() => {
465
+ // canNodeHaveChildren: structural constraint
466
+ if (canNodeHaveChildrenRef.current && !canNodeHaveChildrenRef.current(targetNode)) {
467
+ return false;
468
+ }
469
+ // maxDepth: the dragged subtree at (targetLevel + 1) must not exceed maxDepth
470
+ if (maxDepthRef.current !== undefined) {
471
+ const targetLevel = targetNode.level ?? 0;
472
+ const deepest = targetLevel + 1 + draggedSubtreeDepthRef.current;
473
+ if (deepest > maxDepthRef.current) return false;
474
+ }
475
+ return true;
476
+ })();
477
+
478
+ // If "inside" is not allowed, convert to nearest zone
479
+ if (position === "inside" && !canDropInsideTarget) {
480
+ position = positionInItem < 0.5 ? "above" : "below";
481
+ }
482
+
368
483
  // --- Horizontal control at level cliffs ---
369
484
  // At the boundary between nodes at different depths, the user's
370
485
  // horizontal finger position decides the drop level:
@@ -378,7 +493,7 @@ export function useDragDrop<ID>(
378
493
  let logicalPosition: "above" | "below" | "inside" | null = null;
379
494
  let visualDropLevel: number | null = null;
380
495
 
381
- if (position === "below" || position === "inside") {
496
+ if (position === "below") {
382
497
  const currentLevel = targetNode.level ?? 0;
383
498
  let isCliff = false;
384
499
  let shallowLevel = 0;
@@ -397,10 +512,9 @@ export function useDragDrop<ID>(
397
512
  }
398
513
 
399
514
  if (isCliff) {
400
- // Generous threshold: midpoint of the two levels + 2× indent buffer
401
- const threshold =
402
- ((currentLevel + shallowLevel) / 2) * indentationMultiplier
403
- + indentationMultiplier * 2;
515
+ // Midpoint of the item's visible content area
516
+ const itemLeftEdge = currentLevel * indentationMultiplierRef.current;
517
+ const threshold = itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
404
518
 
405
519
  if (fingerLocalX < threshold) {
406
520
  // User wants the shallow level
@@ -434,10 +548,8 @@ export function useDragDrop<ID>(
434
548
  const prevLevel = prevNode?.level ?? 0;
435
549
  const currentLevel = targetNode.level ?? 0;
436
550
  if (prevNode && prevLevel > currentLevel) {
437
- // Level cliff above - same generous threshold
438
- const threshold =
439
- ((prevLevel + currentLevel) / 2) * indentationMultiplier
440
- + indentationMultiplier * 2;
551
+ const itemLeftEdge = prevLevel * indentationMultiplierRef.current;
552
+ const threshold = itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
441
553
 
442
554
  if (fingerLocalX >= threshold) {
443
555
  clampedIndex = clampedIndex - 1;
@@ -447,25 +559,15 @@ export function useDragDrop<ID>(
447
559
  }
448
560
  }
449
561
 
450
- // --- Suppress "below" when it's redundant or confusing ---
451
- // After horizontal control, any remaining "below" that isn't at a
452
- // cliff is redundant with "above" on the next node → show "inside".
453
- if (position === "below") {
562
+ // --- Suppress "below" when it's semantically confusing ---
563
+ // For expanded parents, "below" visually sits at the parent/child
564
+ // junction but semantically inserts as a sibling after the entire
565
+ // subtree. Convert to "inside" which is clearer.
566
+ if (position === "below" && canDropInsideTarget) {
454
567
  const expandedSet = getTreeViewStore<ID>(storeId).getState().expanded;
455
-
456
- // (a) Expanded parent: "below" visually sits at the parent/child junction
457
- // but semantically inserts as a sibling after the entire subtree.
458
568
  if (targetNode.children?.length && expandedSet.has(targetNode.id)) {
459
569
  position = "inside";
460
570
  }
461
- // (b) No level cliff below: convert to "inside" so the highlight
462
- // covers the full bottom of the node.
463
- else if (clampedIndex < nodes.length - 1) {
464
- const nextNode = nodes[clampedIndex + 1];
465
- if (nextNode && (nextNode.level ?? 0) >= (targetNode.level ?? 0)) {
466
- position = "inside";
467
- }
468
- }
469
571
  }
470
572
 
471
573
  // --- Hysteresis: prevent flicker between "below N" and "above N+1" ---
@@ -500,9 +602,24 @@ export function useDragDrop<ID>(
500
602
  const store = getTreeViewStore<ID>(storeId);
501
603
  const { invalidDragTargetIds, draggedNodeId, expanded } =
502
604
  store.getState();
605
+
606
+ // maxDepth check for above/below (sibling) positions
607
+ let maxDepthValid = true;
608
+ if (maxDepthRef.current !== undefined && (position === "above" || position === "below")) {
609
+ const targetLevel = targetNode.level ?? 0;
610
+ const deepest = targetLevel + draggedSubtreeDepthRef.current;
611
+ if (deepest > maxDepthRef.current) maxDepthValid = false;
612
+ }
613
+
503
614
  const isValid =
504
615
  targetNode.id !== draggedNodeId &&
505
- !invalidDragTargetIds.has(targetNode.id);
616
+ !invalidDragTargetIds.has(targetNode.id) &&
617
+ maxDepthValid &&
618
+ (!canDropRef.current || canDropRef.current(
619
+ draggedNodeRef.current!,
620
+ targetNode,
621
+ position
622
+ ));
506
623
 
507
624
  // --- Auto-expand: if hovering "inside" a collapsed expandable node ---
508
625
  if (isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
@@ -513,9 +630,9 @@ export function useDragDrop<ID>(
513
630
  autoExpandTimerRef.current = setTimeout(() => {
514
631
  autoExpandTimerRef.current = null;
515
632
  // Expand the node and track it
516
- handleToggleExpand(storeId, targetNode.id);
633
+ expandNodes(storeId, [targetNode.id]);
517
634
  autoExpandedDuringDragRef.current.add(targetNode.id);
518
- }, autoExpandDelay);
635
+ }, autoExpandDelayRef.current);
519
636
  }
520
637
  } else {
521
638
  // Not hovering inside a collapsed expandable node - cancel timer
@@ -547,7 +664,7 @@ export function useDragDrop<ID>(
547
664
  // then spring to 0 for a smooth "magnetic snap" transition.
548
665
  if (prevLevel !== effectiveLevel) {
549
666
  overlayX.setValue(
550
- (prevLevel - effectiveLevel) * indentationMultiplier
667
+ (prevLevel - effectiveLevel) * indentationMultiplierRef.current
551
668
  );
552
669
  Animated.spring(overlayX, {
553
670
  toValue: 0,
@@ -599,7 +716,7 @@ export function useDragDrop<ID>(
599
716
  return newTarget;
600
717
  });
601
718
  },
602
- [storeId, autoExpandDelay, cancelAutoExpandTimer, indentationMultiplier, overlayX]
719
+ [storeId, cancelAutoExpandTimer, overlayX]
603
720
  );
604
721
 
605
722
  // --- Handle drag end ---
@@ -673,32 +790,25 @@ export function useDragDrop<ID>(
673
790
  newTreeData: newData,
674
791
  });
675
792
 
676
- // Scroll to the dropped node after React processes the expansion,
677
- // but only if it's outside the visible viewport. An animated
678
- // scroll would consume the user's next touch (RN stops the
679
- // animation on tap), so we skip when the node is already visible.
680
- setTimeout(() => {
681
- const nodes = flattenedNodesRef.current;
682
- const idx = nodes.findIndex(n => n.id === droppedNodeId);
683
- if (idx < 0) return;
684
-
685
- const itemH = itemHeightRef.current;
686
- const scrollTop = scrollOffsetRef.current;
687
- const containerH = containerHeightRef.current;
688
- const estimatedTop = idx * itemH;
689
- const estimatedBottom = estimatedTop + itemH;
690
-
691
- // Already in view → no scroll needed
692
- if (estimatedTop >= scrollTop && estimatedBottom <= scrollTop + containerH) {
693
- return;
694
- }
695
-
696
- flashListRef.current?.scrollToIndex?.({
697
- index: idx,
698
- animated: true,
699
- viewPosition: 0.5,
700
- });
701
- }, 100);
793
+ // Auto-scroll to the dropped node unless disabled by the user.
794
+ const scrollOpts = autoScrollToDroppedNode;
795
+ const scrollEnabled = scrollOpts === undefined || scrollOpts === true
796
+ || (typeof scrollOpts === "object" && scrollOpts.enabled !== false);
797
+
798
+ if (scrollEnabled) {
799
+ const custom = typeof scrollOpts === "object" ? scrollOpts : {};
800
+ setTimeout(() => {
801
+ scrollToNodeHandlerRef.current?.scrollToNodeID({
802
+ nodeId: droppedNodeId,
803
+ animated: custom.animated ?? true,
804
+ viewPosition: custom.viewPosition ?? 0.5,
805
+ viewOffset: custom.viewOffset,
806
+ });
807
+ }, 0);
808
+ }
809
+ } else if (droppedNodeId !== null) {
810
+ // Drag ended without a valid drop - notify consumer
811
+ onDragCancelRef.current?.({ draggedNodeId: droppedNodeId });
702
812
  }
703
813
 
704
814
  // Collapse auto-expanded nodes that aren't ancestors of the drop target
@@ -791,7 +901,7 @@ export function useDragDrop<ID>(
791
901
 
792
902
  // Update overlay position (with configurable offset)
793
903
  const overlayLocalY =
794
- fingerLocalY - grabOffsetYRef.current + dragOverlayOffset * itemHeightRef.current;
904
+ fingerLocalY - grabOffsetYRef.current + (dragOverlayOffsetRef.current + PLATFORM_OVERLAY_Y_CORRECTION) * itemHeightRef.current;
795
905
  overlayY.setValue(overlayLocalY);
796
906
 
797
907
  // Calculate drop target (horizontal position used at level cliffs)