react-native-tree-multi-select 3.0.0-beta.4 → 3.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +64 -30
  2. package/lib/module/TreeView.js +130 -24
  3. package/lib/module/TreeView.js.map +1 -1
  4. package/lib/module/components/DragOverlay.js +19 -2
  5. package/lib/module/components/DragOverlay.js.map +1 -1
  6. package/lib/module/components/NodeList.js +83 -31
  7. package/lib/module/components/NodeList.js.map +1 -1
  8. package/lib/module/constants/treeView.constants.js +5 -0
  9. package/lib/module/constants/treeView.constants.js.map +1 -1
  10. package/lib/module/helpers/moveTreeNode.helper.js +175 -47
  11. package/lib/module/helpers/moveTreeNode.helper.js.map +1 -1
  12. package/lib/module/helpers/toggleCheckbox.helper.js +6 -13
  13. package/lib/module/helpers/toggleCheckbox.helper.js.map +1 -1
  14. package/lib/module/helpers/treeNode.helper.js +49 -0
  15. package/lib/module/helpers/treeNode.helper.js.map +1 -1
  16. package/lib/module/hooks/useDragDrop.js +486 -216
  17. package/lib/module/hooks/useDragDrop.js.map +1 -1
  18. package/lib/module/hooks/useScrollToNode.js +18 -1
  19. package/lib/module/hooks/useScrollToNode.js.map +1 -1
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/store/treeView.store.js +7 -0
  22. package/lib/module/store/treeView.store.js.map +1 -1
  23. package/lib/module/types/dragDrop.types.js +0 -2
  24. package/lib/typescript/src/TreeView.d.ts.map +1 -1
  25. package/lib/typescript/src/components/DragOverlay.d.ts.map +1 -1
  26. package/lib/typescript/src/components/NodeList.d.ts.map +1 -1
  27. package/lib/typescript/src/constants/treeView.constants.d.ts +4 -0
  28. package/lib/typescript/src/constants/treeView.constants.d.ts.map +1 -1
  29. package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts +32 -0
  30. package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts.map +1 -1
  31. package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts.map +1 -1
  32. package/lib/typescript/src/helpers/treeNode.helper.d.ts +15 -0
  33. package/lib/typescript/src/helpers/treeNode.helper.d.ts.map +1 -1
  34. package/lib/typescript/src/hooks/useDragDrop.d.ts +30 -7
  35. package/lib/typescript/src/hooks/useDragDrop.d.ts.map +1 -1
  36. package/lib/typescript/src/hooks/useScrollToNode.d.ts +10 -0
  37. package/lib/typescript/src/hooks/useScrollToNode.d.ts.map +1 -1
  38. package/lib/typescript/src/index.d.ts +3 -3
  39. package/lib/typescript/src/index.d.ts.map +1 -1
  40. package/lib/typescript/src/store/treeView.store.d.ts +6 -0
  41. package/lib/typescript/src/store/treeView.store.d.ts.map +1 -1
  42. package/lib/typescript/src/types/dragDrop.types.d.ts +24 -12
  43. package/lib/typescript/src/types/dragDrop.types.d.ts.map +1 -1
  44. package/lib/typescript/src/types/treeView.types.d.ts +78 -12
  45. package/lib/typescript/src/types/treeView.types.d.ts.map +1 -1
  46. package/package.json +2 -2
  47. package/src/TreeView.tsx +158 -26
  48. package/src/components/DragOverlay.tsx +32 -3
  49. package/src/components/NodeList.tsx +84 -29
  50. package/src/constants/treeView.constants.ts +6 -1
  51. package/src/helpers/moveTreeNode.helper.ts +160 -43
  52. package/src/helpers/toggleCheckbox.helper.ts +6 -13
  53. package/src/helpers/treeNode.helper.ts +52 -1
  54. package/src/hooks/useDragDrop.ts +597 -250
  55. package/src/hooks/useScrollToNode.ts +22 -1
  56. package/src/index.tsx +5 -1
  57. package/src/store/treeView.store.ts +6 -0
  58. package/src/types/dragDrop.types.ts +25 -13
  59. package/src/types/treeView.types.ts +82 -11
  60. package/lib/module/components/DropIndicator.js +0 -79
  61. package/lib/module/components/DropIndicator.js.map +0 -1
  62. package/lib/typescript/src/components/DropIndicator.d.ts +0 -12
  63. package/lib/typescript/src/components/DropIndicator.d.ts.map +0 -1
  64. package/src/components/DropIndicator.tsx +0 -95
@@ -1,11 +1,36 @@
1
1
  "use strict";
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
- import { Animated, PanResponder } 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";
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;
9
34
  export function useDragDrop(params) {
10
35
  const {
11
36
  storeId,
@@ -19,39 +44,60 @@ export function useDragDrop(params) {
19
44
  longPressDuration,
20
45
  autoScrollThreshold,
21
46
  autoScrollSpeed,
22
- internalDataRef,
23
47
  measuredItemHeightRef,
48
+ contentHeightRef,
49
+ itemHeightsRef,
24
50
  dragOverlayOffset,
51
+ overlayYCorrection,
25
52
  autoExpandDelay,
53
+ autoExpand = true,
54
+ magneticSnap = true,
26
55
  indentationMultiplier,
27
56
  canDrop: canDropCallback,
28
57
  maxDepth,
29
58
  canNodeHaveChildren,
30
- canDrag
59
+ canDrag,
60
+ scrollToNodeHandlerRef,
61
+ autoScrollToDroppedNode
31
62
  } = params;
32
63
 
33
64
  // --- Refs for mutable state (no stale closures in PanResponder) ---
34
65
  const isDraggingRef = useRef(false);
35
66
  const draggedNodeRef = useRef(null);
36
- const draggedNodeIdRef = useRef(null);
37
- 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);
38
70
  const longPressTimerRef = useRef(null);
39
71
  const containerPageXRef = useRef(0);
40
72
  const containerPageYRef = useRef(0);
73
+ const containerWidthRef = useRef(0);
41
74
  const containerHeightRef = useRef(0);
42
75
  const grabOffsetYRef = useRef(0);
43
76
  const scrollOffsetRef = useRef(0);
44
77
  const headerOffsetRef = useRef(0);
45
- const itemHeightRef = useRef(36);
78
+ const itemHeightRef = useRef(defaultItemHeight);
46
79
  const overlayY = useRef(new Animated.Value(0)).current;
47
80
  const overlayX = useRef(new Animated.Value(0)).current;
48
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);
49
86
  const autoScrollRAFRef = useRef(null);
50
87
  const autoScrollSpeedRef = useRef(0);
51
88
 
52
- // Delta-based auto-scroll: avoids unreliable containerPageY
53
- const initialFingerPageYRef = useRef(0);
54
- 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);
55
101
 
56
102
  // Auto-expand timer for hovering over collapsed nodes
57
103
  const autoExpandTimerRef = useRef(null);
@@ -62,8 +108,16 @@ export function useDragDrop(params) {
62
108
  // Tracks whether the PanResponder has captured the current gesture
63
109
  const panResponderActiveRef = useRef(false);
64
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
+
65
116
  // Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
66
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);
67
121
 
68
122
  // Depth of the dragged subtree (computed once at drag start, used for maxDepth check)
69
123
  const draggedSubtreeDepthRef = useRef(0);
@@ -89,32 +143,49 @@ export function useDragDrop(params) {
89
143
  // Keep config values current for PanResponder closures
90
144
  const dragOverlayOffsetRef = useRef(dragOverlayOffset);
91
145
  dragOverlayOffsetRef.current = dragOverlayOffset;
146
+ const overlayYCorrectionRef = useRef(overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION);
147
+ overlayYCorrectionRef.current = overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION;
92
148
  const autoScrollThresholdRef = useRef(autoScrollThreshold);
93
149
  autoScrollThresholdRef.current = autoScrollThreshold;
94
150
  const autoScrollSpeedParamRef = useRef(autoScrollSpeed);
95
151
  autoScrollSpeedParamRef.current = autoScrollSpeed;
96
152
  const autoExpandDelayRef = useRef(autoExpandDelay);
97
153
  autoExpandDelayRef.current = autoExpandDelay;
154
+ const autoExpandRef = useRef(autoExpand);
155
+ autoExpandRef.current = autoExpand;
156
+ const magneticSnapRef = useRef(magneticSnap);
157
+ magneticSnapRef.current = magneticSnap;
98
158
  const indentationMultiplierRef = useRef(indentationMultiplier);
99
159
  indentationMultiplierRef.current = indentationMultiplier;
100
160
  const maxDepthRef = useRef(maxDepth);
101
161
  maxDepthRef.current = maxDepth;
102
162
 
103
- // --- 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) ---
104
164
  const [isDragging, setIsDragging] = useState(false);
105
165
  const [draggedNode, setDraggedNode] = useState(null);
106
- const [dropTarget, setDropTarget] = useState(null);
107
- const [effectiveDropLevel, setEffectiveDropLevel] = useState(0);
108
166
 
109
- // 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.
110
172
  const dropTargetRef = useRef(null);
111
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
+
112
179
  // --- Long press timer ---
113
180
  const cancelLongPressTimer = useCallback(() => {
114
181
  if (longPressTimerRef.current) {
115
182
  clearTimeout(longPressTimerRef.current);
116
183
  longPressTimerRef.current = null;
117
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;
118
189
  }, []);
119
190
 
120
191
  // --- Get all descendant IDs of a node ---
@@ -140,27 +211,41 @@ export function useDragDrop(params) {
140
211
 
141
212
  // --- Get the maximum depth of a subtree (0 for leaf nodes) ---
142
213
  const getSubtreeDepth = useCallback(nodeId => {
143
- const store = getTreeViewStore(storeId);
144
214
  const {
145
215
  nodeMap
146
- } = store.getState();
147
- const node = nodeMap.get(nodeId);
148
- if (!node?.children?.length) return 0;
149
- let max = 0;
150
- for (const child of node.children) {
151
- max = Math.max(max, 1 + getSubtreeDepth(child.id));
152
- }
153
- return max;
216
+ } = getTreeViewStore(storeId).getState();
217
+ return getSubtreeDepthFromMap(nodeMap, nodeId);
154
218
  }, [storeId]);
155
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
+
156
232
  // --- Initiate drag ---
157
233
  const initiateDrag = useCallback((nodeId, pageY, locationY, nodeIndex) => {
158
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;
159
238
  const container = containerRef.current;
160
239
  if (!container) return;
161
- container.measureInWindow((x, y, _w, h) => {
240
+ pendingDragRef.current = true;
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;
162
246
  containerPageXRef.current = x;
163
247
  containerPageYRef.current = y;
248
+ containerWidthRef.current = w;
164
249
  containerHeightRef.current = h;
165
250
 
166
251
  // Find the node in flattened list
@@ -168,25 +253,25 @@ export function useDragDrop(params) {
168
253
  const node = nodes[nodeIndex];
169
254
  if (!node) return;
170
255
 
171
- // Collapse node if expanded
256
+ // Collapse node if expanded (restored if the drag is cancelled)
172
257
  const store = getTreeViewStore(storeId);
173
258
  const {
174
259
  expanded
175
260
  } = store.getState();
261
+ wasDraggedNodeExpandedRef.current = false;
176
262
  if (expanded.has(nodeId) && node.children?.length) {
177
263
  handleToggleExpand(storeId, nodeId);
264
+ wasDraggedNodeExpandedRef.current = true;
178
265
  }
179
266
 
180
267
  // Store grab metadata
181
268
  grabOffsetYRef.current = locationY;
182
269
  draggedNodeRef.current = node;
183
- draggedNodeIdRef.current = nodeId;
184
- draggedNodeIndexRef.current = nodeIndex;
185
270
  draggedSubtreeDepthRef.current = getSubtreeDepth(nodeId);
186
271
 
187
272
  // Use measured item height if available, fall back to default
188
273
  const measured = measuredItemHeightRef.current;
189
- itemHeightRef.current = measured > 0 ? measured : 36;
274
+ itemHeightRef.current = measured > 0 ? measured : defaultItemHeight;
190
275
 
191
276
  // Calculate headerOffset dynamically:
192
277
  // fingerLocalY = pageY - containerPageY
@@ -194,13 +279,7 @@ export function useDragDrop(params) {
194
279
  // So: headerOffset = fingerLocalY + scrollOffset - grabOffsetY - nodeIndex * itemHeight
195
280
  const fingerLocalY = pageY - containerPageYRef.current;
196
281
  headerOffsetRef.current = fingerLocalY + scrollOffsetRef.current - locationY - nodeIndex * itemHeightRef.current;
197
-
198
- // Delta-based auto-scroll: compute finger's position in the container
199
- // from the node's known index (avoids unreliable containerPageY).
200
- const iH = itemHeightRef.current;
201
- const listHeaderHeight = listHeaderFooterPadding * 2;
202
- initialFingerPageYRef.current = pageY;
203
- initialFingerContainerYRef.current = listHeaderHeight + nodeIndex * iH - scrollOffsetRef.current + locationY;
282
+ lastFingerPageYRef.current = pageY;
204
283
 
205
284
  // Compute invalid targets (self + descendants)
206
285
  const descendants = getDescendantIds(nodeId);
@@ -211,26 +290,27 @@ export function useDragDrop(params) {
211
290
  store.getState().updateInvalidDragTargetIds(descendants);
212
291
 
213
292
  // Set overlay initial position (with configurable offset)
214
- const overlayLocalY = fingerLocalY - locationY + dragOverlayOffsetRef.current * itemHeightRef.current;
215
- overlayY.setValue(overlayLocalY);
293
+ overlayY.setValue(computeOverlayLocalY(fingerLocalY));
216
294
 
217
295
  // Reset magnetic overlay
218
296
  overlayX.setValue(0);
219
297
  prevEffectiveLevelRef.current = node.level ?? 0;
298
+ cancelLevelSettleTimer();
220
299
 
221
300
  // Set React state
222
301
  isDraggingRef.current = true;
223
302
  autoExpandedDuringDragRef.current.clear();
224
303
  setIsDragging(true);
225
304
  setDraggedNode(node);
226
- setEffectiveDropLevel(node.level ?? 0);
227
- setDropTarget(null);
228
305
 
229
306
  // Notify consumer that drag has started
230
307
  onDragStartRef.current?.({
231
308
  draggedNodeId: nodeId
232
309
  });
233
310
 
311
+ // Announce for screen readers (no-op when no assistive tech is active).
312
+ AccessibilityInfo.announceForAccessibility?.(`Picked up ${node.name}`);
313
+
234
314
  // Start auto-scroll loop
235
315
  startAutoScrollLoop();
236
316
  });
@@ -241,6 +321,15 @@ export function useDragDrop(params) {
241
321
  // --- Handle node touch start (long press detection) ---
242
322
  const handleNodeTouchStart = useCallback((nodeId, pageY, locationY, nodeIndex) => {
243
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;
244
333
 
245
334
  // Check if this node can be dragged
246
335
  if (canDragRef.current) {
@@ -256,24 +345,63 @@ export function useDragDrop(params) {
256
345
  longPressTimerRef.current = null;
257
346
  initiateDrag(nodeId, pageY, locationY, nodeIndex);
258
347
  }, longPressDuration);
259
- }, [dragEnabled, longPressDuration, cancelLongPressTimer, initiateDrag]);
348
+ }, [dragEnabled, storeId, longPressDuration, cancelLongPressTimer, initiateDrag]);
260
349
 
261
350
  // --- Auto-scroll ---
262
351
  const startAutoScrollLoop = useCallback(() => {
263
- 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 => {
264
363
  if (!isDraggingRef.current) return;
265
- if (autoScrollSpeedRef.current !== 0) {
266
- const newOffset = Math.max(0, scrollOffsetRef.current + autoScrollSpeedRef.current);
267
- scrollOffsetRef.current = newOffset;
268
- flashListRef.current?.scrollToOffset?.({
269
- offset: newOffset,
270
- animated: false
271
- });
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
+ }
272
400
  }
273
401
  autoScrollRAFRef.current = requestAnimationFrame(loop);
274
402
  };
275
403
  autoScrollRAFRef.current = requestAnimationFrame(loop);
276
- }, [flashListRef]);
404
+ }, [flashListRef, contentHeightRef]);
277
405
  const stopAutoScroll = useCallback(() => {
278
406
  if (autoScrollRAFRef.current !== null) {
279
407
  cancelAnimationFrame(autoScrollRAFRef.current);
@@ -283,16 +411,21 @@ export function useDragDrop(params) {
283
411
  }, []);
284
412
  const updateAutoScroll = useCallback(fingerInContainer => {
285
413
  const threshold = autoScrollThresholdRef.current;
286
- 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;
287
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.
288
421
  if (fingerInContainer < threshold) {
289
422
  // Scroll up
290
423
  const ratio = 1 - Math.max(0, fingerInContainer) / threshold;
291
- autoScrollSpeedRef.current = -maxSpeed * ratio;
424
+ autoScrollSpeedRef.current = -maxSpeed * Math.sqrt(ratio);
292
425
  } else if (fingerInContainer > containerH - threshold) {
293
426
  // Scroll down
294
427
  const ratio = 1 - Math.max(0, containerH - fingerInContainer) / threshold;
295
- autoScrollSpeedRef.current = maxSpeed * ratio;
428
+ autoScrollSpeedRef.current = maxSpeed * Math.sqrt(ratio);
296
429
  } else {
297
430
  autoScrollSpeedRef.current = 0;
298
431
  }
@@ -307,25 +440,121 @@ export function useDragDrop(params) {
307
440
  autoExpandTargetRef.current = null;
308
441
  }, []);
309
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
+
310
460
  // --- Calculate drop target ---
311
461
  const calculateDropTarget = useCallback((fingerPageY, fingerPageX) => {
312
462
  const nodes = flattenedNodesRef.current;
313
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();
314
486
  const fingerLocalY = fingerPageY - containerPageYRef.current;
315
487
  const fingerContentY = fingerLocalY + scrollOffsetRef.current;
316
488
  const adjustedContentY = fingerContentY - headerOffsetRef.current;
317
489
  const iH = itemHeightRef.current;
318
- const rawIndex = Math.floor(adjustedContentY / iH);
319
- 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
+ }
320
533
  let targetNode = nodes[clampedIndex];
321
534
  if (!targetNode) return;
322
535
 
323
- // Determine zone within item
324
- 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
+ }
325
554
  let position;
326
- if (positionInItem < 0.15) {
555
+ if (positionInItem < aboveBound) {
327
556
  position = "above";
328
- } else if (positionInItem > 0.85) {
557
+ } else if (positionInItem > belowBound) {
329
558
  position = "below";
330
559
  } else {
331
560
  position = "inside";
@@ -363,7 +592,7 @@ export function useDragDrop(params) {
363
592
  let logicalTargetId = null;
364
593
  let logicalPosition = null;
365
594
  let visualDropLevel = null;
366
- if (position === "below" || position === "inside") {
595
+ if (position === "below") {
367
596
  const currentLevel = targetNode.level ?? 0;
368
597
  let isCliff = false;
369
598
  let shallowLevel = 0;
@@ -380,10 +609,7 @@ export function useDragDrop(params) {
380
609
  shallowLevel = 0;
381
610
  }
382
611
  if (isCliff) {
383
- // Generous threshold: midpoint of the two levels + 2× indent buffer
384
- const indent = indentationMultiplierRef.current;
385
- const threshold = (currentLevel + shallowLevel) / 2 * indent + indent * 2;
386
- if (fingerLocalX < threshold) {
612
+ if (fingerLeftOfLevelThreshold(currentLevel, fingerLocalX)) {
387
613
  // User wants the shallow level
388
614
  if (clampedIndex < nodes.length - 1) {
389
615
  // Non-last item: switch to "above" on the next (shallower) node
@@ -393,9 +619,6 @@ export function useDragDrop(params) {
393
619
  position = "above";
394
620
  } else {
395
621
  // Last item: find ancestor at shallow level, target it with "below"
396
- const {
397
- childToParentMap
398
- } = getTreeViewStore(storeId).getState();
399
622
  let ancestorId = targetNode.id;
400
623
  let walkLevel = currentLevel;
401
624
  while (walkLevel > shallowLevel) {
@@ -417,10 +640,7 @@ export function useDragDrop(params) {
417
640
  const prevLevel = prevNode?.level ?? 0;
418
641
  const currentLevel = targetNode.level ?? 0;
419
642
  if (prevNode && prevLevel > currentLevel) {
420
- // Level cliff above - same generous threshold
421
- const indent = indentationMultiplierRef.current;
422
- const threshold = (prevLevel + currentLevel) / 2 * indent + indent * 2;
423
- if (fingerLocalX >= threshold) {
643
+ if (!fingerLeftOfLevelThreshold(prevLevel, fingerLocalX)) {
424
644
  clampedIndex = clampedIndex - 1;
425
645
  targetNode = prevNode;
426
646
  position = "below";
@@ -428,26 +648,14 @@ export function useDragDrop(params) {
428
648
  }
429
649
  }
430
650
 
431
- // --- Suppress "below" when it's redundant or confusing ---
432
- // After horizontal control, any remaining "below" that isn't at a
433
- // cliff is redundant with "above" on the next node → show "inside".
434
- // Only convert to "inside" if inside drops are allowed for this target.
651
+ // --- Suppress "below" when it's semantically confusing ---
652
+ // For expanded parents, "below" visually sits at the parent/child
653
+ // junction but semantically inserts as a sibling after the entire
654
+ // subtree. Convert to "inside" which is clearer.
435
655
  if (position === "below" && canDropInsideTarget) {
436
- const expandedSet = getTreeViewStore(storeId).getState().expanded;
437
-
438
- // (a) Expanded parent: "below" visually sits at the parent/child junction
439
- // but semantically inserts as a sibling after the entire subtree.
440
- if (targetNode.children?.length && expandedSet.has(targetNode.id)) {
656
+ if (targetNode.children?.length && expanded.has(targetNode.id)) {
441
657
  position = "inside";
442
658
  }
443
- // (b) No level cliff below: convert to "inside" so the highlight
444
- // covers the full bottom of the node.
445
- else if (clampedIndex < nodes.length - 1) {
446
- const nextNode = nodes[clampedIndex + 1];
447
- if (nextNode && (nextNode.level ?? 0) >= (targetNode.level ?? 0)) {
448
- position = "inside";
449
- }
450
- }
451
659
  }
452
660
 
453
661
  // --- Hysteresis: prevent flicker between "below N" and "above N+1" ---
@@ -473,27 +681,29 @@ export function useDragDrop(params) {
473
681
  targetIndex: clampedIndex,
474
682
  position
475
683
  };
476
- const indicatorTop = fingerLocalY - grabOffsetYRef.current;
477
684
 
478
- // Validity check
479
- const store = getTreeViewStore(storeId);
480
- const {
481
- invalidDragTargetIds,
482
- draggedNodeId,
483
- expanded
484
- } = 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;
485
693
 
486
694
  // maxDepth check for above/below (sibling) positions
487
695
  let maxDepthValid = true;
488
- if (maxDepthRef.current !== undefined && (position === "above" || position === "below")) {
489
- 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;
490
700
  const deepest = targetLevel + draggedSubtreeDepthRef.current;
491
701
  if (deepest > maxDepthRef.current) maxDepthValid = false;
492
702
  }
493
- 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));
494
704
 
495
705
  // --- Auto-expand: if hovering "inside" a collapsed expandable node ---
496
- 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)) {
497
707
  if (autoExpandTargetRef.current !== targetNode.id) {
498
708
  // New hover target - start timer
499
709
  cancelAutoExpandTimer();
@@ -501,7 +711,7 @@ export function useDragDrop(params) {
501
711
  autoExpandTimerRef.current = setTimeout(() => {
502
712
  autoExpandTimerRef.current = null;
503
713
  // Expand the node and track it
504
- handleToggleExpand(storeId, targetNode.id);
714
+ expandNodes(storeId, [targetNode.id]);
505
715
  autoExpandedDuringDragRef.current.add(targetNode.id);
506
716
  }, autoExpandDelayRef.current);
507
717
  }
@@ -520,41 +730,79 @@ export function useDragDrop(params) {
520
730
  // the effective level comes from the visual drop level, not the target node.
521
731
  const effectiveLevel = isValid ? visualDropLevel !== null ? visualDropLevel // "below" ancestor → sibling at that level
522
732
  : position === "inside" ? (targetNode.level ?? 0) + 1 : targetNode.level ?? 0 : draggedLevel;
523
- if (effectiveLevel !== prevEffectiveLevelRef.current) {
524
- const prevLevel = prevEffectiveLevelRef.current ?? effectiveLevel;
525
- prevEffectiveLevelRef.current = effectiveLevel;
526
- setEffectiveDropLevel(effectiveLevel);
527
-
528
- // The level prop change snaps the content to the correct indent.
529
- // Counteract that visual jump with an initial translateX offset,
530
- // then spring to 0 for a smooth "magnetic snap" transition.
531
- if (prevLevel !== effectiveLevel) {
532
- 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) {
533
747
  Animated.spring(overlayX, {
534
- toValue: 0,
748
+ toValue: targetX,
535
749
  useNativeDriver: true,
536
750
  speed: 40,
537
751
  bounciness: 4
538
752
  }).start();
753
+ } else {
754
+ overlayX.setValue(targetX);
539
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);
540
778
  }
541
779
  const newTarget = {
542
780
  targetNodeId: targetNode.id,
543
781
  targetIndex: clampedIndex,
544
782
  position,
545
- isValid,
546
- targetLevel: targetNode.level ?? 0,
547
- indicatorTop
783
+ isValid
548
784
  };
549
785
 
550
- // Update the store so each Node can render its own indicator
551
- if (isValid) {
552
- store.getState().updateDropTarget(targetNode.id, position, visualDropLevel);
553
- } else {
554
- 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
+ };
555
803
  }
556
804
 
557
- // Keep ref in sync (used by handleDragEnd to avoid setState-during-render)
805
+ // Keep the commit ref in sync (read by handleDragEnd).
558
806
  // When a logical target exists (e.g. ancestor at a cliff), use it
559
807
  // for the actual move while the visual indicator stays on the current node.
560
808
  if (logicalTargetId !== null && logicalPosition !== null) {
@@ -566,24 +814,22 @@ export function useDragDrop(params) {
566
814
  } else {
567
815
  dropTargetRef.current = newTarget;
568
816
  }
569
- setDropTarget(prevTarget => {
570
- if (prevTarget?.targetNodeId === newTarget.targetNodeId && prevTarget?.position === newTarget.position && prevTarget?.isValid === newTarget.isValid && prevTarget?.indicatorTop === newTarget.indicatorTop) {
571
- return prevTarget;
572
- }
573
- return newTarget;
574
- });
575
- }, [storeId, cancelAutoExpandTimer, overlayX]);
817
+ }, [storeId, cancelAutoExpandTimer, cancelLevelSettleTimer, fingerLeftOfLevelThreshold, overlayX, itemHeightsRef]);
818
+ calculateDropTargetRef.current = calculateDropTarget;
576
819
 
577
820
  // --- Handle drag end ---
578
- const handleDragEnd = useCallback((fingerPageY, fingerPageX) => {
821
+ const handleDragEnd = useCallback((fingerPageY, fingerPageX, cancel = false) => {
579
822
  stopAutoScroll();
580
823
  cancelLongPressTimer();
581
824
  cancelAutoExpandTimer();
582
- prevDropTargetRef.current = null;
583
825
  if (!isDraggingRef.current) return;
584
826
  isDraggingRef.current = false;
585
827
 
586
- // 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.
587
833
  if (fingerPageY !== undefined) {
588
834
  calculateDropTarget(fingerPageY, fingerPageX ?? 0);
589
835
  }
@@ -592,90 +838,99 @@ export function useDragDrop(params) {
592
838
  // Without this, the timer fires after drag ends and toggles the target back to collapsed.
593
839
  cancelAutoExpandTimer();
594
840
 
595
- // Read current drop target from ref via a small delay to ensure
596
- // the last setDropTarget has been processed
597
- // We use the current dropTarget state via a callback
598
- // 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).
599
843
  const currentTarget = dropTargetRef.current;
600
- const droppedNodeId = draggedNodeIdRef.current;
601
- if (currentTarget?.isValid && droppedNodeId !== null) {
602
- const store = getTreeViewStore(storeId);
603
- const currentData = store.getState().initialTreeViewData;
604
- const newData = moveTreeNode(currentData, droppedNodeId, currentTarget.targetNodeId, currentTarget.position);
605
-
606
- // Update store directly (preserves checked/expanded)
607
- store.getState().updateInitialTreeViewData(newData);
608
- initializeNodeMaps(storeId, newData);
609
-
610
- // Recalculate checked/indeterminate states for all parents
611
- // since the tree structure changed
612
- recalculateCheckedStates(storeId);
613
-
614
- // If dropped "inside" a node, expand it so the dropped node is visible
615
- if (currentTarget.position === "inside") {
616
- expandNodes(storeId, [currentTarget.targetNodeId]);
617
- }
618
-
619
- // Expand ancestors of the dropped node so it's visible
620
- expandNodes(storeId, [droppedNodeId], true);
621
-
622
- // Set internal data ref to prevent useDeepCompareEffect
623
- // from reinitializing
624
- internalDataRef.current = newData;
625
-
626
- // 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().
627
868
  onDragEndRef.current?.({
628
869
  draggedNodeId: droppedNodeId,
629
870
  targetNodeId: currentTarget.targetNodeId,
630
871
  position: currentTarget.position,
631
- newTreeData: newData
872
+ previousParentId: prevPosition?.parentId ?? null,
873
+ previousIndex: prevPosition?.index ?? -1,
874
+ newParentId: newPosition?.parentId ?? null,
875
+ newIndex: newPosition?.index ?? -1
632
876
  });
633
877
 
634
- // Scroll to the dropped node after React processes the expansion,
635
- // but only if it's outside the visible viewport. An animated
636
- // scroll would consume the user's next touch (RN stops the
637
- // animation on tap), so we skip when the node is already visible.
638
- setTimeout(() => {
639
- const nodes = flattenedNodesRef.current;
640
- const idx = nodes.findIndex(n => n.id === droppedNodeId);
641
- if (idx < 0) return;
642
- const itemH = itemHeightRef.current;
643
- const scrollTop = scrollOffsetRef.current;
644
- const containerH = containerHeightRef.current;
645
- const estimatedTop = idx * itemH;
646
- const estimatedBottom = estimatedTop + itemH;
647
-
648
- // Already in view no scroll needed
649
- if (estimatedTop >= scrollTop && estimatedBottom <= scrollTop + containerH) {
650
- return;
878
+ // Announce the result for screen readers (no-op without assistive tech).
879
+ AccessibilityInfo.announceForAccessibility?.(`Moved ${draggedNodeRef.current?.name ?? "node"}`);
880
+
881
+ // Auto-scroll to the dropped node unless disabled by the user.
882
+ const scrollOpts = autoScrollToDroppedNode;
883
+ const scrollEnabled = scrollOpts === undefined || scrollOpts === true || typeof scrollOpts === "object" && scrollOpts.enabled !== false;
884
+ if (scrollEnabled) {
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;
651
900
  }
652
- flashListRef.current?.scrollToIndex?.({
653
- index: idx,
654
- animated: true,
655
- viewPosition: 0.5
656
- });
657
- }, 100);
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
+ }
906
+ }
658
907
  } else if (droppedNodeId !== null) {
659
- // 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
+ }
660
913
  onDragCancelRef.current?.({
661
914
  draggedNodeId: droppedNodeId
662
915
  });
916
+ AccessibilityInfo.announceForAccessibility?.(`Cancelled moving ${draggedNodeRef.current?.name ?? "node"}`);
663
917
  }
664
918
 
665
919
  // Collapse auto-expanded nodes that aren't ancestors of the drop target
666
920
  if (autoExpandedDuringDragRef.current.size > 0) {
667
- const store3 = getTreeViewStore(storeId);
921
+ // Re-read: the maps were rebuilt if a move committed above.
668
922
  const {
669
- childToParentMap
670
- } = store3.getState();
923
+ childToParentMap: postMoveParentMap
924
+ } = store.getState();
671
925
 
672
- // 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.
673
928
  const ancestorIds = new Set();
674
- if (currentTarget?.isValid) {
929
+ if (!cancel && currentTarget?.isValid) {
675
930
  let walkId = currentTarget.targetNodeId;
676
931
  while (walkId !== undefined) {
677
932
  ancestorIds.add(walkId);
678
- walkId = childToParentMap.get(walkId);
933
+ walkId = postMoveParentMap.get(walkId);
679
934
  }
680
935
  }
681
936
 
@@ -693,29 +948,38 @@ export function useDragDrop(params) {
693
948
  }
694
949
 
695
950
  // Clear drag state
696
- const store2 = getTreeViewStore(storeId);
697
- store2.getState().updateDraggedNodeId(null);
698
- store2.getState().updateInvalidDragTargetIds(new Set());
699
- store2.getState().updateDropTarget(null, null);
951
+ resetDragStoreState();
700
952
 
701
953
  // Reset all refs
702
954
  overlayX.setValue(0);
703
955
  prevEffectiveLevelRef.current = null;
956
+ cancelLevelSettleTimer();
957
+ prevDropTargetRef.current = null;
704
958
  dropTargetRef.current = null;
959
+ lastStoreDropTargetRef.current = null;
705
960
  draggedNodeRef.current = null;
706
- draggedNodeIdRef.current = null;
707
- draggedNodeIndexRef.current = -1;
708
- setDropTarget(null);
961
+ wasDraggedNodeExpandedRef.current = false;
709
962
  setIsDragging(false);
710
963
  setDraggedNode(null);
711
964
  },
712
965
  // eslint-disable-next-line react-hooks/exhaustive-deps
713
- [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]);
714
976
 
715
977
  // --- Handle node touch end ---
716
978
  // If the PanResponder never captured the gesture (no movement after long
717
979
  // press fired), end the drag here so the node doesn't stay "lifted".
718
980
  const handleNodeTouchEnd = useCallback(() => {
981
+ // cancelLongPressTimer also aborts any drag still awaiting its
982
+ // measureInWindow callback (clears pendingDragRef).
719
983
  cancelLongPressTimer();
720
984
  if (isDraggingRef.current && !panResponderActiveRef.current) {
721
985
  handleDragEnd();
@@ -731,21 +995,25 @@ export function useDragDrop(params) {
731
995
  onPanResponderGrant: () => {
732
996
  panResponderActiveRef.current = true;
733
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,
734
1002
  onPanResponderMove: evt => {
735
1003
  if (!isDraggingRef.current) return;
736
1004
  const fingerPageY = evt.nativeEvent.pageY;
1005
+ lastFingerPageYRef.current = fingerPageY;
1006
+ lastFingerPageXRef.current = evt.nativeEvent.pageX;
737
1007
  const fingerLocalY = fingerPageY - containerPageYRef.current;
738
1008
 
739
1009
  // Update overlay position (with configurable offset)
740
- const overlayLocalY = fingerLocalY - grabOffsetYRef.current + dragOverlayOffsetRef.current * itemHeightRef.current;
741
- overlayY.setValue(overlayLocalY);
1010
+ overlayY.setValue(computeOverlayLocalY(fingerLocalY));
742
1011
 
743
1012
  // Calculate drop target (horizontal position used at level cliffs)
744
1013
  calculateDropTarget(fingerPageY, evt.nativeEvent.pageX);
745
1014
 
746
- // Auto-scroll at edges - use delta-based position relative to container
747
- const fingerInContainer = initialFingerContainerYRef.current + (fingerPageY - initialFingerPageYRef.current);
748
- updateAutoScroll(fingerInContainer);
1015
+ // Auto-scroll at edges, from the finger's container-local position
1016
+ updateAutoScroll(fingerLocalY);
749
1017
  },
750
1018
  onPanResponderRelease: evt => {
751
1019
  panResponderActiveRef.current = false;
@@ -753,7 +1021,9 @@ export function useDragDrop(params) {
753
1021
  },
754
1022
  onPanResponderTerminate: () => {
755
1023
  panResponderActiveRef.current = false;
756
- 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);
757
1027
  }
758
1028
  })).current;
759
1029
 
@@ -762,29 +1032,29 @@ export function useDragDrop(params) {
762
1032
  return () => {
763
1033
  cancelLongPressTimer();
764
1034
  cancelAutoExpandTimer();
1035
+ cancelLevelSettleTimer();
765
1036
  stopAutoScroll();
1037
+ pendingDragRef.current = false;
1038
+ lastStoreDropTargetRef.current = null;
766
1039
  if (isDraggingRef.current) {
767
1040
  isDraggingRef.current = false;
768
- const store = getTreeViewStore(storeId);
769
- store.getState().updateDraggedNodeId(null);
770
- store.getState().updateInvalidDragTargetIds(new Set());
771
- store.getState().updateDropTarget(null, null);
1041
+ resetDragStoreState();
772
1042
  }
773
1043
  };
774
- }, [storeId, cancelLongPressTimer, cancelAutoExpandTimer, stopAutoScroll]);
1044
+ }, [cancelLongPressTimer, cancelAutoExpandTimer, cancelLevelSettleTimer, stopAutoScroll, resetDragStoreState]);
775
1045
  return {
776
1046
  panResponder,
777
1047
  overlayY,
778
1048
  overlayX,
779
1049
  isDragging,
780
1050
  draggedNode,
781
- dropTarget,
782
- effectiveDropLevel,
783
1051
  handleNodeTouchStart,
784
1052
  handleNodeTouchEnd,
785
1053
  cancelLongPressTimer,
1054
+ handleScroll,
786
1055
  scrollOffsetRef,
787
- headerOffsetRef
1056
+ headerOffsetRef,
1057
+ containerHeightRef
788
1058
  };
789
1059
  }
790
1060
  //# sourceMappingURL=useDragDrop.js.map