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