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