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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +57 -26
  2. package/lib/module/TreeView.js +130 -24
  3. package/lib/module/TreeView.js.map +1 -1
  4. package/lib/module/components/DragOverlay.js +19 -2
  5. package/lib/module/components/DragOverlay.js.map +1 -1
  6. package/lib/module/components/NodeList.js +82 -29
  7. package/lib/module/components/NodeList.js.map +1 -1
  8. package/lib/module/constants/treeView.constants.js +5 -0
  9. package/lib/module/constants/treeView.constants.js.map +1 -1
  10. package/lib/module/helpers/moveTreeNode.helper.js +175 -47
  11. package/lib/module/helpers/moveTreeNode.helper.js.map +1 -1
  12. package/lib/module/helpers/toggleCheckbox.helper.js +5 -12
  13. package/lib/module/helpers/toggleCheckbox.helper.js.map +1 -1
  14. package/lib/module/helpers/treeNode.helper.js +49 -0
  15. package/lib/module/helpers/treeNode.helper.js.map +1 -1
  16. package/lib/module/hooks/useDragDrop.js +470 -186
  17. package/lib/module/hooks/useDragDrop.js.map +1 -1
  18. package/lib/module/hooks/useScrollToNode.js +17 -0
  19. package/lib/module/hooks/useScrollToNode.js.map +1 -1
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/store/treeView.store.js +7 -0
  22. package/lib/module/store/treeView.store.js.map +1 -1
  23. package/lib/module/types/dragDrop.types.js +0 -2
  24. package/lib/typescript/src/TreeView.d.ts.map +1 -1
  25. package/lib/typescript/src/components/DragOverlay.d.ts.map +1 -1
  26. package/lib/typescript/src/components/NodeList.d.ts.map +1 -1
  27. package/lib/typescript/src/constants/treeView.constants.d.ts +4 -0
  28. package/lib/typescript/src/constants/treeView.constants.d.ts.map +1 -1
  29. package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts +32 -0
  30. package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts.map +1 -1
  31. package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts.map +1 -1
  32. package/lib/typescript/src/helpers/treeNode.helper.d.ts +15 -0
  33. package/lib/typescript/src/helpers/treeNode.helper.d.ts.map +1 -1
  34. package/lib/typescript/src/hooks/useDragDrop.d.ts +24 -6
  35. package/lib/typescript/src/hooks/useDragDrop.d.ts.map +1 -1
  36. package/lib/typescript/src/hooks/useScrollToNode.d.ts +10 -0
  37. package/lib/typescript/src/hooks/useScrollToNode.d.ts.map +1 -1
  38. package/lib/typescript/src/index.d.ts +2 -2
  39. package/lib/typescript/src/index.d.ts.map +1 -1
  40. package/lib/typescript/src/store/treeView.store.d.ts +6 -0
  41. package/lib/typescript/src/store/treeView.store.d.ts.map +1 -1
  42. package/lib/typescript/src/types/dragDrop.types.d.ts +24 -12
  43. package/lib/typescript/src/types/dragDrop.types.d.ts.map +1 -1
  44. package/lib/typescript/src/types/treeView.types.d.ts +68 -12
  45. package/lib/typescript/src/types/treeView.types.d.ts.map +1 -1
  46. package/package.json +2 -2
  47. package/src/TreeView.tsx +158 -26
  48. package/src/components/DragOverlay.tsx +32 -3
  49. package/src/components/NodeList.tsx +82 -28
  50. package/src/constants/treeView.constants.ts +6 -1
  51. package/src/helpers/moveTreeNode.helper.ts +160 -43
  52. package/src/helpers/toggleCheckbox.helper.ts +5 -12
  53. package/src/helpers/treeNode.helper.ts +52 -1
  54. package/src/hooks/useDragDrop.ts +573 -214
  55. package/src/hooks/useScrollToNode.ts +21 -0
  56. package/src/index.tsx +3 -1
  57. package/src/store/treeView.store.ts +6 -0
  58. package/src/types/dragDrop.types.ts +25 -13
  59. package/src/types/treeView.types.ts +71 -11
  60. package/lib/module/components/DropIndicator.js +0 -79
  61. package/lib/module/components/DropIndicator.js.map +0 -1
  62. package/lib/typescript/src/components/DropIndicator.d.ts +0 -12
  63. package/lib/typescript/src/components/DropIndicator.d.ts.map +0 -1
  64. package/src/components/DropIndicator.tsx +0 -95
@@ -7,30 +7,58 @@ import {
7
7
  useState
8
8
  } from "react";
9
9
  import {
10
+ AccessibilityInfo,
10
11
  Animated,
11
12
  PanResponder,
12
13
  Platform,
14
+ type NativeScrollEvent,
15
+ type NativeSyntheticEvent,
13
16
  type PanResponderInstance,
14
17
  } from "react-native";
15
18
  import type { FlashList } from "@shopify/flash-list";
16
19
 
17
20
  import type { __FlattenedTreeNode__, TreeNode, DropAutoScrollOptions } from "../types/treeView.types";
18
21
  import type { ScrollToNodeHandlerRef } from "./useScrollToNode";
19
- import type { DragCancelEvent, DragEndEvent, DragStartEvent, DropTarget } from "../types/dragDrop.types";
22
+ import type { DragCancelEvent, DragEndEvent, DragStartEvent, DropPosition, DropTarget } from "../types/dragDrop.types";
20
23
  import { getTreeViewStore } from "../store/treeView.store";
21
24
  import {
22
25
  collapseNodes,
23
26
  expandNodes,
27
+ getSubtreeDepthFromMap,
24
28
  handleToggleExpand,
25
- initializeNodeMaps,
26
- recalculateCheckedStates
27
29
  } from "../helpers";
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;
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;
34
62
 
35
63
  interface UseDragDropParams<ID> {
36
64
  storeId: string;
@@ -44,14 +72,25 @@ interface UseDragDropParams<ID> {
44
72
  longPressDuration: number;
45
73
  autoScrollThreshold: number;
46
74
  autoScrollSpeed: number;
47
- internalDataRef: MutableRefObject<TreeNode<ID>[] | null>;
48
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>>;
49
82
  dragOverlayOffset: number;
83
+ /** Optional override (item-height units) for the platform overlay-Y correction. */
84
+ overlayYCorrection?: number;
50
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;
51
90
  /** Pixels per nesting level, used for magnetic overlay shift. */
52
91
  indentationMultiplier: number;
53
92
  /** 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;
93
+ canDrop?: (draggedNode: TreeNode<ID>, targetNode: TreeNode<ID>, position: DropPosition) => boolean;
55
94
  /** Maximum nesting depth allowed. */
56
95
  maxDepth?: number;
57
96
  /** Callback to determine if a node can accept children. */
@@ -70,8 +109,6 @@ interface UseDragDropReturn<ID> {
70
109
  overlayX: Animated.Value;
71
110
  isDragging: boolean;
72
111
  draggedNode: __FlattenedTreeNode__<ID> | null;
73
- dropTarget: DropTarget<ID> | null;
74
- effectiveDropLevel: number;
75
112
  handleNodeTouchStart: (
76
113
  nodeId: ID,
77
114
  pageY: number,
@@ -80,8 +117,17 @@ interface UseDragDropReturn<ID> {
80
117
  ) => void;
81
118
  handleNodeTouchEnd: () => void;
82
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;
83
126
  scrollOffsetRef: MutableRefObject<number>;
84
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>;
85
131
  }
86
132
 
87
133
  export function useDragDrop<ID>(
@@ -99,10 +145,14 @@ export function useDragDrop<ID>(
99
145
  longPressDuration,
100
146
  autoScrollThreshold,
101
147
  autoScrollSpeed,
102
- internalDataRef,
103
148
  measuredItemHeightRef,
149
+ contentHeightRef,
150
+ itemHeightsRef,
104
151
  dragOverlayOffset,
152
+ overlayYCorrection,
105
153
  autoExpandDelay,
154
+ autoExpand = true,
155
+ magneticSnap = true,
106
156
  indentationMultiplier,
107
157
  canDrop: canDropCallback,
108
158
  maxDepth,
@@ -115,8 +165,9 @@ export function useDragDrop<ID>(
115
165
  // --- Refs for mutable state (no stale closures in PanResponder) ---
116
166
  const isDraggingRef = useRef(false);
117
167
  const draggedNodeRef = useRef<__FlattenedTreeNode__<ID> | null>(null);
118
- const draggedNodeIdRef = useRef<ID | null>(null);
119
- 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);
120
171
 
121
172
  const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
122
173
 
@@ -127,18 +178,34 @@ export function useDragDrop<ID>(
127
178
  const grabOffsetYRef = useRef(0);
128
179
  const scrollOffsetRef = useRef(0);
129
180
  const headerOffsetRef = useRef(0);
130
- const itemHeightRef = useRef(36);
181
+ const itemHeightRef = useRef(defaultItemHeight);
131
182
 
132
183
  const overlayY = useRef(new Animated.Value(0)).current;
133
184
  const overlayX = useRef(new Animated.Value(0)).current;
134
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);
135
190
 
136
191
  const autoScrollRAFRef = useRef<number | null>(null);
137
192
  const autoScrollSpeedRef = useRef(0);
138
193
 
139
- // Delta-based auto-scroll: avoids unreliable containerPageY
140
- const initialFingerPageYRef = useRef(0);
141
- 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);
142
209
 
143
210
  // Auto-expand timer for hovering over collapsed nodes
144
211
  const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -149,8 +216,16 @@ export function useDragDrop<ID>(
149
216
  // Tracks whether the PanResponder has captured the current gesture
150
217
  const panResponderActiveRef = useRef(false);
151
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
+
152
224
  // Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
153
- 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);
154
229
 
155
230
  // Depth of the dragged subtree (computed once at drag start, used for maxDepth check)
156
231
  const draggedSubtreeDepthRef = useRef(0);
@@ -176,32 +251,53 @@ export function useDragDrop<ID>(
176
251
  // Keep config values current for PanResponder closures
177
252
  const dragOverlayOffsetRef = useRef(dragOverlayOffset);
178
253
  dragOverlayOffsetRef.current = dragOverlayOffset;
254
+ const overlayYCorrectionRef = useRef(overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION);
255
+ overlayYCorrectionRef.current = overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION;
179
256
  const autoScrollThresholdRef = useRef(autoScrollThreshold);
180
257
  autoScrollThresholdRef.current = autoScrollThreshold;
181
258
  const autoScrollSpeedParamRef = useRef(autoScrollSpeed);
182
259
  autoScrollSpeedParamRef.current = autoScrollSpeed;
183
260
  const autoExpandDelayRef = useRef(autoExpandDelay);
184
261
  autoExpandDelayRef.current = autoExpandDelay;
262
+ const autoExpandRef = useRef(autoExpand);
263
+ autoExpandRef.current = autoExpand;
264
+ const magneticSnapRef = useRef(magneticSnap);
265
+ magneticSnapRef.current = magneticSnap;
185
266
  const indentationMultiplierRef = useRef(indentationMultiplier);
186
267
  indentationMultiplierRef.current = indentationMultiplier;
187
268
  const maxDepthRef = useRef(maxDepth);
188
269
  maxDepthRef.current = maxDepth;
189
270
 
190
- // --- 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) ---
191
272
  const [isDragging, setIsDragging] = useState(false);
192
273
  const [draggedNode, setDraggedNode] = useState<__FlattenedTreeNode__<ID> | null>(null);
193
- const [dropTarget, setDropTarget] = useState<DropTarget<ID> | null>(null);
194
- const [effectiveDropLevel, setEffectiveDropLevel] = useState(0);
195
274
 
196
- // 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.
197
280
  const dropTargetRef = useRef<DropTarget<ID> | null>(null);
198
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
+
199
291
  // --- Long press timer ---
200
292
  const cancelLongPressTimer = useCallback(() => {
201
293
  if (longPressTimerRef.current) {
202
294
  clearTimeout(longPressTimerRef.current);
203
295
  longPressTimerRef.current = null;
204
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;
205
301
  }, []);
206
302
 
207
303
  // --- Get all descendant IDs of a node ---
@@ -230,28 +326,52 @@ export function useDragDrop<ID>(
230
326
  // --- Get the maximum depth of a subtree (0 for leaf nodes) ---
231
327
  const getSubtreeDepth = useCallback(
232
328
  (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;
329
+ const { nodeMap } = getTreeViewStore<ID>(storeId).getState();
330
+ return getSubtreeDepthFromMap(nodeMap, nodeId);
242
331
  },
243
332
  [storeId]
244
333
  );
245
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
+
246
357
  // --- Initiate drag ---
247
358
  const initiateDrag = useCallback(
248
359
  (nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
249
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;
250
364
 
251
365
  const container = containerRef.current;
252
366
  if (!container) return;
253
367
 
368
+ pendingDragRef.current = true;
254
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
+
255
375
  containerPageXRef.current = x;
256
376
  containerPageYRef.current = y;
257
377
  containerWidthRef.current = w;
@@ -262,23 +382,23 @@ export function useDragDrop<ID>(
262
382
  const node = nodes[nodeIndex];
263
383
  if (!node) return;
264
384
 
265
- // Collapse node if expanded
385
+ // Collapse node if expanded (restored if the drag is cancelled)
266
386
  const store = getTreeViewStore<ID>(storeId);
267
387
  const { expanded } = store.getState();
388
+ wasDraggedNodeExpandedRef.current = false;
268
389
  if (expanded.has(nodeId) && node.children?.length) {
269
390
  handleToggleExpand(storeId, nodeId);
391
+ wasDraggedNodeExpandedRef.current = true;
270
392
  }
271
393
 
272
394
  // Store grab metadata
273
395
  grabOffsetYRef.current = locationY;
274
396
  draggedNodeRef.current = node;
275
- draggedNodeIdRef.current = nodeId;
276
- draggedNodeIndexRef.current = nodeIndex;
277
397
  draggedSubtreeDepthRef.current = getSubtreeDepth(nodeId);
278
398
 
279
399
  // Use measured item height if available, fall back to default
280
400
  const measured = measuredItemHeightRef.current;
281
- itemHeightRef.current = measured > 0 ? measured : 36;
401
+ itemHeightRef.current = measured > 0 ? measured : defaultItemHeight;
282
402
 
283
403
  // Calculate headerOffset dynamically:
284
404
  // fingerLocalY = pageY - containerPageY
@@ -291,13 +411,7 @@ export function useDragDrop<ID>(
291
411
  locationY -
292
412
  nodeIndex * itemHeightRef.current;
293
413
 
294
- // Delta-based auto-scroll: compute finger's position in the container
295
- // from the node's known index (avoids unreliable containerPageY).
296
- const iH = itemHeightRef.current;
297
- const listHeaderHeight = listHeaderFooterPadding * 2;
298
- initialFingerPageYRef.current = pageY;
299
- initialFingerContainerYRef.current =
300
- listHeaderHeight + nodeIndex * iH - scrollOffsetRef.current + locationY;
414
+ lastFingerPageYRef.current = pageY;
301
415
 
302
416
  // Compute invalid targets (self + descendants)
303
417
  const descendants = getDescendantIds(nodeId);
@@ -308,24 +422,25 @@ export function useDragDrop<ID>(
308
422
  store.getState().updateInvalidDragTargetIds(descendants);
309
423
 
310
424
  // Set overlay initial position (with configurable offset)
311
- const overlayLocalY = fingerLocalY - locationY + (dragOverlayOffsetRef.current + PLATFORM_OVERLAY_Y_CORRECTION) * itemHeightRef.current;
312
- overlayY.setValue(overlayLocalY);
425
+ overlayY.setValue(computeOverlayLocalY(fingerLocalY));
313
426
 
314
427
  // Reset magnetic overlay
315
428
  overlayX.setValue(0);
316
429
  prevEffectiveLevelRef.current = node.level ?? 0;
430
+ cancelLevelSettleTimer();
317
431
 
318
432
  // Set React state
319
433
  isDraggingRef.current = true;
320
434
  autoExpandedDuringDragRef.current.clear();
321
435
  setIsDragging(true);
322
436
  setDraggedNode(node);
323
- setEffectiveDropLevel(node.level ?? 0);
324
- setDropTarget(null);
325
437
 
326
438
  // Notify consumer that drag has started
327
439
  onDragStartRef.current?.({ draggedNodeId: nodeId });
328
440
 
441
+ // Announce for screen readers (no-op when no assistive tech is active).
442
+ AccessibilityInfo.announceForAccessibility?.(`Picked up ${node.name}`);
443
+
329
444
  // Start auto-scroll loop
330
445
  startAutoScrollLoop();
331
446
  });
@@ -346,6 +461,15 @@ export function useDragDrop<ID>(
346
461
  const handleNodeTouchStart = useCallback(
347
462
  (nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
348
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;
349
473
 
350
474
  // Check if this node can be dragged
351
475
  if (canDragRef.current) {
@@ -362,30 +486,75 @@ export function useDragDrop<ID>(
362
486
  initiateDrag(nodeId, pageY, locationY, nodeIndex);
363
487
  }, longPressDuration);
364
488
  },
365
- [dragEnabled, longPressDuration, cancelLongPressTimer, initiateDrag]
489
+ [dragEnabled, storeId, longPressDuration, cancelLongPressTimer, initiateDrag]
366
490
  );
367
491
 
368
492
  // --- Auto-scroll ---
369
493
  const startAutoScrollLoop = useCallback(() => {
370
- 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) => {
371
505
  if (!isDraggingRef.current) return;
372
506
 
373
- if (autoScrollSpeedRef.current !== 0) {
374
- const newOffset = Math.max(
375
- 0,
376
- 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)
377
526
  );
378
- scrollOffsetRef.current = newOffset;
379
- flashListRef.current?.scrollToOffset?.({
380
- offset: newOffset,
381
- animated: false,
382
- });
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
+ }
383
552
  }
384
553
 
385
554
  autoScrollRAFRef.current = requestAnimationFrame(loop);
386
555
  };
387
556
  autoScrollRAFRef.current = requestAnimationFrame(loop);
388
- }, [flashListRef]);
557
+ }, [flashListRef, contentHeightRef]);
389
558
 
390
559
  const stopAutoScroll = useCallback(() => {
391
560
  if (autoScrollRAFRef.current !== null) {
@@ -398,18 +567,22 @@ export function useDragDrop<ID>(
398
567
  const updateAutoScroll = useCallback(
399
568
  (fingerInContainer: number) => {
400
569
  const threshold = autoScrollThresholdRef.current;
401
- 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;
402
572
  const containerH = containerHeightRef.current;
403
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.
404
577
  if (fingerInContainer < threshold) {
405
578
  // Scroll up
406
579
  const ratio = 1 - Math.max(0, fingerInContainer) / threshold;
407
- autoScrollSpeedRef.current = -maxSpeed * ratio;
580
+ autoScrollSpeedRef.current = -maxSpeed * Math.sqrt(ratio);
408
581
  } else if (fingerInContainer > containerH - threshold) {
409
582
  // Scroll down
410
583
  const ratio =
411
584
  1 - Math.max(0, containerH - fingerInContainer) / threshold;
412
- autoScrollSpeedRef.current = maxSpeed * ratio;
585
+ autoScrollSpeedRef.current = maxSpeed * Math.sqrt(ratio);
413
586
  } else {
414
587
  autoScrollSpeedRef.current = 0;
415
588
  }
@@ -426,12 +599,51 @@ export function useDragDrop<ID>(
426
599
  autoExpandTargetRef.current = null;
427
600
  }, []);
428
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
+
429
619
  // --- Calculate drop target ---
430
620
  const calculateDropTarget = useCallback(
431
621
  (fingerPageY: number, fingerPageX: number) => {
432
622
  const nodes = flattenedNodesRef.current;
433
623
  if (nodes.length === 0) return;
434
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
+
435
647
  const fingerLocalY =
436
648
  fingerPageY - containerPageYRef.current;
437
649
  const fingerContentY =
@@ -440,21 +652,71 @@ export function useDragDrop<ID>(
440
652
  fingerContentY - headerOffsetRef.current;
441
653
  const iH = itemHeightRef.current;
442
654
 
443
- const rawIndex = Math.floor(adjustedContentY / iH);
444
- let clampedIndex = Math.max(
445
- 0,
446
- Math.min(rawIndex, nodes.length - 1)
447
- );
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
+ }
448
694
  let targetNode = nodes[clampedIndex];
449
695
  if (!targetNode) return;
450
696
 
451
- // 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.
452
701
  const positionInItem =
453
- (adjustedContentY - clampedIndex * iH) / iH;
454
- let position: "above" | "below" | "inside";
455
- if (positionInItem < 0.25) {
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) {
456
718
  position = "above";
457
- } else if (positionInItem > 0.75) {
719
+ } else if (positionInItem > belowBound) {
458
720
  position = "below";
459
721
  } else {
460
722
  position = "inside";
@@ -490,7 +752,7 @@ export function useDragDrop<ID>(
490
752
  // logicalTargetId/logicalPosition: when the visual indicator node differs
491
753
  // from the actual moveTreeNode target (e.g., ancestor at a shallower level).
492
754
  let logicalTargetId: ID | null = null;
493
- let logicalPosition: "above" | "below" | "inside" | null = null;
755
+ let logicalPosition: DropPosition | null = null;
494
756
  let visualDropLevel: number | null = null;
495
757
 
496
758
  if (position === "below") {
@@ -512,11 +774,7 @@ export function useDragDrop<ID>(
512
774
  }
513
775
 
514
776
  if (isCliff) {
515
- // Midpoint of the item's visible content area
516
- const itemLeftEdge = currentLevel * indentationMultiplierRef.current;
517
- const threshold = itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
518
-
519
- if (fingerLocalX < threshold) {
777
+ if (fingerLeftOfLevelThreshold(currentLevel, fingerLocalX)) {
520
778
  // User wants the shallow level
521
779
  if (clampedIndex < nodes.length - 1) {
522
780
  // Non-last item: switch to "above" on the next (shallower) node
@@ -526,7 +784,6 @@ export function useDragDrop<ID>(
526
784
  position = "above";
527
785
  } else {
528
786
  // Last item: find ancestor at shallow level, target it with "below"
529
- const { childToParentMap } = getTreeViewStore<ID>(storeId).getState();
530
787
  let ancestorId = targetNode.id;
531
788
  let walkLevel = currentLevel;
532
789
  while (walkLevel > shallowLevel) {
@@ -548,10 +805,7 @@ export function useDragDrop<ID>(
548
805
  const prevLevel = prevNode?.level ?? 0;
549
806
  const currentLevel = targetNode.level ?? 0;
550
807
  if (prevNode && prevLevel > currentLevel) {
551
- const itemLeftEdge = prevLevel * indentationMultiplierRef.current;
552
- const threshold = itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
553
-
554
- if (fingerLocalX >= threshold) {
808
+ if (!fingerLeftOfLevelThreshold(prevLevel, fingerLocalX)) {
555
809
  clampedIndex = clampedIndex - 1;
556
810
  targetNode = prevNode;
557
811
  position = "below";
@@ -564,8 +818,7 @@ export function useDragDrop<ID>(
564
818
  // junction but semantically inserts as a sibling after the entire
565
819
  // subtree. Convert to "inside" which is clearer.
566
820
  if (position === "below" && canDropInsideTarget) {
567
- const expandedSet = getTreeViewStore<ID>(storeId).getState().expanded;
568
- if (targetNode.children?.length && expandedSet.has(targetNode.id)) {
821
+ if (targetNode.children?.length && expanded.has(targetNode.id)) {
569
822
  position = "inside";
570
823
  }
571
824
  }
@@ -596,33 +849,40 @@ export function useDragDrop<ID>(
596
849
  }
597
850
  prevDropTargetRef.current = { targetIndex: clampedIndex, position };
598
851
 
599
- const indicatorTop = fingerLocalY - grabOffsetYRef.current;
600
-
601
- // Validity check
602
- const store = getTreeViewStore<ID>(storeId);
603
- const { invalidDragTargetIds, draggedNodeId, expanded } =
604
- 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;
605
863
 
606
864
  // maxDepth check for above/below (sibling) positions
607
865
  let maxDepthValid = true;
608
- if (maxDepthRef.current !== undefined && (position === "above" || position === "below")) {
609
- 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);
610
870
  const deepest = targetLevel + draggedSubtreeDepthRef.current;
611
871
  if (deepest > maxDepthRef.current) maxDepthValid = false;
612
872
  }
613
873
 
614
874
  const isValid =
615
- targetNode.id !== draggedNodeId &&
616
- !invalidDragTargetIds.has(targetNode.id) &&
875
+ effectiveTargetId !== draggedNodeId &&
876
+ !invalidDragTargetIds.has(effectiveTargetId) &&
617
877
  maxDepthValid &&
618
878
  (!canDropRef.current || canDropRef.current(
619
879
  draggedNodeRef.current!,
620
- targetNode,
621
- position
880
+ effectiveTargetNode,
881
+ effectivePosition
622
882
  ));
623
883
 
624
884
  // --- Auto-expand: if hovering "inside" a collapsed expandable node ---
625
- 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)) {
626
886
  if (autoExpandTargetRef.current !== targetNode.id) {
627
887
  // New hover target - start timer
628
888
  cancelAutoExpandTimer();
@@ -654,25 +914,54 @@ export function useDragDrop<ID>(
654
914
  ? (targetNode.level ?? 0) + 1
655
915
  : (targetNode.level ?? 0))
656
916
  : draggedLevel;
657
- if (effectiveLevel !== prevEffectiveLevelRef.current) {
658
- const prevLevel = prevEffectiveLevelRef.current ?? effectiveLevel;
659
- prevEffectiveLevelRef.current = effectiveLevel;
660
- setEffectiveDropLevel(effectiveLevel);
661
-
662
- // The level prop change snaps the content to the correct indent.
663
- // Counteract that visual jump with an initial translateX offset,
664
- // then spring to 0 for a smooth "magnetic snap" transition.
665
- if (prevLevel !== effectiveLevel) {
666
- overlayX.setValue(
667
- (prevLevel - effectiveLevel) * indentationMultiplierRef.current
668
- );
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) {
669
933
  Animated.spring(overlayX, {
670
- toValue: 0,
934
+ toValue: targetX,
671
935
  useNativeDriver: true,
672
936
  speed: 40,
673
937
  bounciness: 4,
674
938
  }).start();
939
+ } else {
940
+ overlayX.setValue(targetX);
675
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);
676
965
  }
677
966
 
678
967
  const newTarget: DropTarget<ID> = {
@@ -680,18 +969,33 @@ export function useDragDrop<ID>(
680
969
  targetIndex: clampedIndex,
681
970
  position,
682
971
  isValid,
683
- targetLevel: targetNode.level ?? 0,
684
- indicatorTop,
685
972
  };
686
973
 
687
- // Update the store so each Node can render its own indicator
688
- if (isValid) {
689
- store.getState().updateDropTarget(targetNode.id, position, visualDropLevel);
690
- } else {
691
- 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
+ };
692
996
  }
693
997
 
694
- // Keep ref in sync (used by handleDragEnd to avoid setState-during-render)
998
+ // Keep the commit ref in sync (read by handleDragEnd).
695
999
  // When a logical target exists (e.g. ancestor at a cliff), use it
696
1000
  // for the actual move while the visual indicator stays on the current node.
697
1001
  if (logicalTargetId !== null && logicalPosition !== null) {
@@ -703,34 +1007,33 @@ export function useDragDrop<ID>(
703
1007
  } else {
704
1008
  dropTargetRef.current = newTarget;
705
1009
  }
706
-
707
- setDropTarget((prevTarget) => {
708
- if (
709
- prevTarget?.targetNodeId === newTarget.targetNodeId &&
710
- prevTarget?.position === newTarget.position &&
711
- prevTarget?.isValid === newTarget.isValid &&
712
- prevTarget?.indicatorTop === newTarget.indicatorTop
713
- ) {
714
- return prevTarget;
715
- }
716
- return newTarget;
717
- });
718
1010
  },
719
- [storeId, cancelAutoExpandTimer, overlayX]
1011
+ [
1012
+ storeId,
1013
+ cancelAutoExpandTimer,
1014
+ cancelLevelSettleTimer,
1015
+ fingerLeftOfLevelThreshold,
1016
+ overlayX,
1017
+ itemHeightsRef,
1018
+ ]
720
1019
  );
1020
+ calculateDropTargetRef.current = calculateDropTarget;
721
1021
 
722
1022
  // --- Handle drag end ---
723
1023
  const handleDragEnd = useCallback(
724
- (fingerPageY?: number, fingerPageX?: number) => {
1024
+ (fingerPageY?: number, fingerPageX?: number, cancel: boolean = false) => {
725
1025
  stopAutoScroll();
726
1026
  cancelLongPressTimer();
727
1027
  cancelAutoExpandTimer();
728
- prevDropTargetRef.current = null;
729
1028
 
730
1029
  if (!isDraggingRef.current) return;
731
1030
  isDraggingRef.current = false;
732
1031
 
733
- // 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.
734
1037
  if (fingerPageY !== undefined) {
735
1038
  calculateDropTarget(fingerPageY, fingerPageX ?? 0);
736
1039
  }
@@ -739,90 +1042,126 @@ export function useDragDrop<ID>(
739
1042
  // Without this, the timer fires after drag ends and toggles the target back to collapsed.
740
1043
  cancelAutoExpandTimer();
741
1044
 
742
- // Read current drop target from ref via a small delay to ensure
743
- // the last setDropTarget has been processed
744
- // We use the current dropTarget state via a callback
745
- // 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).
746
1047
  const currentTarget = dropTargetRef.current;
747
- 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;
748
1072
 
749
1073
  if (
1074
+ !cancel &&
750
1075
  currentTarget?.isValid &&
751
- droppedNodeId !== null
1076
+ droppedNodeId !== null &&
1077
+ newData !== currentData &&
1078
+ !isNoOpMove
752
1079
  ) {
753
- const store = getTreeViewStore<ID>(storeId);
754
- const currentData =
755
- store.getState().initialTreeViewData;
756
- const newData = moveTreeNode(
757
- currentData,
758
- droppedNodeId,
759
- currentTarget.targetNodeId,
760
- 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
761
1085
  );
762
1086
 
763
- // Update store directly (preserves checked/expanded)
764
- store
765
- .getState()
766
- .updateInitialTreeViewData(newData);
767
- initializeNodeMaps(storeId, newData);
768
-
769
- // Recalculate checked/indeterminate states for all parents
770
- // since the tree structure changed
771
- recalculateCheckedStates<ID>(storeId);
772
-
773
- // If dropped "inside" a node, expand it so the dropped node is visible
774
- if (currentTarget.position === "inside") {
775
- expandNodes(storeId, [currentTarget.targetNodeId]);
776
- }
777
-
778
- // Expand ancestors of the dropped node so it's visible
779
- expandNodes(storeId, [droppedNodeId], true);
780
-
781
- // Set internal data ref to prevent useDeepCompareEffect
782
- // from reinitializing
783
- internalDataRef.current = newData;
784
-
785
- // 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().
786
1090
  onDragEndRef.current?.({
787
1091
  draggedNodeId: droppedNodeId,
788
1092
  targetNodeId: currentTarget.targetNodeId,
789
1093
  position: currentTarget.position,
790
- newTreeData: newData,
1094
+ previousParentId: prevPosition?.parentId ?? null,
1095
+ previousIndex: prevPosition?.index ?? -1,
1096
+ newParentId: newPosition?.parentId ?? null,
1097
+ newIndex: newPosition?.index ?? -1,
791
1098
  });
792
1099
 
1100
+ // Announce the result for screen readers (no-op without assistive tech).
1101
+ AccessibilityInfo.announceForAccessibility?.(
1102
+ `Moved ${draggedNodeRef.current?.name ?? "node"}`
1103
+ );
1104
+
793
1105
  // Auto-scroll to the dropped node unless disabled by the user.
794
1106
  const scrollOpts = autoScrollToDroppedNode;
795
1107
  const scrollEnabled = scrollOpts === undefined || scrollOpts === true
796
1108
  || (typeof scrollOpts === "object" && scrollOpts.enabled !== false);
797
1109
 
798
1110
  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);
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
+ }
808
1139
  }
809
1140
  } else if (droppedNodeId !== null) {
810
- // 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
+ }
811
1146
  onDragCancelRef.current?.({ draggedNodeId: droppedNodeId });
1147
+ AccessibilityInfo.announceForAccessibility?.(
1148
+ `Cancelled moving ${draggedNodeRef.current?.name ?? "node"}`
1149
+ );
812
1150
  }
813
1151
 
814
1152
  // Collapse auto-expanded nodes that aren't ancestors of the drop target
815
1153
  if (autoExpandedDuringDragRef.current.size > 0) {
816
- const store3 = getTreeViewStore<ID>(storeId);
817
- const { childToParentMap } = store3.getState();
1154
+ // Re-read: the maps were rebuilt if a move committed above.
1155
+ const { childToParentMap: postMoveParentMap } = store.getState();
818
1156
 
819
- // 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.
820
1159
  const ancestorIds = new Set<ID>();
821
- if (currentTarget?.isValid) {
1160
+ if (!cancel && currentTarget?.isValid) {
822
1161
  let walkId: ID | undefined = currentTarget.targetNodeId;
823
1162
  while (walkId !== undefined) {
824
1163
  ancestorIds.add(walkId);
825
- walkId = childToParentMap.get(walkId);
1164
+ walkId = postMoveParentMap.get(walkId);
826
1165
  }
827
1166
  }
828
1167
 
@@ -840,20 +1179,18 @@ export function useDragDrop<ID>(
840
1179
  }
841
1180
 
842
1181
  // Clear drag state
843
- const store2 = getTreeViewStore<ID>(storeId);
844
- store2.getState().updateDraggedNodeId(null);
845
- store2.getState().updateInvalidDragTargetIds(new Set());
846
- store2.getState().updateDropTarget(null, null);
1182
+ resetDragStoreState();
847
1183
 
848
1184
  // Reset all refs
849
1185
  overlayX.setValue(0);
850
1186
  prevEffectiveLevelRef.current = null;
1187
+ cancelLevelSettleTimer();
1188
+ prevDropTargetRef.current = null;
851
1189
  dropTargetRef.current = null;
1190
+ lastStoreDropTargetRef.current = null;
852
1191
  draggedNodeRef.current = null;
853
- draggedNodeIdRef.current = null;
854
- draggedNodeIndexRef.current = -1;
1192
+ wasDraggedNodeExpandedRef.current = false;
855
1193
 
856
- setDropTarget(null);
857
1194
  setIsDragging(false);
858
1195
  setDraggedNode(null);
859
1196
  },
@@ -864,14 +1201,26 @@ export function useDragDrop<ID>(
864
1201
  cancelLongPressTimer,
865
1202
  cancelAutoExpandTimer,
866
1203
  calculateDropTarget,
867
- internalDataRef,
868
1204
  ]
869
1205
  );
870
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
+
871
1218
  // --- Handle node touch end ---
872
1219
  // If the PanResponder never captured the gesture (no movement after long
873
1220
  // press fired), end the drag here so the node doesn't stay "lifted".
874
1221
  const handleNodeTouchEnd = useCallback(() => {
1222
+ // cancelLongPressTimer also aborts any drag still awaiting its
1223
+ // measureInWindow callback (clears pendingDragRef).
875
1224
  cancelLongPressTimer();
876
1225
  if (isDraggingRef.current && !panResponderActiveRef.current) {
877
1226
  handleDragEnd();
@@ -892,26 +1241,28 @@ export function useDragDrop<ID>(
892
1241
  panResponderActiveRef.current = true;
893
1242
  },
894
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
+
895
1249
  onPanResponderMove: (evt) => {
896
1250
  if (!isDraggingRef.current) return;
897
1251
 
898
1252
  const fingerPageY = evt.nativeEvent.pageY;
1253
+ lastFingerPageYRef.current = fingerPageY;
1254
+ lastFingerPageXRef.current = evt.nativeEvent.pageX;
899
1255
  const fingerLocalY =
900
1256
  fingerPageY - containerPageYRef.current;
901
1257
 
902
1258
  // Update overlay position (with configurable offset)
903
- const overlayLocalY =
904
- fingerLocalY - grabOffsetYRef.current + (dragOverlayOffsetRef.current + PLATFORM_OVERLAY_Y_CORRECTION) * itemHeightRef.current;
905
- overlayY.setValue(overlayLocalY);
1259
+ overlayY.setValue(computeOverlayLocalY(fingerLocalY));
906
1260
 
907
1261
  // Calculate drop target (horizontal position used at level cliffs)
908
1262
  calculateDropTarget(fingerPageY, evt.nativeEvent.pageX);
909
1263
 
910
- // Auto-scroll at edges - use delta-based position relative to container
911
- const fingerInContainer =
912
- initialFingerContainerYRef.current +
913
- (fingerPageY - initialFingerPageYRef.current);
914
- updateAutoScroll(fingerInContainer);
1264
+ // Auto-scroll at edges, from the finger's container-local position
1265
+ updateAutoScroll(fingerLocalY);
915
1266
  },
916
1267
 
917
1268
  onPanResponderRelease: (evt) => {
@@ -921,7 +1272,9 @@ export function useDragDrop<ID>(
921
1272
 
922
1273
  onPanResponderTerminate: () => {
923
1274
  panResponderActiveRef.current = false;
924
- 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);
925
1278
  },
926
1279
  })
927
1280
  ).current;
@@ -931,16 +1284,22 @@ export function useDragDrop<ID>(
931
1284
  return () => {
932
1285
  cancelLongPressTimer();
933
1286
  cancelAutoExpandTimer();
1287
+ cancelLevelSettleTimer();
934
1288
  stopAutoScroll();
1289
+ pendingDragRef.current = false;
1290
+ lastStoreDropTargetRef.current = null;
935
1291
  if (isDraggingRef.current) {
936
1292
  isDraggingRef.current = false;
937
- const store = getTreeViewStore<ID>(storeId);
938
- store.getState().updateDraggedNodeId(null);
939
- store.getState().updateInvalidDragTargetIds(new Set());
940
- store.getState().updateDropTarget(null, null);
1293
+ resetDragStoreState();
941
1294
  }
942
1295
  };
943
- }, [storeId, cancelLongPressTimer, cancelAutoExpandTimer, stopAutoScroll]);
1296
+ }, [
1297
+ cancelLongPressTimer,
1298
+ cancelAutoExpandTimer,
1299
+ cancelLevelSettleTimer,
1300
+ stopAutoScroll,
1301
+ resetDragStoreState,
1302
+ ]);
944
1303
 
945
1304
  return {
946
1305
  panResponder,
@@ -948,12 +1307,12 @@ export function useDragDrop<ID>(
948
1307
  overlayX,
949
1308
  isDragging,
950
1309
  draggedNode,
951
- dropTarget,
952
- effectiveDropLevel,
953
1310
  handleNodeTouchStart,
954
1311
  handleNodeTouchEnd,
955
1312
  cancelLongPressTimer,
1313
+ handleScroll,
956
1314
  scrollOffsetRef,
957
1315
  headerOffsetRef,
1316
+ containerHeightRef,
958
1317
  };
959
1318
  }