react-native-tree-multi-select 3.0.0-beta.1 → 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.
- package/lib/module/components/DragOverlay.js +3 -0
- package/lib/module/components/DragOverlay.js.map +1 -1
- package/lib/module/components/NodeList.js +54 -25
- package/lib/module/components/NodeList.js.map +1 -1
- package/lib/module/hooks/useDragDrop.js +191 -19
- 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/treeView.types.d.ts +4 -0
- 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 +54 -29
- package/src/hooks/useDragDrop.ts +210 -18
- package/src/store/treeView.store.ts +6 -2
- package/src/types/treeView.types.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-tree-multi-select",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.2",
|
|
4
4
|
"description": "A super-fast, customizable tree view component for React Native with multi-selection, checkboxes, and search filtering capabilities.",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"types": "./lib/typescript/src/index.d.ts",
|
|
@@ -13,6 +13,7 @@ import { defaultIndentationMultiplier } from "../constants/treeView.constants";
|
|
|
13
13
|
|
|
14
14
|
interface DragOverlayProps<ID> extends TreeItemCustomizations<ID> {
|
|
15
15
|
overlayY: Animated.Value;
|
|
16
|
+
overlayX: Animated.Value;
|
|
16
17
|
node: __FlattenedTreeNode__<ID>;
|
|
17
18
|
level: number;
|
|
18
19
|
dragDropCustomizations?: DragDropCustomizations<ID>;
|
|
@@ -21,6 +22,7 @@ interface DragOverlayProps<ID> extends TreeItemCustomizations<ID> {
|
|
|
21
22
|
function _DragOverlay<ID>(props: DragOverlayProps<ID>) {
|
|
22
23
|
const {
|
|
23
24
|
overlayY,
|
|
25
|
+
overlayX,
|
|
24
26
|
node,
|
|
25
27
|
level,
|
|
26
28
|
indentationMultiplier = defaultIndentationMultiplier,
|
|
@@ -47,7 +49,7 @@ function _DragOverlay<ID>(props: DragOverlayProps<ID>) {
|
|
|
47
49
|
...(overlayStyleProps.elevation != null && { elevation: overlayStyleProps.elevation }),
|
|
48
50
|
},
|
|
49
51
|
overlayStyleProps?.style,
|
|
50
|
-
{ transform: [{ translateY: overlayY }] },
|
|
52
|
+
{ transform: [{ translateX: overlayX }, { translateY: overlayY }] },
|
|
51
53
|
]}
|
|
52
54
|
>
|
|
53
55
|
{CustomOverlay ? (
|
|
@@ -114,13 +114,18 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
114
114
|
updateInnerMostChildrenIds(updatedInnerMostChildrenIds);
|
|
115
115
|
}, [filteredTree, updateInnerMostChildrenIds]);
|
|
116
116
|
|
|
117
|
+
const effectiveIndentationMultiplier = indentationMultiplier ?? defaultIndentationMultiplier;
|
|
118
|
+
|
|
117
119
|
// --- Drag and drop ---
|
|
118
120
|
const {
|
|
119
121
|
panResponder,
|
|
120
122
|
overlayY,
|
|
123
|
+
overlayX,
|
|
121
124
|
isDragging,
|
|
122
125
|
draggedNode,
|
|
126
|
+
effectiveDropLevel,
|
|
123
127
|
handleNodeTouchStart,
|
|
128
|
+
handleNodeTouchEnd,
|
|
124
129
|
cancelLongPressTimer,
|
|
125
130
|
scrollOffsetRef,
|
|
126
131
|
} = useDragDrop<ID>({
|
|
@@ -137,6 +142,7 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
137
142
|
measuredItemHeightRef,
|
|
138
143
|
dragOverlayOffset,
|
|
139
144
|
autoExpandDelay,
|
|
145
|
+
indentationMultiplier: effectiveIndentationMultiplier,
|
|
140
146
|
});
|
|
141
147
|
|
|
142
148
|
// Combined onScroll handler
|
|
@@ -150,8 +156,6 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
150
156
|
treeFlashListProps?.onScroll?.(event as any);
|
|
151
157
|
}, [scrollOffsetRef, cancelLongPressTimer, treeFlashListProps]);
|
|
152
158
|
|
|
153
|
-
const effectiveIndentationMultiplier = indentationMultiplier ?? defaultIndentationMultiplier;
|
|
154
|
-
|
|
155
159
|
const nodeRenderer = React.useCallback((
|
|
156
160
|
{ item, index }: { item: __FlattenedTreeNode__<ID>; index: number; }
|
|
157
161
|
) => {
|
|
@@ -174,7 +178,7 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
174
178
|
dragEnabled={dragEnabled}
|
|
175
179
|
isDragging={isDragging}
|
|
176
180
|
onNodeTouchStart={dragEnabled ? handleNodeTouchStart : undefined}
|
|
177
|
-
onNodeTouchEnd={dragEnabled ?
|
|
181
|
+
onNodeTouchEnd={dragEnabled ? handleNodeTouchEnd : undefined}
|
|
178
182
|
onItemLayout={dragEnabled ? handleItemLayout : undefined}
|
|
179
183
|
dragDropCustomizations={dragDropCustomizations}
|
|
180
184
|
/>
|
|
@@ -190,8 +194,8 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
190
194
|
dragEnabled,
|
|
191
195
|
isDragging,
|
|
192
196
|
handleNodeTouchStart,
|
|
197
|
+
handleNodeTouchEnd,
|
|
193
198
|
dragDropCustomizations,
|
|
194
|
-
cancelLongPressTimer,
|
|
195
199
|
handleItemLayout,
|
|
196
200
|
]);
|
|
197
201
|
|
|
@@ -237,8 +241,9 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
|
|
|
237
241
|
{isDragging && draggedNode && (
|
|
238
242
|
<DragOverlay<ID>
|
|
239
243
|
overlayY={overlayY}
|
|
244
|
+
overlayX={overlayX}
|
|
240
245
|
node={draggedNode}
|
|
241
|
-
level={
|
|
246
|
+
level={effectiveDropLevel}
|
|
242
247
|
indentationMultiplier={effectiveIndentationMultiplier}
|
|
243
248
|
CheckboxComponent={CheckboxComponent}
|
|
244
249
|
ExpandCollapseIconComponent={ExpandCollapseIconComponent}
|
|
@@ -306,6 +311,7 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
306
311
|
isDragInvalid,
|
|
307
312
|
isDropTarget,
|
|
308
313
|
nodeDropPosition,
|
|
314
|
+
nodeDropLevel,
|
|
309
315
|
} = useTreeViewStore<ID>(storeId)(useShallow(
|
|
310
316
|
state => ({
|
|
311
317
|
isExpanded: state.expanded.has(node.id),
|
|
@@ -317,18 +323,30 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
317
323
|
isDragInvalid: state.invalidDragTargetIds.has(node.id),
|
|
318
324
|
isDropTarget: state.dropTargetNodeId === node.id,
|
|
319
325
|
nodeDropPosition: state.dropTargetNodeId === node.id ? state.dropPosition : null,
|
|
326
|
+
nodeDropLevel: state.dropTargetNodeId === node.id ? state.dropLevel : null,
|
|
320
327
|
})
|
|
321
328
|
));
|
|
322
329
|
|
|
330
|
+
// Track when this node was dragged so we can swallow the onPress/onCheck
|
|
331
|
+
// that fires when the user lifts their finger after a long-press-initiated drag.
|
|
332
|
+
// The flag is set during render (synchronous) and cleared on the next touch start.
|
|
333
|
+
const wasDraggedRef = React.useRef(false);
|
|
334
|
+
if (isDraggingGlobal && isBeingDragged) {
|
|
335
|
+
wasDraggedRef.current = true;
|
|
336
|
+
}
|
|
337
|
+
|
|
323
338
|
const _onToggleExpand = React.useCallback(() => {
|
|
339
|
+
if (wasDraggedRef.current) return;
|
|
324
340
|
handleToggleExpand(storeId, node.id);
|
|
325
341
|
}, [storeId, node.id]);
|
|
326
342
|
|
|
327
343
|
const _onCheck = React.useCallback(() => {
|
|
344
|
+
if (wasDraggedRef.current) return;
|
|
328
345
|
toggleCheckboxes(storeId, [node.id]);
|
|
329
346
|
}, [storeId, node.id]);
|
|
330
347
|
|
|
331
348
|
const handleTouchStart = React.useCallback((e: any) => {
|
|
349
|
+
wasDraggedRef.current = false;
|
|
332
350
|
if (!onNodeTouchStart) return;
|
|
333
351
|
const { pageY, locationY } = e.nativeEvent;
|
|
334
352
|
onNodeTouchStart(node.id, pageY, locationY, nodeIndex);
|
|
@@ -355,10 +373,11 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
355
373
|
} : undefined;
|
|
356
374
|
|
|
357
375
|
const CustomDropIndicator = dragDropCustomizations?.CustomDropIndicatorComponent;
|
|
376
|
+
const indicatorLevel = nodeDropLevel ?? level;
|
|
358
377
|
const dropIndicator = isDropTarget && nodeDropPosition ? (
|
|
359
378
|
CustomDropIndicator
|
|
360
|
-
? <CustomDropIndicator position={nodeDropPosition} />
|
|
361
|
-
: <NodeDropIndicator position={nodeDropPosition} styleProps={dragDropCustomizations?.dropIndicatorStyleProps} />
|
|
379
|
+
? <CustomDropIndicator position={nodeDropPosition} level={indicatorLevel} indentationMultiplier={indentationMultiplier} />
|
|
380
|
+
: <NodeDropIndicator position={nodeDropPosition} level={indicatorLevel} indentationMultiplier={indentationMultiplier} styleProps={dragDropCustomizations?.dropIndicatorStyleProps} />
|
|
362
381
|
) : null;
|
|
363
382
|
|
|
364
383
|
if (!CustomNodeRowComponent) {
|
|
@@ -371,6 +390,7 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
371
390
|
styles.nodeCheckboxAndArrowRow,
|
|
372
391
|
{ paddingStart: level * indentationMultiplier },
|
|
373
392
|
{ opacity: nodeOpacity },
|
|
393
|
+
dropIndicator ? styles.nodeOverflowVisible : undefined,
|
|
374
394
|
]}>
|
|
375
395
|
{dropIndicator}
|
|
376
396
|
<CheckboxComponent
|
|
@@ -398,7 +418,10 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
398
418
|
<View
|
|
399
419
|
{...touchHandlers}
|
|
400
420
|
onLayout={onItemLayout ? handleLayout : undefined}
|
|
401
|
-
style={
|
|
421
|
+
style={[
|
|
422
|
+
{ opacity: nodeOpacity },
|
|
423
|
+
dropIndicator ? styles.nodeOverflowVisible : undefined,
|
|
424
|
+
]}
|
|
402
425
|
>
|
|
403
426
|
{dropIndicator}
|
|
404
427
|
<CustomNodeRowComponent
|
|
@@ -417,8 +440,10 @@ function _Node<ID>(props: NodeProps<ID>) {
|
|
|
417
440
|
}
|
|
418
441
|
};
|
|
419
442
|
|
|
420
|
-
function NodeDropIndicator({ position, styleProps }: {
|
|
443
|
+
function NodeDropIndicator({ position, level, indentationMultiplier, styleProps }: {
|
|
421
444
|
position: DropPosition;
|
|
445
|
+
level: number;
|
|
446
|
+
indentationMultiplier: number;
|
|
422
447
|
styleProps?: DropIndicatorStyleProps;
|
|
423
448
|
}) {
|
|
424
449
|
const lineColor = styleProps?.lineColor ?? "#0078FF";
|
|
@@ -427,6 +452,10 @@ function NodeDropIndicator({ position, styleProps }: {
|
|
|
427
452
|
const highlightColor = styleProps?.highlightColor ?? "rgba(0, 120, 255, 0.15)";
|
|
428
453
|
const highlightBorderColor = styleProps?.highlightBorderColor ?? "rgba(0, 120, 255, 0.5)";
|
|
429
454
|
|
|
455
|
+
// Indent the line to match the node's nesting level so users can
|
|
456
|
+
// visually distinguish drops at different tree depths.
|
|
457
|
+
const leftOffset = level * indentationMultiplier;
|
|
458
|
+
|
|
430
459
|
if (position === "inside") {
|
|
431
460
|
return (
|
|
432
461
|
<View
|
|
@@ -434,6 +463,7 @@ function NodeDropIndicator({ position, styleProps }: {
|
|
|
434
463
|
style={[
|
|
435
464
|
styles.dropHighlight,
|
|
436
465
|
{
|
|
466
|
+
left: leftOffset,
|
|
437
467
|
backgroundColor: highlightColor,
|
|
438
468
|
borderColor: highlightBorderColor,
|
|
439
469
|
},
|
|
@@ -442,26 +472,25 @@ function NodeDropIndicator({ position, styleProps }: {
|
|
|
442
472
|
);
|
|
443
473
|
}
|
|
444
474
|
|
|
475
|
+
// Ensure the circle isn't clipped at shallow indent levels
|
|
476
|
+
const safeLeftOffset = Math.max(leftOffset, circleSize / 2);
|
|
477
|
+
|
|
445
478
|
return (
|
|
446
479
|
<View
|
|
447
480
|
pointerEvents="none"
|
|
448
481
|
style={[
|
|
449
482
|
styles.dropLineContainer,
|
|
450
|
-
{ height: lineThickness },
|
|
483
|
+
{ height: lineThickness, left: safeLeftOffset },
|
|
451
484
|
position === "above" ? styles.dropLineTop : styles.dropLineBottom,
|
|
452
485
|
]}
|
|
453
486
|
>
|
|
454
|
-
<View style={
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
marginLeft: -(circleSize / 2),
|
|
462
|
-
marginTop: -(circleSize / 2 - lineThickness / 2),
|
|
463
|
-
},
|
|
464
|
-
]} />
|
|
487
|
+
<View style={{
|
|
488
|
+
width: circleSize,
|
|
489
|
+
height: circleSize,
|
|
490
|
+
borderRadius: circleSize / 2,
|
|
491
|
+
backgroundColor: lineColor,
|
|
492
|
+
marginLeft: -(circleSize / 2),
|
|
493
|
+
}} />
|
|
465
494
|
<View style={[
|
|
466
495
|
styles.dropLine,
|
|
467
496
|
{
|
|
@@ -510,6 +539,7 @@ const styles = StyleSheet.create({
|
|
|
510
539
|
alignItems: "center",
|
|
511
540
|
height: 3,
|
|
512
541
|
zIndex: 10,
|
|
542
|
+
overflow: "visible",
|
|
513
543
|
},
|
|
514
544
|
dropLineTop: {
|
|
515
545
|
top: 0,
|
|
@@ -517,17 +547,12 @@ const styles = StyleSheet.create({
|
|
|
517
547
|
dropLineBottom: {
|
|
518
548
|
bottom: 0,
|
|
519
549
|
},
|
|
520
|
-
dropLineCircle: {
|
|
521
|
-
width: 10,
|
|
522
|
-
height: 10,
|
|
523
|
-
borderRadius: 5,
|
|
524
|
-
backgroundColor: "#0078FF",
|
|
525
|
-
marginLeft: -5,
|
|
526
|
-
marginTop: -4,
|
|
527
|
-
},
|
|
528
550
|
dropLine: {
|
|
529
551
|
flex: 1,
|
|
530
552
|
height: 3,
|
|
531
553
|
backgroundColor: "#0078FF",
|
|
532
554
|
},
|
|
555
|
+
nodeOverflowVisible: {
|
|
556
|
+
overflow: "visible",
|
|
557
|
+
},
|
|
533
558
|
});
|
package/src/hooks/useDragDrop.ts
CHANGED
|
@@ -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,6 +103,9 @@ 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
110
|
const prevDropTargetRef = useRef<{ targetIndex: number; position: "above" | "below" | "inside" } | null>(null);
|
|
99
111
|
|
|
@@ -109,6 +121,7 @@ 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
126
|
// Ref mirror of dropTarget — avoids nesting Zustand updates inside React state updaters
|
|
114
127
|
const dropTargetRef = useRef<DropTarget<ID> | null>(null);
|
|
@@ -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 };
|
|
@@ -393,6 +524,40 @@ export function useDragDrop<ID>(
|
|
|
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.
|
|
@@ -547,6 +722,8 @@ export function useDragDrop<ID>(
|
|
|
547
722
|
store2.getState().updateDropTarget(null, null);
|
|
548
723
|
|
|
549
724
|
// Reset all refs
|
|
725
|
+
overlayX.setValue(0);
|
|
726
|
+
prevEffectiveLevelRef.current = null;
|
|
550
727
|
dropTargetRef.current = null;
|
|
551
728
|
draggedNodeRef.current = null;
|
|
552
729
|
draggedNodeIdRef.current = null;
|
|
@@ -567,6 +744,16 @@ export function useDragDrop<ID>(
|
|
|
567
744
|
]
|
|
568
745
|
);
|
|
569
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
|
+
|
|
570
757
|
// --- PanResponder ---
|
|
571
758
|
const panResponder = useRef(
|
|
572
759
|
PanResponder.create({
|
|
@@ -578,7 +765,7 @@ export function useDragDrop<ID>(
|
|
|
578
765
|
isDraggingRef.current,
|
|
579
766
|
|
|
580
767
|
onPanResponderGrant: () => {
|
|
581
|
-
|
|
768
|
+
panResponderActiveRef.current = true;
|
|
582
769
|
},
|
|
583
770
|
|
|
584
771
|
onPanResponderMove: (evt) => {
|
|
@@ -593,8 +780,8 @@ export function useDragDrop<ID>(
|
|
|
593
780
|
fingerLocalY - grabOffsetYRef.current + dragOverlayOffset * itemHeightRef.current;
|
|
594
781
|
overlayY.setValue(overlayLocalY);
|
|
595
782
|
|
|
596
|
-
// Calculate drop target
|
|
597
|
-
calculateDropTarget(fingerPageY);
|
|
783
|
+
// Calculate drop target (horizontal position used at level cliffs)
|
|
784
|
+
calculateDropTarget(fingerPageY, evt.nativeEvent.pageX);
|
|
598
785
|
|
|
599
786
|
// Auto-scroll at edges — use delta-based position relative to container
|
|
600
787
|
const fingerInContainer =
|
|
@@ -604,10 +791,12 @@ export function useDragDrop<ID>(
|
|
|
604
791
|
},
|
|
605
792
|
|
|
606
793
|
onPanResponderRelease: (evt) => {
|
|
607
|
-
|
|
794
|
+
panResponderActiveRef.current = false;
|
|
795
|
+
handleDragEnd(evt.nativeEvent.pageY, evt.nativeEvent.pageX);
|
|
608
796
|
},
|
|
609
797
|
|
|
610
798
|
onPanResponderTerminate: () => {
|
|
799
|
+
panResponderActiveRef.current = false;
|
|
611
800
|
handleDragEnd();
|
|
612
801
|
},
|
|
613
802
|
})
|
|
@@ -632,10 +821,13 @@ export function useDragDrop<ID>(
|
|
|
632
821
|
return {
|
|
633
822
|
panResponder,
|
|
634
823
|
overlayY,
|
|
824
|
+
overlayX,
|
|
635
825
|
isDragging,
|
|
636
826
|
draggedNode,
|
|
637
827
|
dropTarget,
|
|
828
|
+
effectiveDropLevel,
|
|
638
829
|
handleNodeTouchStart,
|
|
830
|
+
handleNodeTouchEnd,
|
|
639
831
|
cancelLongPressTimer,
|
|
640
832
|
scrollOffsetRef,
|
|
641
833
|
headerOffsetRef,
|