react-native-tree-multi-select 2.0.10 → 3.0.0-beta.1

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