react-native-tree-multi-select 2.0.13 → 3.0.0-beta.2

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 (58) hide show
  1. package/README.md +151 -0
  2. package/lib/module/TreeView.js +32 -2
  3. package/lib/module/TreeView.js.map +1 -1
  4. package/lib/module/components/DragOverlay.js +104 -0
  5. package/lib/module/components/DragOverlay.js.map +1 -0
  6. package/lib/module/components/DropIndicator.js +79 -0
  7. package/lib/module/components/DropIndicator.js.map +1 -0
  8. package/lib/module/components/NodeList.js +288 -33
  9. package/lib/module/components/NodeList.js.map +1 -1
  10. package/lib/module/helpers/index.js +1 -0
  11. package/lib/module/helpers/index.js.map +1 -1
  12. package/lib/module/helpers/moveTreeNode.helper.js +96 -0
  13. package/lib/module/helpers/moveTreeNode.helper.js.map +1 -0
  14. package/lib/module/helpers/toggleCheckbox.helper.js +88 -0
  15. package/lib/module/helpers/toggleCheckbox.helper.js.map +1 -1
  16. package/lib/module/hooks/useDragDrop.js +683 -0
  17. package/lib/module/hooks/useDragDrop.js.map +1 -0
  18. package/lib/module/index.js +1 -1
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/module/store/treeView.store.js +22 -1
  21. package/lib/module/store/treeView.store.js.map +1 -1
  22. package/lib/module/types/dragDrop.types.js +4 -0
  23. package/lib/module/types/dragDrop.types.js.map +1 -0
  24. package/lib/typescript/src/TreeView.d.ts.map +1 -1
  25. package/lib/typescript/src/components/DragOverlay.d.ts +13 -0
  26. package/lib/typescript/src/components/DragOverlay.d.ts.map +1 -0
  27. package/lib/typescript/src/components/DropIndicator.d.ts +13 -0
  28. package/lib/typescript/src/components/DropIndicator.d.ts.map +1 -0
  29. package/lib/typescript/src/components/NodeList.d.ts.map +1 -1
  30. package/lib/typescript/src/helpers/index.d.ts +1 -0
  31. package/lib/typescript/src/helpers/index.d.ts.map +1 -1
  32. package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts +13 -0
  33. package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts.map +1 -0
  34. package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts +6 -0
  35. package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts.map +1 -1
  36. package/lib/typescript/src/hooks/useDragDrop.d.ts +40 -0
  37. package/lib/typescript/src/hooks/useDragDrop.d.ts.map +1 -0
  38. package/lib/typescript/src/index.d.ts +4 -2
  39. package/lib/typescript/src/index.d.ts.map +1 -1
  40. package/lib/typescript/src/store/treeView.store.d.ts +9 -0
  41. package/lib/typescript/src/store/treeView.store.d.ts.map +1 -1
  42. package/lib/typescript/src/types/dragDrop.types.d.ts +21 -0
  43. package/lib/typescript/src/types/dragDrop.types.d.ts.map +1 -0
  44. package/lib/typescript/src/types/treeView.types.d.ts +94 -0
  45. package/lib/typescript/src/types/treeView.types.d.ts.map +1 -1
  46. package/package.json +1 -1
  47. package/src/TreeView.tsx +34 -0
  48. package/src/components/DragOverlay.tsx +114 -0
  49. package/src/components/DropIndicator.tsx +95 -0
  50. package/src/components/NodeList.tsx +327 -30
  51. package/src/helpers/index.ts +2 -1
  52. package/src/helpers/moveTreeNode.helper.ts +105 -0
  53. package/src/helpers/toggleCheckbox.helper.ts +96 -0
  54. package/src/hooks/useDragDrop.ts +835 -0
  55. package/src/index.tsx +19 -2
  56. package/src/store/treeView.store.ts +36 -0
  57. package/src/types/dragDrop.types.ts +23 -0
  58. package/src/types/treeView.types.ts +110 -0
@@ -0,0 +1,835 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import {
3
+ Animated,
4
+ PanResponder,
5
+ type PanResponderInstance,
6
+ } from "react-native";
7
+ import type { FlashList } from "@shopify/flash-list";
8
+
9
+ import type { __FlattenedTreeNode__, TreeNode } from "../types/treeView.types";
10
+ import type { DragEndEvent, DropTarget } from "../types/dragDrop.types";
11
+ import { getTreeViewStore } from "../store/treeView.store";
12
+ import { collapseNodes, expandNodes, handleToggleExpand, initializeNodeMaps, recalculateCheckedStates } from "../helpers";
13
+ import { moveTreeNode } from "../helpers/moveTreeNode.helper";
14
+
15
+ interface UseDragDropParams<ID> {
16
+ storeId: string;
17
+ flattenedNodes: __FlattenedTreeNode__<ID>[];
18
+ flashListRef: React.RefObject<FlashList<__FlattenedTreeNode__<ID>> | null>;
19
+ containerRef: React.RefObject<{ measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => void } | null>;
20
+ dragEnabled: boolean;
21
+ onDragEnd?: (event: DragEndEvent<ID>) => void;
22
+ longPressDuration: number;
23
+ autoScrollThreshold: number;
24
+ autoScrollSpeed: number;
25
+ internalDataRef: React.MutableRefObject<TreeNode<ID>[] | null>;
26
+ measuredItemHeightRef: React.MutableRefObject<number>;
27
+ dragOverlayOffset: number;
28
+ autoExpandDelay: number;
29
+ /** Pixels per nesting level, used for magnetic overlay shift. */
30
+ indentationMultiplier: number;
31
+ }
32
+
33
+ interface UseDragDropReturn<ID> {
34
+ panResponder: PanResponderInstance;
35
+ overlayY: Animated.Value;
36
+ overlayX: Animated.Value;
37
+ isDragging: boolean;
38
+ draggedNode: __FlattenedTreeNode__<ID> | null;
39
+ dropTarget: DropTarget<ID> | null;
40
+ effectiveDropLevel: number;
41
+ handleNodeTouchStart: (
42
+ nodeId: ID,
43
+ pageY: number,
44
+ locationY: number,
45
+ nodeIndex: number,
46
+ ) => void;
47
+ handleNodeTouchEnd: () => void;
48
+ cancelLongPressTimer: () => void;
49
+ scrollOffsetRef: React.MutableRefObject<number>;
50
+ headerOffsetRef: React.MutableRefObject<number>;
51
+ }
52
+
53
+ export function useDragDrop<ID>(
54
+ params: UseDragDropParams<ID>
55
+ ): UseDragDropReturn<ID> {
56
+ const {
57
+ storeId,
58
+ flattenedNodes,
59
+ flashListRef,
60
+ containerRef,
61
+ dragEnabled,
62
+ onDragEnd,
63
+ longPressDuration,
64
+ autoScrollThreshold,
65
+ autoScrollSpeed,
66
+ internalDataRef,
67
+ measuredItemHeightRef,
68
+ dragOverlayOffset,
69
+ autoExpandDelay,
70
+ indentationMultiplier,
71
+ } = params;
72
+
73
+ // --- Refs for mutable state (no stale closures in PanResponder) ---
74
+ const isDraggingRef = useRef(false);
75
+ const draggedNodeRef = useRef<__FlattenedTreeNode__<ID> | null>(null);
76
+ const draggedNodeIdRef = useRef<ID | null>(null);
77
+ const draggedNodeIndexRef = useRef(-1);
78
+
79
+ const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
80
+
81
+ const containerPageXRef = useRef(0);
82
+ const containerPageYRef = useRef(0);
83
+ const containerHeightRef = useRef(0);
84
+ const grabOffsetYRef = useRef(0);
85
+ const scrollOffsetRef = useRef(0);
86
+ const headerOffsetRef = useRef(0);
87
+ const itemHeightRef = useRef(36);
88
+
89
+ const overlayY = useRef(new Animated.Value(0)).current;
90
+ const overlayX = useRef(new Animated.Value(0)).current;
91
+ const prevEffectiveLevelRef = useRef<number | null>(null);
92
+
93
+ const autoScrollRAFRef = useRef<number | null>(null);
94
+ const autoScrollSpeedRef = useRef(0);
95
+
96
+ // Delta-based auto-scroll: avoids unreliable containerPageY
97
+ const initialFingerPageYRef = useRef(0);
98
+ const initialFingerContainerYRef = useRef(0);
99
+
100
+ // Auto-expand timer for hovering over collapsed nodes
101
+ const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
102
+ const autoExpandTargetRef = useRef<ID | null>(null);
103
+ // Track which nodes were auto-expanded during this drag (to collapse on drag end)
104
+ const autoExpandedDuringDragRef = useRef<Set<ID>>(new Set());
105
+
106
+ // Tracks whether the PanResponder has captured the current gesture
107
+ const panResponderActiveRef = useRef(false);
108
+
109
+ // Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
110
+ const prevDropTargetRef = useRef<{ targetIndex: number; position: "above" | "below" | "inside" } | null>(null);
111
+
112
+ // Keep flattenedNodes ref current for PanResponder closures
113
+ const flattenedNodesRef = useRef(flattenedNodes);
114
+ flattenedNodesRef.current = flattenedNodes;
115
+
116
+ // Keep callbacks current
117
+ const onDragEndRef = useRef(onDragEnd);
118
+ onDragEndRef.current = onDragEnd;
119
+
120
+ // --- React state (triggers re-renders only at drag start/end + indicator changes) ---
121
+ const [isDragging, setIsDragging] = useState(false);
122
+ const [draggedNode, setDraggedNode] = useState<__FlattenedTreeNode__<ID> | null>(null);
123
+ const [dropTarget, setDropTarget] = useState<DropTarget<ID> | null>(null);
124
+ const [effectiveDropLevel, setEffectiveDropLevel] = useState(0);
125
+
126
+ // Ref mirror of dropTarget — avoids nesting Zustand updates inside React state updaters
127
+ const dropTargetRef = useRef<DropTarget<ID> | null>(null);
128
+
129
+ // --- Long press timer ---
130
+ const cancelLongPressTimer = useCallback(() => {
131
+ if (longPressTimerRef.current) {
132
+ clearTimeout(longPressTimerRef.current);
133
+ longPressTimerRef.current = null;
134
+ }
135
+ }, []);
136
+
137
+ // --- Get all descendant IDs of a node ---
138
+ const getDescendantIds = useCallback(
139
+ (nodeId: ID): Set<ID> => {
140
+ const store = getTreeViewStore<ID>(storeId);
141
+ const { nodeMap } = store.getState();
142
+ const descendants = new Set<ID>();
143
+ const stack: ID[] = [nodeId];
144
+
145
+ while (stack.length > 0) {
146
+ const currentId = stack.pop()!;
147
+ const node = nodeMap.get(currentId);
148
+ if (node?.children) {
149
+ for (const child of node.children) {
150
+ descendants.add(child.id);
151
+ stack.push(child.id);
152
+ }
153
+ }
154
+ }
155
+ return descendants;
156
+ },
157
+ [storeId]
158
+ );
159
+
160
+ // --- Initiate drag ---
161
+ const initiateDrag = useCallback(
162
+ (nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
163
+ if (!dragEnabled) return;
164
+
165
+ const container = containerRef.current;
166
+ if (!container) return;
167
+
168
+ container.measureInWindow((x, y, _w, h) => {
169
+ containerPageXRef.current = x;
170
+ containerPageYRef.current = y;
171
+ containerHeightRef.current = h;
172
+
173
+ // Find the node in flattened list
174
+ const nodes = flattenedNodesRef.current;
175
+ const node = nodes[nodeIndex];
176
+ if (!node) return;
177
+
178
+ // Collapse node if expanded
179
+ const store = getTreeViewStore<ID>(storeId);
180
+ const { expanded } = store.getState();
181
+ if (expanded.has(nodeId) && node.children?.length) {
182
+ handleToggleExpand(storeId, nodeId);
183
+ }
184
+
185
+ // Store grab metadata
186
+ grabOffsetYRef.current = locationY;
187
+ draggedNodeRef.current = node;
188
+ draggedNodeIdRef.current = nodeId;
189
+ draggedNodeIndexRef.current = nodeIndex;
190
+
191
+ // Use measured item height if available, fall back to estimatedItemSize
192
+ const measured = measuredItemHeightRef.current;
193
+ const estimatedSize =
194
+ (flashListRef.current as any)?.props?.estimatedItemSize ?? 36;
195
+ itemHeightRef.current = measured > 0 ? measured : estimatedSize;
196
+
197
+ // Calculate headerOffset dynamically:
198
+ // fingerLocalY = pageY - containerPageY
199
+ // fingerLocalY = headerOffset + nodeIndex * itemHeight - scrollOffset + grabOffsetY
200
+ // So: headerOffset = fingerLocalY + scrollOffset - grabOffsetY - nodeIndex * itemHeight
201
+ const fingerLocalY = pageY - containerPageYRef.current;
202
+ headerOffsetRef.current =
203
+ fingerLocalY +
204
+ scrollOffsetRef.current -
205
+ locationY -
206
+ nodeIndex * itemHeightRef.current;
207
+
208
+ // Delta-based auto-scroll: compute finger's position in the container
209
+ // from the node's known index (avoids unreliable containerPageY).
210
+ // The FlashList header (padding:5 → ~10px) + nodeIndex * itemHeight - scroll + locationY
211
+ const iH = itemHeightRef.current;
212
+ const listHeaderHeight = 10; // HeaderFooterView has padding: 5 → 10px total
213
+ initialFingerPageYRef.current = pageY;
214
+ initialFingerContainerYRef.current =
215
+ listHeaderHeight + nodeIndex * iH - scrollOffsetRef.current + locationY;
216
+
217
+ // Compute invalid targets (self + descendants)
218
+ const descendants = getDescendantIds(nodeId);
219
+ descendants.add(nodeId);
220
+
221
+ // Update store (triggers one re-render of nodes to show greyed-out state)
222
+ store.getState().updateDraggedNodeId(nodeId);
223
+ store.getState().updateInvalidDragTargetIds(descendants);
224
+
225
+ // Set overlay initial position (with configurable offset)
226
+ const overlayLocalY = fingerLocalY - locationY + dragOverlayOffset * itemHeightRef.current;
227
+ overlayY.setValue(overlayLocalY);
228
+
229
+ // Reset magnetic overlay
230
+ overlayX.setValue(0);
231
+ prevEffectiveLevelRef.current = node.level ?? 0;
232
+
233
+ // Set React state
234
+ isDraggingRef.current = true;
235
+ autoExpandedDuringDragRef.current.clear();
236
+ setIsDragging(true);
237
+ setDraggedNode(node);
238
+ setEffectiveDropLevel(node.level ?? 0);
239
+ setDropTarget(null);
240
+
241
+ // Start auto-scroll loop
242
+ startAutoScrollLoop();
243
+ });
244
+ },
245
+ // eslint-disable-next-line react-hooks/exhaustive-deps
246
+ [
247
+ dragEnabled,
248
+ storeId,
249
+ containerRef,
250
+ flashListRef,
251
+ getDescendantIds,
252
+ overlayY,
253
+ ]
254
+ );
255
+
256
+ // --- Handle node touch start (long press detection) ---
257
+ const handleNodeTouchStart = useCallback(
258
+ (nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
259
+ if (!dragEnabled) return;
260
+
261
+ // Cancel any existing timer
262
+ cancelLongPressTimer();
263
+
264
+ // Start new timer
265
+ longPressTimerRef.current = setTimeout(() => {
266
+ longPressTimerRef.current = null;
267
+ initiateDrag(nodeId, pageY, locationY, nodeIndex);
268
+ }, longPressDuration);
269
+ },
270
+ [dragEnabled, longPressDuration, cancelLongPressTimer, initiateDrag]
271
+ );
272
+
273
+ // --- Auto-scroll ---
274
+ const startAutoScrollLoop = useCallback(() => {
275
+ const loop = () => {
276
+ if (!isDraggingRef.current) return;
277
+
278
+ if (autoScrollSpeedRef.current !== 0) {
279
+ const newOffset = Math.max(
280
+ 0,
281
+ scrollOffsetRef.current + autoScrollSpeedRef.current
282
+ );
283
+ scrollOffsetRef.current = newOffset;
284
+ (flashListRef.current as any)?.scrollToOffset?.({
285
+ offset: newOffset,
286
+ animated: false,
287
+ });
288
+ }
289
+
290
+ autoScrollRAFRef.current = requestAnimationFrame(loop);
291
+ };
292
+ autoScrollRAFRef.current = requestAnimationFrame(loop);
293
+ }, [flashListRef]);
294
+
295
+ const stopAutoScroll = useCallback(() => {
296
+ if (autoScrollRAFRef.current !== null) {
297
+ cancelAnimationFrame(autoScrollRAFRef.current);
298
+ autoScrollRAFRef.current = null;
299
+ }
300
+ autoScrollSpeedRef.current = 0;
301
+ }, []);
302
+
303
+ const updateAutoScroll = useCallback(
304
+ (fingerInContainer: number) => {
305
+ const threshold = autoScrollThreshold;
306
+ const maxSpeed = 8 * autoScrollSpeed;
307
+ const containerH = containerHeightRef.current;
308
+
309
+ if (fingerInContainer < threshold) {
310
+ // Scroll up
311
+ const ratio = 1 - Math.max(0, fingerInContainer) / threshold;
312
+ autoScrollSpeedRef.current = -maxSpeed * ratio;
313
+ } else if (fingerInContainer > containerH - threshold) {
314
+ // Scroll down
315
+ const ratio =
316
+ 1 - Math.max(0, containerH - fingerInContainer) / threshold;
317
+ autoScrollSpeedRef.current = maxSpeed * ratio;
318
+ } else {
319
+ autoScrollSpeedRef.current = 0;
320
+ }
321
+ },
322
+ [autoScrollThreshold, autoScrollSpeed]
323
+ );
324
+
325
+ // --- Cancel auto-expand timer ---
326
+ const cancelAutoExpandTimer = useCallback(() => {
327
+ if (autoExpandTimerRef.current) {
328
+ clearTimeout(autoExpandTimerRef.current);
329
+ autoExpandTimerRef.current = null;
330
+ }
331
+ autoExpandTargetRef.current = null;
332
+ }, []);
333
+
334
+ // --- Calculate drop target ---
335
+ const calculateDropTarget = useCallback(
336
+ (fingerPageY: number, fingerPageX: number) => {
337
+ const nodes = flattenedNodesRef.current;
338
+ if (nodes.length === 0) return;
339
+
340
+ const fingerLocalY =
341
+ fingerPageY - containerPageYRef.current;
342
+ const fingerContentY =
343
+ fingerLocalY + scrollOffsetRef.current;
344
+ const adjustedContentY =
345
+ fingerContentY - headerOffsetRef.current;
346
+ const iH = itemHeightRef.current;
347
+
348
+ const rawIndex = Math.floor(adjustedContentY / iH);
349
+ let clampedIndex = Math.max(
350
+ 0,
351
+ Math.min(rawIndex, nodes.length - 1)
352
+ );
353
+ let targetNode = nodes[clampedIndex];
354
+ if (!targetNode) return;
355
+
356
+ // Determine zone within item
357
+ const positionInItem =
358
+ (adjustedContentY - clampedIndex * iH) / iH;
359
+ let position: "above" | "below" | "inside";
360
+ if (positionInItem < 0.15) {
361
+ position = "above";
362
+ } else if (positionInItem > 0.85) {
363
+ position = "below";
364
+ } else {
365
+ position = "inside";
366
+ }
367
+
368
+ // --- Horizontal control at level cliffs ---
369
+ // At the boundary between nodes at different depths, the user's
370
+ // horizontal finger position decides the drop level:
371
+ // finger RIGHT of threshold → stay at deep level (inside parent)
372
+ // finger LEFT of threshold → switch to shallow level (outside parent)
373
+ // The threshold uses a generous buffer so dragging slightly left is enough.
374
+ const fingerLocalX = fingerPageX - containerPageXRef.current;
375
+ // logicalTargetId/logicalPosition: when the visual indicator node differs
376
+ // from the actual moveTreeNode target (e.g., ancestor at a shallower level).
377
+ let logicalTargetId: ID | null = null;
378
+ let logicalPosition: "above" | "below" | "inside" | null = null;
379
+ let visualDropLevel: number | null = null;
380
+
381
+ if (position === "below" || position === "inside") {
382
+ const currentLevel = targetNode.level ?? 0;
383
+ let isCliff = false;
384
+ let shallowLevel = 0;
385
+
386
+ if (clampedIndex < nodes.length - 1) {
387
+ const nextNode = nodes[clampedIndex + 1];
388
+ const nextLevel = nextNode?.level ?? 0;
389
+ if (nextNode && nextLevel < currentLevel) {
390
+ isCliff = true;
391
+ shallowLevel = nextLevel;
392
+ }
393
+ } else if (currentLevel > 0) {
394
+ // Last item in the list — treat as cliff to root level
395
+ isCliff = true;
396
+ shallowLevel = 0;
397
+ }
398
+
399
+ if (isCliff) {
400
+ // Generous threshold: midpoint of the two levels + 2× indent buffer
401
+ const threshold =
402
+ ((currentLevel + shallowLevel) / 2) * indentationMultiplier
403
+ + indentationMultiplier * 2;
404
+
405
+ if (fingerLocalX < threshold) {
406
+ // User wants the shallow level
407
+ if (clampedIndex < nodes.length - 1) {
408
+ // Non-last item: switch to "above" on the next (shallower) node
409
+ const nextNode = nodes[clampedIndex + 1]!;
410
+ clampedIndex = clampedIndex + 1;
411
+ targetNode = nextNode;
412
+ position = "above";
413
+ } else {
414
+ // Last item: find ancestor at shallow level, target it with "below"
415
+ const { childToParentMap } = getTreeViewStore<ID>(storeId).getState();
416
+ let ancestorId = targetNode.id;
417
+ let walkLevel = currentLevel;
418
+ while (walkLevel > shallowLevel) {
419
+ const parentId = childToParentMap.get(ancestorId);
420
+ if (parentId === undefined) break;
421
+ ancestorId = parentId;
422
+ walkLevel--;
423
+ }
424
+ // Visual stays on the last item; logical goes to ancestor
425
+ logicalTargetId = ancestorId;
426
+ logicalPosition = "below";
427
+ visualDropLevel = shallowLevel;
428
+ }
429
+ }
430
+ }
431
+ }
432
+ if (position === "above" && clampedIndex > 0) {
433
+ const prevNode = nodes[clampedIndex - 1];
434
+ const prevLevel = prevNode?.level ?? 0;
435
+ const currentLevel = targetNode.level ?? 0;
436
+ if (prevNode && prevLevel > currentLevel) {
437
+ // Level cliff above — same generous threshold
438
+ const threshold =
439
+ ((prevLevel + currentLevel) / 2) * indentationMultiplier
440
+ + indentationMultiplier * 2;
441
+
442
+ if (fingerLocalX >= threshold) {
443
+ clampedIndex = clampedIndex - 1;
444
+ targetNode = prevNode;
445
+ position = "below";
446
+ }
447
+ }
448
+ }
449
+
450
+ // --- Suppress "below" when it's redundant or confusing ---
451
+ // After horizontal control, any remaining "below" that isn't at a
452
+ // cliff is redundant with "above" on the next node → show "inside".
453
+ if (position === "below") {
454
+ const expandedSet = getTreeViewStore<ID>(storeId).getState().expanded;
455
+
456
+ // (a) Expanded parent: "below" visually sits at the parent/child junction
457
+ // but semantically inserts as a sibling after the entire subtree.
458
+ if (targetNode.children?.length && expandedSet.has(targetNode.id)) {
459
+ position = "inside";
460
+ }
461
+ // (b) No level cliff below: convert to "inside" so the highlight
462
+ // covers the full bottom of the node.
463
+ else if (clampedIndex < nodes.length - 1) {
464
+ const nextNode = nodes[clampedIndex + 1];
465
+ if (nextNode && (nextNode.level ?? 0) >= (targetNode.level ?? 0)) {
466
+ position = "inside";
467
+ }
468
+ }
469
+ }
470
+
471
+ // --- Hysteresis: prevent flicker between "below N" and "above N+1" ---
472
+ // Only applies to same-level boundaries. Level cliffs are handled
473
+ // by horizontal control above, so they pass through without forced resolution.
474
+ const prev = prevDropTargetRef.current;
475
+ if (prev) {
476
+ const sameGap =
477
+ (prev.position === "below" && position === "above" &&
478
+ prev.targetIndex === clampedIndex - 1) ||
479
+ (prev.position === "above" && position === "below" &&
480
+ clampedIndex === prev.targetIndex - 1);
481
+ if (sameGap) {
482
+ const upperIdx = Math.min(prev.targetIndex, clampedIndex);
483
+ const lowerIdx = Math.max(prev.targetIndex, clampedIndex);
484
+ const upperLevel = nodes[upperIdx]?.level ?? 0;
485
+ const lowerLevel = nodes[lowerIdx]?.level ?? 0;
486
+
487
+ if (upperLevel === lowerLevel) {
488
+ // Same level — pure visual hysteresis, keep previous
489
+ return;
490
+ }
491
+ // Level cliff — horizontal control already resolved this,
492
+ // let the result pass through.
493
+ }
494
+ }
495
+ prevDropTargetRef.current = { targetIndex: clampedIndex, position };
496
+
497
+ const indicatorTop = fingerLocalY - grabOffsetYRef.current;
498
+
499
+ // Validity check
500
+ const store = getTreeViewStore<ID>(storeId);
501
+ const { invalidDragTargetIds, draggedNodeId, expanded } =
502
+ store.getState();
503
+ const isValid =
504
+ targetNode.id !== draggedNodeId &&
505
+ !invalidDragTargetIds.has(targetNode.id);
506
+
507
+ // --- Auto-expand: if hovering "inside" a collapsed expandable node ---
508
+ if (isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
509
+ if (autoExpandTargetRef.current !== targetNode.id) {
510
+ // New hover target — start timer
511
+ cancelAutoExpandTimer();
512
+ autoExpandTargetRef.current = targetNode.id;
513
+ autoExpandTimerRef.current = setTimeout(() => {
514
+ autoExpandTimerRef.current = null;
515
+ // Expand the node and track it
516
+ handleToggleExpand(storeId, targetNode.id);
517
+ autoExpandedDuringDragRef.current.add(targetNode.id);
518
+ }, autoExpandDelay);
519
+ }
520
+ } else {
521
+ // Not hovering inside a collapsed expandable node — cancel timer
522
+ if (autoExpandTargetRef.current !== null) {
523
+ cancelAutoExpandTimer();
524
+ }
525
+ }
526
+
527
+ // --- Magnetic overlay: update the effective level so the overlay
528
+ // renders its content at the correct indentation natively.
529
+ // A brief translateX spring provides a smooth transition. ---
530
+ const draggedLevel = draggedNodeRef.current?.level ?? 0;
531
+ // When a logical target overrides the visual (e.g. ancestor at last-item cliff),
532
+ // the effective level comes from the visual drop level, not the target node.
533
+ const effectiveLevel = isValid
534
+ ? (visualDropLevel !== null
535
+ ? visualDropLevel // "below" ancestor → sibling at that level
536
+ : position === "inside"
537
+ ? (targetNode.level ?? 0) + 1
538
+ : (targetNode.level ?? 0))
539
+ : draggedLevel;
540
+ if (effectiveLevel !== prevEffectiveLevelRef.current) {
541
+ const prevLevel = prevEffectiveLevelRef.current ?? effectiveLevel;
542
+ prevEffectiveLevelRef.current = effectiveLevel;
543
+ setEffectiveDropLevel(effectiveLevel);
544
+
545
+ // The level prop change snaps the content to the correct indent.
546
+ // Counteract that visual jump with an initial translateX offset,
547
+ // then spring to 0 for a smooth "magnetic snap" transition.
548
+ if (prevLevel !== effectiveLevel) {
549
+ overlayX.setValue(
550
+ (prevLevel - effectiveLevel) * indentationMultiplier
551
+ );
552
+ Animated.spring(overlayX, {
553
+ toValue: 0,
554
+ useNativeDriver: true,
555
+ speed: 40,
556
+ bounciness: 4,
557
+ }).start();
558
+ }
559
+ }
560
+
561
+ const newTarget: DropTarget<ID> = {
562
+ targetNodeId: targetNode.id,
563
+ targetIndex: clampedIndex,
564
+ position,
565
+ isValid,
566
+ targetLevel: targetNode.level ?? 0,
567
+ indicatorTop,
568
+ };
569
+
570
+ // Update the store so each Node can render its own indicator
571
+ if (isValid) {
572
+ store.getState().updateDropTarget(targetNode.id, position, visualDropLevel);
573
+ } else {
574
+ store.getState().updateDropTarget(null, null);
575
+ }
576
+
577
+ // Keep ref in sync (used by handleDragEnd to avoid setState-during-render)
578
+ // When a logical target exists (e.g. ancestor at a cliff), use it
579
+ // for the actual move while the visual indicator stays on the current node.
580
+ if (logicalTargetId !== null && logicalPosition !== null) {
581
+ dropTargetRef.current = {
582
+ ...newTarget,
583
+ targetNodeId: logicalTargetId,
584
+ position: logicalPosition,
585
+ };
586
+ } else {
587
+ dropTargetRef.current = newTarget;
588
+ }
589
+
590
+ setDropTarget((prevTarget) => {
591
+ if (
592
+ prevTarget?.targetNodeId === newTarget.targetNodeId &&
593
+ prevTarget?.position === newTarget.position &&
594
+ prevTarget?.isValid === newTarget.isValid &&
595
+ prevTarget?.indicatorTop === newTarget.indicatorTop
596
+ ) {
597
+ return prevTarget;
598
+ }
599
+ return newTarget;
600
+ });
601
+ },
602
+ [storeId, autoExpandDelay, cancelAutoExpandTimer, indentationMultiplier, overlayX]
603
+ );
604
+
605
+ // --- Handle drag end ---
606
+ const handleDragEnd = useCallback(
607
+ (fingerPageY?: number, fingerPageX?: number) => {
608
+ stopAutoScroll();
609
+ cancelLongPressTimer();
610
+ cancelAutoExpandTimer();
611
+ prevDropTargetRef.current = null;
612
+
613
+ if (!isDraggingRef.current) return;
614
+ isDraggingRef.current = false;
615
+
616
+ // Recalculate drop target at final position if we have coords
617
+ if (fingerPageY !== undefined) {
618
+ calculateDropTarget(fingerPageY, fingerPageX ?? 0);
619
+ }
620
+
621
+ // Cancel any auto-expand timer that calculateDropTarget may have just started.
622
+ // Without this, the timer fires after drag ends and toggles the target back to collapsed.
623
+ cancelAutoExpandTimer();
624
+
625
+ // Read current drop target from ref via a small delay to ensure
626
+ // the last setDropTarget has been processed
627
+ // We use the current dropTarget state via a callback
628
+ // Read drop target from ref (avoids nesting Zustand updates inside React state updaters)
629
+ const currentTarget = dropTargetRef.current;
630
+ const droppedNodeId = draggedNodeIdRef.current;
631
+
632
+ if (
633
+ currentTarget?.isValid &&
634
+ droppedNodeId !== null
635
+ ) {
636
+ const store = getTreeViewStore<ID>(storeId);
637
+ const currentData =
638
+ store.getState().initialTreeViewData;
639
+ const newData = moveTreeNode(
640
+ currentData,
641
+ droppedNodeId,
642
+ currentTarget.targetNodeId,
643
+ currentTarget.position
644
+ );
645
+
646
+ // Update store directly (preserves checked/expanded)
647
+ store
648
+ .getState()
649
+ .updateInitialTreeViewData(newData);
650
+ initializeNodeMaps(storeId, newData);
651
+
652
+ // Recalculate checked/indeterminate states for all parents
653
+ // since the tree structure changed
654
+ recalculateCheckedStates<ID>(storeId);
655
+
656
+ // If dropped "inside" a node, expand it so the dropped node is visible
657
+ if (currentTarget.position === "inside") {
658
+ expandNodes(storeId, [currentTarget.targetNodeId]);
659
+ }
660
+
661
+ // Expand ancestors of the dropped node so it's visible
662
+ expandNodes(storeId, [droppedNodeId], true);
663
+
664
+ // Set internal data ref to prevent useDeepCompareEffect
665
+ // from reinitializing
666
+ internalDataRef.current = newData;
667
+
668
+ // Notify consumer
669
+ onDragEndRef.current?.({
670
+ draggedNodeId: droppedNodeId,
671
+ targetNodeId: currentTarget.targetNodeId,
672
+ position: currentTarget.position,
673
+ newTreeData: newData,
674
+ });
675
+
676
+ // Scroll to the dropped node after React processes the expansion
677
+ setTimeout(() => {
678
+ const nodes = flattenedNodesRef.current;
679
+ const idx = nodes.findIndex(n => n.id === droppedNodeId);
680
+ if (idx >= 0) {
681
+ flashListRef.current?.scrollToIndex?.({
682
+ index: idx,
683
+ animated: true,
684
+ viewPosition: 0.5,
685
+ });
686
+ }
687
+ }, 100);
688
+ }
689
+
690
+ // Collapse auto-expanded nodes that aren't ancestors of the drop target
691
+ if (autoExpandedDuringDragRef.current.size > 0) {
692
+ const store3 = getTreeViewStore<ID>(storeId);
693
+ const { childToParentMap } = store3.getState();
694
+
695
+ // Collect ancestors of the drop target (keep these expanded)
696
+ const ancestorIds = new Set<ID>();
697
+ if (currentTarget?.isValid) {
698
+ let walkId: ID | undefined = currentTarget.targetNodeId;
699
+ while (walkId !== undefined) {
700
+ ancestorIds.add(walkId);
701
+ walkId = childToParentMap.get(walkId);
702
+ }
703
+ }
704
+
705
+ // Collapse auto-expanded nodes that aren't in the ancestor chain
706
+ const toCollapse: ID[] = [];
707
+ for (const nodeId of autoExpandedDuringDragRef.current) {
708
+ if (!ancestorIds.has(nodeId)) {
709
+ toCollapse.push(nodeId);
710
+ }
711
+ }
712
+ if (toCollapse.length > 0) {
713
+ collapseNodes(storeId, toCollapse);
714
+ }
715
+ autoExpandedDuringDragRef.current.clear();
716
+ }
717
+
718
+ // Clear drag state
719
+ const store2 = getTreeViewStore<ID>(storeId);
720
+ store2.getState().updateDraggedNodeId(null);
721
+ store2.getState().updateInvalidDragTargetIds(new Set());
722
+ store2.getState().updateDropTarget(null, null);
723
+
724
+ // Reset all refs
725
+ overlayX.setValue(0);
726
+ prevEffectiveLevelRef.current = null;
727
+ dropTargetRef.current = null;
728
+ draggedNodeRef.current = null;
729
+ draggedNodeIdRef.current = null;
730
+ draggedNodeIndexRef.current = -1;
731
+
732
+ setDropTarget(null);
733
+ setIsDragging(false);
734
+ setDraggedNode(null);
735
+ },
736
+ // eslint-disable-next-line react-hooks/exhaustive-deps
737
+ [
738
+ storeId,
739
+ stopAutoScroll,
740
+ cancelLongPressTimer,
741
+ cancelAutoExpandTimer,
742
+ calculateDropTarget,
743
+ internalDataRef,
744
+ ]
745
+ );
746
+
747
+ // --- Handle node touch end ---
748
+ // If the PanResponder never captured the gesture (no movement after long
749
+ // press fired), end the drag here so the node doesn't stay "lifted".
750
+ const handleNodeTouchEnd = useCallback(() => {
751
+ cancelLongPressTimer();
752
+ if (isDraggingRef.current && !panResponderActiveRef.current) {
753
+ handleDragEnd();
754
+ }
755
+ }, [cancelLongPressTimer, handleDragEnd]);
756
+
757
+ // --- PanResponder ---
758
+ const panResponder = useRef(
759
+ PanResponder.create({
760
+ onStartShouldSetPanResponder: () => false,
761
+ onStartShouldSetPanResponderCapture: () =>
762
+ isDraggingRef.current,
763
+ onMoveShouldSetPanResponder: () => isDraggingRef.current,
764
+ onMoveShouldSetPanResponderCapture: () =>
765
+ isDraggingRef.current,
766
+
767
+ onPanResponderGrant: () => {
768
+ panResponderActiveRef.current = true;
769
+ },
770
+
771
+ onPanResponderMove: (evt) => {
772
+ if (!isDraggingRef.current) return;
773
+
774
+ const fingerPageY = evt.nativeEvent.pageY;
775
+ const fingerLocalY =
776
+ fingerPageY - containerPageYRef.current;
777
+
778
+ // Update overlay position (with configurable offset)
779
+ const overlayLocalY =
780
+ fingerLocalY - grabOffsetYRef.current + dragOverlayOffset * itemHeightRef.current;
781
+ overlayY.setValue(overlayLocalY);
782
+
783
+ // Calculate drop target (horizontal position used at level cliffs)
784
+ calculateDropTarget(fingerPageY, evt.nativeEvent.pageX);
785
+
786
+ // Auto-scroll at edges — use delta-based position relative to container
787
+ const fingerInContainer =
788
+ initialFingerContainerYRef.current +
789
+ (fingerPageY - initialFingerPageYRef.current);
790
+ updateAutoScroll(fingerInContainer);
791
+ },
792
+
793
+ onPanResponderRelease: (evt) => {
794
+ panResponderActiveRef.current = false;
795
+ handleDragEnd(evt.nativeEvent.pageY, evt.nativeEvent.pageX);
796
+ },
797
+
798
+ onPanResponderTerminate: () => {
799
+ panResponderActiveRef.current = false;
800
+ handleDragEnd();
801
+ },
802
+ })
803
+ ).current;
804
+
805
+ // --- Cleanup on unmount ---
806
+ useEffect(() => {
807
+ return () => {
808
+ cancelLongPressTimer();
809
+ cancelAutoExpandTimer();
810
+ stopAutoScroll();
811
+ if (isDraggingRef.current) {
812
+ isDraggingRef.current = false;
813
+ const store = getTreeViewStore<ID>(storeId);
814
+ store.getState().updateDraggedNodeId(null);
815
+ store.getState().updateInvalidDragTargetIds(new Set());
816
+ store.getState().updateDropTarget(null, null);
817
+ }
818
+ };
819
+ }, [storeId, cancelLongPressTimer, cancelAutoExpandTimer, stopAutoScroll]);
820
+
821
+ return {
822
+ panResponder,
823
+ overlayY,
824
+ overlayX,
825
+ isDragging,
826
+ draggedNode,
827
+ dropTarget,
828
+ effectiveDropLevel,
829
+ handleNodeTouchStart,
830
+ handleNodeTouchEnd,
831
+ cancelLongPressTimer,
832
+ scrollOffsetRef,
833
+ headerOffsetRef,
834
+ };
835
+ }