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
@@ -7,24 +7,58 @@ import {
7
7
  useState
8
8
  } from "react";
9
9
  import {
10
+ AccessibilityInfo,
10
11
  Animated,
11
12
  PanResponder,
13
+ Platform,
14
+ type NativeScrollEvent,
15
+ type NativeSyntheticEvent,
12
16
  type PanResponderInstance,
13
17
  } from "react-native";
14
18
  import type { FlashList } from "@shopify/flash-list";
15
19
 
16
- import type { __FlattenedTreeNode__, TreeNode } from "../types/treeView.types";
17
- import type { DragCancelEvent, DragEndEvent, DragStartEvent, DropTarget } from "../types/dragDrop.types";
20
+ import type { __FlattenedTreeNode__, TreeNode, DropAutoScrollOptions } from "../types/treeView.types";
21
+ import type { ScrollToNodeHandlerRef } from "./useScrollToNode";
22
+ import type { DragCancelEvent, DragEndEvent, DragStartEvent, DropPosition, DropTarget } from "../types/dragDrop.types";
18
23
  import { getTreeViewStore } from "../store/treeView.store";
19
24
  import {
20
25
  collapseNodes,
21
26
  expandNodes,
27
+ getSubtreeDepthFromMap,
22
28
  handleToggleExpand,
23
- initializeNodeMaps,
24
- recalculateCheckedStates
25
29
  } from "../helpers";
26
- import { moveTreeNode } from "../helpers/moveTreeNode.helper";
27
- import { listHeaderFooterPadding } from "../constants/treeView.constants";
30
+ import {
31
+ applyMoveToStore,
32
+ findNodePosition,
33
+ findNodePositionFromMaps,
34
+ moveTreeNode,
35
+ } from "../helpers/moveTreeNode.helper";
36
+ import { scrollMovedNodeIntoView } from "./useScrollToNode";
37
+ import { defaultItemHeight } from "../constants/treeView.constants";
38
+
39
+ // Android reports touch `locationY` slightly differently from iOS, which makes the
40
+ // drag overlay sit ~2 item-heights closer to the finger. This empirical correction
41
+ // (in item-height units, added to dragOverlayOffset) compensates for that. Consumers
42
+ // can override it per-instance via DragAndDropOptions.overlayYCorrection.
43
+ const DEFAULT_OVERLAY_Y_CORRECTION = Platform.OS === "android" ? -2 : 0;
44
+
45
+ // Auto-scroll speed at the very edge in px/SECOND, before the proximity ramp
46
+ // and the consumer's `autoScrollSpeed` multiplier are applied. Time-based (not
47
+ // px/frame) so 60Hz and 120Hz displays scroll at the same real-world speed.
48
+ const MAX_AUTO_SCROLL_SPEED = 1200;
49
+ // During auto-scroll, recompute the drop target at most this often. Every scroll
50
+ // frame moves rows under the finger, but recomputing per frame costs store
51
+ // writes + node re-renders on the JS thread (stuttering the scroll itself), and
52
+ // ~10 indicator updates/sec reads calmer than 60 anyway.
53
+ const AUTO_SCROLL_RECALC_INTERVAL_MS = 100;
54
+ // How long (ms) a candidate drop level must hold before the overlay springs to
55
+ // its indentation. Prevents the indent from chasing every row the finger merely
56
+ // passes through while dragging vertically.
57
+ const LEVEL_SETTLE_MS = 120;
58
+ // How far (fraction of row height) the active zone's boundaries extend outward
59
+ // while the finger stays on the same row. Finger tremor at a zone edge would
60
+ // otherwise flip above/inside/below (and the overlay indent) every few frames.
61
+ const ZONE_STICKINESS = 0.08;
28
62
 
29
63
  interface UseDragDropParams<ID> {
30
64
  storeId: string;
@@ -38,18 +72,33 @@ interface UseDragDropParams<ID> {
38
72
  longPressDuration: number;
39
73
  autoScrollThreshold: number;
40
74
  autoScrollSpeed: number;
41
- internalDataRef: MutableRefObject<TreeNode<ID>[] | null>;
42
75
  measuredItemHeightRef: MutableRefObject<number>;
76
+ /** Live total content height of the list (from FlashList onContentSizeChange),
77
+ * used to clamp auto-scroll so the offset never runs past the end of the list. */
78
+ contentHeightRef: MutableRefObject<number>;
79
+ /** Measured row heights keyed by stable node id. Enables accurate drop
80
+ * targeting for variable-height rows when the whole list is rendered. */
81
+ itemHeightsRef: MutableRefObject<Map<ID, number>>;
43
82
  dragOverlayOffset: number;
83
+ /** Optional override (item-height units) for the platform overlay-Y correction. */
84
+ overlayYCorrection?: number;
44
85
  autoExpandDelay: number;
86
+ /** Whether hovering "inside" a collapsed node auto-expands it. Default: true. */
87
+ autoExpand?: boolean;
88
+ /** Whether the overlay springs ("magnetic snap") when the drop level changes. Default: true. */
89
+ magneticSnap?: boolean;
45
90
  /** Pixels per nesting level, used for magnetic overlay shift. */
46
91
  indentationMultiplier: number;
47
92
  /** Callback to determine if a drop is allowed on a specific target. */
48
- canDrop?: (draggedNode: TreeNode<ID>, targetNode: TreeNode<ID>, position: "above" | "below" | "inside") => boolean;
93
+ canDrop?: (draggedNode: TreeNode<ID>, targetNode: TreeNode<ID>, position: DropPosition) => boolean;
49
94
  /** Maximum nesting depth allowed. */
50
95
  maxDepth?: number;
51
96
  /** Callback to determine if a node can accept children. */
52
97
  canNodeHaveChildren?: (node: TreeNode<ID>) => boolean;
98
+ /** Ref for scrolling to a node after drop. */
99
+ scrollToNodeHandlerRef: RefObject<ScrollToNodeHandlerRef<ID> | null>;
100
+ /** Auto-scroll configuration for after drop. */
101
+ autoScrollToDroppedNode?: boolean | DropAutoScrollOptions;
53
102
  /** Callback to determine if a node can be dragged. */
54
103
  canDrag?: (node: TreeNode<ID>) => boolean;
55
104
  }
@@ -60,8 +109,6 @@ interface UseDragDropReturn<ID> {
60
109
  overlayX: Animated.Value;
61
110
  isDragging: boolean;
62
111
  draggedNode: __FlattenedTreeNode__<ID> | null;
63
- dropTarget: DropTarget<ID> | null;
64
- effectiveDropLevel: number;
65
112
  handleNodeTouchStart: (
66
113
  nodeId: ID,
67
114
  pageY: number,
@@ -70,8 +117,17 @@ interface UseDragDropReturn<ID> {
70
117
  ) => void;
71
118
  handleNodeTouchEnd: () => void;
72
119
  cancelLongPressTimer: () => void;
120
+ /** onScroll handler for the host list. Owns the scroll-offset bookkeeping:
121
+ * real scroll events are ignored while a drag is active (the auto-scroll
122
+ * RAF loop is the sole writer of the offset then - lagging events would
123
+ * fight its accumulated value and make the offset oscillate), and any
124
+ * scroll cancels a pending long-press. */
125
+ handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
73
126
  scrollOffsetRef: MutableRefObject<number>;
74
127
  headerOffsetRef: MutableRefObject<number>;
128
+ /** Live container height; kept fresh via the container's onLayout so the
129
+ * auto-scroll edge/clamp math survives a mid-session resize. */
130
+ containerHeightRef: MutableRefObject<number>;
75
131
  }
76
132
 
77
133
  export function useDragDrop<ID>(
@@ -89,43 +145,67 @@ export function useDragDrop<ID>(
89
145
  longPressDuration,
90
146
  autoScrollThreshold,
91
147
  autoScrollSpeed,
92
- internalDataRef,
93
148
  measuredItemHeightRef,
149
+ contentHeightRef,
150
+ itemHeightsRef,
94
151
  dragOverlayOffset,
152
+ overlayYCorrection,
95
153
  autoExpandDelay,
154
+ autoExpand = true,
155
+ magneticSnap = true,
96
156
  indentationMultiplier,
97
157
  canDrop: canDropCallback,
98
158
  maxDepth,
99
159
  canNodeHaveChildren,
100
160
  canDrag,
161
+ scrollToNodeHandlerRef,
162
+ autoScrollToDroppedNode,
101
163
  } = params;
102
164
 
103
165
  // --- Refs for mutable state (no stale closures in PanResponder) ---
104
166
  const isDraggingRef = useRef(false);
105
167
  const draggedNodeRef = useRef<__FlattenedTreeNode__<ID> | null>(null);
106
- const draggedNodeIdRef = useRef<ID | null>(null);
107
- const draggedNodeIndexRef = useRef(-1);
168
+ // Whether the dragged node was expanded before drag start force-collapsed it,
169
+ // so a cancelled drag can restore the expansion (a cancel must not mutate state).
170
+ const wasDraggedNodeExpandedRef = useRef(false);
108
171
 
109
172
  const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
110
173
 
111
174
  const containerPageXRef = useRef(0);
112
175
  const containerPageYRef = useRef(0);
176
+ const containerWidthRef = useRef(0);
113
177
  const containerHeightRef = useRef(0);
114
178
  const grabOffsetYRef = useRef(0);
115
179
  const scrollOffsetRef = useRef(0);
116
180
  const headerOffsetRef = useRef(0);
117
- const itemHeightRef = useRef(36);
181
+ const itemHeightRef = useRef(defaultItemHeight);
118
182
 
119
183
  const overlayY = useRef(new Animated.Value(0)).current;
120
184
  const overlayX = useRef(new Animated.Value(0)).current;
121
185
  const prevEffectiveLevelRef = useRef<number | null>(null);
186
+ // Settle-debounce for the overlay indent: candidate level + the timer that
187
+ // springs the overlay to it once it has held for LEVEL_SETTLE_MS.
188
+ const pendingLevelRef = useRef<number | null>(null);
189
+ const levelSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
122
190
 
123
191
  const autoScrollRAFRef = useRef<number | null>(null);
124
192
  const autoScrollSpeedRef = useRef(0);
125
193
 
126
- // Delta-based auto-scroll: avoids unreliable containerPageY
127
- const initialFingerPageYRef = useRef(0);
128
- const initialFingerContainerYRef = useRef(0);
194
+ // Last known finger position, so the auto-scroll loop can recompute the drop
195
+ // target for a stationary finger while rows scroll underneath it.
196
+ const lastFingerPageYRef = useRef(0);
197
+ const lastFingerPageXRef = useRef(0);
198
+ // calculateDropTarget is defined below startAutoScrollLoop; the RAF loop reads
199
+ // it through this ref to avoid a use-before-declaration in the dep array.
200
+ const calculateDropTargetRef = useRef<(fingerPageY: number, fingerPageX: number) => void>(() => { });
201
+
202
+ // Caches the "every current row is measured" gate so calculateDropTarget
203
+ // doesn't scan the full flattened list on every pan frame. Invalidated when
204
+ // the flattened list identity or the measured-heights count changes.
205
+ const allHeightsMeasuredRef = useRef<{
206
+ size: number;
207
+ value: boolean;
208
+ } | null>(null);
129
209
 
130
210
  // Auto-expand timer for hovering over collapsed nodes
131
211
  const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -136,8 +216,16 @@ export function useDragDrop<ID>(
136
216
  // Tracks whether the PanResponder has captured the current gesture
137
217
  const panResponderActiveRef = useRef(false);
138
218
 
219
+ // True between the long-press firing and the async measureInWindow callback
220
+ // resolving. Lets a finger-lift in that window abort the pending drag so the
221
+ // node is never left "lifted" with no finger down.
222
+ const pendingDragRef = useRef(false);
223
+
139
224
  // Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
140
- const prevDropTargetRef = useRef<{ targetIndex: number; position: "above" | "below" | "inside"; } | null>(null);
225
+ const prevDropTargetRef = useRef<{ targetIndex: number; position: DropPosition; } | null>(null);
226
+ // Flattened-list identity seen by the last calculateDropTarget call; when it
227
+ // changes mid-drag the index-based hysteresis state above is invalidated.
228
+ const lastCalcNodesRef = useRef<unknown>(null);
141
229
 
142
230
  // Depth of the dragged subtree (computed once at drag start, used for maxDepth check)
143
231
  const draggedSubtreeDepthRef = useRef(0);
@@ -163,32 +251,53 @@ export function useDragDrop<ID>(
163
251
  // Keep config values current for PanResponder closures
164
252
  const dragOverlayOffsetRef = useRef(dragOverlayOffset);
165
253
  dragOverlayOffsetRef.current = dragOverlayOffset;
254
+ const overlayYCorrectionRef = useRef(overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION);
255
+ overlayYCorrectionRef.current = overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION;
166
256
  const autoScrollThresholdRef = useRef(autoScrollThreshold);
167
257
  autoScrollThresholdRef.current = autoScrollThreshold;
168
258
  const autoScrollSpeedParamRef = useRef(autoScrollSpeed);
169
259
  autoScrollSpeedParamRef.current = autoScrollSpeed;
170
260
  const autoExpandDelayRef = useRef(autoExpandDelay);
171
261
  autoExpandDelayRef.current = autoExpandDelay;
262
+ const autoExpandRef = useRef(autoExpand);
263
+ autoExpandRef.current = autoExpand;
264
+ const magneticSnapRef = useRef(magneticSnap);
265
+ magneticSnapRef.current = magneticSnap;
172
266
  const indentationMultiplierRef = useRef(indentationMultiplier);
173
267
  indentationMultiplierRef.current = indentationMultiplier;
174
268
  const maxDepthRef = useRef(maxDepth);
175
269
  maxDepthRef.current = maxDepth;
176
270
 
177
- // --- React state (triggers re-renders only at drag start/end + indicator changes) ---
271
+ // --- React state (triggers re-renders only at drag start/end + level changes) ---
178
272
  const [isDragging, setIsDragging] = useState(false);
179
273
  const [draggedNode, setDraggedNode] = useState<__FlattenedTreeNode__<ID> | null>(null);
180
- const [dropTarget, setDropTarget] = useState<DropTarget<ID> | null>(null);
181
- const [effectiveDropLevel, setEffectiveDropLevel] = useState(0);
182
274
 
183
- // Ref mirror of dropTarget - avoids nesting Zustand updates inside React state updaters
275
+ // The current drop target lives only in a ref (read by handleDragEnd at commit
276
+ // time). It is deliberately NOT React state: calculateDropTarget runs every pan
277
+ // frame, so a per-frame setState would re-render the whole list for nothing -
278
+ // the per-node drop indicator is driven by the store fields (dropTargetNodeId /
279
+ // dropPosition / dropLevel), which are throttled via lastStoreDropTargetRef.
184
280
  const dropTargetRef = useRef<DropTarget<ID> | null>(null);
185
281
 
282
+ // Last value written to the store's drop target. Guards the per-frame Zustand
283
+ // write so updateDropTarget only fires when the indicator actually changes
284
+ // (otherwise every mounted Node's selector would re-run on every pan frame).
285
+ const lastStoreDropTargetRef = useRef<{
286
+ nodeId: ID | null;
287
+ position: DropPosition | null;
288
+ level: number | null;
289
+ } | null>(null);
290
+
186
291
  // --- Long press timer ---
187
292
  const cancelLongPressTimer = useCallback(() => {
188
293
  if (longPressTimerRef.current) {
189
294
  clearTimeout(longPressTimerRef.current);
190
295
  longPressTimerRef.current = null;
191
296
  }
297
+ // Also abort a drag still awaiting its async measureInWindow callback. This
298
+ // makes scrolling (which cancels the long-press) during that window abort the
299
+ // drag cleanly, instead of letting it start later with stale finger coords.
300
+ pendingDragRef.current = false;
192
301
  }, []);
193
302
 
194
303
  // --- Get all descendant IDs of a node ---
@@ -217,30 +326,55 @@ export function useDragDrop<ID>(
217
326
  // --- Get the maximum depth of a subtree (0 for leaf nodes) ---
218
327
  const getSubtreeDepth = useCallback(
219
328
  (nodeId: ID): number => {
220
- const store = getTreeViewStore<ID>(storeId);
221
- const { nodeMap } = store.getState();
222
- const node = nodeMap.get(nodeId);
223
- if (!node?.children?.length) return 0;
224
- let max = 0;
225
- for (const child of node.children) {
226
- max = Math.max(max, 1 + getSubtreeDepth(child.id));
227
- }
228
- return max;
329
+ const { nodeMap } = getTreeViewStore<ID>(storeId).getState();
330
+ return getSubtreeDepthFromMap(nodeMap, nodeId);
229
331
  },
230
332
  [storeId]
231
333
  );
232
334
 
335
+ // --- Overlay Y for a container-local finger Y (grab point + offsets) ---
336
+ const computeOverlayLocalY = useCallback((fingerLocalY: number) =>
337
+ fingerLocalY
338
+ - grabOffsetYRef.current
339
+ + (dragOverlayOffsetRef.current + overlayYCorrectionRef.current)
340
+ * itemHeightRef.current,
341
+ []
342
+ );
343
+
344
+ // --- Level-cliff horizontal control: is the finger left of the threshold
345
+ // that selects the shallower level? (30% into the visible content area
346
+ // of a row indented at `level`.) ---
347
+ const fingerLeftOfLevelThreshold = useCallback(
348
+ (level: number, fingerLocalX: number) => {
349
+ const itemLeftEdge = level * indentationMultiplierRef.current;
350
+ const threshold =
351
+ itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
352
+ return fingerLocalX < threshold;
353
+ },
354
+ []
355
+ );
356
+
233
357
  // --- Initiate drag ---
234
358
  const initiateDrag = useCallback(
235
359
  (nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
236
360
  if (!dragEnabled) return;
361
+ // Never start a second drag on top of one already running or pending
362
+ // (e.g. a competing long-press from a second finger).
363
+ if (isDraggingRef.current || pendingDragRef.current) return;
237
364
 
238
365
  const container = containerRef.current;
239
366
  if (!container) return;
240
367
 
241
- container.measureInWindow((x, y, _w, h) => {
368
+ pendingDragRef.current = true;
369
+ container.measureInWindow((x, y, w, h) => {
370
+ // Finger lifted (or drag cancelled) before the measurement resolved -
371
+ // abort so we don't strand a drag with no finger down.
372
+ if (!pendingDragRef.current) return;
373
+ pendingDragRef.current = false;
374
+
242
375
  containerPageXRef.current = x;
243
376
  containerPageYRef.current = y;
377
+ containerWidthRef.current = w;
244
378
  containerHeightRef.current = h;
245
379
 
246
380
  // Find the node in flattened list
@@ -248,23 +382,23 @@ export function useDragDrop<ID>(
248
382
  const node = nodes[nodeIndex];
249
383
  if (!node) return;
250
384
 
251
- // Collapse node if expanded
385
+ // Collapse node if expanded (restored if the drag is cancelled)
252
386
  const store = getTreeViewStore<ID>(storeId);
253
387
  const { expanded } = store.getState();
388
+ wasDraggedNodeExpandedRef.current = false;
254
389
  if (expanded.has(nodeId) && node.children?.length) {
255
390
  handleToggleExpand(storeId, nodeId);
391
+ wasDraggedNodeExpandedRef.current = true;
256
392
  }
257
393
 
258
394
  // Store grab metadata
259
395
  grabOffsetYRef.current = locationY;
260
396
  draggedNodeRef.current = node;
261
- draggedNodeIdRef.current = nodeId;
262
- draggedNodeIndexRef.current = nodeIndex;
263
397
  draggedSubtreeDepthRef.current = getSubtreeDepth(nodeId);
264
398
 
265
399
  // Use measured item height if available, fall back to default
266
400
  const measured = measuredItemHeightRef.current;
267
- itemHeightRef.current = measured > 0 ? measured : 36;
401
+ itemHeightRef.current = measured > 0 ? measured : defaultItemHeight;
268
402
 
269
403
  // Calculate headerOffset dynamically:
270
404
  // fingerLocalY = pageY - containerPageY
@@ -277,13 +411,7 @@ export function useDragDrop<ID>(
277
411
  locationY -
278
412
  nodeIndex * itemHeightRef.current;
279
413
 
280
- // Delta-based auto-scroll: compute finger's position in the container
281
- // from the node's known index (avoids unreliable containerPageY).
282
- const iH = itemHeightRef.current;
283
- const listHeaderHeight = listHeaderFooterPadding * 2;
284
- initialFingerPageYRef.current = pageY;
285
- initialFingerContainerYRef.current =
286
- listHeaderHeight + nodeIndex * iH - scrollOffsetRef.current + locationY;
414
+ lastFingerPageYRef.current = pageY;
287
415
 
288
416
  // Compute invalid targets (self + descendants)
289
417
  const descendants = getDescendantIds(nodeId);
@@ -294,24 +422,25 @@ export function useDragDrop<ID>(
294
422
  store.getState().updateInvalidDragTargetIds(descendants);
295
423
 
296
424
  // Set overlay initial position (with configurable offset)
297
- const overlayLocalY = fingerLocalY - locationY + dragOverlayOffsetRef.current * itemHeightRef.current;
298
- overlayY.setValue(overlayLocalY);
425
+ overlayY.setValue(computeOverlayLocalY(fingerLocalY));
299
426
 
300
427
  // Reset magnetic overlay
301
428
  overlayX.setValue(0);
302
429
  prevEffectiveLevelRef.current = node.level ?? 0;
430
+ cancelLevelSettleTimer();
303
431
 
304
432
  // Set React state
305
433
  isDraggingRef.current = true;
306
434
  autoExpandedDuringDragRef.current.clear();
307
435
  setIsDragging(true);
308
436
  setDraggedNode(node);
309
- setEffectiveDropLevel(node.level ?? 0);
310
- setDropTarget(null);
311
437
 
312
438
  // Notify consumer that drag has started
313
439
  onDragStartRef.current?.({ draggedNodeId: nodeId });
314
440
 
441
+ // Announce for screen readers (no-op when no assistive tech is active).
442
+ AccessibilityInfo.announceForAccessibility?.(`Picked up ${node.name}`);
443
+
315
444
  // Start auto-scroll loop
316
445
  startAutoScrollLoop();
317
446
  });
@@ -332,6 +461,15 @@ export function useDragDrop<ID>(
332
461
  const handleNodeTouchStart = useCallback(
333
462
  (nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
334
463
  if (!dragEnabled) return;
464
+ // Ignore touches that land while a drag is already running or pending.
465
+ // Without this, a second finger on another row could arm a competing
466
+ // long-press that overwrites the in-flight drag's state.
467
+ if (isDraggingRef.current || pendingDragRef.current) return;
468
+
469
+ // Drag is disabled while a search filter is active: drop targets are
470
+ // computed against the filtered list but the move applies to the full
471
+ // tree, so a drop could land next to siblings hidden by the filter.
472
+ if (getTreeViewStore<ID>(storeId).getState().searchText) return;
335
473
 
336
474
  // Check if this node can be dragged
337
475
  if (canDragRef.current) {
@@ -348,30 +486,75 @@ export function useDragDrop<ID>(
348
486
  initiateDrag(nodeId, pageY, locationY, nodeIndex);
349
487
  }, longPressDuration);
350
488
  },
351
- [dragEnabled, longPressDuration, cancelLongPressTimer, initiateDrag]
489
+ [dragEnabled, storeId, longPressDuration, cancelLongPressTimer, initiateDrag]
352
490
  );
353
491
 
354
492
  // --- Auto-scroll ---
355
493
  const startAutoScrollLoop = useCallback(() => {
356
- const loop = () => {
494
+ // Idempotent: cancel any loop already in flight so a re-entry can never
495
+ // orphan a RAF handle (which would run a second, untracked scroll loop).
496
+ if (autoScrollRAFRef.current !== null) {
497
+ cancelAnimationFrame(autoScrollRAFRef.current);
498
+ autoScrollRAFRef.current = null;
499
+ }
500
+ // Time-based stepping: RAF cadence varies with display refresh rate, so
501
+ // distance is derived from elapsed time, not frame count.
502
+ let lastTs: number | null = null;
503
+ let lastRecalcTs = 0;
504
+ const loop = (ts?: number) => {
357
505
  if (!isDraggingRef.current) return;
358
506
 
359
- if (autoScrollSpeedRef.current !== 0) {
360
- const newOffset = Math.max(
361
- 0,
362
- scrollOffsetRef.current + autoScrollSpeedRef.current
507
+ // Jest's RAF mock invokes the callback without a timestamp.
508
+ const now = typeof ts === "number" ? ts : Date.now();
509
+ // First frame establishes the baseline; cap dt so a long hiccup
510
+ // (background frame drop) can't produce one huge scroll jump.
511
+ const dtMs = lastTs === null ? 0 : Math.min(now - lastTs, 64);
512
+ lastTs = now;
513
+
514
+ if (autoScrollSpeedRef.current !== 0 && dtMs > 0) {
515
+ // Clamp to the scrollable range so dragging at the bottom edge can't
516
+ // grow the offset past the end (which would corrupt drop-index math).
517
+ // Leave the upper bound open until the content height is measured.
518
+ const contentH = contentHeightRef.current;
519
+ const maxOffset = contentH > 0
520
+ ? Math.max(0, contentH - containerHeightRef.current)
521
+ : Number.POSITIVE_INFINITY;
522
+ const newOffset = Math.min(
523
+ maxOffset,
524
+ Math.max(0, scrollOffsetRef.current
525
+ + (autoScrollSpeedRef.current * dtMs) / 1000)
363
526
  );
364
- scrollOffsetRef.current = newOffset;
365
- flashListRef.current?.scrollToOffset?.({
366
- offset: newOffset,
367
- animated: false,
368
- });
527
+
528
+ // Pinned at a boundary: nothing moved, so skip the scroll command
529
+ // and the drop-target recompute entirely.
530
+ if (newOffset !== scrollOffsetRef.current) {
531
+ // The RAF loop is the sole writer of scrollOffsetRef during a
532
+ // drag (NodeList's onScroll stands down) - the accumulated value
533
+ // is the commanded position and the native list converges to it.
534
+ scrollOffsetRef.current = newOffset;
535
+ flashListRef.current?.scrollToOffset?.({
536
+ offset: newOffset,
537
+ animated: false,
538
+ });
539
+
540
+ // Rows are moving under a (possibly stationary) finger; recompute
541
+ // the drop target so the indicator tracks the auto-scroll instead
542
+ // of staying frozen on the last row the finger moved over.
543
+ // Time-throttled - see AUTO_SCROLL_RECALC_INTERVAL_MS.
544
+ if (now - lastRecalcTs >= AUTO_SCROLL_RECALC_INTERVAL_MS) {
545
+ lastRecalcTs = now;
546
+ calculateDropTargetRef.current(
547
+ lastFingerPageYRef.current,
548
+ lastFingerPageXRef.current
549
+ );
550
+ }
551
+ }
369
552
  }
370
553
 
371
554
  autoScrollRAFRef.current = requestAnimationFrame(loop);
372
555
  };
373
556
  autoScrollRAFRef.current = requestAnimationFrame(loop);
374
- }, [flashListRef]);
557
+ }, [flashListRef, contentHeightRef]);
375
558
 
376
559
  const stopAutoScroll = useCallback(() => {
377
560
  if (autoScrollRAFRef.current !== null) {
@@ -384,18 +567,22 @@ export function useDragDrop<ID>(
384
567
  const updateAutoScroll = useCallback(
385
568
  (fingerInContainer: number) => {
386
569
  const threshold = autoScrollThresholdRef.current;
387
- const maxSpeed = 8 * autoScrollSpeedParamRef.current;
570
+ // px/second; the RAF loop converts to distance via elapsed time.
571
+ const maxSpeed = MAX_AUTO_SCROLL_SPEED * autoScrollSpeedParamRef.current;
388
572
  const containerH = containerHeightRef.current;
389
573
 
574
+ // Ease the proximity ramp (sqrt) so speed builds up fast even for a
575
+ // shallow entry into the threshold zone - the screen edge often stops
576
+ // the finger before it reaches the very edge of the list.
390
577
  if (fingerInContainer < threshold) {
391
578
  // Scroll up
392
579
  const ratio = 1 - Math.max(0, fingerInContainer) / threshold;
393
- autoScrollSpeedRef.current = -maxSpeed * ratio;
580
+ autoScrollSpeedRef.current = -maxSpeed * Math.sqrt(ratio);
394
581
  } else if (fingerInContainer > containerH - threshold) {
395
582
  // Scroll down
396
583
  const ratio =
397
584
  1 - Math.max(0, containerH - fingerInContainer) / threshold;
398
- autoScrollSpeedRef.current = maxSpeed * ratio;
585
+ autoScrollSpeedRef.current = maxSpeed * Math.sqrt(ratio);
399
586
  } else {
400
587
  autoScrollSpeedRef.current = 0;
401
588
  }
@@ -412,12 +599,51 @@ export function useDragDrop<ID>(
412
599
  autoExpandTargetRef.current = null;
413
600
  }, []);
414
601
 
602
+ // --- Cancel the overlay-indent settle timer + its pending level ---
603
+ const cancelLevelSettleTimer = useCallback(() => {
604
+ if (levelSettleTimerRef.current) {
605
+ clearTimeout(levelSettleTimerRef.current);
606
+ levelSettleTimerRef.current = null;
607
+ }
608
+ pendingLevelRef.current = null;
609
+ }, []);
610
+
611
+ // --- Clear the store's drag fields (shared by drag end and unmount) ---
612
+ const resetDragStoreState = useCallback(() => {
613
+ const state = getTreeViewStore<ID>(storeId).getState();
614
+ state.updateDraggedNodeId(null);
615
+ state.updateInvalidDragTargetIds(new Set());
616
+ state.updateDropTarget(null, null);
617
+ }, [storeId]);
618
+
415
619
  // --- Calculate drop target ---
416
620
  const calculateDropTarget = useCallback(
417
621
  (fingerPageY: number, fingerPageX: number) => {
418
622
  const nodes = flattenedNodesRef.current;
419
623
  if (nodes.length === 0) return;
420
624
 
625
+ // The flattened list changed mid-drag (e.g. auto-expand inserted the
626
+ // hovered parent's children). Index-based hysteresis/stickiness state
627
+ // refers to rows of the OLD list - drop it so it can't stick the zone
628
+ // or gap decision to whatever row now happens to hold that index. The
629
+ // measured-heights gate cache is keyed to the old list too.
630
+ if (lastCalcNodesRef.current !== nodes) {
631
+ lastCalcNodesRef.current = nodes;
632
+ prevDropTargetRef.current = null;
633
+ allHeightsMeasuredRef.current = null;
634
+ }
635
+
636
+ // Single store snapshot per frame (the store can't change within this
637
+ // synchronous pass) - avoids re-reading getState() several times.
638
+ const {
639
+ childToParentMap,
640
+ expanded,
641
+ invalidDragTargetIds,
642
+ draggedNodeId,
643
+ nodeMap,
644
+ updateDropTarget,
645
+ } = getTreeViewStore<ID>(storeId).getState();
646
+
421
647
  const fingerLocalY =
422
648
  fingerPageY - containerPageYRef.current;
423
649
  const fingerContentY =
@@ -426,21 +652,71 @@ export function useDragDrop<ID>(
426
652
  fingerContentY - headerOffsetRef.current;
427
653
  const iH = itemHeightRef.current;
428
654
 
429
- const rawIndex = Math.floor(adjustedContentY / iH);
430
- let clampedIndex = Math.max(
431
- 0,
432
- Math.min(rawIndex, nodes.length - 1)
433
- );
655
+ // Resolve which row the finger is over. Uniform math (O(1)) is used
656
+ // unless every CURRENT row has been measured (small, fully-rendered
657
+ // lists), in which case a cumulative walk supports variable row heights.
658
+ // The gate checks per-node-id coverage (not map size) so stale heights
659
+ // left by a previous, longer list can never satisfy it. FlashList
660
+ // virtualization can't measure off-screen rows, so large lists fall back
661
+ // to the uniform estimate.
662
+ const heights = itemHeightsRef.current;
663
+ // The full-list scan is cached per measured count (and invalidated on
664
+ // list-identity change above) so it doesn't run on every pan frame.
665
+ const cachedGate = allHeightsMeasuredRef.current;
666
+ let allMeasured: boolean;
667
+ if (cachedGate && cachedGate.size === heights.size) {
668
+ allMeasured = cachedGate.value;
669
+ } else {
670
+ allMeasured = nodes.every((n) => heights.has(n.id));
671
+ allHeightsMeasuredRef.current = { size: heights.size, value: allMeasured };
672
+ }
673
+ let clampedIndex: number;
674
+ let itemTop: number;
675
+ let itemHeight: number;
676
+ if (allMeasured) {
677
+ let top = 0;
678
+ let idx = 0;
679
+ while (idx < nodes.length - 1) {
680
+ const h = heights.get(nodes[idx]!.id) ?? iH;
681
+ if (adjustedContentY < top + h) break;
682
+ top += h;
683
+ idx++;
684
+ }
685
+ clampedIndex = idx;
686
+ itemTop = top;
687
+ itemHeight = heights.get(nodes[idx]!.id) ?? iH;
688
+ } else {
689
+ const rawIndex = Math.floor(adjustedContentY / iH);
690
+ clampedIndex = Math.max(0, Math.min(rawIndex, nodes.length - 1));
691
+ itemTop = clampedIndex * iH;
692
+ itemHeight = iH;
693
+ }
434
694
  let targetNode = nodes[clampedIndex];
435
695
  if (!targetNode) return;
436
696
 
437
- // Determine zone within item
697
+ // Determine zone within item. Sticky zones: while a zone is active for
698
+ // this same row, its boundaries shift outward so natural finger tremor
699
+ // at a zone edge can't flip the position (and with it the indicator
700
+ // and the overlay's indent) back and forth every few frames.
438
701
  const positionInItem =
439
- (adjustedContentY - clampedIndex * iH) / iH;
440
- let position: "above" | "below" | "inside";
441
- if (positionInItem < 0.15) {
702
+ (adjustedContentY - itemTop) / itemHeight;
703
+ let aboveBound = 0.25;
704
+ let belowBound = 0.75;
705
+ const prevZone = prevDropTargetRef.current;
706
+ if (prevZone && prevZone.targetIndex === clampedIndex) {
707
+ if (prevZone.position === "above") {
708
+ aboveBound += ZONE_STICKINESS;
709
+ } else if (prevZone.position === "below") {
710
+ belowBound -= ZONE_STICKINESS;
711
+ } else {
712
+ aboveBound -= ZONE_STICKINESS;
713
+ belowBound += ZONE_STICKINESS;
714
+ }
715
+ }
716
+ let position: DropPosition;
717
+ if (positionInItem < aboveBound) {
442
718
  position = "above";
443
- } else if (positionInItem > 0.85) {
719
+ } else if (positionInItem > belowBound) {
444
720
  position = "below";
445
721
  } else {
446
722
  position = "inside";
@@ -476,10 +752,10 @@ export function useDragDrop<ID>(
476
752
  // logicalTargetId/logicalPosition: when the visual indicator node differs
477
753
  // from the actual moveTreeNode target (e.g., ancestor at a shallower level).
478
754
  let logicalTargetId: ID | null = null;
479
- let logicalPosition: "above" | "below" | "inside" | null = null;
755
+ let logicalPosition: DropPosition | null = null;
480
756
  let visualDropLevel: number | null = null;
481
757
 
482
- if (position === "below" || position === "inside") {
758
+ if (position === "below") {
483
759
  const currentLevel = targetNode.level ?? 0;
484
760
  let isCliff = false;
485
761
  let shallowLevel = 0;
@@ -498,13 +774,7 @@ export function useDragDrop<ID>(
498
774
  }
499
775
 
500
776
  if (isCliff) {
501
- // Generous threshold: midpoint of the two levels + 2× indent buffer
502
- const indent = indentationMultiplierRef.current;
503
- const threshold =
504
- ((currentLevel + shallowLevel) / 2) * indent
505
- + indent * 2;
506
-
507
- if (fingerLocalX < threshold) {
777
+ if (fingerLeftOfLevelThreshold(currentLevel, fingerLocalX)) {
508
778
  // User wants the shallow level
509
779
  if (clampedIndex < nodes.length - 1) {
510
780
  // Non-last item: switch to "above" on the next (shallower) node
@@ -514,7 +784,6 @@ export function useDragDrop<ID>(
514
784
  position = "above";
515
785
  } else {
516
786
  // Last item: find ancestor at shallow level, target it with "below"
517
- const { childToParentMap } = getTreeViewStore<ID>(storeId).getState();
518
787
  let ancestorId = targetNode.id;
519
788
  let walkLevel = currentLevel;
520
789
  while (walkLevel > shallowLevel) {
@@ -536,13 +805,7 @@ export function useDragDrop<ID>(
536
805
  const prevLevel = prevNode?.level ?? 0;
537
806
  const currentLevel = targetNode.level ?? 0;
538
807
  if (prevNode && prevLevel > currentLevel) {
539
- // Level cliff above - same generous threshold
540
- const indent = indentationMultiplierRef.current;
541
- const threshold =
542
- ((prevLevel + currentLevel) / 2) * indent
543
- + indent * 2;
544
-
545
- if (fingerLocalX >= threshold) {
808
+ if (!fingerLeftOfLevelThreshold(prevLevel, fingerLocalX)) {
546
809
  clampedIndex = clampedIndex - 1;
547
810
  targetNode = prevNode;
548
811
  position = "below";
@@ -550,26 +813,14 @@ export function useDragDrop<ID>(
550
813
  }
551
814
  }
552
815
 
553
- // --- Suppress "below" when it's redundant or confusing ---
554
- // After horizontal control, any remaining "below" that isn't at a
555
- // cliff is redundant with "above" on the next node → show "inside".
556
- // Only convert to "inside" if inside drops are allowed for this target.
816
+ // --- Suppress "below" when it's semantically confusing ---
817
+ // For expanded parents, "below" visually sits at the parent/child
818
+ // junction but semantically inserts as a sibling after the entire
819
+ // subtree. Convert to "inside" which is clearer.
557
820
  if (position === "below" && canDropInsideTarget) {
558
- const expandedSet = getTreeViewStore<ID>(storeId).getState().expanded;
559
-
560
- // (a) Expanded parent: "below" visually sits at the parent/child junction
561
- // but semantically inserts as a sibling after the entire subtree.
562
- if (targetNode.children?.length && expandedSet.has(targetNode.id)) {
821
+ if (targetNode.children?.length && expanded.has(targetNode.id)) {
563
822
  position = "inside";
564
823
  }
565
- // (b) No level cliff below: convert to "inside" so the highlight
566
- // covers the full bottom of the node.
567
- else if (clampedIndex < nodes.length - 1) {
568
- const nextNode = nodes[clampedIndex + 1];
569
- if (nextNode && (nextNode.level ?? 0) >= (targetNode.level ?? 0)) {
570
- position = "inside";
571
- }
572
- }
573
824
  }
574
825
 
575
826
  // --- Hysteresis: prevent flicker between "below N" and "above N+1" ---
@@ -598,33 +849,40 @@ export function useDragDrop<ID>(
598
849
  }
599
850
  prevDropTargetRef.current = { targetIndex: clampedIndex, position };
600
851
 
601
- const indicatorTop = fingerLocalY - grabOffsetYRef.current;
602
-
603
- // Validity check
604
- const store = getTreeViewStore<ID>(storeId);
605
- const { invalidDragTargetIds, draggedNodeId, expanded } =
606
- store.getState();
852
+ // Validity check (store snapshot taken once at the top of this frame).
853
+ // The actual move uses the LOGICAL target when a cliff override is active
854
+ // (the visual indicator stays on targetNode). Validate canDrop / maxDepth /
855
+ // invalid-target against this effective target so consumer rules can't be
856
+ // bypassed by the visual/logical split at level cliffs.
857
+ const effectiveTargetId = logicalTargetId ?? targetNode.id;
858
+ const effectivePosition = logicalPosition ?? position;
859
+ const effectiveTargetNode =
860
+ logicalTargetId !== null
861
+ ? (nodeMap.get(effectiveTargetId) ?? targetNode)
862
+ : targetNode;
607
863
 
608
864
  // maxDepth check for above/below (sibling) positions
609
865
  let maxDepthValid = true;
610
- if (maxDepthRef.current !== undefined && (position === "above" || position === "below")) {
611
- const targetLevel = targetNode.level ?? 0;
866
+ if (maxDepthRef.current !== undefined && (effectivePosition === "above" || effectivePosition === "below")) {
867
+ // At a cliff the sibling lands at visualDropLevel; otherwise the
868
+ // effective target's own level.
869
+ const targetLevel = visualDropLevel ?? (effectiveTargetNode.level ?? 0);
612
870
  const deepest = targetLevel + draggedSubtreeDepthRef.current;
613
871
  if (deepest > maxDepthRef.current) maxDepthValid = false;
614
872
  }
615
873
 
616
874
  const isValid =
617
- targetNode.id !== draggedNodeId &&
618
- !invalidDragTargetIds.has(targetNode.id) &&
875
+ effectiveTargetId !== draggedNodeId &&
876
+ !invalidDragTargetIds.has(effectiveTargetId) &&
619
877
  maxDepthValid &&
620
878
  (!canDropRef.current || canDropRef.current(
621
879
  draggedNodeRef.current!,
622
- targetNode,
623
- position
880
+ effectiveTargetNode,
881
+ effectivePosition
624
882
  ));
625
883
 
626
884
  // --- Auto-expand: if hovering "inside" a collapsed expandable node ---
627
- if (isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
885
+ if (autoExpandRef.current && isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
628
886
  if (autoExpandTargetRef.current !== targetNode.id) {
629
887
  // New hover target - start timer
630
888
  cancelAutoExpandTimer();
@@ -632,7 +890,7 @@ export function useDragDrop<ID>(
632
890
  autoExpandTimerRef.current = setTimeout(() => {
633
891
  autoExpandTimerRef.current = null;
634
892
  // Expand the node and track it
635
- handleToggleExpand(storeId, targetNode.id);
893
+ expandNodes(storeId, [targetNode.id]);
636
894
  autoExpandedDuringDragRef.current.add(targetNode.id);
637
895
  }, autoExpandDelayRef.current);
638
896
  }
@@ -656,25 +914,54 @@ export function useDragDrop<ID>(
656
914
  ? (targetNode.level ?? 0) + 1
657
915
  : (targetNode.level ?? 0))
658
916
  : draggedLevel;
659
- if (effectiveLevel !== prevEffectiveLevelRef.current) {
660
- const prevLevel = prevEffectiveLevelRef.current ?? effectiveLevel;
661
- prevEffectiveLevelRef.current = effectiveLevel;
662
- setEffectiveDropLevel(effectiveLevel);
663
-
664
- // The level prop change snaps the content to the correct indent.
665
- // Counteract that visual jump with an initial translateX offset,
666
- // then spring to 0 for a smooth "magnetic snap" transition.
667
- if (prevLevel !== effectiveLevel) {
668
- overlayX.setValue(
669
- (prevLevel - effectiveLevel) * indentationMultiplierRef.current
670
- );
917
+ // The overlay's content keeps the dragged node's original indentation
918
+ // for the whole drag (no re-render); the indent shift is expressed
919
+ // purely as a translateX toward the effective level. Springs retarget
920
+ // mid-flight, so rapid level changes glide instead of jumping - the
921
+ // old scheme (re-render padding + counteracting spring-to-0) split the
922
+ // move across React and the native driver and flickered when the two
923
+ // landed on different frames.
924
+ // A new level must additionally hold for LEVEL_SETTLE_MS before the
925
+ // overlay moves, so the indent doesn't chase every row the finger
926
+ // merely passes through.
927
+ const applyLevel = (nextLevel: number) => {
928
+ prevEffectiveLevelRef.current = nextLevel;
929
+ const targetX =
930
+ (nextLevel - (draggedNodeRef.current?.level ?? 0)) *
931
+ indentationMultiplierRef.current;
932
+ if (magneticSnapRef.current) {
671
933
  Animated.spring(overlayX, {
672
- toValue: 0,
934
+ toValue: targetX,
673
935
  useNativeDriver: true,
674
936
  speed: 40,
675
937
  bounciness: 4,
676
938
  }).start();
939
+ } else {
940
+ overlayX.setValue(targetX);
677
941
  }
942
+ };
943
+
944
+ if (autoScrollSpeedRef.current !== 0) {
945
+ // Auto-scroll is streaming rows of arbitrary depth under a
946
+ // stationary finger; chasing their levels would dart the overlay
947
+ // sideways and back. Hold the current indent (and drop any pending
948
+ // shift) until the scroll settles - the drop indicator itself
949
+ // keeps tracking via the store.
950
+ cancelLevelSettleTimer();
951
+ } else if (effectiveLevel === prevEffectiveLevelRef.current) {
952
+ // Back at the settled level - drop any pending level the finger
953
+ // only transited through.
954
+ cancelLevelSettleTimer();
955
+ } else if (pendingLevelRef.current !== effectiveLevel) {
956
+ // New candidate level - (re)start the settle timer.
957
+ cancelLevelSettleTimer();
958
+ pendingLevelRef.current = effectiveLevel;
959
+ levelSettleTimerRef.current = setTimeout(() => {
960
+ levelSettleTimerRef.current = null;
961
+ const settled = pendingLevelRef.current;
962
+ pendingLevelRef.current = null;
963
+ if (settled !== null && isDraggingRef.current) applyLevel(settled);
964
+ }, LEVEL_SETTLE_MS);
678
965
  }
679
966
 
680
967
  const newTarget: DropTarget<ID> = {
@@ -682,18 +969,33 @@ export function useDragDrop<ID>(
682
969
  targetIndex: clampedIndex,
683
970
  position,
684
971
  isValid,
685
- targetLevel: targetNode.level ?? 0,
686
- indicatorTop,
687
972
  };
688
973
 
689
- // Update the store so each Node can render its own indicator
690
- if (isValid) {
691
- store.getState().updateDropTarget(targetNode.id, position, visualDropLevel);
692
- } else {
693
- store.getState().updateDropTarget(null, null);
974
+ // Update the store so each Node can render its own indicator.
975
+ // The indicator always tracks the VISUAL target/position (what the user
976
+ // sees), even when the actual move target is a logical ancestor.
977
+ // Guard the write so it only fires when the indicator actually changes -
978
+ // calculateDropTarget runs every pan frame and each store write re-runs
979
+ // every mounted Node's selector.
980
+ const nextDropNodeId = isValid ? targetNode.id : null;
981
+ const nextDropPosition = isValid ? position : null;
982
+ const nextDropLevel = isValid ? visualDropLevel : null;
983
+ const lastStore = lastStoreDropTargetRef.current;
984
+ if (
985
+ !lastStore ||
986
+ lastStore.nodeId !== nextDropNodeId ||
987
+ lastStore.position !== nextDropPosition ||
988
+ lastStore.level !== nextDropLevel
989
+ ) {
990
+ updateDropTarget(nextDropNodeId, nextDropPosition, nextDropLevel);
991
+ lastStoreDropTargetRef.current = {
992
+ nodeId: nextDropNodeId,
993
+ position: nextDropPosition,
994
+ level: nextDropLevel,
995
+ };
694
996
  }
695
997
 
696
- // Keep ref in sync (used by handleDragEnd to avoid setState-during-render)
998
+ // Keep the commit ref in sync (read by handleDragEnd).
697
999
  // When a logical target exists (e.g. ancestor at a cliff), use it
698
1000
  // for the actual move while the visual indicator stays on the current node.
699
1001
  if (logicalTargetId !== null && logicalPosition !== null) {
@@ -705,34 +1007,33 @@ export function useDragDrop<ID>(
705
1007
  } else {
706
1008
  dropTargetRef.current = newTarget;
707
1009
  }
708
-
709
- setDropTarget((prevTarget) => {
710
- if (
711
- prevTarget?.targetNodeId === newTarget.targetNodeId &&
712
- prevTarget?.position === newTarget.position &&
713
- prevTarget?.isValid === newTarget.isValid &&
714
- prevTarget?.indicatorTop === newTarget.indicatorTop
715
- ) {
716
- return prevTarget;
717
- }
718
- return newTarget;
719
- });
720
1010
  },
721
- [storeId, cancelAutoExpandTimer, overlayX]
1011
+ [
1012
+ storeId,
1013
+ cancelAutoExpandTimer,
1014
+ cancelLevelSettleTimer,
1015
+ fingerLeftOfLevelThreshold,
1016
+ overlayX,
1017
+ itemHeightsRef,
1018
+ ]
722
1019
  );
1020
+ calculateDropTargetRef.current = calculateDropTarget;
723
1021
 
724
1022
  // --- Handle drag end ---
725
1023
  const handleDragEnd = useCallback(
726
- (fingerPageY?: number, fingerPageX?: number) => {
1024
+ (fingerPageY?: number, fingerPageX?: number, cancel: boolean = false) => {
727
1025
  stopAutoScroll();
728
1026
  cancelLongPressTimer();
729
1027
  cancelAutoExpandTimer();
730
- prevDropTargetRef.current = null;
731
1028
 
732
1029
  if (!isDraggingRef.current) return;
733
1030
  isDraggingRef.current = false;
734
1031
 
735
- // Recalculate drop target at final position if we have coords
1032
+ // Recalculate drop target at final position if we have coords. Hysteresis
1033
+ // (prevDropTargetRef) is intentionally NOT cleared first, so this final
1034
+ // commit frame resolves to the same target the user last saw the indicator
1035
+ // on (clearing it here would let the release snap to the other side of an
1036
+ // ambiguous same-level boundary). It is reset in the ref-cleanup below.
736
1037
  if (fingerPageY !== undefined) {
737
1038
  calculateDropTarget(fingerPageY, fingerPageX ?? 0);
738
1039
  }
@@ -741,100 +1042,126 @@ export function useDragDrop<ID>(
741
1042
  // Without this, the timer fires after drag ends and toggles the target back to collapsed.
742
1043
  cancelAutoExpandTimer();
743
1044
 
744
- // Read current drop target from ref via a small delay to ensure
745
- // the last setDropTarget has been processed
746
- // We use the current dropTarget state via a callback
747
- // Read drop target from ref (avoids nesting Zustand updates inside React state updaters)
1045
+ // Read the final drop target from the ref (the per-frame calculation
1046
+ // keeps it current; there is no React state to wait on).
748
1047
  const currentTarget = dropTargetRef.current;
749
- const droppedNodeId = draggedNodeIdRef.current;
1048
+ const droppedNodeId = draggedNodeRef.current?.id ?? null;
1049
+
1050
+ const store = getTreeViewStore<ID>(storeId);
1051
+ const { initialTreeViewData: currentData, nodeMap, childToParentMap } =
1052
+ store.getState();
1053
+ // Capture the node's position before the move for the MoveResult delta
1054
+ // (the maps still describe the pre-move tree here).
1055
+ const prevPosition = droppedNodeId !== null
1056
+ ? findNodePositionFromMaps(currentData, nodeMap, childToParentMap, droppedNodeId)
1057
+ : null;
1058
+ // Compute the move up front so an invalid move (moveTreeNode returns the
1059
+ // same reference) or a positional no-op (node re-dropped where it already
1060
+ // sits) is treated as a cancel rather than a spurious onDragEnd.
1061
+ const newData = (!cancel && currentTarget?.isValid && droppedNodeId !== null)
1062
+ ? moveTreeNode(currentData, droppedNodeId, currentTarget.targetNodeId, currentTarget.position)
1063
+ : currentData;
1064
+ const newPosition = (newData !== currentData && droppedNodeId !== null)
1065
+ ? findNodePosition(newData, droppedNodeId)
1066
+ : null;
1067
+ const isNoOpMove =
1068
+ newPosition !== null &&
1069
+ prevPosition !== null &&
1070
+ newPosition.parentId === prevPosition.parentId &&
1071
+ newPosition.index === prevPosition.index;
750
1072
 
751
1073
  if (
1074
+ !cancel &&
752
1075
  currentTarget?.isValid &&
753
- droppedNodeId !== null
1076
+ droppedNodeId !== null &&
1077
+ newData !== currentData &&
1078
+ !isNoOpMove
754
1079
  ) {
755
- const store = getTreeViewStore<ID>(storeId);
756
- const currentData =
757
- store.getState().initialTreeViewData;
758
- const newData = moveTreeNode(
759
- currentData,
760
- droppedNodeId,
761
- currentTarget.targetNodeId,
762
- currentTarget.position
1080
+ // Commit the move to the store (preserves checked/expanded;
1081
+ // shared with the programmatic moveNode path).
1082
+ applyMoveToStore(
1083
+ storeId, newData, droppedNodeId,
1084
+ currentTarget.targetNodeId, currentTarget.position
763
1085
  );
764
1086
 
765
- // Update store directly (preserves checked/expanded)
766
- store
767
- .getState()
768
- .updateInitialTreeViewData(newData);
769
- initializeNodeMaps(storeId, newData);
770
-
771
- // Recalculate checked/indeterminate states for all parents
772
- // since the tree structure changed
773
- recalculateCheckedStates<ID>(storeId);
774
-
775
- // If dropped "inside" a node, expand it so the dropped node is visible
776
- if (currentTarget.position === "inside") {
777
- expandNodes(storeId, [currentTarget.targetNodeId]);
778
- }
779
-
780
- // Expand ancestors of the dropped node so it's visible
781
- expandNodes(storeId, [droppedNodeId], true);
782
-
783
- // Set internal data ref to prevent useDeepCompareEffect
784
- // from reinitializing
785
- internalDataRef.current = newData;
786
-
787
- // Notify consumer
1087
+ // Notify the consumer with a lightweight move delta. The reordered
1088
+ // tree lives in the store; TreeView's wrapped onDragEnd captures it
1089
+ // for the reinit-skip, and consumers can read it via getTreeData().
788
1090
  onDragEndRef.current?.({
789
1091
  draggedNodeId: droppedNodeId,
790
1092
  targetNodeId: currentTarget.targetNodeId,
791
1093
  position: currentTarget.position,
792
- newTreeData: newData,
1094
+ previousParentId: prevPosition?.parentId ?? null,
1095
+ previousIndex: prevPosition?.index ?? -1,
1096
+ newParentId: newPosition?.parentId ?? null,
1097
+ newIndex: newPosition?.index ?? -1,
793
1098
  });
794
1099
 
795
- // Scroll to the dropped node after React processes the expansion,
796
- // but only if it's outside the visible viewport. An animated
797
- // scroll would consume the user's next touch (RN stops the
798
- // animation on tap), so we skip when the node is already visible.
799
- setTimeout(() => {
800
- const nodes = flattenedNodesRef.current;
801
- const idx = nodes.findIndex(n => n.id === droppedNodeId);
802
- if (idx < 0) return;
803
-
804
- const itemH = itemHeightRef.current;
805
- const scrollTop = scrollOffsetRef.current;
806
- const containerH = containerHeightRef.current;
807
- const estimatedTop = idx * itemH;
808
- const estimatedBottom = estimatedTop + itemH;
809
-
810
- // Already in view → no scroll needed
811
- if (estimatedTop >= scrollTop && estimatedBottom <= scrollTop + containerH) {
812
- return;
813
- }
1100
+ // Announce the result for screen readers (no-op without assistive tech).
1101
+ AccessibilityInfo.announceForAccessibility?.(
1102
+ `Moved ${draggedNodeRef.current?.name ?? "node"}`
1103
+ );
814
1104
 
815
- flashListRef.current?.scrollToIndex?.({
816
- index: idx,
817
- animated: true,
818
- viewPosition: 0.5,
819
- });
820
- }, 100);
1105
+ // Auto-scroll to the dropped node unless disabled by the user.
1106
+ const scrollOpts = autoScrollToDroppedNode;
1107
+ const scrollEnabled = scrollOpts === undefined || scrollOpts === true
1108
+ || (typeof scrollOpts === "object" && scrollOpts.enabled !== false);
1109
+
1110
+ if (scrollEnabled) {
1111
+ // The drop lands under the finger, so the node is almost always
1112
+ // already on-screen; scrolling anyway would yank the list (worst
1113
+ // when dropping near the very bottom, where centering the node
1114
+ // scrolls the list back up). Estimate where the dropped row ends
1115
+ // up in the post-move flattened list and only scroll when it
1116
+ // actually sits outside the viewport.
1117
+ const preMoveNodes = flattenedNodesRef.current;
1118
+ const draggedIdx = preMoveNodes.findIndex(
1119
+ (n) => n.id === droppedNodeId
1120
+ );
1121
+ const iH = itemHeightRef.current;
1122
+ let finalIndex = currentTarget.targetIndex;
1123
+ // "below"/"inside" both land the node right after the target row.
1124
+ if (currentTarget.position !== "above") finalIndex += 1;
1125
+ // Removing the dragged row from above the target shifts rows up one.
1126
+ if (draggedIdx !== -1 && draggedIdx < currentTarget.targetIndex) {
1127
+ finalIndex -= 1;
1128
+ }
1129
+ const rowTop =
1130
+ headerOffsetRef.current + finalIndex * iH - scrollOffsetRef.current;
1131
+ const isOnScreen =
1132
+ rowTop >= 0 && rowTop + iH <= containerHeightRef.current;
1133
+
1134
+ if (!isOnScreen) {
1135
+ scrollMovedNodeIntoView(
1136
+ scrollToNodeHandlerRef, droppedNodeId, scrollOpts
1137
+ );
1138
+ }
1139
+ }
821
1140
  } else if (droppedNodeId !== null) {
822
- // Drag ended without a valid drop notify consumer
1141
+ // Drag ended without a valid drop - a cancel must not mutate state,
1142
+ // so restore the expansion that drag start force-collapsed.
1143
+ if (wasDraggedNodeExpandedRef.current) {
1144
+ expandNodes(storeId, [droppedNodeId]);
1145
+ }
823
1146
  onDragCancelRef.current?.({ draggedNodeId: droppedNodeId });
1147
+ AccessibilityInfo.announceForAccessibility?.(
1148
+ `Cancelled moving ${draggedNodeRef.current?.name ?? "node"}`
1149
+ );
824
1150
  }
825
1151
 
826
1152
  // Collapse auto-expanded nodes that aren't ancestors of the drop target
827
1153
  if (autoExpandedDuringDragRef.current.size > 0) {
828
- const store3 = getTreeViewStore<ID>(storeId);
829
- const { childToParentMap } = store3.getState();
1154
+ // Re-read: the maps were rebuilt if a move committed above.
1155
+ const { childToParentMap: postMoveParentMap } = store.getState();
830
1156
 
831
- // Collect ancestors of the drop target (keep these expanded)
1157
+ // Collect ancestors of the drop target (keep these expanded).
1158
+ // On cancel, retain none so every auto-expanded node collapses back.
832
1159
  const ancestorIds = new Set<ID>();
833
- if (currentTarget?.isValid) {
1160
+ if (!cancel && currentTarget?.isValid) {
834
1161
  let walkId: ID | undefined = currentTarget.targetNodeId;
835
1162
  while (walkId !== undefined) {
836
1163
  ancestorIds.add(walkId);
837
- walkId = childToParentMap.get(walkId);
1164
+ walkId = postMoveParentMap.get(walkId);
838
1165
  }
839
1166
  }
840
1167
 
@@ -852,20 +1179,18 @@ export function useDragDrop<ID>(
852
1179
  }
853
1180
 
854
1181
  // Clear drag state
855
- const store2 = getTreeViewStore<ID>(storeId);
856
- store2.getState().updateDraggedNodeId(null);
857
- store2.getState().updateInvalidDragTargetIds(new Set());
858
- store2.getState().updateDropTarget(null, null);
1182
+ resetDragStoreState();
859
1183
 
860
1184
  // Reset all refs
861
1185
  overlayX.setValue(0);
862
1186
  prevEffectiveLevelRef.current = null;
1187
+ cancelLevelSettleTimer();
1188
+ prevDropTargetRef.current = null;
863
1189
  dropTargetRef.current = null;
1190
+ lastStoreDropTargetRef.current = null;
864
1191
  draggedNodeRef.current = null;
865
- draggedNodeIdRef.current = null;
866
- draggedNodeIndexRef.current = -1;
1192
+ wasDraggedNodeExpandedRef.current = false;
867
1193
 
868
- setDropTarget(null);
869
1194
  setIsDragging(false);
870
1195
  setDraggedNode(null);
871
1196
  },
@@ -876,14 +1201,26 @@ export function useDragDrop<ID>(
876
1201
  cancelLongPressTimer,
877
1202
  cancelAutoExpandTimer,
878
1203
  calculateDropTarget,
879
- internalDataRef,
880
1204
  ]
881
1205
  );
882
1206
 
1207
+ // --- onScroll for the host list (see UseDragDropReturn.handleScroll) ---
1208
+ const handleScroll = useCallback((
1209
+ event: NativeSyntheticEvent<NativeScrollEvent>
1210
+ ) => {
1211
+ if (!isDraggingRef.current) {
1212
+ scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
1213
+ }
1214
+ // Scrolling means this touch isn't a long-press
1215
+ cancelLongPressTimer();
1216
+ }, [cancelLongPressTimer]);
1217
+
883
1218
  // --- Handle node touch end ---
884
1219
  // If the PanResponder never captured the gesture (no movement after long
885
1220
  // press fired), end the drag here so the node doesn't stay "lifted".
886
1221
  const handleNodeTouchEnd = useCallback(() => {
1222
+ // cancelLongPressTimer also aborts any drag still awaiting its
1223
+ // measureInWindow callback (clears pendingDragRef).
887
1224
  cancelLongPressTimer();
888
1225
  if (isDraggingRef.current && !panResponderActiveRef.current) {
889
1226
  handleDragEnd();
@@ -904,26 +1241,28 @@ export function useDragDrop<ID>(
904
1241
  panResponderActiveRef.current = true;
905
1242
  },
906
1243
 
1244
+ // While a drag is active, refuse to hand the gesture to an ancestor
1245
+ // responder (e.g. a parent ScrollView or swipe navigator) so a slight
1246
+ // horizontal drift can't terminate the drag mid-flight.
1247
+ onPanResponderTerminationRequest: () => !isDraggingRef.current,
1248
+
907
1249
  onPanResponderMove: (evt) => {
908
1250
  if (!isDraggingRef.current) return;
909
1251
 
910
1252
  const fingerPageY = evt.nativeEvent.pageY;
1253
+ lastFingerPageYRef.current = fingerPageY;
1254
+ lastFingerPageXRef.current = evt.nativeEvent.pageX;
911
1255
  const fingerLocalY =
912
1256
  fingerPageY - containerPageYRef.current;
913
1257
 
914
1258
  // Update overlay position (with configurable offset)
915
- const overlayLocalY =
916
- fingerLocalY - grabOffsetYRef.current + dragOverlayOffsetRef.current * itemHeightRef.current;
917
- overlayY.setValue(overlayLocalY);
1259
+ overlayY.setValue(computeOverlayLocalY(fingerLocalY));
918
1260
 
919
1261
  // Calculate drop target (horizontal position used at level cliffs)
920
1262
  calculateDropTarget(fingerPageY, evt.nativeEvent.pageX);
921
1263
 
922
- // Auto-scroll at edges - use delta-based position relative to container
923
- const fingerInContainer =
924
- initialFingerContainerYRef.current +
925
- (fingerPageY - initialFingerPageYRef.current);
926
- updateAutoScroll(fingerInContainer);
1264
+ // Auto-scroll at edges, from the finger's container-local position
1265
+ updateAutoScroll(fingerLocalY);
927
1266
  },
928
1267
 
929
1268
  onPanResponderRelease: (evt) => {
@@ -933,7 +1272,9 @@ export function useDragDrop<ID>(
933
1272
 
934
1273
  onPanResponderTerminate: () => {
935
1274
  panResponderActiveRef.current = false;
936
- handleDragEnd();
1275
+ // A terminate (parent scroll steals the gesture, app backgrounds, etc.)
1276
+ // cancels the drop rather than committing at the last hovered target.
1277
+ handleDragEnd(undefined, undefined, true);
937
1278
  },
938
1279
  })
939
1280
  ).current;
@@ -943,16 +1284,22 @@ export function useDragDrop<ID>(
943
1284
  return () => {
944
1285
  cancelLongPressTimer();
945
1286
  cancelAutoExpandTimer();
1287
+ cancelLevelSettleTimer();
946
1288
  stopAutoScroll();
1289
+ pendingDragRef.current = false;
1290
+ lastStoreDropTargetRef.current = null;
947
1291
  if (isDraggingRef.current) {
948
1292
  isDraggingRef.current = false;
949
- const store = getTreeViewStore<ID>(storeId);
950
- store.getState().updateDraggedNodeId(null);
951
- store.getState().updateInvalidDragTargetIds(new Set());
952
- store.getState().updateDropTarget(null, null);
1293
+ resetDragStoreState();
953
1294
  }
954
1295
  };
955
- }, [storeId, cancelLongPressTimer, cancelAutoExpandTimer, stopAutoScroll]);
1296
+ }, [
1297
+ cancelLongPressTimer,
1298
+ cancelAutoExpandTimer,
1299
+ cancelLevelSettleTimer,
1300
+ stopAutoScroll,
1301
+ resetDragStoreState,
1302
+ ]);
956
1303
 
957
1304
  return {
958
1305
  panResponder,
@@ -960,12 +1307,12 @@ export function useDragDrop<ID>(
960
1307
  overlayX,
961
1308
  isDragging,
962
1309
  draggedNode,
963
- dropTarget,
964
- effectiveDropLevel,
965
1310
  handleNodeTouchStart,
966
1311
  handleNodeTouchEnd,
967
1312
  cancelLongPressTimer,
1313
+ handleScroll,
968
1314
  scrollOffsetRef,
969
1315
  headerOffsetRef,
1316
+ containerHeightRef,
970
1317
  };
971
1318
  }