react-native-tree-multi-select
Version:
Super-fast, customizable tree view component for React Native with drag-and-drop reordering, multi-selection, and search filtering.
1,026 lines (937 loc) • 65.2 kB
text/typescript
import {
MutableRefObject,
RefObject,
useCallback,
useEffect,
useRef,
useState
} from "react";
import {
AccessibilityInfo,
Animated,
PanResponder,
Platform,
type NativeScrollEvent,
type NativeSyntheticEvent,
type PanResponderInstance,
} from "react-native";
import type { FlashList } from "@shopify/flash-list";
import type { __FlattenedTreeNode__, TreeNode, DropAutoScrollOptions } from "../types/treeView.types";
import type { ScrollToNodeHandlerRef } from "./useScrollToNode";
import type { DragCancelEvent, DragEndEvent, DragStartEvent, DropPosition, DropTarget } from "../types/dragDrop.types";
import { getTreeViewStore } from "../store/treeView.store";
import {
collapseNodes,
expandNodes,
getSubtreeDepthFromMap,
handleToggleExpand,
} from "../helpers";
import {
applyMoveToStore,
findNodePosition,
findNodePositionFromMaps,
moveTreeNode,
} from "../helpers/moveTreeNode.helper";
import { scrollMovedNodeIntoView } from "./useScrollToNode";
import { defaultItemHeight } from "../constants/treeView.constants";
// Android reports touch `locationY` slightly differently from iOS, which makes the
// drag overlay sit ~2 item-heights closer to the finger. This empirical correction
// (in item-height units, added to dragOverlayOffset) compensates for that. Consumers
// can override it per-instance via DragAndDropOptions.overlayYCorrection.
/* istanbul ignore next -- Platform.OS is never "android" in jest */
const DEFAULT_OVERLAY_Y_CORRECTION = Platform.OS === "android" ? -2 : 0;
// Auto-scroll speed at the very edge in px/SECOND, before the proximity ramp
// and the consumer's `autoScrollSpeed` multiplier are applied. Time-based (not
// px/frame) so 60Hz and 120Hz displays scroll at the same real-world speed.
const MAX_AUTO_SCROLL_SPEED = 1200;
// During auto-scroll, recompute the drop target at most this often. Every scroll
// frame moves rows under the finger, but recomputing per frame costs store
// writes + node re-renders on the JS thread (stuttering the scroll itself), and
// ~10 indicator updates/sec reads calmer than 60 anyway.
const AUTO_SCROLL_RECALC_INTERVAL_MS = 100;
// How long (ms) a candidate drop level must hold before the overlay springs to
// its indentation. Prevents the indent from chasing every row the finger merely
// passes through while dragging vertically.
const LEVEL_SETTLE_MS = 120;
// How far (fraction of row height) the active zone's boundaries extend outward
// while the finger stays on the same row. Finger tremor at a zone edge would
// otherwise flip above/inside/below (and the overlay indent) every few frames.
const ZONE_STICKINESS = 0.08;
interface UseDragDropParams<ID> {
storeId: string;
flattenedNodes: __FlattenedTreeNode__<ID>[];
flashListRef: RefObject<FlashList<__FlattenedTreeNode__<ID>> | null>;
containerRef: RefObject<{ measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => void; } | null>;
dragEnabled: boolean;
onDragStart?: (event: DragStartEvent<ID>) => void;
onDragEnd?: (event: DragEndEvent<ID>) => void;
onDragCancel?: (event: DragCancelEvent<ID>) => void;
longPressDuration: number;
autoScrollThreshold: number;
autoScrollSpeed: number;
measuredItemHeightRef: MutableRefObject<number>;
/** Live total content height of the list (from FlashList onContentSizeChange),
* used to clamp auto-scroll so the offset never runs past the end of the list. */
contentHeightRef: MutableRefObject<number>;
/** Measured row heights keyed by stable node id. Enables accurate drop
* targeting for variable-height rows when the whole list is rendered. */
itemHeightsRef: MutableRefObject<Map<ID, number>>;
dragOverlayOffset: number;
/** Optional override (item-height units) for the platform overlay-Y correction. */
overlayYCorrection?: number;
autoExpandDelay: number;
/** Whether hovering "inside" a collapsed node auto-expands it. Default: true. */
autoExpand?: boolean;
/** Whether the overlay springs ("magnetic snap") when the drop level changes. Default: true. */
magneticSnap?: boolean;
/** Pixels per nesting level, used for magnetic overlay shift. */
indentationMultiplier: number;
/** Callback to determine if a drop is allowed on a specific target. */
canDrop?: (draggedNode: TreeNode<ID>, targetNode: TreeNode<ID>, position: DropPosition) => boolean;
/** Maximum nesting depth allowed. */
maxDepth?: number;
/** Callback to determine if a node can accept children. */
canNodeHaveChildren?: (node: TreeNode<ID>) => boolean;
/** Ref for scrolling to a node after drop. */
scrollToNodeHandlerRef: RefObject<ScrollToNodeHandlerRef<ID> | null>;
/** Auto-scroll configuration for after drop. */
autoScrollToDroppedNode?: boolean | DropAutoScrollOptions;
/** Callback to determine if a node can be dragged. */
canDrag?: (node: TreeNode<ID>) => boolean;
}
interface UseDragDropReturn<ID> {
panResponder: PanResponderInstance;
overlayY: Animated.Value;
overlayX: Animated.Value;
isDragging: boolean;
draggedNode: __FlattenedTreeNode__<ID> | null;
handleNodeTouchStart: (
nodeId: ID,
pageY: number,
locationY: number,
nodeIndex: number,
) => void;
handleNodeTouchEnd: () => void;
cancelLongPressTimer: () => void;
/** onScroll handler for the host list. Owns the scroll-offset bookkeeping:
* real scroll events are ignored while a drag is active (the auto-scroll
* RAF loop is the sole writer of the offset then - lagging events would
* fight its accumulated value and make the offset oscillate), and any
* scroll cancels a pending long-press. */
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
scrollOffsetRef: MutableRefObject<number>;
headerOffsetRef: MutableRefObject<number>;
/** Live container height; kept fresh via the container's onLayout so the
* auto-scroll edge/clamp math survives a mid-session resize. */
containerHeightRef: MutableRefObject<number>;
}
export function useDragDrop<ID>(
params: UseDragDropParams<ID>
): UseDragDropReturn<ID> {
const {
storeId,
flattenedNodes,
flashListRef,
containerRef,
dragEnabled,
onDragStart,
onDragEnd,
onDragCancel,
longPressDuration,
autoScrollThreshold,
autoScrollSpeed,
measuredItemHeightRef,
contentHeightRef,
itemHeightsRef,
dragOverlayOffset,
overlayYCorrection,
autoExpandDelay,
autoExpand = true,
magneticSnap = true,
indentationMultiplier,
canDrop: canDropCallback,
maxDepth,
canNodeHaveChildren,
canDrag,
scrollToNodeHandlerRef,
autoScrollToDroppedNode,
} = params;
// --- Refs for mutable state (no stale closures in PanResponder) ---
const isDraggingRef = useRef(false);
const draggedNodeRef = useRef<__FlattenedTreeNode__<ID> | null>(null);
// Whether the dragged node was expanded before drag start force-collapsed it,
// so a cancelled drag can restore the expansion (a cancel must not mutate state).
const wasDraggedNodeExpandedRef = useRef(false);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const containerPageXRef = useRef(0);
const containerPageYRef = useRef(0);
const containerWidthRef = useRef(0);
const containerHeightRef = useRef(0);
const grabOffsetYRef = useRef(0);
const scrollOffsetRef = useRef(0);
const headerOffsetRef = useRef(0);
const itemHeightRef = useRef(defaultItemHeight);
const overlayY = useRef(new Animated.Value(0)).current;
const overlayX = useRef(new Animated.Value(0)).current;
const prevEffectiveLevelRef = useRef<number | null>(null);
// Settle-debounce for the overlay indent: candidate level + the timer that
// springs the overlay to it once it has held for LEVEL_SETTLE_MS.
const pendingLevelRef = useRef<number | null>(null);
const levelSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoScrollRAFRef = useRef<number | null>(null);
const autoScrollSpeedRef = useRef(0);
// Last known finger position, so the auto-scroll loop can recompute the drop
// target for a stationary finger while rows scroll underneath it.
const lastFingerPageYRef = useRef(0);
const lastFingerPageXRef = useRef(0);
// calculateDropTarget is defined below startAutoScrollLoop; the RAF loop reads
// it through this ref to avoid a use-before-declaration in the dep array.
const calculateDropTargetRef = useRef<(fingerPageY: number, fingerPageX: number) => void>(/* istanbul ignore next -- placeholder, overwritten each render */ () => { });
// Caches the "every current row is measured" gate so calculateDropTarget
// doesn't scan the full flattened list on every pan frame. Invalidated when
// the flattened list identity or the measured-heights count changes.
const allHeightsMeasuredRef = useRef<{
size: number;
value: boolean;
} | null>(null);
// Auto-expand timer for hovering over collapsed nodes
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoExpandTargetRef = useRef<ID | null>(null);
// Track which nodes were auto-expanded during this drag (to collapse on drag end)
const autoExpandedDuringDragRef = useRef<Set<ID>>(new Set());
// Tracks whether the PanResponder has captured the current gesture
const panResponderActiveRef = useRef(false);
// True between the long-press firing and the async measureInWindow callback
// resolving. Lets a finger-lift in that window abort the pending drag so the
// node is never left "lifted" with no finger down.
const pendingDragRef = useRef(false);
// Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
const prevDropTargetRef = useRef<{ targetIndex: number; position: DropPosition; } | null>(null);
// Flattened-list identity seen by the last calculateDropTarget call; when it
// changes mid-drag the index-based hysteresis state above is invalidated.
const lastCalcNodesRef = useRef<unknown>(null);
// Depth of the dragged subtree (computed once at drag start, used for maxDepth check)
const draggedSubtreeDepthRef = useRef(0);
// Keep flattenedNodes ref current for PanResponder closures
const flattenedNodesRef = useRef(flattenedNodes);
flattenedNodesRef.current = flattenedNodes;
// Keep dragEnabled current for the long-press timer: the armed setTimeout
// captures initiateDrag at press time, so a closure read would see a stale
// value and start a drag after the consumer disabled dragging mid-press.
const dragEnabledRef = useRef(dragEnabled);
dragEnabledRef.current = dragEnabled;
// Keep callbacks current
const onDragStartRef = useRef(onDragStart);
onDragStartRef.current = onDragStart;
const onDragEndRef = useRef(onDragEnd);
onDragEndRef.current = onDragEnd;
const onDragCancelRef = useRef(onDragCancel);
onDragCancelRef.current = onDragCancel;
const canDropRef = useRef(canDropCallback);
canDropRef.current = canDropCallback;
const canNodeHaveChildrenRef = useRef(canNodeHaveChildren);
canNodeHaveChildrenRef.current = canNodeHaveChildren;
const canDragRef = useRef(canDrag);
canDragRef.current = canDrag;
// Keep config values current for PanResponder closures
const dragOverlayOffsetRef = useRef(dragOverlayOffset);
dragOverlayOffsetRef.current = dragOverlayOffset;
const overlayYCorrectionRef = useRef(overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION);
overlayYCorrectionRef.current = overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION;
const autoScrollThresholdRef = useRef(autoScrollThreshold);
autoScrollThresholdRef.current = autoScrollThreshold;
const autoScrollSpeedParamRef = useRef(autoScrollSpeed);
autoScrollSpeedParamRef.current = autoScrollSpeed;
const autoExpandDelayRef = useRef(autoExpandDelay);
autoExpandDelayRef.current = autoExpandDelay;
const autoExpandRef = useRef(autoExpand);
autoExpandRef.current = autoExpand;
const magneticSnapRef = useRef(magneticSnap);
magneticSnapRef.current = magneticSnap;
const indentationMultiplierRef = useRef(indentationMultiplier);
indentationMultiplierRef.current = indentationMultiplier;
const maxDepthRef = useRef(maxDepth);
maxDepthRef.current = maxDepth;
// --- React state (triggers re-renders only at drag start/end + level changes) ---
const [isDragging, setIsDragging] = useState(false);
const [draggedNode, setDraggedNode] = useState<__FlattenedTreeNode__<ID> | null>(null);
// The current drop target lives only in a ref (read by handleDragEnd at commit
// time). It is deliberately NOT React state: calculateDropTarget runs every pan
// frame, so a per-frame setState would re-render the whole list for nothing -
// the per-node drop indicator is driven by the store fields (dropTargetNodeId /
// dropPosition / dropLevel), which are throttled via lastStoreDropTargetRef.
const dropTargetRef = useRef<DropTarget<ID> | null>(null);
// Last value written to the store's drop target. Guards the per-frame Zustand
// write so updateDropTarget only fires when the indicator actually changes
// (otherwise every mounted Node's selector would re-run on every pan frame).
const lastStoreDropTargetRef = useRef<{
nodeId: ID | null;
position: DropPosition | null;
level: number | null;
} | null>(null);
// --- Long press timer ---
const cancelLongPressTimer = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
// Also abort a drag still awaiting its async measureInWindow callback. This
// makes scrolling (which cancels the long-press) during that window abort the
// drag cleanly, instead of letting it start later with stale finger coords.
pendingDragRef.current = false;
}, []);
// --- Get all descendant IDs of a node ---
const getDescendantIds = useCallback(
(nodeId: ID): Set<ID> => {
const store = getTreeViewStore<ID>(storeId);
const { nodeMap } = store.getState();
const descendants = new Set<ID>();
const stack: ID[] = [nodeId];
while (stack.length > 0) {
const currentId = stack.pop()!;
const node = nodeMap.get(currentId);
if (node?.children) {
for (const child of node.children) {
descendants.add(child.id);
stack.push(child.id);
}
}
}
return descendants;
},
[storeId]
);
// --- Get the maximum depth of a subtree (0 for leaf nodes) ---
const getSubtreeDepth = useCallback(
(nodeId: ID): number => {
const { nodeMap } = getTreeViewStore<ID>(storeId).getState();
return getSubtreeDepthFromMap(nodeMap, nodeId);
},
[storeId]
);
// --- Overlay Y for a container-local finger Y (grab point + offsets) ---
const computeOverlayLocalY = useCallback((fingerLocalY: number) =>
fingerLocalY
- grabOffsetYRef.current
+ (dragOverlayOffsetRef.current + overlayYCorrectionRef.current)
* itemHeightRef.current,
[]
);
// --- Level-cliff horizontal control: is the finger left of the threshold
// that selects the shallower level? (30% into the visible content area
// of a row indented at `level`.) ---
const fingerLeftOfLevelThreshold = useCallback(
(level: number, fingerLocalX: number) => {
const itemLeftEdge = level * indentationMultiplierRef.current;
const threshold =
itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
return fingerLocalX < threshold;
},
[]
);
// --- Initiate drag ---
const initiateDrag = useCallback(
(nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
// Read through the ref: the long-press timer holds the initiateDrag
// closure from press time, and dragging may have been disabled since.
if (!dragEnabledRef.current) return;
// Never start a second drag on top of one already running or pending
// (e.g. a competing long-press from a second finger).
/* istanbul ignore next -- handleNodeTouchStart cancels the pending
long-press timer before arming a new one, so a second initiateDrag
cannot fire while one is running; kept as defense in depth */
if (isDraggingRef.current || pendingDragRef.current) return;
const container = containerRef.current;
if (!container) return;
pendingDragRef.current = true;
container.measureInWindow((x, y, w, h) => {
// Finger lifted (or drag cancelled) before the measurement resolved -
// abort so we don't strand a drag with no finger down.
if (!pendingDragRef.current) return;
pendingDragRef.current = false;
containerPageXRef.current = x;
containerPageYRef.current = y;
containerWidthRef.current = w;
containerHeightRef.current = h;
// Find the node in flattened list
const nodes = flattenedNodesRef.current;
const node = nodes[nodeIndex];
if (!node) return;
// Collapse node if expanded (restored if the drag is cancelled)
const store = getTreeViewStore<ID>(storeId);
const { expanded } = store.getState();
wasDraggedNodeExpandedRef.current = false;
if (expanded.has(nodeId) && node.children?.length) {
handleToggleExpand(storeId, nodeId);
wasDraggedNodeExpandedRef.current = true;
}
// Store grab metadata
grabOffsetYRef.current = locationY;
draggedNodeRef.current = node;
draggedSubtreeDepthRef.current = getSubtreeDepth(nodeId);
// Use measured item height if available, fall back to default
const measured = measuredItemHeightRef.current;
itemHeightRef.current = measured > 0 ? measured : defaultItemHeight;
// Calculate headerOffset dynamically:
// fingerLocalY = pageY - containerPageY
// fingerLocalY = headerOffset + nodeIndex * itemHeight - scrollOffset + grabOffsetY
// So: headerOffset = fingerLocalY + scrollOffset - grabOffsetY - nodeIndex * itemHeight
const fingerLocalY = pageY - containerPageYRef.current;
headerOffsetRef.current =
fingerLocalY +
scrollOffsetRef.current -
locationY -
nodeIndex * itemHeightRef.current;
lastFingerPageYRef.current = pageY;
// Compute invalid targets (self + descendants)
const descendants = getDescendantIds(nodeId);
descendants.add(nodeId);
// Update store (triggers one re-render of nodes to show greyed-out state)
store.getState().updateDraggedNodeId(nodeId);
store.getState().updateInvalidDragTargetIds(descendants);
// Set overlay initial position (with configurable offset)
overlayY.setValue(computeOverlayLocalY(fingerLocalY));
// Reset magnetic overlay
overlayX.setValue(0);
prevEffectiveLevelRef.current = /* istanbul ignore next -- level is always set by flattenTree */ node.level ?? 0;
cancelLevelSettleTimer();
// Set React state
isDraggingRef.current = true;
autoExpandedDuringDragRef.current.clear();
setIsDragging(true);
setDraggedNode(node);
// Notify consumer that drag has started
onDragStartRef.current?.({ draggedNodeId: nodeId });
// Announce for screen readers (no-op when no assistive tech is active).
AccessibilityInfo.announceForAccessibility?.(`Picked up ${node.name}`);
// Start auto-scroll loop
startAutoScrollLoop();
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
dragEnabled,
storeId,
containerRef,
flashListRef,
getDescendantIds,
getSubtreeDepth,
overlayY,
]
);
// --- Handle node touch start (long press detection) ---
const handleNodeTouchStart = useCallback(
(nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
if (!dragEnabled) return;
// Ignore touches that land while a drag is already running or pending.
// Without this, a second finger on another row could arm a competing
// long-press that overwrites the in-flight drag's state.
if (isDraggingRef.current || pendingDragRef.current) return;
// Drag is disabled while a search filter is active: drop targets are
// computed against the filtered list but the move applies to the full
// tree, so a drop could land next to siblings hidden by the filter.
if (getTreeViewStore<ID>(storeId).getState().searchText) return;
// Check if this node can be dragged
if (canDragRef.current) {
const node = flattenedNodesRef.current[nodeIndex];
if (node && !canDragRef.current(node)) return;
}
// Cancel any existing timer
cancelLongPressTimer();
// Start new timer
longPressTimerRef.current = setTimeout(() => {
longPressTimerRef.current = null;
initiateDrag(nodeId, pageY, locationY, nodeIndex);
}, longPressDuration);
},
[dragEnabled, storeId, longPressDuration, cancelLongPressTimer, initiateDrag]
);
// --- Auto-scroll ---
const startAutoScrollLoop = useCallback(() => {
// Idempotent: cancel any loop already in flight so a re-entry can never
// orphan a RAF handle (which would run a second, untracked scroll loop).
/* istanbul ignore next -- the loop starts once per drag and every drag-end
path cancels it; re-entry with a live handle is defense in depth */
if (autoScrollRAFRef.current !== null) {
cancelAnimationFrame(autoScrollRAFRef.current);
autoScrollRAFRef.current = null;
}
// Time-based stepping: RAF cadence varies with display refresh rate, so
// distance is derived from elapsed time, not frame count.
let lastTs: number | null = null;
let lastRecalcTs = 0;
const loop = (ts?: number) => {
/* istanbul ignore next -- stopAutoScroll cancels the pending frame on
every drag-end path before this could observe a finished drag */
if (!isDraggingRef.current) return;
// Jest's RAF mock invokes the callback without a timestamp.
const now = typeof ts === "number" ? ts : Date.now();
// First frame establishes the baseline; cap dt so a long hiccup
// (background frame drop) can't produce one huge scroll jump.
const dtMs = lastTs === null ? 0 : Math.min(now - lastTs, 64);
lastTs = now;
if (autoScrollSpeedRef.current !== 0 && dtMs > 0) {
// Clamp to the scrollable range so dragging at the bottom edge can't
// grow the offset past the end (which would corrupt drop-index math).
// Leave the upper bound open until the content height is measured.
const contentH = contentHeightRef.current;
const maxOffset = contentH > 0
? Math.max(0, contentH - containerHeightRef.current)
: Number.POSITIVE_INFINITY;
const newOffset = Math.min(
maxOffset,
Math.max(0, scrollOffsetRef.current
+ (autoScrollSpeedRef.current * dtMs) / 1000)
);
// Pinned at a boundary: nothing moved, so skip the scroll command
// and the drop-target recompute entirely.
if (newOffset !== scrollOffsetRef.current) {
// The RAF loop is the sole writer of scrollOffsetRef during a
// drag (NodeList's onScroll stands down) - the accumulated value
// is the commanded position and the native list converges to it.
scrollOffsetRef.current = newOffset;
flashListRef.current?.scrollToOffset?.({
offset: newOffset,
animated: false,
});
// Rows are moving under a (possibly stationary) finger; recompute
// the drop target so the indicator tracks the auto-scroll instead
// of staying frozen on the last row the finger moved over.
// Time-throttled - see AUTO_SCROLL_RECALC_INTERVAL_MS.
if (now - lastRecalcTs >= AUTO_SCROLL_RECALC_INTERVAL_MS) {
lastRecalcTs = now;
calculateDropTargetRef.current(
lastFingerPageYRef.current,
lastFingerPageXRef.current
);
}
}
}
autoScrollRAFRef.current = requestAnimationFrame(loop);
};
autoScrollRAFRef.current = requestAnimationFrame(loop);
}, [flashListRef, contentHeightRef]);
const stopAutoScroll = useCallback(() => {
if (autoScrollRAFRef.current !== null) {
cancelAnimationFrame(autoScrollRAFRef.current);
autoScrollRAFRef.current = null;
}
autoScrollSpeedRef.current = 0;
}, []);
const updateAutoScroll = useCallback(
(fingerInContainer: number) => {
const threshold = autoScrollThresholdRef.current;
// px/second; the RAF loop converts to distance via elapsed time.
const maxSpeed = MAX_AUTO_SCROLL_SPEED * autoScrollSpeedParamRef.current;
const containerH = containerHeightRef.current;
// Ease the proximity ramp (sqrt) so speed builds up fast even for a
// shallow entry into the threshold zone - the screen edge often stops
// the finger before it reaches the very edge of the list.
if (fingerInContainer < threshold) {
// Scroll up
const ratio = 1 - Math.max(0, fingerInContainer) / threshold;
autoScrollSpeedRef.current = -maxSpeed * Math.sqrt(ratio);
} else if (fingerInContainer > containerH - threshold) {
// Scroll down
const ratio =
1 - Math.max(0, containerH - fingerInContainer) / threshold;
autoScrollSpeedRef.current = maxSpeed * Math.sqrt(ratio);
} else {
autoScrollSpeedRef.current = 0;
}
},
[]
);
// --- Cancel auto-expand timer ---
const cancelAutoExpandTimer = useCallback(() => {
if (autoExpandTimerRef.current) {
clearTimeout(autoExpandTimerRef.current);
autoExpandTimerRef.current = null;
}
autoExpandTargetRef.current = null;
}, []);
// --- Cancel the overlay-indent settle timer + its pending level ---
const cancelLevelSettleTimer = useCallback(() => {
if (levelSettleTimerRef.current) {
clearTimeout(levelSettleTimerRef.current);
levelSettleTimerRef.current = null;
}
pendingLevelRef.current = null;
}, []);
// --- Clear the store's drag fields (shared by drag end and unmount) ---
const resetDragStoreState = useCallback(() => {
const state = getTreeViewStore<ID>(storeId).getState();
state.updateDraggedNodeId(null);
state.updateInvalidDragTargetIds(new Set());
state.updateDropTarget(null, null);
}, [storeId]);
// --- Calculate drop target ---
const calculateDropTarget = useCallback(
(fingerPageY: number, fingerPageX: number) => {
const nodes = flattenedNodesRef.current;
if (nodes.length === 0) return;
// The flattened list changed mid-drag (e.g. auto-expand inserted the
// hovered parent's children). Index-based hysteresis/stickiness state
// refers to rows of the OLD list - drop it so it can't stick the zone
// or gap decision to whatever row now happens to hold that index. The
// measured-heights gate cache is keyed to the old list too.
if (lastCalcNodesRef.current !== nodes) {
lastCalcNodesRef.current = nodes;
prevDropTargetRef.current = null;
allHeightsMeasuredRef.current = null;
}
// Single store snapshot per frame (the store can't change within this
// synchronous pass) - avoids re-reading getState() several times.
const {
childToParentMap,
expanded,
invalidDragTargetIds,
draggedNodeId,
nodeMap,
updateDropTarget,
} = getTreeViewStore<ID>(storeId).getState();
const fingerLocalY =
fingerPageY - containerPageYRef.current;
const fingerContentY =
fingerLocalY + scrollOffsetRef.current;
const adjustedContentY =
fingerContentY - headerOffsetRef.current;
const iH = itemHeightRef.current;
// Resolve which row the finger is over. Uniform math (O(1)) is used
// unless every CURRENT row has been measured (small, fully-rendered
// lists), in which case a cumulative walk supports variable row heights.
// The gate checks per-node-id coverage (not map size) so stale heights
// left by a previous, longer list can never satisfy it. FlashList
// virtualization can't measure off-screen rows, so large lists fall back
// to the uniform estimate.
const heights = itemHeightsRef.current;
// The full-list scan is cached per measured count (and invalidated on
// list-identity change above) so it doesn't run on every pan frame.
const cachedGate = allHeightsMeasuredRef.current;
let allMeasured: boolean;
if (cachedGate && cachedGate.size === heights.size) {
allMeasured = cachedGate.value;
} else {
allMeasured = nodes.every((n) => heights.has(n.id));
allHeightsMeasuredRef.current = { size: heights.size, value: allMeasured };
}
let clampedIndex: number;
let itemTop: number;
let itemHeight: number;
if (allMeasured) {
let top = 0;
let idx = 0;
while (idx < nodes.length - 1) {
/* istanbul ignore next -- gate guarantees every current row is measured */
const h = heights.get(nodes[idx]!.id) ?? iH;
if (adjustedContentY < top + h) break;
top += h;
idx++;
}
clampedIndex = idx;
itemTop = top;
/* istanbul ignore next -- gate guarantees every current row is measured */
itemHeight = heights.get(nodes[idx]!.id) ?? iH;
} else {
const rawIndex = Math.floor(adjustedContentY / iH);
clampedIndex = Math.max(0, Math.min(rawIndex, nodes.length - 1));
itemTop = clampedIndex * iH;
itemHeight = iH;
}
let targetNode = nodes[clampedIndex];
/* istanbul ignore next -- clampedIndex is clamped into [0, length-1]
and the empty list bails out above; noUncheckedIndexedAccess guard */
if (!targetNode) return;
// Determine zone within item. Sticky zones: while a zone is active for
// this same row, its boundaries shift outward so natural finger tremor
// at a zone edge can't flip the position (and with it the indicator
// and the overlay's indent) back and forth every few frames.
const positionInItem =
(adjustedContentY - itemTop) / itemHeight;
let aboveBound = 0.25;
let belowBound = 0.75;
const prevZone = prevDropTargetRef.current;
if (prevZone && prevZone.targetIndex === clampedIndex) {
if (prevZone.position === "above") {
aboveBound += ZONE_STICKINESS;
} else if (prevZone.position === "below") {
belowBound -= ZONE_STICKINESS;
} else {
aboveBound -= ZONE_STICKINESS;
belowBound += ZONE_STICKINESS;
}
}
let position: DropPosition;
if (positionInItem < aboveBound) {
position = "above";
} else if (positionInItem > belowBound) {
position = "below";
} else {
position = "inside";
}
// --- Determine if "inside" drop is allowed for this target ---
const canDropInsideTarget = (() => {
// canNodeHaveChildren: structural constraint
if (canNodeHaveChildrenRef.current && !canNodeHaveChildrenRef.current(targetNode)) {
return false;
}
// maxDepth: the dragged subtree at (targetLevel + 1) must not exceed maxDepth
if (maxDepthRef.current !== undefined) {
/* istanbul ignore next -- level is always set by flattenTree */
const targetLevel = targetNode.level ?? 0;
const deepest = targetLevel + 1 + draggedSubtreeDepthRef.current;
if (deepest > maxDepthRef.current) return false;
}
return true;
})();
// If "inside" is not allowed, convert to nearest zone
if (position === "inside" && !canDropInsideTarget) {
position = positionInItem < 0.5 ? "above" : "below";
}
// --- Horizontal control at level cliffs ---
// At the boundary between nodes at different depths, the user's
// horizontal finger position decides the drop level:
// finger RIGHT of threshold → stay at deep level (inside parent)
// finger LEFT of threshold → switch to shallow level (outside parent)
// The threshold uses a generous buffer so dragging slightly left is enough.
const fingerLocalX = fingerPageX - containerPageXRef.current;
// logicalTargetId/logicalPosition: when the visual indicator node differs
// from the actual moveTreeNode target (e.g., ancestor at a shallower level).
let logicalTargetId: ID | null = null;
let logicalPosition: DropPosition | null = null;
let visualDropLevel: number | null = null;
if (position === "below") {
/* istanbul ignore next -- level is always set by flattenTree */
const currentLevel = targetNode.level ?? 0;
let isCliff = false;
let shallowLevel = 0;
if (clampedIndex < nodes.length - 1) {
const nextNode = nodes[clampedIndex + 1];
/* istanbul ignore next -- level is always set by flattenTree */
const nextLevel = nextNode?.level ?? 0;
if (nextNode && nextLevel < currentLevel) {
isCliff = true;
shallowLevel = nextLevel;
}
} else if (currentLevel > 0) {
// Last item in the list - treat as cliff to root level
isCliff = true;
shallowLevel = 0;
}
if (isCliff) {
if (fingerLeftOfLevelThreshold(currentLevel, fingerLocalX)) {
// User wants the shallow level
if (clampedIndex < nodes.length - 1) {
// Non-last item: switch to "above" on the next (shallower) node
const nextNode = nodes[clampedIndex + 1]!;
clampedIndex = clampedIndex + 1;
targetNode = nextNode;
position = "above";
} else {
// Last item: find ancestor at shallow level, target it with "below"
let ancestorId = targetNode.id;
let walkLevel = currentLevel;
while (walkLevel > shallowLevel) {
const parentId = childToParentMap.get(ancestorId);
/* istanbul ignore next -- a node at level > 0 always
has a parent in childToParentMap; corrupt-map guard */
if (parentId === undefined) break;
ancestorId = parentId;
walkLevel--;
}
// Visual stays on the last item; logical goes to ancestor
logicalTargetId = ancestorId;
logicalPosition = "below";
visualDropLevel = shallowLevel;
}
}
}
}
if (position === "above" && clampedIndex > 0) {
const prevNode = nodes[clampedIndex - 1];
/* istanbul ignore next -- level is always set by flattenTree */
const prevLevel = prevNode?.level ?? 0;
/* istanbul ignore next -- level is always set by flattenTree */
const currentLevel = targetNode.level ?? 0;
if (prevNode && prevLevel > currentLevel) {
if (!fingerLeftOfLevelThreshold(prevLevel, fingerLocalX)) {
clampedIndex = clampedIndex - 1;
targetNode = prevNode;
position = "below";
}
}
}
// --- Suppress "below" when it's semantically confusing ---
// For expanded parents, "below" visually sits at the parent/child
// junction but semantically inserts as a sibling after the entire
// subtree. Convert to "inside" which is clearer.
if (position === "below" && canDropInsideTarget) {
if (targetNode.children?.length && expanded.has(targetNode.id)) {
position = "inside";
}
}
// --- Hysteresis: prevent flicker between "below N" and "above N+1" ---
// Only applies to same-level boundaries. Level cliffs are handled
// by horizontal control above, so they pass through without forced resolution.
const prev = prevDropTargetRef.current;
if (prev) {
const sameGap =
(prev.position === "below" && position === "above" &&
prev.targetIndex === clampedIndex - 1) ||
(prev.position === "above" && position === "below" &&
clampedIndex === prev.targetIndex - 1);
if (sameGap) {
const upperIdx = Math.min(prev.targetIndex, clampedIndex);
const lowerIdx = Math.max(prev.targetIndex, clampedIndex);
/* istanbul ignore next -- level is always set by flattenTree */
const upperLevel = nodes[upperIdx]?.level ?? 0;
/* istanbul ignore next -- level is always set by flattenTree */
const lowerLevel = nodes[lowerIdx]?.level ?? 0;
if (upperLevel === lowerLevel) {
// Same level - pure visual hysteresis, keep previous
return;
}
// Level cliff - horizontal control already resolved this,
// let the result pass through.
}
}
prevDropTargetRef.current = { targetIndex: clampedIndex, position };
// Validity check (store snapshot taken once at the top of this frame).
// The actual move uses the LOGICAL target when a cliff override is active
// (the visual indicator stays on targetNode). Validate canDrop / maxDepth /
// invalid-target against this effective target so consumer rules can't be
// bypassed by the visual/logical split at level cliffs.
const effectiveTargetId = logicalTargetId ?? targetNode.id;
const effectivePosition = logicalPosition ?? position;
/* istanbul ignore next -- the logical ancestor came from
childToParentMap, so nodeMap always resolves it */
const effectiveTargetNode =
logicalTargetId !== null
? (nodeMap.get(effectiveTargetId) ?? targetNode)
: targetNode;
// maxDepth check for above/below (sibling) positions
let maxDepthValid = true;
if (maxDepthRef.current !== undefined && (effectivePosition === "above" || effectivePosition === "below")) {
// At a cliff the sibling lands at visualDropLevel; otherwise the
// effective target's own level.
const targetLevel = visualDropLevel ?? (/* istanbul ignore next -- level is always set by flattenTree */ effectiveTargetNode.level ?? 0);
const deepest = targetLevel + draggedSubtreeDepthRef.current;
if (deepest > maxDepthRef.current) maxDepthValid = false;
}
const isValid =
effectiveTargetId !== draggedNodeId &&
!invalidDragTargetIds.has(effectiveTargetId) &&
maxDepthValid &&
(!canDropRef.current || canDropRef.current(
draggedNodeRef.current!,
effectiveTargetNode,
effectivePosition
));
// --- Auto-expand: if hovering "inside" a collapsed expandable node ---
if (autoExpandRef.current && isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
if (autoExpandTargetRef.current !== targetNode.id) {
// New hover target - start timer
cancelAutoExpandTimer();
autoExpandTargetRef.current = targetNode.id;
autoExpandTimerRef.current = setTimeout(() => {
autoExpandTimerRef.current = null;
// Expand the node and track it
expandNodes(storeId, [targetNode.id]);
autoExpandedDuringDragRef.current.add(targetNode.id);
}, autoExpandDelayRef.current);
}
} else {
// Not hovering inside a collapsed expandable node - cancel timer
if (autoExpandTargetRef.current !== null) {
cancelAutoExpandTimer();
}
}
// --- Magnetic overlay: update the effective level so the overlay
// renders its content at the correct indentation natively.
// A brief translateX spring provides a smooth transition. ---
/* istanbul ignore next -- level is always set by flattenTree */
const draggedLevel = draggedNodeRef.current?.level ?? 0;
// When a logical target overrides the visual (e.g. ancestor at last-item cliff),
// the effective level comes from the visual drop level, not the target node.
/* istanbul ignore next -- level is always set by flattenTree */
const effectiveLevel = isValid
? (visualDropLevel !== null
? visualDropLevel // "below" ancestor → sibling at that level
: position === "inside"
? (targetNode.level ?? 0) + 1
: (targetNode.level ?? 0))
: draggedLevel;
// The overlay's content keeps the dragged node's original indentation
// for the whole drag (no re-render); the indent shift is expressed
// purely as a translateX toward the effective level. Springs retarget
// mid-flight, so rapid level changes glide instead of jumping - the
// old scheme (re-render padding + counteracting spring-to-0) split the
// move across React and the native driver and flickered when the two
// landed on different frames.
// A new level must additionally hold for LEVEL_SETTLE_MS before the
// overlay moves, so the indent doesn't chase every row the finger
// merely passes through.
const applyLevel = (nextLevel: number) => {
prevEffectiveLevelRef.current = nextLevel;
const targetX =
(nextLevel - (/* istanbul ignore next -- level is always set by flattenTree */ draggedNodeRef.current?.level ?? 0)) *
indentationMultiplierRef.current;
if (magneticSnapRef.current) {
Animated.spring(overlayX, {
toValue: targetX,
useNativeDriver: true,
speed: 40,
bounciness: 4,
}).start();
} else {
overlayX.setValue(targetX);
}
};
if (autoScrollSpeedRef.current !== 0) {
// Auto-scroll is streaming rows of arbitrary depth under a
// stationary finger; chasing their levels would dart the overlay
// sideways and back. Hold the current indent (and drop any pending
// shift) until the scroll settles - the drop indicator itself
// keeps tracking via the store.
cancelLevelSettleTimer();
} else if (effectiveLevel === prevEffectiveLevelRef.current) {
// Back at the settled level - drop any pending level the finger
// only transited through.
cancelLevelSettleTimer();
} else if (pendingLevelRef.current !== effectiveLevel) {
// New candidate level - (re)start the settle timer.
cancelLevelSettleTimer();
pendingLevelRef.current = effectiveLevel;
levelSettleTimerRef.current = setTimeout(() => {
levelSettleTimerRef.current = null;
const settled = pendingLevelRef.current;
pendingLevelRef.current = null;
/* istanbul ignore next -- every drag-end path clears this timer,
so it can only fire mid-drag with a pending level set */
if (settled !== null && isDraggingRef.current) applyLevel(settled);
}, LEVEL_SETTLE_MS);
}
const newTarget: DropTarget<ID> = {
targetNodeId: targetNode.id,
targetIndex: clampedIndex,
position,
isValid,
};
// Update the store so each Node can render its own indicator.
// The indicator always tracks the VISUAL target/position (what the user
// sees), even when the actual move target is a logical ancestor.
// Guard the write so it only fires when the indicator actually changes -
// calculateDropTarget runs every pan frame and each store write re-runs
// every mounted Node's selector.
const nextDropNodeId = isValid ? targetNode.id : null;
const nextDropPosition = isValid ? position : null;
const nextDropLevel = isValid ? visualDropLevel : null;
const lastStore = lastStoreDropTargetRef.current;
if (
!lastStore ||
lastStore.nodeId !== nextDropNodeId ||
lastStore.position !== nextDropPosition ||
lastStore.level !== nextDropLevel
) {
updateDropTarget(nextDropNodeId, nextDropPosition, nextDropLevel);
lastStoreDropTargetRef.cur