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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-tree-multi-select",
3
- "version": "3.0.0-beta.1",
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 ? cancelLongPressTimer : undefined}
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={draggedNode.level ?? 0}
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={{ opacity: nodeOpacity }}
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
- styles.dropLineCircle,
456
- {
457
- width: circleSize,
458
- height: circleSize,
459
- borderRadius: circleSize / 2,
460
- backgroundColor: lineColor,
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
  });
@@ -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((_x, y, _w, h) => {
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.3) {
360
+ if (positionInItem < 0.15) {
342
361
  position = "above";
343
- } else if (positionInItem > 0.7) {
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
- // These two positions represent the same visual gap between nodes.
351
- // Keep the previous one to avoid the indicator jumping between nodes.
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
- // Keep previous target — they're at the same visual gap
361
- return;
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
- dropTargetRef.current = newTarget;
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 a pageY
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
- // Touch captured successfully
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
- handleDragEnd(evt.nativeEvent.pageY);
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,