react-native-tree-multi-select 3.0.0-beta.5 → 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 +45 -21
  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 +76 -27
  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 +79 -27
  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
@@ -1,15 +1,36 @@
1
1
  "use strict";
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
- import { Animated, PanResponder, Platform } from "react-native";
4
+ import { AccessibilityInfo, Animated, PanResponder, Platform } from "react-native";
5
5
  import { getTreeViewStore } from "../store/treeView.store.js";
6
- import { collapseNodes, expandNodes, handleToggleExpand, initializeNodeMaps, recalculateCheckedStates } from "../helpers/index.js";
7
- import { moveTreeNode } from "../helpers/moveTreeNode.helper.js";
8
- import { listHeaderFooterPadding } from "../constants/treeView.constants.js";
9
-
10
- // Android reports locationY slightly differently, causing the overlay
11
- // to appear ~1 item height closer to the finger than on iOS.
12
- const PLATFORM_OVERLAY_Y_CORRECTION = Platform.OS === "android" ? -2 : 0;
6
+ import { collapseNodes, expandNodes, getSubtreeDepthFromMap, handleToggleExpand } from "../helpers/index.js";
7
+ import { applyMoveToStore, findNodePosition, findNodePositionFromMaps, moveTreeNode } from "../helpers/moveTreeNode.helper.js";
8
+ import { scrollMovedNodeIntoView } from "./useScrollToNode.js";
9
+ import { defaultItemHeight } from "../constants/treeView.constants.js";
10
+
11
+ // Android reports touch `locationY` slightly differently from iOS, which makes the
12
+ // drag overlay sit ~2 item-heights closer to the finger. This empirical correction
13
+ // (in item-height units, added to dragOverlayOffset) compensates for that. Consumers
14
+ // can override it per-instance via DragAndDropOptions.overlayYCorrection.
15
+ const DEFAULT_OVERLAY_Y_CORRECTION = Platform.OS === "android" ? -2 : 0;
16
+
17
+ // Auto-scroll speed at the very edge in px/SECOND, before the proximity ramp
18
+ // and the consumer's `autoScrollSpeed` multiplier are applied. Time-based (not
19
+ // px/frame) so 60Hz and 120Hz displays scroll at the same real-world speed.
20
+ const MAX_AUTO_SCROLL_SPEED = 1200;
21
+ // During auto-scroll, recompute the drop target at most this often. Every scroll
22
+ // frame moves rows under the finger, but recomputing per frame costs store
23
+ // writes + node re-renders on the JS thread (stuttering the scroll itself), and
24
+ // ~10 indicator updates/sec reads calmer than 60 anyway.
25
+ const AUTO_SCROLL_RECALC_INTERVAL_MS = 100;
26
+ // How long (ms) a candidate drop level must hold before the overlay springs to
27
+ // its indentation. Prevents the indent from chasing every row the finger merely
28
+ // passes through while dragging vertically.
29
+ const LEVEL_SETTLE_MS = 120;
30
+ // How far (fraction of row height) the active zone's boundaries extend outward
31
+ // while the finger stays on the same row. Finger tremor at a zone edge would
32
+ // otherwise flip above/inside/below (and the overlay indent) every few frames.
33
+ const ZONE_STICKINESS = 0.08;
13
34
  export function useDragDrop(params) {
14
35
  const {
15
36
  storeId,
@@ -23,10 +44,14 @@ export function useDragDrop(params) {
23
44
  longPressDuration,
24
45
  autoScrollThreshold,
25
46
  autoScrollSpeed,
26
- internalDataRef,
27
47
  measuredItemHeightRef,
48
+ contentHeightRef,
49
+ itemHeightsRef,
28
50
  dragOverlayOffset,
51
+ overlayYCorrection,
29
52
  autoExpandDelay,
53
+ autoExpand = true,
54
+ magneticSnap = true,
30
55
  indentationMultiplier,
31
56
  canDrop: canDropCallback,
32
57
  maxDepth,
@@ -39,8 +64,9 @@ export function useDragDrop(params) {
39
64
  // --- Refs for mutable state (no stale closures in PanResponder) ---
40
65
  const isDraggingRef = useRef(false);
41
66
  const draggedNodeRef = useRef(null);
42
- const draggedNodeIdRef = useRef(null);
43
- const draggedNodeIndexRef = useRef(-1);
67
+ // Whether the dragged node was expanded before drag start force-collapsed it,
68
+ // so a cancelled drag can restore the expansion (a cancel must not mutate state).
69
+ const wasDraggedNodeExpandedRef = useRef(false);
44
70
  const longPressTimerRef = useRef(null);
45
71
  const containerPageXRef = useRef(0);
46
72
  const containerPageYRef = useRef(0);
@@ -49,16 +75,29 @@ export function useDragDrop(params) {
49
75
  const grabOffsetYRef = useRef(0);
50
76
  const scrollOffsetRef = useRef(0);
51
77
  const headerOffsetRef = useRef(0);
52
- const itemHeightRef = useRef(36);
78
+ const itemHeightRef = useRef(defaultItemHeight);
53
79
  const overlayY = useRef(new Animated.Value(0)).current;
54
80
  const overlayX = useRef(new Animated.Value(0)).current;
55
81
  const prevEffectiveLevelRef = useRef(null);
82
+ // Settle-debounce for the overlay indent: candidate level + the timer that
83
+ // springs the overlay to it once it has held for LEVEL_SETTLE_MS.
84
+ const pendingLevelRef = useRef(null);
85
+ const levelSettleTimerRef = useRef(null);
56
86
  const autoScrollRAFRef = useRef(null);
57
87
  const autoScrollSpeedRef = useRef(0);
58
88
 
59
- // Delta-based auto-scroll: avoids unreliable containerPageY
60
- const initialFingerPageYRef = useRef(0);
61
- const initialFingerContainerYRef = useRef(0);
89
+ // Last known finger position, so the auto-scroll loop can recompute the drop
90
+ // target for a stationary finger while rows scroll underneath it.
91
+ const lastFingerPageYRef = useRef(0);
92
+ const lastFingerPageXRef = useRef(0);
93
+ // calculateDropTarget is defined below startAutoScrollLoop; the RAF loop reads
94
+ // it through this ref to avoid a use-before-declaration in the dep array.
95
+ const calculateDropTargetRef = useRef(() => {});
96
+
97
+ // Caches the "every current row is measured" gate so calculateDropTarget
98
+ // doesn't scan the full flattened list on every pan frame. Invalidated when
99
+ // the flattened list identity or the measured-heights count changes.
100
+ const allHeightsMeasuredRef = useRef(null);
62
101
 
63
102
  // Auto-expand timer for hovering over collapsed nodes
64
103
  const autoExpandTimerRef = useRef(null);
@@ -69,8 +108,16 @@ export function useDragDrop(params) {
69
108
  // Tracks whether the PanResponder has captured the current gesture
70
109
  const panResponderActiveRef = useRef(false);
71
110
 
111
+ // True between the long-press firing and the async measureInWindow callback
112
+ // resolving. Lets a finger-lift in that window abort the pending drag so the
113
+ // node is never left "lifted" with no finger down.
114
+ const pendingDragRef = useRef(false);
115
+
72
116
  // Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
73
117
  const prevDropTargetRef = useRef(null);
118
+ // Flattened-list identity seen by the last calculateDropTarget call; when it
119
+ // changes mid-drag the index-based hysteresis state above is invalidated.
120
+ const lastCalcNodesRef = useRef(null);
74
121
 
75
122
  // Depth of the dragged subtree (computed once at drag start, used for maxDepth check)
76
123
  const draggedSubtreeDepthRef = useRef(0);
@@ -96,32 +143,49 @@ export function useDragDrop(params) {
96
143
  // Keep config values current for PanResponder closures
97
144
  const dragOverlayOffsetRef = useRef(dragOverlayOffset);
98
145
  dragOverlayOffsetRef.current = dragOverlayOffset;
146
+ const overlayYCorrectionRef = useRef(overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION);
147
+ overlayYCorrectionRef.current = overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION;
99
148
  const autoScrollThresholdRef = useRef(autoScrollThreshold);
100
149
  autoScrollThresholdRef.current = autoScrollThreshold;
101
150
  const autoScrollSpeedParamRef = useRef(autoScrollSpeed);
102
151
  autoScrollSpeedParamRef.current = autoScrollSpeed;
103
152
  const autoExpandDelayRef = useRef(autoExpandDelay);
104
153
  autoExpandDelayRef.current = autoExpandDelay;
154
+ const autoExpandRef = useRef(autoExpand);
155
+ autoExpandRef.current = autoExpand;
156
+ const magneticSnapRef = useRef(magneticSnap);
157
+ magneticSnapRef.current = magneticSnap;
105
158
  const indentationMultiplierRef = useRef(indentationMultiplier);
106
159
  indentationMultiplierRef.current = indentationMultiplier;
107
160
  const maxDepthRef = useRef(maxDepth);
108
161
  maxDepthRef.current = maxDepth;
109
162
 
110
- // --- React state (triggers re-renders only at drag start/end + indicator changes) ---
163
+ // --- React state (triggers re-renders only at drag start/end + level changes) ---
111
164
  const [isDragging, setIsDragging] = useState(false);
112
165
  const [draggedNode, setDraggedNode] = useState(null);
113
- const [dropTarget, setDropTarget] = useState(null);
114
- const [effectiveDropLevel, setEffectiveDropLevel] = useState(0);
115
166
 
116
- // Ref mirror of dropTarget - avoids nesting Zustand updates inside React state updaters
167
+ // The current drop target lives only in a ref (read by handleDragEnd at commit
168
+ // time). It is deliberately NOT React state: calculateDropTarget runs every pan
169
+ // frame, so a per-frame setState would re-render the whole list for nothing -
170
+ // the per-node drop indicator is driven by the store fields (dropTargetNodeId /
171
+ // dropPosition / dropLevel), which are throttled via lastStoreDropTargetRef.
117
172
  const dropTargetRef = useRef(null);
118
173
 
174
+ // Last value written to the store's drop target. Guards the per-frame Zustand
175
+ // write so updateDropTarget only fires when the indicator actually changes
176
+ // (otherwise every mounted Node's selector would re-run on every pan frame).
177
+ const lastStoreDropTargetRef = useRef(null);
178
+
119
179
  // --- Long press timer ---
120
180
  const cancelLongPressTimer = useCallback(() => {
121
181
  if (longPressTimerRef.current) {
122
182
  clearTimeout(longPressTimerRef.current);
123
183
  longPressTimerRef.current = null;
124
184
  }
185
+ // Also abort a drag still awaiting its async measureInWindow callback. This
186
+ // makes scrolling (which cancels the long-press) during that window abort the
187
+ // drag cleanly, instead of letting it start later with stale finger coords.
188
+ pendingDragRef.current = false;
125
189
  }, []);
126
190
 
127
191
  // --- Get all descendant IDs of a node ---
@@ -147,25 +211,38 @@ export function useDragDrop(params) {
147
211
 
148
212
  // --- Get the maximum depth of a subtree (0 for leaf nodes) ---
149
213
  const getSubtreeDepth = useCallback(nodeId => {
150
- const store = getTreeViewStore(storeId);
151
214
  const {
152
215
  nodeMap
153
- } = store.getState();
154
- const node = nodeMap.get(nodeId);
155
- if (!node?.children?.length) return 0;
156
- let max = 0;
157
- for (const child of node.children) {
158
- max = Math.max(max, 1 + getSubtreeDepth(child.id));
159
- }
160
- return max;
216
+ } = getTreeViewStore(storeId).getState();
217
+ return getSubtreeDepthFromMap(nodeMap, nodeId);
161
218
  }, [storeId]);
162
219
 
220
+ // --- Overlay Y for a container-local finger Y (grab point + offsets) ---
221
+ const computeOverlayLocalY = useCallback(fingerLocalY => fingerLocalY - grabOffsetYRef.current + (dragOverlayOffsetRef.current + overlayYCorrectionRef.current) * itemHeightRef.current, []);
222
+
223
+ // --- Level-cliff horizontal control: is the finger left of the threshold
224
+ // that selects the shallower level? (30% into the visible content area
225
+ // of a row indented at `level`.) ---
226
+ const fingerLeftOfLevelThreshold = useCallback((level, fingerLocalX) => {
227
+ const itemLeftEdge = level * indentationMultiplierRef.current;
228
+ const threshold = itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
229
+ return fingerLocalX < threshold;
230
+ }, []);
231
+
163
232
  // --- Initiate drag ---
164
233
  const initiateDrag = useCallback((nodeId, pageY, locationY, nodeIndex) => {
165
234
  if (!dragEnabled) return;
235
+ // Never start a second drag on top of one already running or pending
236
+ // (e.g. a competing long-press from a second finger).
237
+ if (isDraggingRef.current || pendingDragRef.current) return;
166
238
  const container = containerRef.current;
167
239
  if (!container) return;
240
+ pendingDragRef.current = true;
168
241
  container.measureInWindow((x, y, w, h) => {
242
+ // Finger lifted (or drag cancelled) before the measurement resolved -
243
+ // abort so we don't strand a drag with no finger down.
244
+ if (!pendingDragRef.current) return;
245
+ pendingDragRef.current = false;
169
246
  containerPageXRef.current = x;
170
247
  containerPageYRef.current = y;
171
248
  containerWidthRef.current = w;
@@ -176,25 +253,25 @@ export function useDragDrop(params) {
176
253
  const node = nodes[nodeIndex];
177
254
  if (!node) return;
178
255
 
179
- // Collapse node if expanded
256
+ // Collapse node if expanded (restored if the drag is cancelled)
180
257
  const store = getTreeViewStore(storeId);
181
258
  const {
182
259
  expanded
183
260
  } = store.getState();
261
+ wasDraggedNodeExpandedRef.current = false;
184
262
  if (expanded.has(nodeId) && node.children?.length) {
185
263
  handleToggleExpand(storeId, nodeId);
264
+ wasDraggedNodeExpandedRef.current = true;
186
265
  }
187
266
 
188
267
  // Store grab metadata
189
268
  grabOffsetYRef.current = locationY;
190
269
  draggedNodeRef.current = node;
191
- draggedNodeIdRef.current = nodeId;
192
- draggedNodeIndexRef.current = nodeIndex;
193
270
  draggedSubtreeDepthRef.current = getSubtreeDepth(nodeId);
194
271
 
195
272
  // Use measured item height if available, fall back to default
196
273
  const measured = measuredItemHeightRef.current;
197
- itemHeightRef.current = measured > 0 ? measured : 36;
274
+ itemHeightRef.current = measured > 0 ? measured : defaultItemHeight;
198
275
 
199
276
  // Calculate headerOffset dynamically:
200
277
  // fingerLocalY = pageY - containerPageY
@@ -202,13 +279,7 @@ export function useDragDrop(params) {
202
279
  // So: headerOffset = fingerLocalY + scrollOffset - grabOffsetY - nodeIndex * itemHeight
203
280
  const fingerLocalY = pageY - containerPageYRef.current;
204
281
  headerOffsetRef.current = fingerLocalY + scrollOffsetRef.current - locationY - nodeIndex * itemHeightRef.current;
205
-
206
- // Delta-based auto-scroll: compute finger's position in the container
207
- // from the node's known index (avoids unreliable containerPageY).
208
- const iH = itemHeightRef.current;
209
- const listHeaderHeight = listHeaderFooterPadding * 2;
210
- initialFingerPageYRef.current = pageY;
211
- initialFingerContainerYRef.current = listHeaderHeight + nodeIndex * iH - scrollOffsetRef.current + locationY;
282
+ lastFingerPageYRef.current = pageY;
212
283
 
213
284
  // Compute invalid targets (self + descendants)
214
285
  const descendants = getDescendantIds(nodeId);
@@ -219,26 +290,27 @@ export function useDragDrop(params) {
219
290
  store.getState().updateInvalidDragTargetIds(descendants);
220
291
 
221
292
  // Set overlay initial position (with configurable offset)
222
- const overlayLocalY = fingerLocalY - locationY + (dragOverlayOffsetRef.current + PLATFORM_OVERLAY_Y_CORRECTION) * itemHeightRef.current;
223
- overlayY.setValue(overlayLocalY);
293
+ overlayY.setValue(computeOverlayLocalY(fingerLocalY));
224
294
 
225
295
  // Reset magnetic overlay
226
296
  overlayX.setValue(0);
227
297
  prevEffectiveLevelRef.current = node.level ?? 0;
298
+ cancelLevelSettleTimer();
228
299
 
229
300
  // Set React state
230
301
  isDraggingRef.current = true;
231
302
  autoExpandedDuringDragRef.current.clear();
232
303
  setIsDragging(true);
233
304
  setDraggedNode(node);
234
- setEffectiveDropLevel(node.level ?? 0);
235
- setDropTarget(null);
236
305
 
237
306
  // Notify consumer that drag has started
238
307
  onDragStartRef.current?.({
239
308
  draggedNodeId: nodeId
240
309
  });
241
310
 
311
+ // Announce for screen readers (no-op when no assistive tech is active).
312
+ AccessibilityInfo.announceForAccessibility?.(`Picked up ${node.name}`);
313
+
242
314
  // Start auto-scroll loop
243
315
  startAutoScrollLoop();
244
316
  });
@@ -249,6 +321,15 @@ export function useDragDrop(params) {
249
321
  // --- Handle node touch start (long press detection) ---
250
322
  const handleNodeTouchStart = useCallback((nodeId, pageY, locationY, nodeIndex) => {
251
323
  if (!dragEnabled) return;
324
+ // Ignore touches that land while a drag is already running or pending.
325
+ // Without this, a second finger on another row could arm a competing
326
+ // long-press that overwrites the in-flight drag's state.
327
+ if (isDraggingRef.current || pendingDragRef.current) return;
328
+
329
+ // Drag is disabled while a search filter is active: drop targets are
330
+ // computed against the filtered list but the move applies to the full
331
+ // tree, so a drop could land next to siblings hidden by the filter.
332
+ if (getTreeViewStore(storeId).getState().searchText) return;
252
333
 
253
334
  // Check if this node can be dragged
254
335
  if (canDragRef.current) {
@@ -264,24 +345,63 @@ export function useDragDrop(params) {
264
345
  longPressTimerRef.current = null;
265
346
  initiateDrag(nodeId, pageY, locationY, nodeIndex);
266
347
  }, longPressDuration);
267
- }, [dragEnabled, longPressDuration, cancelLongPressTimer, initiateDrag]);
348
+ }, [dragEnabled, storeId, longPressDuration, cancelLongPressTimer, initiateDrag]);
268
349
 
269
350
  // --- Auto-scroll ---
270
351
  const startAutoScrollLoop = useCallback(() => {
271
- const loop = () => {
352
+ // Idempotent: cancel any loop already in flight so a re-entry can never
353
+ // orphan a RAF handle (which would run a second, untracked scroll loop).
354
+ if (autoScrollRAFRef.current !== null) {
355
+ cancelAnimationFrame(autoScrollRAFRef.current);
356
+ autoScrollRAFRef.current = null;
357
+ }
358
+ // Time-based stepping: RAF cadence varies with display refresh rate, so
359
+ // distance is derived from elapsed time, not frame count.
360
+ let lastTs = null;
361
+ let lastRecalcTs = 0;
362
+ const loop = ts => {
272
363
  if (!isDraggingRef.current) return;
273
- if (autoScrollSpeedRef.current !== 0) {
274
- const newOffset = Math.max(0, scrollOffsetRef.current + autoScrollSpeedRef.current);
275
- scrollOffsetRef.current = newOffset;
276
- flashListRef.current?.scrollToOffset?.({
277
- offset: newOffset,
278
- animated: false
279
- });
364
+
365
+ // Jest's RAF mock invokes the callback without a timestamp.
366
+ const now = typeof ts === "number" ? ts : Date.now();
367
+ // First frame establishes the baseline; cap dt so a long hiccup
368
+ // (background frame drop) can't produce one huge scroll jump.
369
+ const dtMs = lastTs === null ? 0 : Math.min(now - lastTs, 64);
370
+ lastTs = now;
371
+ if (autoScrollSpeedRef.current !== 0 && dtMs > 0) {
372
+ // Clamp to the scrollable range so dragging at the bottom edge can't
373
+ // grow the offset past the end (which would corrupt drop-index math).
374
+ // Leave the upper bound open until the content height is measured.
375
+ const contentH = contentHeightRef.current;
376
+ const maxOffset = contentH > 0 ? Math.max(0, contentH - containerHeightRef.current) : Number.POSITIVE_INFINITY;
377
+ const newOffset = Math.min(maxOffset, Math.max(0, scrollOffsetRef.current + autoScrollSpeedRef.current * dtMs / 1000));
378
+
379
+ // Pinned at a boundary: nothing moved, so skip the scroll command
380
+ // and the drop-target recompute entirely.
381
+ if (newOffset !== scrollOffsetRef.current) {
382
+ // The RAF loop is the sole writer of scrollOffsetRef during a
383
+ // drag (NodeList's onScroll stands down) - the accumulated value
384
+ // is the commanded position and the native list converges to it.
385
+ scrollOffsetRef.current = newOffset;
386
+ flashListRef.current?.scrollToOffset?.({
387
+ offset: newOffset,
388
+ animated: false
389
+ });
390
+
391
+ // Rows are moving under a (possibly stationary) finger; recompute
392
+ // the drop target so the indicator tracks the auto-scroll instead
393
+ // of staying frozen on the last row the finger moved over.
394
+ // Time-throttled - see AUTO_SCROLL_RECALC_INTERVAL_MS.
395
+ if (now - lastRecalcTs >= AUTO_SCROLL_RECALC_INTERVAL_MS) {
396
+ lastRecalcTs = now;
397
+ calculateDropTargetRef.current(lastFingerPageYRef.current, lastFingerPageXRef.current);
398
+ }
399
+ }
280
400
  }
281
401
  autoScrollRAFRef.current = requestAnimationFrame(loop);
282
402
  };
283
403
  autoScrollRAFRef.current = requestAnimationFrame(loop);
284
- }, [flashListRef]);
404
+ }, [flashListRef, contentHeightRef]);
285
405
  const stopAutoScroll = useCallback(() => {
286
406
  if (autoScrollRAFRef.current !== null) {
287
407
  cancelAnimationFrame(autoScrollRAFRef.current);
@@ -291,16 +411,21 @@ export function useDragDrop(params) {
291
411
  }, []);
292
412
  const updateAutoScroll = useCallback(fingerInContainer => {
293
413
  const threshold = autoScrollThresholdRef.current;
294
- const maxSpeed = 8 * autoScrollSpeedParamRef.current;
414
+ // px/second; the RAF loop converts to distance via elapsed time.
415
+ const maxSpeed = MAX_AUTO_SCROLL_SPEED * autoScrollSpeedParamRef.current;
295
416
  const containerH = containerHeightRef.current;
417
+
418
+ // Ease the proximity ramp (sqrt) so speed builds up fast even for a
419
+ // shallow entry into the threshold zone - the screen edge often stops
420
+ // the finger before it reaches the very edge of the list.
296
421
  if (fingerInContainer < threshold) {
297
422
  // Scroll up
298
423
  const ratio = 1 - Math.max(0, fingerInContainer) / threshold;
299
- autoScrollSpeedRef.current = -maxSpeed * ratio;
424
+ autoScrollSpeedRef.current = -maxSpeed * Math.sqrt(ratio);
300
425
  } else if (fingerInContainer > containerH - threshold) {
301
426
  // Scroll down
302
427
  const ratio = 1 - Math.max(0, containerH - fingerInContainer) / threshold;
303
- autoScrollSpeedRef.current = maxSpeed * ratio;
428
+ autoScrollSpeedRef.current = maxSpeed * Math.sqrt(ratio);
304
429
  } else {
305
430
  autoScrollSpeedRef.current = 0;
306
431
  }
@@ -315,25 +440,121 @@ export function useDragDrop(params) {
315
440
  autoExpandTargetRef.current = null;
316
441
  }, []);
317
442
 
443
+ // --- Cancel the overlay-indent settle timer + its pending level ---
444
+ const cancelLevelSettleTimer = useCallback(() => {
445
+ if (levelSettleTimerRef.current) {
446
+ clearTimeout(levelSettleTimerRef.current);
447
+ levelSettleTimerRef.current = null;
448
+ }
449
+ pendingLevelRef.current = null;
450
+ }, []);
451
+
452
+ // --- Clear the store's drag fields (shared by drag end and unmount) ---
453
+ const resetDragStoreState = useCallback(() => {
454
+ const state = getTreeViewStore(storeId).getState();
455
+ state.updateDraggedNodeId(null);
456
+ state.updateInvalidDragTargetIds(new Set());
457
+ state.updateDropTarget(null, null);
458
+ }, [storeId]);
459
+
318
460
  // --- Calculate drop target ---
319
461
  const calculateDropTarget = useCallback((fingerPageY, fingerPageX) => {
320
462
  const nodes = flattenedNodesRef.current;
321
463
  if (nodes.length === 0) return;
464
+
465
+ // The flattened list changed mid-drag (e.g. auto-expand inserted the
466
+ // hovered parent's children). Index-based hysteresis/stickiness state
467
+ // refers to rows of the OLD list - drop it so it can't stick the zone
468
+ // or gap decision to whatever row now happens to hold that index. The
469
+ // measured-heights gate cache is keyed to the old list too.
470
+ if (lastCalcNodesRef.current !== nodes) {
471
+ lastCalcNodesRef.current = nodes;
472
+ prevDropTargetRef.current = null;
473
+ allHeightsMeasuredRef.current = null;
474
+ }
475
+
476
+ // Single store snapshot per frame (the store can't change within this
477
+ // synchronous pass) - avoids re-reading getState() several times.
478
+ const {
479
+ childToParentMap,
480
+ expanded,
481
+ invalidDragTargetIds,
482
+ draggedNodeId,
483
+ nodeMap,
484
+ updateDropTarget
485
+ } = getTreeViewStore(storeId).getState();
322
486
  const fingerLocalY = fingerPageY - containerPageYRef.current;
323
487
  const fingerContentY = fingerLocalY + scrollOffsetRef.current;
324
488
  const adjustedContentY = fingerContentY - headerOffsetRef.current;
325
489
  const iH = itemHeightRef.current;
326
- const rawIndex = Math.floor(adjustedContentY / iH);
327
- let clampedIndex = Math.max(0, Math.min(rawIndex, nodes.length - 1));
490
+
491
+ // Resolve which row the finger is over. Uniform math (O(1)) is used
492
+ // unless every CURRENT row has been measured (small, fully-rendered
493
+ // lists), in which case a cumulative walk supports variable row heights.
494
+ // The gate checks per-node-id coverage (not map size) so stale heights
495
+ // left by a previous, longer list can never satisfy it. FlashList
496
+ // virtualization can't measure off-screen rows, so large lists fall back
497
+ // to the uniform estimate.
498
+ const heights = itemHeightsRef.current;
499
+ // The full-list scan is cached per measured count (and invalidated on
500
+ // list-identity change above) so it doesn't run on every pan frame.
501
+ const cachedGate = allHeightsMeasuredRef.current;
502
+ let allMeasured;
503
+ if (cachedGate && cachedGate.size === heights.size) {
504
+ allMeasured = cachedGate.value;
505
+ } else {
506
+ allMeasured = nodes.every(n => heights.has(n.id));
507
+ allHeightsMeasuredRef.current = {
508
+ size: heights.size,
509
+ value: allMeasured
510
+ };
511
+ }
512
+ let clampedIndex;
513
+ let itemTop;
514
+ let itemHeight;
515
+ if (allMeasured) {
516
+ let top = 0;
517
+ let idx = 0;
518
+ while (idx < nodes.length - 1) {
519
+ const h = heights.get(nodes[idx].id) ?? iH;
520
+ if (adjustedContentY < top + h) break;
521
+ top += h;
522
+ idx++;
523
+ }
524
+ clampedIndex = idx;
525
+ itemTop = top;
526
+ itemHeight = heights.get(nodes[idx].id) ?? iH;
527
+ } else {
528
+ const rawIndex = Math.floor(adjustedContentY / iH);
529
+ clampedIndex = Math.max(0, Math.min(rawIndex, nodes.length - 1));
530
+ itemTop = clampedIndex * iH;
531
+ itemHeight = iH;
532
+ }
328
533
  let targetNode = nodes[clampedIndex];
329
534
  if (!targetNode) return;
330
535
 
331
- // Determine zone within item
332
- const positionInItem = (adjustedContentY - clampedIndex * iH) / iH;
536
+ // Determine zone within item. Sticky zones: while a zone is active for
537
+ // this same row, its boundaries shift outward so natural finger tremor
538
+ // at a zone edge can't flip the position (and with it the indicator
539
+ // and the overlay's indent) back and forth every few frames.
540
+ const positionInItem = (adjustedContentY - itemTop) / itemHeight;
541
+ let aboveBound = 0.25;
542
+ let belowBound = 0.75;
543
+ const prevZone = prevDropTargetRef.current;
544
+ if (prevZone && prevZone.targetIndex === clampedIndex) {
545
+ if (prevZone.position === "above") {
546
+ aboveBound += ZONE_STICKINESS;
547
+ } else if (prevZone.position === "below") {
548
+ belowBound -= ZONE_STICKINESS;
549
+ } else {
550
+ aboveBound -= ZONE_STICKINESS;
551
+ belowBound += ZONE_STICKINESS;
552
+ }
553
+ }
333
554
  let position;
334
- if (positionInItem < 0.25) {
555
+ if (positionInItem < aboveBound) {
335
556
  position = "above";
336
- } else if (positionInItem > 0.75) {
557
+ } else if (positionInItem > belowBound) {
337
558
  position = "below";
338
559
  } else {
339
560
  position = "inside";
@@ -388,10 +609,7 @@ export function useDragDrop(params) {
388
609
  shallowLevel = 0;
389
610
  }
390
611
  if (isCliff) {
391
- // Midpoint of the item's visible content area
392
- const itemLeftEdge = currentLevel * indentationMultiplierRef.current;
393
- const threshold = itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
394
- if (fingerLocalX < threshold) {
612
+ if (fingerLeftOfLevelThreshold(currentLevel, fingerLocalX)) {
395
613
  // User wants the shallow level
396
614
  if (clampedIndex < nodes.length - 1) {
397
615
  // Non-last item: switch to "above" on the next (shallower) node
@@ -401,9 +619,6 @@ export function useDragDrop(params) {
401
619
  position = "above";
402
620
  } else {
403
621
  // Last item: find ancestor at shallow level, target it with "below"
404
- const {
405
- childToParentMap
406
- } = getTreeViewStore(storeId).getState();
407
622
  let ancestorId = targetNode.id;
408
623
  let walkLevel = currentLevel;
409
624
  while (walkLevel > shallowLevel) {
@@ -425,9 +640,7 @@ export function useDragDrop(params) {
425
640
  const prevLevel = prevNode?.level ?? 0;
426
641
  const currentLevel = targetNode.level ?? 0;
427
642
  if (prevNode && prevLevel > currentLevel) {
428
- const itemLeftEdge = prevLevel * indentationMultiplierRef.current;
429
- const threshold = itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
430
- if (fingerLocalX >= threshold) {
643
+ if (!fingerLeftOfLevelThreshold(prevLevel, fingerLocalX)) {
431
644
  clampedIndex = clampedIndex - 1;
432
645
  targetNode = prevNode;
433
646
  position = "below";
@@ -440,8 +653,7 @@ export function useDragDrop(params) {
440
653
  // junction but semantically inserts as a sibling after the entire
441
654
  // subtree. Convert to "inside" which is clearer.
442
655
  if (position === "below" && canDropInsideTarget) {
443
- const expandedSet = getTreeViewStore(storeId).getState().expanded;
444
- if (targetNode.children?.length && expandedSet.has(targetNode.id)) {
656
+ if (targetNode.children?.length && expanded.has(targetNode.id)) {
445
657
  position = "inside";
446
658
  }
447
659
  }
@@ -469,27 +681,29 @@ export function useDragDrop(params) {
469
681
  targetIndex: clampedIndex,
470
682
  position
471
683
  };
472
- const indicatorTop = fingerLocalY - grabOffsetYRef.current;
473
684
 
474
- // Validity check
475
- const store = getTreeViewStore(storeId);
476
- const {
477
- invalidDragTargetIds,
478
- draggedNodeId,
479
- expanded
480
- } = store.getState();
685
+ // Validity check (store snapshot taken once at the top of this frame).
686
+ // The actual move uses the LOGICAL target when a cliff override is active
687
+ // (the visual indicator stays on targetNode). Validate canDrop / maxDepth /
688
+ // invalid-target against this effective target so consumer rules can't be
689
+ // bypassed by the visual/logical split at level cliffs.
690
+ const effectiveTargetId = logicalTargetId ?? targetNode.id;
691
+ const effectivePosition = logicalPosition ?? position;
692
+ const effectiveTargetNode = logicalTargetId !== null ? nodeMap.get(effectiveTargetId) ?? targetNode : targetNode;
481
693
 
482
694
  // maxDepth check for above/below (sibling) positions
483
695
  let maxDepthValid = true;
484
- if (maxDepthRef.current !== undefined && (position === "above" || position === "below")) {
485
- const targetLevel = targetNode.level ?? 0;
696
+ if (maxDepthRef.current !== undefined && (effectivePosition === "above" || effectivePosition === "below")) {
697
+ // At a cliff the sibling lands at visualDropLevel; otherwise the
698
+ // effective target's own level.
699
+ const targetLevel = visualDropLevel ?? effectiveTargetNode.level ?? 0;
486
700
  const deepest = targetLevel + draggedSubtreeDepthRef.current;
487
701
  if (deepest > maxDepthRef.current) maxDepthValid = false;
488
702
  }
489
- const isValid = targetNode.id !== draggedNodeId && !invalidDragTargetIds.has(targetNode.id) && maxDepthValid && (!canDropRef.current || canDropRef.current(draggedNodeRef.current, targetNode, position));
703
+ const isValid = effectiveTargetId !== draggedNodeId && !invalidDragTargetIds.has(effectiveTargetId) && maxDepthValid && (!canDropRef.current || canDropRef.current(draggedNodeRef.current, effectiveTargetNode, effectivePosition));
490
704
 
491
705
  // --- Auto-expand: if hovering "inside" a collapsed expandable node ---
492
- if (isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
706
+ if (autoExpandRef.current && isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
493
707
  if (autoExpandTargetRef.current !== targetNode.id) {
494
708
  // New hover target - start timer
495
709
  cancelAutoExpandTimer();
@@ -516,41 +730,79 @@ export function useDragDrop(params) {
516
730
  // the effective level comes from the visual drop level, not the target node.
517
731
  const effectiveLevel = isValid ? visualDropLevel !== null ? visualDropLevel // "below" ancestor → sibling at that level
518
732
  : position === "inside" ? (targetNode.level ?? 0) + 1 : targetNode.level ?? 0 : draggedLevel;
519
- if (effectiveLevel !== prevEffectiveLevelRef.current) {
520
- const prevLevel = prevEffectiveLevelRef.current ?? effectiveLevel;
521
- prevEffectiveLevelRef.current = effectiveLevel;
522
- setEffectiveDropLevel(effectiveLevel);
523
-
524
- // The level prop change snaps the content to the correct indent.
525
- // Counteract that visual jump with an initial translateX offset,
526
- // then spring to 0 for a smooth "magnetic snap" transition.
527
- if (prevLevel !== effectiveLevel) {
528
- overlayX.setValue((prevLevel - effectiveLevel) * indentationMultiplierRef.current);
733
+ // The overlay's content keeps the dragged node's original indentation
734
+ // for the whole drag (no re-render); the indent shift is expressed
735
+ // purely as a translateX toward the effective level. Springs retarget
736
+ // mid-flight, so rapid level changes glide instead of jumping - the
737
+ // old scheme (re-render padding + counteracting spring-to-0) split the
738
+ // move across React and the native driver and flickered when the two
739
+ // landed on different frames.
740
+ // A new level must additionally hold for LEVEL_SETTLE_MS before the
741
+ // overlay moves, so the indent doesn't chase every row the finger
742
+ // merely passes through.
743
+ const applyLevel = nextLevel => {
744
+ prevEffectiveLevelRef.current = nextLevel;
745
+ const targetX = (nextLevel - (draggedNodeRef.current?.level ?? 0)) * indentationMultiplierRef.current;
746
+ if (magneticSnapRef.current) {
529
747
  Animated.spring(overlayX, {
530
- toValue: 0,
748
+ toValue: targetX,
531
749
  useNativeDriver: true,
532
750
  speed: 40,
533
751
  bounciness: 4
534
752
  }).start();
753
+ } else {
754
+ overlayX.setValue(targetX);
535
755
  }
756
+ };
757
+ if (autoScrollSpeedRef.current !== 0) {
758
+ // Auto-scroll is streaming rows of arbitrary depth under a
759
+ // stationary finger; chasing their levels would dart the overlay
760
+ // sideways and back. Hold the current indent (and drop any pending
761
+ // shift) until the scroll settles - the drop indicator itself
762
+ // keeps tracking via the store.
763
+ cancelLevelSettleTimer();
764
+ } else if (effectiveLevel === prevEffectiveLevelRef.current) {
765
+ // Back at the settled level - drop any pending level the finger
766
+ // only transited through.
767
+ cancelLevelSettleTimer();
768
+ } else if (pendingLevelRef.current !== effectiveLevel) {
769
+ // New candidate level - (re)start the settle timer.
770
+ cancelLevelSettleTimer();
771
+ pendingLevelRef.current = effectiveLevel;
772
+ levelSettleTimerRef.current = setTimeout(() => {
773
+ levelSettleTimerRef.current = null;
774
+ const settled = pendingLevelRef.current;
775
+ pendingLevelRef.current = null;
776
+ if (settled !== null && isDraggingRef.current) applyLevel(settled);
777
+ }, LEVEL_SETTLE_MS);
536
778
  }
537
779
  const newTarget = {
538
780
  targetNodeId: targetNode.id,
539
781
  targetIndex: clampedIndex,
540
782
  position,
541
- isValid,
542
- targetLevel: targetNode.level ?? 0,
543
- indicatorTop
783
+ isValid
544
784
  };
545
785
 
546
- // Update the store so each Node can render its own indicator
547
- if (isValid) {
548
- store.getState().updateDropTarget(targetNode.id, position, visualDropLevel);
549
- } else {
550
- store.getState().updateDropTarget(null, null);
786
+ // Update the store so each Node can render its own indicator.
787
+ // The indicator always tracks the VISUAL target/position (what the user
788
+ // sees), even when the actual move target is a logical ancestor.
789
+ // Guard the write so it only fires when the indicator actually changes -
790
+ // calculateDropTarget runs every pan frame and each store write re-runs
791
+ // every mounted Node's selector.
792
+ const nextDropNodeId = isValid ? targetNode.id : null;
793
+ const nextDropPosition = isValid ? position : null;
794
+ const nextDropLevel = isValid ? visualDropLevel : null;
795
+ const lastStore = lastStoreDropTargetRef.current;
796
+ if (!lastStore || lastStore.nodeId !== nextDropNodeId || lastStore.position !== nextDropPosition || lastStore.level !== nextDropLevel) {
797
+ updateDropTarget(nextDropNodeId, nextDropPosition, nextDropLevel);
798
+ lastStoreDropTargetRef.current = {
799
+ nodeId: nextDropNodeId,
800
+ position: nextDropPosition,
801
+ level: nextDropLevel
802
+ };
551
803
  }
552
804
 
553
- // Keep ref in sync (used by handleDragEnd to avoid setState-during-render)
805
+ // Keep the commit ref in sync (read by handleDragEnd).
554
806
  // When a logical target exists (e.g. ancestor at a cliff), use it
555
807
  // for the actual move while the visual indicator stays on the current node.
556
808
  if (logicalTargetId !== null && logicalPosition !== null) {
@@ -562,24 +814,22 @@ export function useDragDrop(params) {
562
814
  } else {
563
815
  dropTargetRef.current = newTarget;
564
816
  }
565
- setDropTarget(prevTarget => {
566
- if (prevTarget?.targetNodeId === newTarget.targetNodeId && prevTarget?.position === newTarget.position && prevTarget?.isValid === newTarget.isValid && prevTarget?.indicatorTop === newTarget.indicatorTop) {
567
- return prevTarget;
568
- }
569
- return newTarget;
570
- });
571
- }, [storeId, cancelAutoExpandTimer, overlayX]);
817
+ }, [storeId, cancelAutoExpandTimer, cancelLevelSettleTimer, fingerLeftOfLevelThreshold, overlayX, itemHeightsRef]);
818
+ calculateDropTargetRef.current = calculateDropTarget;
572
819
 
573
820
  // --- Handle drag end ---
574
- const handleDragEnd = useCallback((fingerPageY, fingerPageX) => {
821
+ const handleDragEnd = useCallback((fingerPageY, fingerPageX, cancel = false) => {
575
822
  stopAutoScroll();
576
823
  cancelLongPressTimer();
577
824
  cancelAutoExpandTimer();
578
- prevDropTargetRef.current = null;
579
825
  if (!isDraggingRef.current) return;
580
826
  isDraggingRef.current = false;
581
827
 
582
- // Recalculate drop target at final position if we have coords
828
+ // Recalculate drop target at final position if we have coords. Hysteresis
829
+ // (prevDropTargetRef) is intentionally NOT cleared first, so this final
830
+ // commit frame resolves to the same target the user last saw the indicator
831
+ // on (clearing it here would let the release snap to the other side of an
832
+ // ambiguous same-level boundary). It is reset in the ref-cleanup below.
583
833
  if (fingerPageY !== undefined) {
584
834
  calculateDropTarget(fingerPageY, fingerPageX ?? 0);
585
835
  }
@@ -588,80 +838,99 @@ export function useDragDrop(params) {
588
838
  // Without this, the timer fires after drag ends and toggles the target back to collapsed.
589
839
  cancelAutoExpandTimer();
590
840
 
591
- // Read current drop target from ref via a small delay to ensure
592
- // the last setDropTarget has been processed
593
- // We use the current dropTarget state via a callback
594
- // Read drop target from ref (avoids nesting Zustand updates inside React state updaters)
841
+ // Read the final drop target from the ref (the per-frame calculation
842
+ // keeps it current; there is no React state to wait on).
595
843
  const currentTarget = dropTargetRef.current;
596
- const droppedNodeId = draggedNodeIdRef.current;
597
- if (currentTarget?.isValid && droppedNodeId !== null) {
598
- const store = getTreeViewStore(storeId);
599
- const currentData = store.getState().initialTreeViewData;
600
- const newData = moveTreeNode(currentData, droppedNodeId, currentTarget.targetNodeId, currentTarget.position);
601
-
602
- // Update store directly (preserves checked/expanded)
603
- store.getState().updateInitialTreeViewData(newData);
604
- initializeNodeMaps(storeId, newData);
605
-
606
- // Recalculate checked/indeterminate states for all parents
607
- // since the tree structure changed
608
- recalculateCheckedStates(storeId);
609
-
610
- // If dropped "inside" a node, expand it so the dropped node is visible
611
- if (currentTarget.position === "inside") {
612
- expandNodes(storeId, [currentTarget.targetNodeId]);
613
- }
614
-
615
- // Expand ancestors of the dropped node so it's visible
616
- expandNodes(storeId, [droppedNodeId], true);
617
-
618
- // Set internal data ref to prevent useDeepCompareEffect
619
- // from reinitializing
620
- internalDataRef.current = newData;
621
-
622
- // Notify consumer
844
+ const droppedNodeId = draggedNodeRef.current?.id ?? null;
845
+ const store = getTreeViewStore(storeId);
846
+ const {
847
+ initialTreeViewData: currentData,
848
+ nodeMap,
849
+ childToParentMap
850
+ } = store.getState();
851
+ // Capture the node's position before the move for the MoveResult delta
852
+ // (the maps still describe the pre-move tree here).
853
+ const prevPosition = droppedNodeId !== null ? findNodePositionFromMaps(currentData, nodeMap, childToParentMap, droppedNodeId) : null;
854
+ // Compute the move up front so an invalid move (moveTreeNode returns the
855
+ // same reference) or a positional no-op (node re-dropped where it already
856
+ // sits) is treated as a cancel rather than a spurious onDragEnd.
857
+ const newData = !cancel && currentTarget?.isValid && droppedNodeId !== null ? moveTreeNode(currentData, droppedNodeId, currentTarget.targetNodeId, currentTarget.position) : currentData;
858
+ const newPosition = newData !== currentData && droppedNodeId !== null ? findNodePosition(newData, droppedNodeId) : null;
859
+ const isNoOpMove = newPosition !== null && prevPosition !== null && newPosition.parentId === prevPosition.parentId && newPosition.index === prevPosition.index;
860
+ if (!cancel && currentTarget?.isValid && droppedNodeId !== null && newData !== currentData && !isNoOpMove) {
861
+ // Commit the move to the store (preserves checked/expanded;
862
+ // shared with the programmatic moveNode path).
863
+ applyMoveToStore(storeId, newData, droppedNodeId, currentTarget.targetNodeId, currentTarget.position);
864
+
865
+ // Notify the consumer with a lightweight move delta. The reordered
866
+ // tree lives in the store; TreeView's wrapped onDragEnd captures it
867
+ // for the reinit-skip, and consumers can read it via getTreeData().
623
868
  onDragEndRef.current?.({
624
869
  draggedNodeId: droppedNodeId,
625
870
  targetNodeId: currentTarget.targetNodeId,
626
871
  position: currentTarget.position,
627
- newTreeData: newData
872
+ previousParentId: prevPosition?.parentId ?? null,
873
+ previousIndex: prevPosition?.index ?? -1,
874
+ newParentId: newPosition?.parentId ?? null,
875
+ newIndex: newPosition?.index ?? -1
628
876
  });
629
877
 
878
+ // Announce the result for screen readers (no-op without assistive tech).
879
+ AccessibilityInfo.announceForAccessibility?.(`Moved ${draggedNodeRef.current?.name ?? "node"}`);
880
+
630
881
  // Auto-scroll to the dropped node unless disabled by the user.
631
882
  const scrollOpts = autoScrollToDroppedNode;
632
883
  const scrollEnabled = scrollOpts === undefined || scrollOpts === true || typeof scrollOpts === "object" && scrollOpts.enabled !== false;
633
884
  if (scrollEnabled) {
634
- const custom = typeof scrollOpts === "object" ? scrollOpts : {};
635
- setTimeout(() => {
636
- scrollToNodeHandlerRef.current?.scrollToNodeID({
637
- nodeId: droppedNodeId,
638
- animated: custom.animated ?? true,
639
- viewPosition: custom.viewPosition ?? 0.5,
640
- viewOffset: custom.viewOffset
641
- });
642
- }, 0);
885
+ // The drop lands under the finger, so the node is almost always
886
+ // already on-screen; scrolling anyway would yank the list (worst
887
+ // when dropping near the very bottom, where centering the node
888
+ // scrolls the list back up). Estimate where the dropped row ends
889
+ // up in the post-move flattened list and only scroll when it
890
+ // actually sits outside the viewport.
891
+ const preMoveNodes = flattenedNodesRef.current;
892
+ const draggedIdx = preMoveNodes.findIndex(n => n.id === droppedNodeId);
893
+ const iH = itemHeightRef.current;
894
+ let finalIndex = currentTarget.targetIndex;
895
+ // "below"/"inside" both land the node right after the target row.
896
+ if (currentTarget.position !== "above") finalIndex += 1;
897
+ // Removing the dragged row from above the target shifts rows up one.
898
+ if (draggedIdx !== -1 && draggedIdx < currentTarget.targetIndex) {
899
+ finalIndex -= 1;
900
+ }
901
+ const rowTop = headerOffsetRef.current + finalIndex * iH - scrollOffsetRef.current;
902
+ const isOnScreen = rowTop >= 0 && rowTop + iH <= containerHeightRef.current;
903
+ if (!isOnScreen) {
904
+ scrollMovedNodeIntoView(scrollToNodeHandlerRef, droppedNodeId, scrollOpts);
905
+ }
643
906
  }
644
907
  } else if (droppedNodeId !== null) {
645
- // Drag ended without a valid drop - notify consumer
908
+ // Drag ended without a valid drop - a cancel must not mutate state,
909
+ // so restore the expansion that drag start force-collapsed.
910
+ if (wasDraggedNodeExpandedRef.current) {
911
+ expandNodes(storeId, [droppedNodeId]);
912
+ }
646
913
  onDragCancelRef.current?.({
647
914
  draggedNodeId: droppedNodeId
648
915
  });
916
+ AccessibilityInfo.announceForAccessibility?.(`Cancelled moving ${draggedNodeRef.current?.name ?? "node"}`);
649
917
  }
650
918
 
651
919
  // Collapse auto-expanded nodes that aren't ancestors of the drop target
652
920
  if (autoExpandedDuringDragRef.current.size > 0) {
653
- const store3 = getTreeViewStore(storeId);
921
+ // Re-read: the maps were rebuilt if a move committed above.
654
922
  const {
655
- childToParentMap
656
- } = store3.getState();
923
+ childToParentMap: postMoveParentMap
924
+ } = store.getState();
657
925
 
658
- // Collect ancestors of the drop target (keep these expanded)
926
+ // Collect ancestors of the drop target (keep these expanded).
927
+ // On cancel, retain none so every auto-expanded node collapses back.
659
928
  const ancestorIds = new Set();
660
- if (currentTarget?.isValid) {
929
+ if (!cancel && currentTarget?.isValid) {
661
930
  let walkId = currentTarget.targetNodeId;
662
931
  while (walkId !== undefined) {
663
932
  ancestorIds.add(walkId);
664
- walkId = childToParentMap.get(walkId);
933
+ walkId = postMoveParentMap.get(walkId);
665
934
  }
666
935
  }
667
936
 
@@ -679,29 +948,38 @@ export function useDragDrop(params) {
679
948
  }
680
949
 
681
950
  // Clear drag state
682
- const store2 = getTreeViewStore(storeId);
683
- store2.getState().updateDraggedNodeId(null);
684
- store2.getState().updateInvalidDragTargetIds(new Set());
685
- store2.getState().updateDropTarget(null, null);
951
+ resetDragStoreState();
686
952
 
687
953
  // Reset all refs
688
954
  overlayX.setValue(0);
689
955
  prevEffectiveLevelRef.current = null;
956
+ cancelLevelSettleTimer();
957
+ prevDropTargetRef.current = null;
690
958
  dropTargetRef.current = null;
959
+ lastStoreDropTargetRef.current = null;
691
960
  draggedNodeRef.current = null;
692
- draggedNodeIdRef.current = null;
693
- draggedNodeIndexRef.current = -1;
694
- setDropTarget(null);
961
+ wasDraggedNodeExpandedRef.current = false;
695
962
  setIsDragging(false);
696
963
  setDraggedNode(null);
697
964
  },
698
965
  // eslint-disable-next-line react-hooks/exhaustive-deps
699
- [storeId, stopAutoScroll, cancelLongPressTimer, cancelAutoExpandTimer, calculateDropTarget, internalDataRef]);
966
+ [storeId, stopAutoScroll, cancelLongPressTimer, cancelAutoExpandTimer, calculateDropTarget]);
967
+
968
+ // --- onScroll for the host list (see UseDragDropReturn.handleScroll) ---
969
+ const handleScroll = useCallback(event => {
970
+ if (!isDraggingRef.current) {
971
+ scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
972
+ }
973
+ // Scrolling means this touch isn't a long-press
974
+ cancelLongPressTimer();
975
+ }, [cancelLongPressTimer]);
700
976
 
701
977
  // --- Handle node touch end ---
702
978
  // If the PanResponder never captured the gesture (no movement after long
703
979
  // press fired), end the drag here so the node doesn't stay "lifted".
704
980
  const handleNodeTouchEnd = useCallback(() => {
981
+ // cancelLongPressTimer also aborts any drag still awaiting its
982
+ // measureInWindow callback (clears pendingDragRef).
705
983
  cancelLongPressTimer();
706
984
  if (isDraggingRef.current && !panResponderActiveRef.current) {
707
985
  handleDragEnd();
@@ -717,21 +995,25 @@ export function useDragDrop(params) {
717
995
  onPanResponderGrant: () => {
718
996
  panResponderActiveRef.current = true;
719
997
  },
998
+ // While a drag is active, refuse to hand the gesture to an ancestor
999
+ // responder (e.g. a parent ScrollView or swipe navigator) so a slight
1000
+ // horizontal drift can't terminate the drag mid-flight.
1001
+ onPanResponderTerminationRequest: () => !isDraggingRef.current,
720
1002
  onPanResponderMove: evt => {
721
1003
  if (!isDraggingRef.current) return;
722
1004
  const fingerPageY = evt.nativeEvent.pageY;
1005
+ lastFingerPageYRef.current = fingerPageY;
1006
+ lastFingerPageXRef.current = evt.nativeEvent.pageX;
723
1007
  const fingerLocalY = fingerPageY - containerPageYRef.current;
724
1008
 
725
1009
  // Update overlay position (with configurable offset)
726
- const overlayLocalY = fingerLocalY - grabOffsetYRef.current + (dragOverlayOffsetRef.current + PLATFORM_OVERLAY_Y_CORRECTION) * itemHeightRef.current;
727
- overlayY.setValue(overlayLocalY);
1010
+ overlayY.setValue(computeOverlayLocalY(fingerLocalY));
728
1011
 
729
1012
  // Calculate drop target (horizontal position used at level cliffs)
730
1013
  calculateDropTarget(fingerPageY, evt.nativeEvent.pageX);
731
1014
 
732
- // Auto-scroll at edges - use delta-based position relative to container
733
- const fingerInContainer = initialFingerContainerYRef.current + (fingerPageY - initialFingerPageYRef.current);
734
- updateAutoScroll(fingerInContainer);
1015
+ // Auto-scroll at edges, from the finger's container-local position
1016
+ updateAutoScroll(fingerLocalY);
735
1017
  },
736
1018
  onPanResponderRelease: evt => {
737
1019
  panResponderActiveRef.current = false;
@@ -739,7 +1021,9 @@ export function useDragDrop(params) {
739
1021
  },
740
1022
  onPanResponderTerminate: () => {
741
1023
  panResponderActiveRef.current = false;
742
- handleDragEnd();
1024
+ // A terminate (parent scroll steals the gesture, app backgrounds, etc.)
1025
+ // cancels the drop rather than committing at the last hovered target.
1026
+ handleDragEnd(undefined, undefined, true);
743
1027
  }
744
1028
  })).current;
745
1029
 
@@ -748,29 +1032,29 @@ export function useDragDrop(params) {
748
1032
  return () => {
749
1033
  cancelLongPressTimer();
750
1034
  cancelAutoExpandTimer();
1035
+ cancelLevelSettleTimer();
751
1036
  stopAutoScroll();
1037
+ pendingDragRef.current = false;
1038
+ lastStoreDropTargetRef.current = null;
752
1039
  if (isDraggingRef.current) {
753
1040
  isDraggingRef.current = false;
754
- const store = getTreeViewStore(storeId);
755
- store.getState().updateDraggedNodeId(null);
756
- store.getState().updateInvalidDragTargetIds(new Set());
757
- store.getState().updateDropTarget(null, null);
1041
+ resetDragStoreState();
758
1042
  }
759
1043
  };
760
- }, [storeId, cancelLongPressTimer, cancelAutoExpandTimer, stopAutoScroll]);
1044
+ }, [cancelLongPressTimer, cancelAutoExpandTimer, cancelLevelSettleTimer, stopAutoScroll, resetDragStoreState]);
761
1045
  return {
762
1046
  panResponder,
763
1047
  overlayY,
764
1048
  overlayX,
765
1049
  isDragging,
766
1050
  draggedNode,
767
- dropTarget,
768
- effectiveDropLevel,
769
1051
  handleNodeTouchStart,
770
1052
  handleNodeTouchEnd,
771
1053
  cancelLongPressTimer,
1054
+ handleScroll,
772
1055
  scrollOffsetRef,
773
- headerOffsetRef
1056
+ headerOffsetRef,
1057
+ containerHeightRef
774
1058
  };
775
1059
  }
776
1060
  //# sourceMappingURL=useDragDrop.js.map