react-native-tree-multi-select 3.0.0-beta.1 → 3.0.0-beta.3
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.
- package/README.md +1 -1
- package/lib/module/components/DragOverlay.js +3 -0
- package/lib/module/components/DragOverlay.js.map +1 -1
- package/lib/module/components/NodeList.js +61 -25
- package/lib/module/components/NodeList.js.map +1 -1
- package/lib/module/helpers/toggleCheckbox.helper.js +1 -1
- package/lib/module/hooks/useDragDrop.js +214 -30
- package/lib/module/hooks/useDragDrop.js.map +1 -1
- package/lib/module/store/treeView.store.js +6 -3
- package/lib/module/store/treeView.store.js.map +1 -1
- package/lib/typescript/src/components/DragOverlay.d.ts +1 -0
- package/lib/typescript/src/components/DragOverlay.d.ts.map +1 -1
- package/lib/typescript/src/components/NodeList.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useDragDrop.d.ts +5 -0
- package/lib/typescript/src/hooks/useDragDrop.d.ts.map +1 -1
- package/lib/typescript/src/store/treeView.store.d.ts +2 -1
- package/lib/typescript/src/store/treeView.store.d.ts.map +1 -1
- package/lib/typescript/src/types/dragDrop.types.d.ts +9 -0
- package/lib/typescript/src/types/dragDrop.types.d.ts.map +1 -1
- package/lib/typescript/src/types/treeView.types.d.ts +94 -4
- package/lib/typescript/src/types/treeView.types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/DragOverlay.tsx +3 -1
- package/src/components/NodeList.tsx +62 -29
- package/src/helpers/toggleCheckbox.helper.ts +1 -1
- package/src/hooks/useDragDrop.ts +237 -31
- package/src/store/treeView.store.ts +6 -2
- package/src/types/dragDrop.types.ts +9 -0
- package/src/types/treeView.types.ts +94 -7
package/src/hooks/useDragDrop.ts
CHANGED
|
@@ -16,7 +16,7 @@ interface UseDragDropParams<ID> {
|
|
|
16
16
|
storeId: string;
|
|
17
17
|
flattenedNodes: __FlattenedTreeNode__<ID>[];
|
|
18
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>;
|
|
19
|
+
containerRef: React.RefObject<{ measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => void; } | null>;
|
|
20
20
|
dragEnabled: boolean;
|
|
21
21
|
onDragEnd?: (event: DragEndEvent<ID>) => void;
|
|
22
22
|
longPressDuration: number;
|
|
@@ -26,20 +26,25 @@ interface UseDragDropParams<ID> {
|
|
|
26
26
|
measuredItemHeightRef: React.MutableRefObject<number>;
|
|
27
27
|
dragOverlayOffset: number;
|
|
28
28
|
autoExpandDelay: number;
|
|
29
|
+
/** Pixels per nesting level, used for magnetic overlay shift. */
|
|
30
|
+
indentationMultiplier: number;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
interface UseDragDropReturn<ID> {
|
|
32
34
|
panResponder: PanResponderInstance;
|
|
33
35
|
overlayY: Animated.Value;
|
|
36
|
+
overlayX: Animated.Value;
|
|
34
37
|
isDragging: boolean;
|
|
35
38
|
draggedNode: __FlattenedTreeNode__<ID> | null;
|
|
36
39
|
dropTarget: DropTarget<ID> | null;
|
|
40
|
+
effectiveDropLevel: number;
|
|
37
41
|
handleNodeTouchStart: (
|
|
38
42
|
nodeId: ID,
|
|
39
43
|
pageY: number,
|
|
40
44
|
locationY: number,
|
|
41
45
|
nodeIndex: number,
|
|
42
46
|
) => void;
|
|
47
|
+
handleNodeTouchEnd: () => void;
|
|
43
48
|
cancelLongPressTimer: () => void;
|
|
44
49
|
scrollOffsetRef: React.MutableRefObject<number>;
|
|
45
50
|
headerOffsetRef: React.MutableRefObject<number>;
|
|
@@ -62,6 +67,7 @@ export function useDragDrop<ID>(
|
|
|
62
67
|
measuredItemHeightRef,
|
|
63
68
|
dragOverlayOffset,
|
|
64
69
|
autoExpandDelay,
|
|
70
|
+
indentationMultiplier,
|
|
65
71
|
} = params;
|
|
66
72
|
|
|
67
73
|
// --- Refs for mutable state (no stale closures in PanResponder) ---
|
|
@@ -72,6 +78,7 @@ export function useDragDrop<ID>(
|
|
|
72
78
|
|
|
73
79
|
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
74
80
|
|
|
81
|
+
const containerPageXRef = useRef(0);
|
|
75
82
|
const containerPageYRef = useRef(0);
|
|
76
83
|
const containerHeightRef = useRef(0);
|
|
77
84
|
const grabOffsetYRef = useRef(0);
|
|
@@ -80,6 +87,8 @@ export function useDragDrop<ID>(
|
|
|
80
87
|
const itemHeightRef = useRef(36);
|
|
81
88
|
|
|
82
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);
|
|
83
92
|
|
|
84
93
|
const autoScrollRAFRef = useRef<number | null>(null);
|
|
85
94
|
const autoScrollSpeedRef = useRef(0);
|
|
@@ -94,8 +103,11 @@ export function useDragDrop<ID>(
|
|
|
94
103
|
// Track which nodes were auto-expanded during this drag (to collapse on drag end)
|
|
95
104
|
const autoExpandedDuringDragRef = useRef<Set<ID>>(new Set());
|
|
96
105
|
|
|
106
|
+
// Tracks whether the PanResponder has captured the current gesture
|
|
107
|
+
const panResponderActiveRef = useRef(false);
|
|
108
|
+
|
|
97
109
|
// 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);
|
|
110
|
+
const prevDropTargetRef = useRef<{ targetIndex: number; position: "above" | "below" | "inside"; } | null>(null);
|
|
99
111
|
|
|
100
112
|
// Keep flattenedNodes ref current for PanResponder closures
|
|
101
113
|
const flattenedNodesRef = useRef(flattenedNodes);
|
|
@@ -109,8 +121,9 @@ export function useDragDrop<ID>(
|
|
|
109
121
|
const [isDragging, setIsDragging] = useState(false);
|
|
110
122
|
const [draggedNode, setDraggedNode] = useState<__FlattenedTreeNode__<ID> | null>(null);
|
|
111
123
|
const [dropTarget, setDropTarget] = useState<DropTarget<ID> | null>(null);
|
|
124
|
+
const [effectiveDropLevel, setEffectiveDropLevel] = useState(0);
|
|
112
125
|
|
|
113
|
-
// Ref mirror of dropTarget
|
|
126
|
+
// Ref mirror of dropTarget - avoids nesting Zustand updates inside React state updaters
|
|
114
127
|
const dropTargetRef = useRef<DropTarget<ID> | null>(null);
|
|
115
128
|
|
|
116
129
|
// --- Long press timer ---
|
|
@@ -152,7 +165,8 @@ export function useDragDrop<ID>(
|
|
|
152
165
|
const container = containerRef.current;
|
|
153
166
|
if (!container) return;
|
|
154
167
|
|
|
155
|
-
container.measureInWindow((
|
|
168
|
+
container.measureInWindow((x, y, _w, h) => {
|
|
169
|
+
containerPageXRef.current = x;
|
|
156
170
|
containerPageYRef.current = y;
|
|
157
171
|
containerHeightRef.current = h;
|
|
158
172
|
|
|
@@ -212,11 +226,16 @@ export function useDragDrop<ID>(
|
|
|
212
226
|
const overlayLocalY = fingerLocalY - locationY + dragOverlayOffset * itemHeightRef.current;
|
|
213
227
|
overlayY.setValue(overlayLocalY);
|
|
214
228
|
|
|
229
|
+
// Reset magnetic overlay
|
|
230
|
+
overlayX.setValue(0);
|
|
231
|
+
prevEffectiveLevelRef.current = node.level ?? 0;
|
|
232
|
+
|
|
215
233
|
// Set React state
|
|
216
234
|
isDraggingRef.current = true;
|
|
217
235
|
autoExpandedDuringDragRef.current.clear();
|
|
218
236
|
setIsDragging(true);
|
|
219
237
|
setDraggedNode(node);
|
|
238
|
+
setEffectiveDropLevel(node.level ?? 0);
|
|
220
239
|
setDropTarget(null);
|
|
221
240
|
|
|
222
241
|
// Start auto-scroll loop
|
|
@@ -314,7 +333,7 @@ export function useDragDrop<ID>(
|
|
|
314
333
|
|
|
315
334
|
// --- Calculate drop target ---
|
|
316
335
|
const calculateDropTarget = useCallback(
|
|
317
|
-
(fingerPageY: number) => {
|
|
336
|
+
(fingerPageY: number, fingerPageX: number) => {
|
|
318
337
|
const nodes = flattenedNodesRef.current;
|
|
319
338
|
if (nodes.length === 0) return;
|
|
320
339
|
|
|
@@ -338,17 +357,120 @@ export function useDragDrop<ID>(
|
|
|
338
357
|
const positionInItem =
|
|
339
358
|
(adjustedContentY - clampedIndex * iH) / iH;
|
|
340
359
|
let position: "above" | "below" | "inside";
|
|
341
|
-
if (positionInItem < 0.
|
|
360
|
+
if (positionInItem < 0.15) {
|
|
342
361
|
position = "above";
|
|
343
|
-
} else if (positionInItem > 0.
|
|
362
|
+
} else if (positionInItem > 0.85) {
|
|
344
363
|
position = "below";
|
|
345
364
|
} else {
|
|
346
365
|
position = "inside";
|
|
347
366
|
}
|
|
348
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
|
+
|
|
349
471
|
// --- Hysteresis: prevent flicker between "below N" and "above N+1" ---
|
|
350
|
-
//
|
|
351
|
-
//
|
|
472
|
+
// Only applies to same-level boundaries. Level cliffs are handled
|
|
473
|
+
// by horizontal control above, so they pass through without forced resolution.
|
|
352
474
|
const prev = prevDropTargetRef.current;
|
|
353
475
|
if (prev) {
|
|
354
476
|
const sameGap =
|
|
@@ -357,8 +479,17 @@ export function useDragDrop<ID>(
|
|
|
357
479
|
(prev.position === "above" && position === "below" &&
|
|
358
480
|
clampedIndex === prev.targetIndex - 1);
|
|
359
481
|
if (sameGap) {
|
|
360
|
-
|
|
361
|
-
|
|
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.
|
|
362
493
|
}
|
|
363
494
|
}
|
|
364
495
|
prevDropTargetRef.current = { targetIndex: clampedIndex, position };
|
|
@@ -376,7 +507,7 @@ export function useDragDrop<ID>(
|
|
|
376
507
|
// --- Auto-expand: if hovering "inside" a collapsed expandable node ---
|
|
377
508
|
if (isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
|
|
378
509
|
if (autoExpandTargetRef.current !== targetNode.id) {
|
|
379
|
-
// New hover target
|
|
510
|
+
// New hover target - start timer
|
|
380
511
|
cancelAutoExpandTimer();
|
|
381
512
|
autoExpandTargetRef.current = targetNode.id;
|
|
382
513
|
autoExpandTimerRef.current = setTimeout(() => {
|
|
@@ -387,12 +518,46 @@ export function useDragDrop<ID>(
|
|
|
387
518
|
}, autoExpandDelay);
|
|
388
519
|
}
|
|
389
520
|
} else {
|
|
390
|
-
// Not hovering inside a collapsed expandable node
|
|
521
|
+
// Not hovering inside a collapsed expandable node - cancel timer
|
|
391
522
|
if (autoExpandTargetRef.current !== null) {
|
|
392
523
|
cancelAutoExpandTimer();
|
|
393
524
|
}
|
|
394
525
|
}
|
|
395
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
|
+
|
|
396
561
|
const newTarget: DropTarget<ID> = {
|
|
397
562
|
targetNodeId: targetNode.id,
|
|
398
563
|
targetIndex: clampedIndex,
|
|
@@ -404,13 +569,23 @@ export function useDragDrop<ID>(
|
|
|
404
569
|
|
|
405
570
|
// Update the store so each Node can render its own indicator
|
|
406
571
|
if (isValid) {
|
|
407
|
-
store.getState().updateDropTarget(targetNode.id, position);
|
|
572
|
+
store.getState().updateDropTarget(targetNode.id, position, visualDropLevel);
|
|
408
573
|
} else {
|
|
409
574
|
store.getState().updateDropTarget(null, null);
|
|
410
575
|
}
|
|
411
576
|
|
|
412
577
|
// Keep ref in sync (used by handleDragEnd to avoid setState-during-render)
|
|
413
|
-
|
|
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
|
+
}
|
|
414
589
|
|
|
415
590
|
setDropTarget((prevTarget) => {
|
|
416
591
|
if (
|
|
@@ -424,12 +599,12 @@ export function useDragDrop<ID>(
|
|
|
424
599
|
return newTarget;
|
|
425
600
|
});
|
|
426
601
|
},
|
|
427
|
-
[storeId, autoExpandDelay, cancelAutoExpandTimer]
|
|
602
|
+
[storeId, autoExpandDelay, cancelAutoExpandTimer, indentationMultiplier, overlayX]
|
|
428
603
|
);
|
|
429
604
|
|
|
430
605
|
// --- Handle drag end ---
|
|
431
606
|
const handleDragEnd = useCallback(
|
|
432
|
-
(fingerPageY?: number) => {
|
|
607
|
+
(fingerPageY?: number, fingerPageX?: number) => {
|
|
433
608
|
stopAutoScroll();
|
|
434
609
|
cancelLongPressTimer();
|
|
435
610
|
cancelAutoExpandTimer();
|
|
@@ -438,9 +613,9 @@ export function useDragDrop<ID>(
|
|
|
438
613
|
if (!isDraggingRef.current) return;
|
|
439
614
|
isDraggingRef.current = false;
|
|
440
615
|
|
|
441
|
-
// Recalculate drop target at final position if we have
|
|
616
|
+
// Recalculate drop target at final position if we have coords
|
|
442
617
|
if (fingerPageY !== undefined) {
|
|
443
|
-
calculateDropTarget(fingerPageY);
|
|
618
|
+
calculateDropTarget(fingerPageY, fingerPageX ?? 0);
|
|
444
619
|
}
|
|
445
620
|
|
|
446
621
|
// Cancel any auto-expand timer that calculateDropTarget may have just started.
|
|
@@ -498,17 +673,31 @@ export function useDragDrop<ID>(
|
|
|
498
673
|
newTreeData: newData,
|
|
499
674
|
});
|
|
500
675
|
|
|
501
|
-
// Scroll to the dropped node after React processes the expansion
|
|
676
|
+
// Scroll to the dropped node after React processes the expansion,
|
|
677
|
+
// but only if it's outside the visible viewport. An animated
|
|
678
|
+
// scroll would consume the user's next touch (RN stops the
|
|
679
|
+
// animation on tap), so we skip when the node is already visible.
|
|
502
680
|
setTimeout(() => {
|
|
503
681
|
const nodes = flattenedNodesRef.current;
|
|
504
682
|
const idx = nodes.findIndex(n => n.id === droppedNodeId);
|
|
505
|
-
if (idx
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
683
|
+
if (idx < 0) return;
|
|
684
|
+
|
|
685
|
+
const itemH = itemHeightRef.current;
|
|
686
|
+
const scrollTop = scrollOffsetRef.current;
|
|
687
|
+
const containerH = containerHeightRef.current;
|
|
688
|
+
const estimatedTop = idx * itemH;
|
|
689
|
+
const estimatedBottom = estimatedTop + itemH;
|
|
690
|
+
|
|
691
|
+
// Already in view → no scroll needed
|
|
692
|
+
if (estimatedTop >= scrollTop && estimatedBottom <= scrollTop + containerH) {
|
|
693
|
+
return;
|
|
511
694
|
}
|
|
695
|
+
|
|
696
|
+
flashListRef.current?.scrollToIndex?.({
|
|
697
|
+
index: idx,
|
|
698
|
+
animated: true,
|
|
699
|
+
viewPosition: 0.5,
|
|
700
|
+
});
|
|
512
701
|
}, 100);
|
|
513
702
|
}
|
|
514
703
|
|
|
@@ -547,6 +736,8 @@ export function useDragDrop<ID>(
|
|
|
547
736
|
store2.getState().updateDropTarget(null, null);
|
|
548
737
|
|
|
549
738
|
// Reset all refs
|
|
739
|
+
overlayX.setValue(0);
|
|
740
|
+
prevEffectiveLevelRef.current = null;
|
|
550
741
|
dropTargetRef.current = null;
|
|
551
742
|
draggedNodeRef.current = null;
|
|
552
743
|
draggedNodeIdRef.current = null;
|
|
@@ -567,6 +758,16 @@ export function useDragDrop<ID>(
|
|
|
567
758
|
]
|
|
568
759
|
);
|
|
569
760
|
|
|
761
|
+
// --- Handle node touch end ---
|
|
762
|
+
// If the PanResponder never captured the gesture (no movement after long
|
|
763
|
+
// press fired), end the drag here so the node doesn't stay "lifted".
|
|
764
|
+
const handleNodeTouchEnd = useCallback(() => {
|
|
765
|
+
cancelLongPressTimer();
|
|
766
|
+
if (isDraggingRef.current && !panResponderActiveRef.current) {
|
|
767
|
+
handleDragEnd();
|
|
768
|
+
}
|
|
769
|
+
}, [cancelLongPressTimer, handleDragEnd]);
|
|
770
|
+
|
|
570
771
|
// --- PanResponder ---
|
|
571
772
|
const panResponder = useRef(
|
|
572
773
|
PanResponder.create({
|
|
@@ -578,7 +779,7 @@ export function useDragDrop<ID>(
|
|
|
578
779
|
isDraggingRef.current,
|
|
579
780
|
|
|
580
781
|
onPanResponderGrant: () => {
|
|
581
|
-
|
|
782
|
+
panResponderActiveRef.current = true;
|
|
582
783
|
},
|
|
583
784
|
|
|
584
785
|
onPanResponderMove: (evt) => {
|
|
@@ -593,10 +794,10 @@ export function useDragDrop<ID>(
|
|
|
593
794
|
fingerLocalY - grabOffsetYRef.current + dragOverlayOffset * itemHeightRef.current;
|
|
594
795
|
overlayY.setValue(overlayLocalY);
|
|
595
796
|
|
|
596
|
-
// Calculate drop target
|
|
597
|
-
calculateDropTarget(fingerPageY);
|
|
797
|
+
// Calculate drop target (horizontal position used at level cliffs)
|
|
798
|
+
calculateDropTarget(fingerPageY, evt.nativeEvent.pageX);
|
|
598
799
|
|
|
599
|
-
// Auto-scroll at edges
|
|
800
|
+
// Auto-scroll at edges - use delta-based position relative to container
|
|
600
801
|
const fingerInContainer =
|
|
601
802
|
initialFingerContainerYRef.current +
|
|
602
803
|
(fingerPageY - initialFingerPageYRef.current);
|
|
@@ -604,10 +805,12 @@ export function useDragDrop<ID>(
|
|
|
604
805
|
},
|
|
605
806
|
|
|
606
807
|
onPanResponderRelease: (evt) => {
|
|
607
|
-
|
|
808
|
+
panResponderActiveRef.current = false;
|
|
809
|
+
handleDragEnd(evt.nativeEvent.pageY, evt.nativeEvent.pageX);
|
|
608
810
|
},
|
|
609
811
|
|
|
610
812
|
onPanResponderTerminate: () => {
|
|
813
|
+
panResponderActiveRef.current = false;
|
|
611
814
|
handleDragEnd();
|
|
612
815
|
},
|
|
613
816
|
})
|
|
@@ -632,10 +835,13 @@ export function useDragDrop<ID>(
|
|
|
632
835
|
return {
|
|
633
836
|
panResponder,
|
|
634
837
|
overlayY,
|
|
838
|
+
overlayX,
|
|
635
839
|
isDragging,
|
|
636
840
|
draggedNode,
|
|
637
841
|
dropTarget,
|
|
842
|
+
effectiveDropLevel,
|
|
638
843
|
handleNodeTouchStart,
|
|
844
|
+
handleNodeTouchEnd,
|
|
639
845
|
cancelLongPressTimer,
|
|
640
846
|
scrollOffsetRef,
|
|
641
847
|
headerOffsetRef,
|
|
@@ -54,7 +54,8 @@ export type TreeViewState<ID> = {
|
|
|
54
54
|
// Drop target state (used by nodes to render their own indicator)
|
|
55
55
|
dropTargetNodeId: ID | null;
|
|
56
56
|
dropPosition: DropPosition | null;
|
|
57
|
-
|
|
57
|
+
dropLevel: number | null;
|
|
58
|
+
updateDropTarget: (nodeId: ID | null, position: DropPosition | null, level?: number | null) => void;
|
|
58
59
|
|
|
59
60
|
// Cleanup all states in this store
|
|
60
61
|
cleanUpTreeViewStore: () => void;
|
|
@@ -120,9 +121,11 @@ export function getTreeViewStore<ID>(id: string): UseBoundStore<StoreApi<TreeVie
|
|
|
120
121
|
|
|
121
122
|
dropTargetNodeId: null,
|
|
122
123
|
dropPosition: null,
|
|
123
|
-
|
|
124
|
+
dropLevel: null,
|
|
125
|
+
updateDropTarget: (nodeId, position, level) => set({
|
|
124
126
|
dropTargetNodeId: nodeId,
|
|
125
127
|
dropPosition: position,
|
|
128
|
+
dropLevel: level ?? null,
|
|
126
129
|
}),
|
|
127
130
|
|
|
128
131
|
cleanUpTreeViewStore: () =>
|
|
@@ -141,6 +144,7 @@ export function getTreeViewStore<ID>(id: string): UseBoundStore<StoreApi<TreeVie
|
|
|
141
144
|
invalidDragTargetIds: new Set<ID>(),
|
|
142
145
|
dropTargetNodeId: null,
|
|
143
146
|
dropPosition: null,
|
|
147
|
+
dropLevel: null,
|
|
144
148
|
}),
|
|
145
149
|
}));
|
|
146
150
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { TreeNode } from "./treeView.types";
|
|
2
2
|
|
|
3
|
+
/** Where a node is dropped relative to the target: as a sibling above/below, or as a child inside */
|
|
3
4
|
export type DropPosition = "above" | "below" | "inside";
|
|
4
5
|
|
|
6
|
+
/** Event payload passed to the onDragEnd callback after a successful drop */
|
|
5
7
|
export interface DragEndEvent<ID = string> {
|
|
6
8
|
/** The id of the node that was dragged */
|
|
7
9
|
draggedNodeId: ID;
|
|
@@ -13,11 +15,18 @@ export interface DragEndEvent<ID = string> {
|
|
|
13
15
|
newTreeData: TreeNode<ID>[];
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/** Internal representation of the current drop target during a drag operation */
|
|
16
19
|
export interface DropTarget<ID = string> {
|
|
20
|
+
/** The id of the node being hovered over */
|
|
17
21
|
targetNodeId: ID;
|
|
22
|
+
/** Index of the target node in the flattened list */
|
|
18
23
|
targetIndex: number;
|
|
24
|
+
/** Where the drop would occur relative to the target */
|
|
19
25
|
position: DropPosition;
|
|
26
|
+
/** Whether this is a valid drop location (e.g. not dropping a node onto itself or its descendants) */
|
|
20
27
|
isValid: boolean;
|
|
28
|
+
/** Nesting level of the target node */
|
|
21
29
|
targetLevel: number;
|
|
30
|
+
/** Y-coordinate for positioning the drop indicator */
|
|
22
31
|
indicatorTop: number;
|
|
23
32
|
}
|