react-native-tree-multi-select 3.0.0-beta.5 → 3.0.0-beta.7
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 +57 -26
- package/lib/module/TreeView.js +130 -24
- package/lib/module/TreeView.js.map +1 -1
- package/lib/module/components/DragOverlay.js +19 -2
- package/lib/module/components/DragOverlay.js.map +1 -1
- package/lib/module/components/NodeList.js +82 -29
- package/lib/module/components/NodeList.js.map +1 -1
- package/lib/module/constants/treeView.constants.js +5 -0
- package/lib/module/constants/treeView.constants.js.map +1 -1
- package/lib/module/helpers/moveTreeNode.helper.js +175 -47
- package/lib/module/helpers/moveTreeNode.helper.js.map +1 -1
- package/lib/module/helpers/toggleCheckbox.helper.js +5 -12
- package/lib/module/helpers/toggleCheckbox.helper.js.map +1 -1
- package/lib/module/helpers/treeNode.helper.js +49 -0
- package/lib/module/helpers/treeNode.helper.js.map +1 -1
- package/lib/module/hooks/useDragDrop.js +470 -186
- package/lib/module/hooks/useDragDrop.js.map +1 -1
- package/lib/module/hooks/useScrollToNode.js +17 -0
- package/lib/module/hooks/useScrollToNode.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/store/treeView.store.js +7 -0
- package/lib/module/store/treeView.store.js.map +1 -1
- package/lib/module/types/dragDrop.types.js +0 -2
- package/lib/typescript/src/TreeView.d.ts.map +1 -1
- package/lib/typescript/src/components/DragOverlay.d.ts.map +1 -1
- package/lib/typescript/src/components/NodeList.d.ts.map +1 -1
- package/lib/typescript/src/constants/treeView.constants.d.ts +4 -0
- package/lib/typescript/src/constants/treeView.constants.d.ts.map +1 -1
- package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts +32 -0
- package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts.map +1 -1
- package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts.map +1 -1
- package/lib/typescript/src/helpers/treeNode.helper.d.ts +15 -0
- package/lib/typescript/src/helpers/treeNode.helper.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useDragDrop.d.ts +24 -6
- package/lib/typescript/src/hooks/useDragDrop.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useScrollToNode.d.ts +10 -0
- package/lib/typescript/src/hooks/useScrollToNode.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/store/treeView.store.d.ts +6 -0
- package/lib/typescript/src/store/treeView.store.d.ts.map +1 -1
- package/lib/typescript/src/types/dragDrop.types.d.ts +24 -12
- package/lib/typescript/src/types/dragDrop.types.d.ts.map +1 -1
- package/lib/typescript/src/types/treeView.types.d.ts +68 -12
- package/lib/typescript/src/types/treeView.types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/TreeView.tsx +158 -26
- package/src/components/DragOverlay.tsx +32 -3
- package/src/components/NodeList.tsx +82 -28
- package/src/constants/treeView.constants.ts +6 -1
- package/src/helpers/moveTreeNode.helper.ts +160 -43
- package/src/helpers/toggleCheckbox.helper.ts +5 -12
- package/src/helpers/treeNode.helper.ts +52 -1
- package/src/hooks/useDragDrop.ts +573 -214
- package/src/hooks/useScrollToNode.ts +21 -0
- package/src/index.tsx +3 -1
- package/src/store/treeView.store.ts +6 -0
- package/src/types/dragDrop.types.ts +25 -13
- package/src/types/treeView.types.ts +71 -11
- package/lib/module/components/DropIndicator.js +0 -79
- package/lib/module/components/DropIndicator.js.map +0 -1
- package/lib/typescript/src/components/DropIndicator.d.ts +0 -12
- package/lib/typescript/src/components/DropIndicator.d.ts.map +0 -1
- package/src/components/DropIndicator.tsx +0 -95
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import { Animated, PanResponder, Platform } from "react-native";
|
|
4
|
+
import { AccessibilityInfo, Animated, PanResponder, Platform } from "react-native";
|
|
5
5
|
import { getTreeViewStore } from "../store/treeView.store.js";
|
|
6
|
-
import { collapseNodes, expandNodes,
|
|
7
|
-
import { moveTreeNode } from "../helpers/moveTreeNode.helper.js";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
6
|
+
import { collapseNodes, expandNodes, getSubtreeDepthFromMap, handleToggleExpand } from "../helpers/index.js";
|
|
7
|
+
import { applyMoveToStore, findNodePosition, findNodePositionFromMaps, moveTreeNode } from "../helpers/moveTreeNode.helper.js";
|
|
8
|
+
import { scrollMovedNodeIntoView } from "./useScrollToNode.js";
|
|
9
|
+
import { defaultItemHeight } from "../constants/treeView.constants.js";
|
|
10
|
+
|
|
11
|
+
// Android reports touch `locationY` slightly differently from iOS, which makes the
|
|
12
|
+
// drag overlay sit ~2 item-heights closer to the finger. This empirical correction
|
|
13
|
+
// (in item-height units, added to dragOverlayOffset) compensates for that. Consumers
|
|
14
|
+
// can override it per-instance via DragAndDropOptions.overlayYCorrection.
|
|
15
|
+
const DEFAULT_OVERLAY_Y_CORRECTION = Platform.OS === "android" ? -2 : 0;
|
|
16
|
+
|
|
17
|
+
// Auto-scroll speed at the very edge in px/SECOND, before the proximity ramp
|
|
18
|
+
// and the consumer's `autoScrollSpeed` multiplier are applied. Time-based (not
|
|
19
|
+
// px/frame) so 60Hz and 120Hz displays scroll at the same real-world speed.
|
|
20
|
+
const MAX_AUTO_SCROLL_SPEED = 1200;
|
|
21
|
+
// During auto-scroll, recompute the drop target at most this often. Every scroll
|
|
22
|
+
// frame moves rows under the finger, but recomputing per frame costs store
|
|
23
|
+
// writes + node re-renders on the JS thread (stuttering the scroll itself), and
|
|
24
|
+
// ~10 indicator updates/sec reads calmer than 60 anyway.
|
|
25
|
+
const AUTO_SCROLL_RECALC_INTERVAL_MS = 100;
|
|
26
|
+
// How long (ms) a candidate drop level must hold before the overlay springs to
|
|
27
|
+
// its indentation. Prevents the indent from chasing every row the finger merely
|
|
28
|
+
// passes through while dragging vertically.
|
|
29
|
+
const LEVEL_SETTLE_MS = 120;
|
|
30
|
+
// How far (fraction of row height) the active zone's boundaries extend outward
|
|
31
|
+
// while the finger stays on the same row. Finger tremor at a zone edge would
|
|
32
|
+
// otherwise flip above/inside/below (and the overlay indent) every few frames.
|
|
33
|
+
const ZONE_STICKINESS = 0.08;
|
|
13
34
|
export function useDragDrop(params) {
|
|
14
35
|
const {
|
|
15
36
|
storeId,
|
|
@@ -23,10 +44,14 @@ export function useDragDrop(params) {
|
|
|
23
44
|
longPressDuration,
|
|
24
45
|
autoScrollThreshold,
|
|
25
46
|
autoScrollSpeed,
|
|
26
|
-
internalDataRef,
|
|
27
47
|
measuredItemHeightRef,
|
|
48
|
+
contentHeightRef,
|
|
49
|
+
itemHeightsRef,
|
|
28
50
|
dragOverlayOffset,
|
|
51
|
+
overlayYCorrection,
|
|
29
52
|
autoExpandDelay,
|
|
53
|
+
autoExpand = true,
|
|
54
|
+
magneticSnap = true,
|
|
30
55
|
indentationMultiplier,
|
|
31
56
|
canDrop: canDropCallback,
|
|
32
57
|
maxDepth,
|
|
@@ -39,8 +64,9 @@ export function useDragDrop(params) {
|
|
|
39
64
|
// --- Refs for mutable state (no stale closures in PanResponder) ---
|
|
40
65
|
const isDraggingRef = useRef(false);
|
|
41
66
|
const draggedNodeRef = useRef(null);
|
|
42
|
-
|
|
43
|
-
|
|
67
|
+
// Whether the dragged node was expanded before drag start force-collapsed it,
|
|
68
|
+
// so a cancelled drag can restore the expansion (a cancel must not mutate state).
|
|
69
|
+
const wasDraggedNodeExpandedRef = useRef(false);
|
|
44
70
|
const longPressTimerRef = useRef(null);
|
|
45
71
|
const containerPageXRef = useRef(0);
|
|
46
72
|
const containerPageYRef = useRef(0);
|
|
@@ -49,16 +75,29 @@ export function useDragDrop(params) {
|
|
|
49
75
|
const grabOffsetYRef = useRef(0);
|
|
50
76
|
const scrollOffsetRef = useRef(0);
|
|
51
77
|
const headerOffsetRef = useRef(0);
|
|
52
|
-
const itemHeightRef = useRef(
|
|
78
|
+
const itemHeightRef = useRef(defaultItemHeight);
|
|
53
79
|
const overlayY = useRef(new Animated.Value(0)).current;
|
|
54
80
|
const overlayX = useRef(new Animated.Value(0)).current;
|
|
55
81
|
const prevEffectiveLevelRef = useRef(null);
|
|
82
|
+
// Settle-debounce for the overlay indent: candidate level + the timer that
|
|
83
|
+
// springs the overlay to it once it has held for LEVEL_SETTLE_MS.
|
|
84
|
+
const pendingLevelRef = useRef(null);
|
|
85
|
+
const levelSettleTimerRef = useRef(null);
|
|
56
86
|
const autoScrollRAFRef = useRef(null);
|
|
57
87
|
const autoScrollSpeedRef = useRef(0);
|
|
58
88
|
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
const
|
|
89
|
+
// Last known finger position, so the auto-scroll loop can recompute the drop
|
|
90
|
+
// target for a stationary finger while rows scroll underneath it.
|
|
91
|
+
const lastFingerPageYRef = useRef(0);
|
|
92
|
+
const lastFingerPageXRef = useRef(0);
|
|
93
|
+
// calculateDropTarget is defined below startAutoScrollLoop; the RAF loop reads
|
|
94
|
+
// it through this ref to avoid a use-before-declaration in the dep array.
|
|
95
|
+
const calculateDropTargetRef = useRef(() => {});
|
|
96
|
+
|
|
97
|
+
// Caches the "every current row is measured" gate so calculateDropTarget
|
|
98
|
+
// doesn't scan the full flattened list on every pan frame. Invalidated when
|
|
99
|
+
// the flattened list identity or the measured-heights count changes.
|
|
100
|
+
const allHeightsMeasuredRef = useRef(null);
|
|
62
101
|
|
|
63
102
|
// Auto-expand timer for hovering over collapsed nodes
|
|
64
103
|
const autoExpandTimerRef = useRef(null);
|
|
@@ -69,8 +108,16 @@ export function useDragDrop(params) {
|
|
|
69
108
|
// Tracks whether the PanResponder has captured the current gesture
|
|
70
109
|
const panResponderActiveRef = useRef(false);
|
|
71
110
|
|
|
111
|
+
// True between the long-press firing and the async measureInWindow callback
|
|
112
|
+
// resolving. Lets a finger-lift in that window abort the pending drag so the
|
|
113
|
+
// node is never left "lifted" with no finger down.
|
|
114
|
+
const pendingDragRef = useRef(false);
|
|
115
|
+
|
|
72
116
|
// Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
|
|
73
117
|
const prevDropTargetRef = useRef(null);
|
|
118
|
+
// Flattened-list identity seen by the last calculateDropTarget call; when it
|
|
119
|
+
// changes mid-drag the index-based hysteresis state above is invalidated.
|
|
120
|
+
const lastCalcNodesRef = useRef(null);
|
|
74
121
|
|
|
75
122
|
// Depth of the dragged subtree (computed once at drag start, used for maxDepth check)
|
|
76
123
|
const draggedSubtreeDepthRef = useRef(0);
|
|
@@ -96,32 +143,49 @@ export function useDragDrop(params) {
|
|
|
96
143
|
// Keep config values current for PanResponder closures
|
|
97
144
|
const dragOverlayOffsetRef = useRef(dragOverlayOffset);
|
|
98
145
|
dragOverlayOffsetRef.current = dragOverlayOffset;
|
|
146
|
+
const overlayYCorrectionRef = useRef(overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION);
|
|
147
|
+
overlayYCorrectionRef.current = overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION;
|
|
99
148
|
const autoScrollThresholdRef = useRef(autoScrollThreshold);
|
|
100
149
|
autoScrollThresholdRef.current = autoScrollThreshold;
|
|
101
150
|
const autoScrollSpeedParamRef = useRef(autoScrollSpeed);
|
|
102
151
|
autoScrollSpeedParamRef.current = autoScrollSpeed;
|
|
103
152
|
const autoExpandDelayRef = useRef(autoExpandDelay);
|
|
104
153
|
autoExpandDelayRef.current = autoExpandDelay;
|
|
154
|
+
const autoExpandRef = useRef(autoExpand);
|
|
155
|
+
autoExpandRef.current = autoExpand;
|
|
156
|
+
const magneticSnapRef = useRef(magneticSnap);
|
|
157
|
+
magneticSnapRef.current = magneticSnap;
|
|
105
158
|
const indentationMultiplierRef = useRef(indentationMultiplier);
|
|
106
159
|
indentationMultiplierRef.current = indentationMultiplier;
|
|
107
160
|
const maxDepthRef = useRef(maxDepth);
|
|
108
161
|
maxDepthRef.current = maxDepth;
|
|
109
162
|
|
|
110
|
-
// --- React state (triggers re-renders only at drag start/end +
|
|
163
|
+
// --- React state (triggers re-renders only at drag start/end + level changes) ---
|
|
111
164
|
const [isDragging, setIsDragging] = useState(false);
|
|
112
165
|
const [draggedNode, setDraggedNode] = useState(null);
|
|
113
|
-
const [dropTarget, setDropTarget] = useState(null);
|
|
114
|
-
const [effectiveDropLevel, setEffectiveDropLevel] = useState(0);
|
|
115
166
|
|
|
116
|
-
//
|
|
167
|
+
// The current drop target lives only in a ref (read by handleDragEnd at commit
|
|
168
|
+
// time). It is deliberately NOT React state: calculateDropTarget runs every pan
|
|
169
|
+
// frame, so a per-frame setState would re-render the whole list for nothing -
|
|
170
|
+
// the per-node drop indicator is driven by the store fields (dropTargetNodeId /
|
|
171
|
+
// dropPosition / dropLevel), which are throttled via lastStoreDropTargetRef.
|
|
117
172
|
const dropTargetRef = useRef(null);
|
|
118
173
|
|
|
174
|
+
// Last value written to the store's drop target. Guards the per-frame Zustand
|
|
175
|
+
// write so updateDropTarget only fires when the indicator actually changes
|
|
176
|
+
// (otherwise every mounted Node's selector would re-run on every pan frame).
|
|
177
|
+
const lastStoreDropTargetRef = useRef(null);
|
|
178
|
+
|
|
119
179
|
// --- Long press timer ---
|
|
120
180
|
const cancelLongPressTimer = useCallback(() => {
|
|
121
181
|
if (longPressTimerRef.current) {
|
|
122
182
|
clearTimeout(longPressTimerRef.current);
|
|
123
183
|
longPressTimerRef.current = null;
|
|
124
184
|
}
|
|
185
|
+
// Also abort a drag still awaiting its async measureInWindow callback. This
|
|
186
|
+
// makes scrolling (which cancels the long-press) during that window abort the
|
|
187
|
+
// drag cleanly, instead of letting it start later with stale finger coords.
|
|
188
|
+
pendingDragRef.current = false;
|
|
125
189
|
}, []);
|
|
126
190
|
|
|
127
191
|
// --- Get all descendant IDs of a node ---
|
|
@@ -147,25 +211,38 @@ export function useDragDrop(params) {
|
|
|
147
211
|
|
|
148
212
|
// --- Get the maximum depth of a subtree (0 for leaf nodes) ---
|
|
149
213
|
const getSubtreeDepth = useCallback(nodeId => {
|
|
150
|
-
const store = getTreeViewStore(storeId);
|
|
151
214
|
const {
|
|
152
215
|
nodeMap
|
|
153
|
-
} =
|
|
154
|
-
|
|
155
|
-
if (!node?.children?.length) return 0;
|
|
156
|
-
let max = 0;
|
|
157
|
-
for (const child of node.children) {
|
|
158
|
-
max = Math.max(max, 1 + getSubtreeDepth(child.id));
|
|
159
|
-
}
|
|
160
|
-
return max;
|
|
216
|
+
} = getTreeViewStore(storeId).getState();
|
|
217
|
+
return getSubtreeDepthFromMap(nodeMap, nodeId);
|
|
161
218
|
}, [storeId]);
|
|
162
219
|
|
|
220
|
+
// --- Overlay Y for a container-local finger Y (grab point + offsets) ---
|
|
221
|
+
const computeOverlayLocalY = useCallback(fingerLocalY => fingerLocalY - grabOffsetYRef.current + (dragOverlayOffsetRef.current + overlayYCorrectionRef.current) * itemHeightRef.current, []);
|
|
222
|
+
|
|
223
|
+
// --- Level-cliff horizontal control: is the finger left of the threshold
|
|
224
|
+
// that selects the shallower level? (30% into the visible content area
|
|
225
|
+
// of a row indented at `level`.) ---
|
|
226
|
+
const fingerLeftOfLevelThreshold = useCallback((level, fingerLocalX) => {
|
|
227
|
+
const itemLeftEdge = level * indentationMultiplierRef.current;
|
|
228
|
+
const threshold = itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
|
|
229
|
+
return fingerLocalX < threshold;
|
|
230
|
+
}, []);
|
|
231
|
+
|
|
163
232
|
// --- Initiate drag ---
|
|
164
233
|
const initiateDrag = useCallback((nodeId, pageY, locationY, nodeIndex) => {
|
|
165
234
|
if (!dragEnabled) return;
|
|
235
|
+
// Never start a second drag on top of one already running or pending
|
|
236
|
+
// (e.g. a competing long-press from a second finger).
|
|
237
|
+
if (isDraggingRef.current || pendingDragRef.current) return;
|
|
166
238
|
const container = containerRef.current;
|
|
167
239
|
if (!container) return;
|
|
240
|
+
pendingDragRef.current = true;
|
|
168
241
|
container.measureInWindow((x, y, w, h) => {
|
|
242
|
+
// Finger lifted (or drag cancelled) before the measurement resolved -
|
|
243
|
+
// abort so we don't strand a drag with no finger down.
|
|
244
|
+
if (!pendingDragRef.current) return;
|
|
245
|
+
pendingDragRef.current = false;
|
|
169
246
|
containerPageXRef.current = x;
|
|
170
247
|
containerPageYRef.current = y;
|
|
171
248
|
containerWidthRef.current = w;
|
|
@@ -176,25 +253,25 @@ export function useDragDrop(params) {
|
|
|
176
253
|
const node = nodes[nodeIndex];
|
|
177
254
|
if (!node) return;
|
|
178
255
|
|
|
179
|
-
// Collapse node if expanded
|
|
256
|
+
// Collapse node if expanded (restored if the drag is cancelled)
|
|
180
257
|
const store = getTreeViewStore(storeId);
|
|
181
258
|
const {
|
|
182
259
|
expanded
|
|
183
260
|
} = store.getState();
|
|
261
|
+
wasDraggedNodeExpandedRef.current = false;
|
|
184
262
|
if (expanded.has(nodeId) && node.children?.length) {
|
|
185
263
|
handleToggleExpand(storeId, nodeId);
|
|
264
|
+
wasDraggedNodeExpandedRef.current = true;
|
|
186
265
|
}
|
|
187
266
|
|
|
188
267
|
// Store grab metadata
|
|
189
268
|
grabOffsetYRef.current = locationY;
|
|
190
269
|
draggedNodeRef.current = node;
|
|
191
|
-
draggedNodeIdRef.current = nodeId;
|
|
192
|
-
draggedNodeIndexRef.current = nodeIndex;
|
|
193
270
|
draggedSubtreeDepthRef.current = getSubtreeDepth(nodeId);
|
|
194
271
|
|
|
195
272
|
// Use measured item height if available, fall back to default
|
|
196
273
|
const measured = measuredItemHeightRef.current;
|
|
197
|
-
itemHeightRef.current = measured > 0 ? measured :
|
|
274
|
+
itemHeightRef.current = measured > 0 ? measured : defaultItemHeight;
|
|
198
275
|
|
|
199
276
|
// Calculate headerOffset dynamically:
|
|
200
277
|
// fingerLocalY = pageY - containerPageY
|
|
@@ -202,13 +279,7 @@ export function useDragDrop(params) {
|
|
|
202
279
|
// So: headerOffset = fingerLocalY + scrollOffset - grabOffsetY - nodeIndex * itemHeight
|
|
203
280
|
const fingerLocalY = pageY - containerPageYRef.current;
|
|
204
281
|
headerOffsetRef.current = fingerLocalY + scrollOffsetRef.current - locationY - nodeIndex * itemHeightRef.current;
|
|
205
|
-
|
|
206
|
-
// Delta-based auto-scroll: compute finger's position in the container
|
|
207
|
-
// from the node's known index (avoids unreliable containerPageY).
|
|
208
|
-
const iH = itemHeightRef.current;
|
|
209
|
-
const listHeaderHeight = listHeaderFooterPadding * 2;
|
|
210
|
-
initialFingerPageYRef.current = pageY;
|
|
211
|
-
initialFingerContainerYRef.current = listHeaderHeight + nodeIndex * iH - scrollOffsetRef.current + locationY;
|
|
282
|
+
lastFingerPageYRef.current = pageY;
|
|
212
283
|
|
|
213
284
|
// Compute invalid targets (self + descendants)
|
|
214
285
|
const descendants = getDescendantIds(nodeId);
|
|
@@ -219,26 +290,27 @@ export function useDragDrop(params) {
|
|
|
219
290
|
store.getState().updateInvalidDragTargetIds(descendants);
|
|
220
291
|
|
|
221
292
|
// Set overlay initial position (with configurable offset)
|
|
222
|
-
|
|
223
|
-
overlayY.setValue(overlayLocalY);
|
|
293
|
+
overlayY.setValue(computeOverlayLocalY(fingerLocalY));
|
|
224
294
|
|
|
225
295
|
// Reset magnetic overlay
|
|
226
296
|
overlayX.setValue(0);
|
|
227
297
|
prevEffectiveLevelRef.current = node.level ?? 0;
|
|
298
|
+
cancelLevelSettleTimer();
|
|
228
299
|
|
|
229
300
|
// Set React state
|
|
230
301
|
isDraggingRef.current = true;
|
|
231
302
|
autoExpandedDuringDragRef.current.clear();
|
|
232
303
|
setIsDragging(true);
|
|
233
304
|
setDraggedNode(node);
|
|
234
|
-
setEffectiveDropLevel(node.level ?? 0);
|
|
235
|
-
setDropTarget(null);
|
|
236
305
|
|
|
237
306
|
// Notify consumer that drag has started
|
|
238
307
|
onDragStartRef.current?.({
|
|
239
308
|
draggedNodeId: nodeId
|
|
240
309
|
});
|
|
241
310
|
|
|
311
|
+
// Announce for screen readers (no-op when no assistive tech is active).
|
|
312
|
+
AccessibilityInfo.announceForAccessibility?.(`Picked up ${node.name}`);
|
|
313
|
+
|
|
242
314
|
// Start auto-scroll loop
|
|
243
315
|
startAutoScrollLoop();
|
|
244
316
|
});
|
|
@@ -249,6 +321,15 @@ export function useDragDrop(params) {
|
|
|
249
321
|
// --- Handle node touch start (long press detection) ---
|
|
250
322
|
const handleNodeTouchStart = useCallback((nodeId, pageY, locationY, nodeIndex) => {
|
|
251
323
|
if (!dragEnabled) return;
|
|
324
|
+
// Ignore touches that land while a drag is already running or pending.
|
|
325
|
+
// Without this, a second finger on another row could arm a competing
|
|
326
|
+
// long-press that overwrites the in-flight drag's state.
|
|
327
|
+
if (isDraggingRef.current || pendingDragRef.current) return;
|
|
328
|
+
|
|
329
|
+
// Drag is disabled while a search filter is active: drop targets are
|
|
330
|
+
// computed against the filtered list but the move applies to the full
|
|
331
|
+
// tree, so a drop could land next to siblings hidden by the filter.
|
|
332
|
+
if (getTreeViewStore(storeId).getState().searchText) return;
|
|
252
333
|
|
|
253
334
|
// Check if this node can be dragged
|
|
254
335
|
if (canDragRef.current) {
|
|
@@ -264,24 +345,63 @@ export function useDragDrop(params) {
|
|
|
264
345
|
longPressTimerRef.current = null;
|
|
265
346
|
initiateDrag(nodeId, pageY, locationY, nodeIndex);
|
|
266
347
|
}, longPressDuration);
|
|
267
|
-
}, [dragEnabled, longPressDuration, cancelLongPressTimer, initiateDrag]);
|
|
348
|
+
}, [dragEnabled, storeId, longPressDuration, cancelLongPressTimer, initiateDrag]);
|
|
268
349
|
|
|
269
350
|
// --- Auto-scroll ---
|
|
270
351
|
const startAutoScrollLoop = useCallback(() => {
|
|
271
|
-
|
|
352
|
+
// Idempotent: cancel any loop already in flight so a re-entry can never
|
|
353
|
+
// orphan a RAF handle (which would run a second, untracked scroll loop).
|
|
354
|
+
if (autoScrollRAFRef.current !== null) {
|
|
355
|
+
cancelAnimationFrame(autoScrollRAFRef.current);
|
|
356
|
+
autoScrollRAFRef.current = null;
|
|
357
|
+
}
|
|
358
|
+
// Time-based stepping: RAF cadence varies with display refresh rate, so
|
|
359
|
+
// distance is derived from elapsed time, not frame count.
|
|
360
|
+
let lastTs = null;
|
|
361
|
+
let lastRecalcTs = 0;
|
|
362
|
+
const loop = ts => {
|
|
272
363
|
if (!isDraggingRef.current) return;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
364
|
+
|
|
365
|
+
// Jest's RAF mock invokes the callback without a timestamp.
|
|
366
|
+
const now = typeof ts === "number" ? ts : Date.now();
|
|
367
|
+
// First frame establishes the baseline; cap dt so a long hiccup
|
|
368
|
+
// (background frame drop) can't produce one huge scroll jump.
|
|
369
|
+
const dtMs = lastTs === null ? 0 : Math.min(now - lastTs, 64);
|
|
370
|
+
lastTs = now;
|
|
371
|
+
if (autoScrollSpeedRef.current !== 0 && dtMs > 0) {
|
|
372
|
+
// Clamp to the scrollable range so dragging at the bottom edge can't
|
|
373
|
+
// grow the offset past the end (which would corrupt drop-index math).
|
|
374
|
+
// Leave the upper bound open until the content height is measured.
|
|
375
|
+
const contentH = contentHeightRef.current;
|
|
376
|
+
const maxOffset = contentH > 0 ? Math.max(0, contentH - containerHeightRef.current) : Number.POSITIVE_INFINITY;
|
|
377
|
+
const newOffset = Math.min(maxOffset, Math.max(0, scrollOffsetRef.current + autoScrollSpeedRef.current * dtMs / 1000));
|
|
378
|
+
|
|
379
|
+
// Pinned at a boundary: nothing moved, so skip the scroll command
|
|
380
|
+
// and the drop-target recompute entirely.
|
|
381
|
+
if (newOffset !== scrollOffsetRef.current) {
|
|
382
|
+
// The RAF loop is the sole writer of scrollOffsetRef during a
|
|
383
|
+
// drag (NodeList's onScroll stands down) - the accumulated value
|
|
384
|
+
// is the commanded position and the native list converges to it.
|
|
385
|
+
scrollOffsetRef.current = newOffset;
|
|
386
|
+
flashListRef.current?.scrollToOffset?.({
|
|
387
|
+
offset: newOffset,
|
|
388
|
+
animated: false
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Rows are moving under a (possibly stationary) finger; recompute
|
|
392
|
+
// the drop target so the indicator tracks the auto-scroll instead
|
|
393
|
+
// of staying frozen on the last row the finger moved over.
|
|
394
|
+
// Time-throttled - see AUTO_SCROLL_RECALC_INTERVAL_MS.
|
|
395
|
+
if (now - lastRecalcTs >= AUTO_SCROLL_RECALC_INTERVAL_MS) {
|
|
396
|
+
lastRecalcTs = now;
|
|
397
|
+
calculateDropTargetRef.current(lastFingerPageYRef.current, lastFingerPageXRef.current);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
280
400
|
}
|
|
281
401
|
autoScrollRAFRef.current = requestAnimationFrame(loop);
|
|
282
402
|
};
|
|
283
403
|
autoScrollRAFRef.current = requestAnimationFrame(loop);
|
|
284
|
-
}, [flashListRef]);
|
|
404
|
+
}, [flashListRef, contentHeightRef]);
|
|
285
405
|
const stopAutoScroll = useCallback(() => {
|
|
286
406
|
if (autoScrollRAFRef.current !== null) {
|
|
287
407
|
cancelAnimationFrame(autoScrollRAFRef.current);
|
|
@@ -291,16 +411,21 @@ export function useDragDrop(params) {
|
|
|
291
411
|
}, []);
|
|
292
412
|
const updateAutoScroll = useCallback(fingerInContainer => {
|
|
293
413
|
const threshold = autoScrollThresholdRef.current;
|
|
294
|
-
|
|
414
|
+
// px/second; the RAF loop converts to distance via elapsed time.
|
|
415
|
+
const maxSpeed = MAX_AUTO_SCROLL_SPEED * autoScrollSpeedParamRef.current;
|
|
295
416
|
const containerH = containerHeightRef.current;
|
|
417
|
+
|
|
418
|
+
// Ease the proximity ramp (sqrt) so speed builds up fast even for a
|
|
419
|
+
// shallow entry into the threshold zone - the screen edge often stops
|
|
420
|
+
// the finger before it reaches the very edge of the list.
|
|
296
421
|
if (fingerInContainer < threshold) {
|
|
297
422
|
// Scroll up
|
|
298
423
|
const ratio = 1 - Math.max(0, fingerInContainer) / threshold;
|
|
299
|
-
autoScrollSpeedRef.current = -maxSpeed * ratio;
|
|
424
|
+
autoScrollSpeedRef.current = -maxSpeed * Math.sqrt(ratio);
|
|
300
425
|
} else if (fingerInContainer > containerH - threshold) {
|
|
301
426
|
// Scroll down
|
|
302
427
|
const ratio = 1 - Math.max(0, containerH - fingerInContainer) / threshold;
|
|
303
|
-
autoScrollSpeedRef.current = maxSpeed * ratio;
|
|
428
|
+
autoScrollSpeedRef.current = maxSpeed * Math.sqrt(ratio);
|
|
304
429
|
} else {
|
|
305
430
|
autoScrollSpeedRef.current = 0;
|
|
306
431
|
}
|
|
@@ -315,25 +440,121 @@ export function useDragDrop(params) {
|
|
|
315
440
|
autoExpandTargetRef.current = null;
|
|
316
441
|
}, []);
|
|
317
442
|
|
|
443
|
+
// --- Cancel the overlay-indent settle timer + its pending level ---
|
|
444
|
+
const cancelLevelSettleTimer = useCallback(() => {
|
|
445
|
+
if (levelSettleTimerRef.current) {
|
|
446
|
+
clearTimeout(levelSettleTimerRef.current);
|
|
447
|
+
levelSettleTimerRef.current = null;
|
|
448
|
+
}
|
|
449
|
+
pendingLevelRef.current = null;
|
|
450
|
+
}, []);
|
|
451
|
+
|
|
452
|
+
// --- Clear the store's drag fields (shared by drag end and unmount) ---
|
|
453
|
+
const resetDragStoreState = useCallback(() => {
|
|
454
|
+
const state = getTreeViewStore(storeId).getState();
|
|
455
|
+
state.updateDraggedNodeId(null);
|
|
456
|
+
state.updateInvalidDragTargetIds(new Set());
|
|
457
|
+
state.updateDropTarget(null, null);
|
|
458
|
+
}, [storeId]);
|
|
459
|
+
|
|
318
460
|
// --- Calculate drop target ---
|
|
319
461
|
const calculateDropTarget = useCallback((fingerPageY, fingerPageX) => {
|
|
320
462
|
const nodes = flattenedNodesRef.current;
|
|
321
463
|
if (nodes.length === 0) return;
|
|
464
|
+
|
|
465
|
+
// The flattened list changed mid-drag (e.g. auto-expand inserted the
|
|
466
|
+
// hovered parent's children). Index-based hysteresis/stickiness state
|
|
467
|
+
// refers to rows of the OLD list - drop it so it can't stick the zone
|
|
468
|
+
// or gap decision to whatever row now happens to hold that index. The
|
|
469
|
+
// measured-heights gate cache is keyed to the old list too.
|
|
470
|
+
if (lastCalcNodesRef.current !== nodes) {
|
|
471
|
+
lastCalcNodesRef.current = nodes;
|
|
472
|
+
prevDropTargetRef.current = null;
|
|
473
|
+
allHeightsMeasuredRef.current = null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Single store snapshot per frame (the store can't change within this
|
|
477
|
+
// synchronous pass) - avoids re-reading getState() several times.
|
|
478
|
+
const {
|
|
479
|
+
childToParentMap,
|
|
480
|
+
expanded,
|
|
481
|
+
invalidDragTargetIds,
|
|
482
|
+
draggedNodeId,
|
|
483
|
+
nodeMap,
|
|
484
|
+
updateDropTarget
|
|
485
|
+
} = getTreeViewStore(storeId).getState();
|
|
322
486
|
const fingerLocalY = fingerPageY - containerPageYRef.current;
|
|
323
487
|
const fingerContentY = fingerLocalY + scrollOffsetRef.current;
|
|
324
488
|
const adjustedContentY = fingerContentY - headerOffsetRef.current;
|
|
325
489
|
const iH = itemHeightRef.current;
|
|
326
|
-
|
|
327
|
-
|
|
490
|
+
|
|
491
|
+
// Resolve which row the finger is over. Uniform math (O(1)) is used
|
|
492
|
+
// unless every CURRENT row has been measured (small, fully-rendered
|
|
493
|
+
// lists), in which case a cumulative walk supports variable row heights.
|
|
494
|
+
// The gate checks per-node-id coverage (not map size) so stale heights
|
|
495
|
+
// left by a previous, longer list can never satisfy it. FlashList
|
|
496
|
+
// virtualization can't measure off-screen rows, so large lists fall back
|
|
497
|
+
// to the uniform estimate.
|
|
498
|
+
const heights = itemHeightsRef.current;
|
|
499
|
+
// The full-list scan is cached per measured count (and invalidated on
|
|
500
|
+
// list-identity change above) so it doesn't run on every pan frame.
|
|
501
|
+
const cachedGate = allHeightsMeasuredRef.current;
|
|
502
|
+
let allMeasured;
|
|
503
|
+
if (cachedGate && cachedGate.size === heights.size) {
|
|
504
|
+
allMeasured = cachedGate.value;
|
|
505
|
+
} else {
|
|
506
|
+
allMeasured = nodes.every(n => heights.has(n.id));
|
|
507
|
+
allHeightsMeasuredRef.current = {
|
|
508
|
+
size: heights.size,
|
|
509
|
+
value: allMeasured
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
let clampedIndex;
|
|
513
|
+
let itemTop;
|
|
514
|
+
let itemHeight;
|
|
515
|
+
if (allMeasured) {
|
|
516
|
+
let top = 0;
|
|
517
|
+
let idx = 0;
|
|
518
|
+
while (idx < nodes.length - 1) {
|
|
519
|
+
const h = heights.get(nodes[idx].id) ?? iH;
|
|
520
|
+
if (adjustedContentY < top + h) break;
|
|
521
|
+
top += h;
|
|
522
|
+
idx++;
|
|
523
|
+
}
|
|
524
|
+
clampedIndex = idx;
|
|
525
|
+
itemTop = top;
|
|
526
|
+
itemHeight = heights.get(nodes[idx].id) ?? iH;
|
|
527
|
+
} else {
|
|
528
|
+
const rawIndex = Math.floor(adjustedContentY / iH);
|
|
529
|
+
clampedIndex = Math.max(0, Math.min(rawIndex, nodes.length - 1));
|
|
530
|
+
itemTop = clampedIndex * iH;
|
|
531
|
+
itemHeight = iH;
|
|
532
|
+
}
|
|
328
533
|
let targetNode = nodes[clampedIndex];
|
|
329
534
|
if (!targetNode) return;
|
|
330
535
|
|
|
331
|
-
// Determine zone within item
|
|
332
|
-
|
|
536
|
+
// Determine zone within item. Sticky zones: while a zone is active for
|
|
537
|
+
// this same row, its boundaries shift outward so natural finger tremor
|
|
538
|
+
// at a zone edge can't flip the position (and with it the indicator
|
|
539
|
+
// and the overlay's indent) back and forth every few frames.
|
|
540
|
+
const positionInItem = (adjustedContentY - itemTop) / itemHeight;
|
|
541
|
+
let aboveBound = 0.25;
|
|
542
|
+
let belowBound = 0.75;
|
|
543
|
+
const prevZone = prevDropTargetRef.current;
|
|
544
|
+
if (prevZone && prevZone.targetIndex === clampedIndex) {
|
|
545
|
+
if (prevZone.position === "above") {
|
|
546
|
+
aboveBound += ZONE_STICKINESS;
|
|
547
|
+
} else if (prevZone.position === "below") {
|
|
548
|
+
belowBound -= ZONE_STICKINESS;
|
|
549
|
+
} else {
|
|
550
|
+
aboveBound -= ZONE_STICKINESS;
|
|
551
|
+
belowBound += ZONE_STICKINESS;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
333
554
|
let position;
|
|
334
|
-
if (positionInItem <
|
|
555
|
+
if (positionInItem < aboveBound) {
|
|
335
556
|
position = "above";
|
|
336
|
-
} else if (positionInItem >
|
|
557
|
+
} else if (positionInItem > belowBound) {
|
|
337
558
|
position = "below";
|
|
338
559
|
} else {
|
|
339
560
|
position = "inside";
|
|
@@ -388,10 +609,7 @@ export function useDragDrop(params) {
|
|
|
388
609
|
shallowLevel = 0;
|
|
389
610
|
}
|
|
390
611
|
if (isCliff) {
|
|
391
|
-
|
|
392
|
-
const itemLeftEdge = currentLevel * indentationMultiplierRef.current;
|
|
393
|
-
const threshold = itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
|
|
394
|
-
if (fingerLocalX < threshold) {
|
|
612
|
+
if (fingerLeftOfLevelThreshold(currentLevel, fingerLocalX)) {
|
|
395
613
|
// User wants the shallow level
|
|
396
614
|
if (clampedIndex < nodes.length - 1) {
|
|
397
615
|
// Non-last item: switch to "above" on the next (shallower) node
|
|
@@ -401,9 +619,6 @@ export function useDragDrop(params) {
|
|
|
401
619
|
position = "above";
|
|
402
620
|
} else {
|
|
403
621
|
// Last item: find ancestor at shallow level, target it with "below"
|
|
404
|
-
const {
|
|
405
|
-
childToParentMap
|
|
406
|
-
} = getTreeViewStore(storeId).getState();
|
|
407
622
|
let ancestorId = targetNode.id;
|
|
408
623
|
let walkLevel = currentLevel;
|
|
409
624
|
while (walkLevel > shallowLevel) {
|
|
@@ -425,9 +640,7 @@ export function useDragDrop(params) {
|
|
|
425
640
|
const prevLevel = prevNode?.level ?? 0;
|
|
426
641
|
const currentLevel = targetNode.level ?? 0;
|
|
427
642
|
if (prevNode && prevLevel > currentLevel) {
|
|
428
|
-
|
|
429
|
-
const threshold = itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
|
|
430
|
-
if (fingerLocalX >= threshold) {
|
|
643
|
+
if (!fingerLeftOfLevelThreshold(prevLevel, fingerLocalX)) {
|
|
431
644
|
clampedIndex = clampedIndex - 1;
|
|
432
645
|
targetNode = prevNode;
|
|
433
646
|
position = "below";
|
|
@@ -440,8 +653,7 @@ export function useDragDrop(params) {
|
|
|
440
653
|
// junction but semantically inserts as a sibling after the entire
|
|
441
654
|
// subtree. Convert to "inside" which is clearer.
|
|
442
655
|
if (position === "below" && canDropInsideTarget) {
|
|
443
|
-
|
|
444
|
-
if (targetNode.children?.length && expandedSet.has(targetNode.id)) {
|
|
656
|
+
if (targetNode.children?.length && expanded.has(targetNode.id)) {
|
|
445
657
|
position = "inside";
|
|
446
658
|
}
|
|
447
659
|
}
|
|
@@ -469,27 +681,29 @@ export function useDragDrop(params) {
|
|
|
469
681
|
targetIndex: clampedIndex,
|
|
470
682
|
position
|
|
471
683
|
};
|
|
472
|
-
const indicatorTop = fingerLocalY - grabOffsetYRef.current;
|
|
473
684
|
|
|
474
|
-
// Validity check
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
685
|
+
// Validity check (store snapshot taken once at the top of this frame).
|
|
686
|
+
// The actual move uses the LOGICAL target when a cliff override is active
|
|
687
|
+
// (the visual indicator stays on targetNode). Validate canDrop / maxDepth /
|
|
688
|
+
// invalid-target against this effective target so consumer rules can't be
|
|
689
|
+
// bypassed by the visual/logical split at level cliffs.
|
|
690
|
+
const effectiveTargetId = logicalTargetId ?? targetNode.id;
|
|
691
|
+
const effectivePosition = logicalPosition ?? position;
|
|
692
|
+
const effectiveTargetNode = logicalTargetId !== null ? nodeMap.get(effectiveTargetId) ?? targetNode : targetNode;
|
|
481
693
|
|
|
482
694
|
// maxDepth check for above/below (sibling) positions
|
|
483
695
|
let maxDepthValid = true;
|
|
484
|
-
if (maxDepthRef.current !== undefined && (
|
|
485
|
-
|
|
696
|
+
if (maxDepthRef.current !== undefined && (effectivePosition === "above" || effectivePosition === "below")) {
|
|
697
|
+
// At a cliff the sibling lands at visualDropLevel; otherwise the
|
|
698
|
+
// effective target's own level.
|
|
699
|
+
const targetLevel = visualDropLevel ?? effectiveTargetNode.level ?? 0;
|
|
486
700
|
const deepest = targetLevel + draggedSubtreeDepthRef.current;
|
|
487
701
|
if (deepest > maxDepthRef.current) maxDepthValid = false;
|
|
488
702
|
}
|
|
489
|
-
const isValid =
|
|
703
|
+
const isValid = effectiveTargetId !== draggedNodeId && !invalidDragTargetIds.has(effectiveTargetId) && maxDepthValid && (!canDropRef.current || canDropRef.current(draggedNodeRef.current, effectiveTargetNode, effectivePosition));
|
|
490
704
|
|
|
491
705
|
// --- Auto-expand: if hovering "inside" a collapsed expandable node ---
|
|
492
|
-
if (isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
|
|
706
|
+
if (autoExpandRef.current && isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
|
|
493
707
|
if (autoExpandTargetRef.current !== targetNode.id) {
|
|
494
708
|
// New hover target - start timer
|
|
495
709
|
cancelAutoExpandTimer();
|
|
@@ -516,41 +730,79 @@ export function useDragDrop(params) {
|
|
|
516
730
|
// the effective level comes from the visual drop level, not the target node.
|
|
517
731
|
const effectiveLevel = isValid ? visualDropLevel !== null ? visualDropLevel // "below" ancestor → sibling at that level
|
|
518
732
|
: position === "inside" ? (targetNode.level ?? 0) + 1 : targetNode.level ?? 0 : draggedLevel;
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
733
|
+
// The overlay's content keeps the dragged node's original indentation
|
|
734
|
+
// for the whole drag (no re-render); the indent shift is expressed
|
|
735
|
+
// purely as a translateX toward the effective level. Springs retarget
|
|
736
|
+
// mid-flight, so rapid level changes glide instead of jumping - the
|
|
737
|
+
// old scheme (re-render padding + counteracting spring-to-0) split the
|
|
738
|
+
// move across React and the native driver and flickered when the two
|
|
739
|
+
// landed on different frames.
|
|
740
|
+
// A new level must additionally hold for LEVEL_SETTLE_MS before the
|
|
741
|
+
// overlay moves, so the indent doesn't chase every row the finger
|
|
742
|
+
// merely passes through.
|
|
743
|
+
const applyLevel = nextLevel => {
|
|
744
|
+
prevEffectiveLevelRef.current = nextLevel;
|
|
745
|
+
const targetX = (nextLevel - (draggedNodeRef.current?.level ?? 0)) * indentationMultiplierRef.current;
|
|
746
|
+
if (magneticSnapRef.current) {
|
|
529
747
|
Animated.spring(overlayX, {
|
|
530
|
-
toValue:
|
|
748
|
+
toValue: targetX,
|
|
531
749
|
useNativeDriver: true,
|
|
532
750
|
speed: 40,
|
|
533
751
|
bounciness: 4
|
|
534
752
|
}).start();
|
|
753
|
+
} else {
|
|
754
|
+
overlayX.setValue(targetX);
|
|
535
755
|
}
|
|
756
|
+
};
|
|
757
|
+
if (autoScrollSpeedRef.current !== 0) {
|
|
758
|
+
// Auto-scroll is streaming rows of arbitrary depth under a
|
|
759
|
+
// stationary finger; chasing their levels would dart the overlay
|
|
760
|
+
// sideways and back. Hold the current indent (and drop any pending
|
|
761
|
+
// shift) until the scroll settles - the drop indicator itself
|
|
762
|
+
// keeps tracking via the store.
|
|
763
|
+
cancelLevelSettleTimer();
|
|
764
|
+
} else if (effectiveLevel === prevEffectiveLevelRef.current) {
|
|
765
|
+
// Back at the settled level - drop any pending level the finger
|
|
766
|
+
// only transited through.
|
|
767
|
+
cancelLevelSettleTimer();
|
|
768
|
+
} else if (pendingLevelRef.current !== effectiveLevel) {
|
|
769
|
+
// New candidate level - (re)start the settle timer.
|
|
770
|
+
cancelLevelSettleTimer();
|
|
771
|
+
pendingLevelRef.current = effectiveLevel;
|
|
772
|
+
levelSettleTimerRef.current = setTimeout(() => {
|
|
773
|
+
levelSettleTimerRef.current = null;
|
|
774
|
+
const settled = pendingLevelRef.current;
|
|
775
|
+
pendingLevelRef.current = null;
|
|
776
|
+
if (settled !== null && isDraggingRef.current) applyLevel(settled);
|
|
777
|
+
}, LEVEL_SETTLE_MS);
|
|
536
778
|
}
|
|
537
779
|
const newTarget = {
|
|
538
780
|
targetNodeId: targetNode.id,
|
|
539
781
|
targetIndex: clampedIndex,
|
|
540
782
|
position,
|
|
541
|
-
isValid
|
|
542
|
-
targetLevel: targetNode.level ?? 0,
|
|
543
|
-
indicatorTop
|
|
783
|
+
isValid
|
|
544
784
|
};
|
|
545
785
|
|
|
546
|
-
// Update the store so each Node can render its own indicator
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
786
|
+
// Update the store so each Node can render its own indicator.
|
|
787
|
+
// The indicator always tracks the VISUAL target/position (what the user
|
|
788
|
+
// sees), even when the actual move target is a logical ancestor.
|
|
789
|
+
// Guard the write so it only fires when the indicator actually changes -
|
|
790
|
+
// calculateDropTarget runs every pan frame and each store write re-runs
|
|
791
|
+
// every mounted Node's selector.
|
|
792
|
+
const nextDropNodeId = isValid ? targetNode.id : null;
|
|
793
|
+
const nextDropPosition = isValid ? position : null;
|
|
794
|
+
const nextDropLevel = isValid ? visualDropLevel : null;
|
|
795
|
+
const lastStore = lastStoreDropTargetRef.current;
|
|
796
|
+
if (!lastStore || lastStore.nodeId !== nextDropNodeId || lastStore.position !== nextDropPosition || lastStore.level !== nextDropLevel) {
|
|
797
|
+
updateDropTarget(nextDropNodeId, nextDropPosition, nextDropLevel);
|
|
798
|
+
lastStoreDropTargetRef.current = {
|
|
799
|
+
nodeId: nextDropNodeId,
|
|
800
|
+
position: nextDropPosition,
|
|
801
|
+
level: nextDropLevel
|
|
802
|
+
};
|
|
551
803
|
}
|
|
552
804
|
|
|
553
|
-
// Keep ref in sync (
|
|
805
|
+
// Keep the commit ref in sync (read by handleDragEnd).
|
|
554
806
|
// When a logical target exists (e.g. ancestor at a cliff), use it
|
|
555
807
|
// for the actual move while the visual indicator stays on the current node.
|
|
556
808
|
if (logicalTargetId !== null && logicalPosition !== null) {
|
|
@@ -562,24 +814,22 @@ export function useDragDrop(params) {
|
|
|
562
814
|
} else {
|
|
563
815
|
dropTargetRef.current = newTarget;
|
|
564
816
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
return prevTarget;
|
|
568
|
-
}
|
|
569
|
-
return newTarget;
|
|
570
|
-
});
|
|
571
|
-
}, [storeId, cancelAutoExpandTimer, overlayX]);
|
|
817
|
+
}, [storeId, cancelAutoExpandTimer, cancelLevelSettleTimer, fingerLeftOfLevelThreshold, overlayX, itemHeightsRef]);
|
|
818
|
+
calculateDropTargetRef.current = calculateDropTarget;
|
|
572
819
|
|
|
573
820
|
// --- Handle drag end ---
|
|
574
|
-
const handleDragEnd = useCallback((fingerPageY, fingerPageX) => {
|
|
821
|
+
const handleDragEnd = useCallback((fingerPageY, fingerPageX, cancel = false) => {
|
|
575
822
|
stopAutoScroll();
|
|
576
823
|
cancelLongPressTimer();
|
|
577
824
|
cancelAutoExpandTimer();
|
|
578
|
-
prevDropTargetRef.current = null;
|
|
579
825
|
if (!isDraggingRef.current) return;
|
|
580
826
|
isDraggingRef.current = false;
|
|
581
827
|
|
|
582
|
-
// Recalculate drop target at final position if we have coords
|
|
828
|
+
// Recalculate drop target at final position if we have coords. Hysteresis
|
|
829
|
+
// (prevDropTargetRef) is intentionally NOT cleared first, so this final
|
|
830
|
+
// commit frame resolves to the same target the user last saw the indicator
|
|
831
|
+
// on (clearing it here would let the release snap to the other side of an
|
|
832
|
+
// ambiguous same-level boundary). It is reset in the ref-cleanup below.
|
|
583
833
|
if (fingerPageY !== undefined) {
|
|
584
834
|
calculateDropTarget(fingerPageY, fingerPageX ?? 0);
|
|
585
835
|
}
|
|
@@ -588,80 +838,99 @@ export function useDragDrop(params) {
|
|
|
588
838
|
// Without this, the timer fires after drag ends and toggles the target back to collapsed.
|
|
589
839
|
cancelAutoExpandTimer();
|
|
590
840
|
|
|
591
|
-
// Read
|
|
592
|
-
//
|
|
593
|
-
// We use the current dropTarget state via a callback
|
|
594
|
-
// Read drop target from ref (avoids nesting Zustand updates inside React state updaters)
|
|
841
|
+
// Read the final drop target from the ref (the per-frame calculation
|
|
842
|
+
// keeps it current; there is no React state to wait on).
|
|
595
843
|
const currentTarget = dropTargetRef.current;
|
|
596
|
-
const droppedNodeId =
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
internalDataRef.current = newData;
|
|
621
|
-
|
|
622
|
-
// Notify consumer
|
|
844
|
+
const droppedNodeId = draggedNodeRef.current?.id ?? null;
|
|
845
|
+
const store = getTreeViewStore(storeId);
|
|
846
|
+
const {
|
|
847
|
+
initialTreeViewData: currentData,
|
|
848
|
+
nodeMap,
|
|
849
|
+
childToParentMap
|
|
850
|
+
} = store.getState();
|
|
851
|
+
// Capture the node's position before the move for the MoveResult delta
|
|
852
|
+
// (the maps still describe the pre-move tree here).
|
|
853
|
+
const prevPosition = droppedNodeId !== null ? findNodePositionFromMaps(currentData, nodeMap, childToParentMap, droppedNodeId) : null;
|
|
854
|
+
// Compute the move up front so an invalid move (moveTreeNode returns the
|
|
855
|
+
// same reference) or a positional no-op (node re-dropped where it already
|
|
856
|
+
// sits) is treated as a cancel rather than a spurious onDragEnd.
|
|
857
|
+
const newData = !cancel && currentTarget?.isValid && droppedNodeId !== null ? moveTreeNode(currentData, droppedNodeId, currentTarget.targetNodeId, currentTarget.position) : currentData;
|
|
858
|
+
const newPosition = newData !== currentData && droppedNodeId !== null ? findNodePosition(newData, droppedNodeId) : null;
|
|
859
|
+
const isNoOpMove = newPosition !== null && prevPosition !== null && newPosition.parentId === prevPosition.parentId && newPosition.index === prevPosition.index;
|
|
860
|
+
if (!cancel && currentTarget?.isValid && droppedNodeId !== null && newData !== currentData && !isNoOpMove) {
|
|
861
|
+
// Commit the move to the store (preserves checked/expanded;
|
|
862
|
+
// shared with the programmatic moveNode path).
|
|
863
|
+
applyMoveToStore(storeId, newData, droppedNodeId, currentTarget.targetNodeId, currentTarget.position);
|
|
864
|
+
|
|
865
|
+
// Notify the consumer with a lightweight move delta. The reordered
|
|
866
|
+
// tree lives in the store; TreeView's wrapped onDragEnd captures it
|
|
867
|
+
// for the reinit-skip, and consumers can read it via getTreeData().
|
|
623
868
|
onDragEndRef.current?.({
|
|
624
869
|
draggedNodeId: droppedNodeId,
|
|
625
870
|
targetNodeId: currentTarget.targetNodeId,
|
|
626
871
|
position: currentTarget.position,
|
|
627
|
-
|
|
872
|
+
previousParentId: prevPosition?.parentId ?? null,
|
|
873
|
+
previousIndex: prevPosition?.index ?? -1,
|
|
874
|
+
newParentId: newPosition?.parentId ?? null,
|
|
875
|
+
newIndex: newPosition?.index ?? -1
|
|
628
876
|
});
|
|
629
877
|
|
|
878
|
+
// Announce the result for screen readers (no-op without assistive tech).
|
|
879
|
+
AccessibilityInfo.announceForAccessibility?.(`Moved ${draggedNodeRef.current?.name ?? "node"}`);
|
|
880
|
+
|
|
630
881
|
// Auto-scroll to the dropped node unless disabled by the user.
|
|
631
882
|
const scrollOpts = autoScrollToDroppedNode;
|
|
632
883
|
const scrollEnabled = scrollOpts === undefined || scrollOpts === true || typeof scrollOpts === "object" && scrollOpts.enabled !== false;
|
|
633
884
|
if (scrollEnabled) {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
885
|
+
// The drop lands under the finger, so the node is almost always
|
|
886
|
+
// already on-screen; scrolling anyway would yank the list (worst
|
|
887
|
+
// when dropping near the very bottom, where centering the node
|
|
888
|
+
// scrolls the list back up). Estimate where the dropped row ends
|
|
889
|
+
// up in the post-move flattened list and only scroll when it
|
|
890
|
+
// actually sits outside the viewport.
|
|
891
|
+
const preMoveNodes = flattenedNodesRef.current;
|
|
892
|
+
const draggedIdx = preMoveNodes.findIndex(n => n.id === droppedNodeId);
|
|
893
|
+
const iH = itemHeightRef.current;
|
|
894
|
+
let finalIndex = currentTarget.targetIndex;
|
|
895
|
+
// "below"/"inside" both land the node right after the target row.
|
|
896
|
+
if (currentTarget.position !== "above") finalIndex += 1;
|
|
897
|
+
// Removing the dragged row from above the target shifts rows up one.
|
|
898
|
+
if (draggedIdx !== -1 && draggedIdx < currentTarget.targetIndex) {
|
|
899
|
+
finalIndex -= 1;
|
|
900
|
+
}
|
|
901
|
+
const rowTop = headerOffsetRef.current + finalIndex * iH - scrollOffsetRef.current;
|
|
902
|
+
const isOnScreen = rowTop >= 0 && rowTop + iH <= containerHeightRef.current;
|
|
903
|
+
if (!isOnScreen) {
|
|
904
|
+
scrollMovedNodeIntoView(scrollToNodeHandlerRef, droppedNodeId, scrollOpts);
|
|
905
|
+
}
|
|
643
906
|
}
|
|
644
907
|
} else if (droppedNodeId !== null) {
|
|
645
|
-
// Drag ended without a valid drop -
|
|
908
|
+
// Drag ended without a valid drop - a cancel must not mutate state,
|
|
909
|
+
// so restore the expansion that drag start force-collapsed.
|
|
910
|
+
if (wasDraggedNodeExpandedRef.current) {
|
|
911
|
+
expandNodes(storeId, [droppedNodeId]);
|
|
912
|
+
}
|
|
646
913
|
onDragCancelRef.current?.({
|
|
647
914
|
draggedNodeId: droppedNodeId
|
|
648
915
|
});
|
|
916
|
+
AccessibilityInfo.announceForAccessibility?.(`Cancelled moving ${draggedNodeRef.current?.name ?? "node"}`);
|
|
649
917
|
}
|
|
650
918
|
|
|
651
919
|
// Collapse auto-expanded nodes that aren't ancestors of the drop target
|
|
652
920
|
if (autoExpandedDuringDragRef.current.size > 0) {
|
|
653
|
-
|
|
921
|
+
// Re-read: the maps were rebuilt if a move committed above.
|
|
654
922
|
const {
|
|
655
|
-
childToParentMap
|
|
656
|
-
} =
|
|
923
|
+
childToParentMap: postMoveParentMap
|
|
924
|
+
} = store.getState();
|
|
657
925
|
|
|
658
|
-
// Collect ancestors of the drop target (keep these expanded)
|
|
926
|
+
// Collect ancestors of the drop target (keep these expanded).
|
|
927
|
+
// On cancel, retain none so every auto-expanded node collapses back.
|
|
659
928
|
const ancestorIds = new Set();
|
|
660
|
-
if (currentTarget?.isValid) {
|
|
929
|
+
if (!cancel && currentTarget?.isValid) {
|
|
661
930
|
let walkId = currentTarget.targetNodeId;
|
|
662
931
|
while (walkId !== undefined) {
|
|
663
932
|
ancestorIds.add(walkId);
|
|
664
|
-
walkId =
|
|
933
|
+
walkId = postMoveParentMap.get(walkId);
|
|
665
934
|
}
|
|
666
935
|
}
|
|
667
936
|
|
|
@@ -679,29 +948,38 @@ export function useDragDrop(params) {
|
|
|
679
948
|
}
|
|
680
949
|
|
|
681
950
|
// Clear drag state
|
|
682
|
-
|
|
683
|
-
store2.getState().updateDraggedNodeId(null);
|
|
684
|
-
store2.getState().updateInvalidDragTargetIds(new Set());
|
|
685
|
-
store2.getState().updateDropTarget(null, null);
|
|
951
|
+
resetDragStoreState();
|
|
686
952
|
|
|
687
953
|
// Reset all refs
|
|
688
954
|
overlayX.setValue(0);
|
|
689
955
|
prevEffectiveLevelRef.current = null;
|
|
956
|
+
cancelLevelSettleTimer();
|
|
957
|
+
prevDropTargetRef.current = null;
|
|
690
958
|
dropTargetRef.current = null;
|
|
959
|
+
lastStoreDropTargetRef.current = null;
|
|
691
960
|
draggedNodeRef.current = null;
|
|
692
|
-
|
|
693
|
-
draggedNodeIndexRef.current = -1;
|
|
694
|
-
setDropTarget(null);
|
|
961
|
+
wasDraggedNodeExpandedRef.current = false;
|
|
695
962
|
setIsDragging(false);
|
|
696
963
|
setDraggedNode(null);
|
|
697
964
|
},
|
|
698
965
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
699
|
-
[storeId, stopAutoScroll, cancelLongPressTimer, cancelAutoExpandTimer, calculateDropTarget
|
|
966
|
+
[storeId, stopAutoScroll, cancelLongPressTimer, cancelAutoExpandTimer, calculateDropTarget]);
|
|
967
|
+
|
|
968
|
+
// --- onScroll for the host list (see UseDragDropReturn.handleScroll) ---
|
|
969
|
+
const handleScroll = useCallback(event => {
|
|
970
|
+
if (!isDraggingRef.current) {
|
|
971
|
+
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
|
972
|
+
}
|
|
973
|
+
// Scrolling means this touch isn't a long-press
|
|
974
|
+
cancelLongPressTimer();
|
|
975
|
+
}, [cancelLongPressTimer]);
|
|
700
976
|
|
|
701
977
|
// --- Handle node touch end ---
|
|
702
978
|
// If the PanResponder never captured the gesture (no movement after long
|
|
703
979
|
// press fired), end the drag here so the node doesn't stay "lifted".
|
|
704
980
|
const handleNodeTouchEnd = useCallback(() => {
|
|
981
|
+
// cancelLongPressTimer also aborts any drag still awaiting its
|
|
982
|
+
// measureInWindow callback (clears pendingDragRef).
|
|
705
983
|
cancelLongPressTimer();
|
|
706
984
|
if (isDraggingRef.current && !panResponderActiveRef.current) {
|
|
707
985
|
handleDragEnd();
|
|
@@ -717,21 +995,25 @@ export function useDragDrop(params) {
|
|
|
717
995
|
onPanResponderGrant: () => {
|
|
718
996
|
panResponderActiveRef.current = true;
|
|
719
997
|
},
|
|
998
|
+
// While a drag is active, refuse to hand the gesture to an ancestor
|
|
999
|
+
// responder (e.g. a parent ScrollView or swipe navigator) so a slight
|
|
1000
|
+
// horizontal drift can't terminate the drag mid-flight.
|
|
1001
|
+
onPanResponderTerminationRequest: () => !isDraggingRef.current,
|
|
720
1002
|
onPanResponderMove: evt => {
|
|
721
1003
|
if (!isDraggingRef.current) return;
|
|
722
1004
|
const fingerPageY = evt.nativeEvent.pageY;
|
|
1005
|
+
lastFingerPageYRef.current = fingerPageY;
|
|
1006
|
+
lastFingerPageXRef.current = evt.nativeEvent.pageX;
|
|
723
1007
|
const fingerLocalY = fingerPageY - containerPageYRef.current;
|
|
724
1008
|
|
|
725
1009
|
// Update overlay position (with configurable offset)
|
|
726
|
-
|
|
727
|
-
overlayY.setValue(overlayLocalY);
|
|
1010
|
+
overlayY.setValue(computeOverlayLocalY(fingerLocalY));
|
|
728
1011
|
|
|
729
1012
|
// Calculate drop target (horizontal position used at level cliffs)
|
|
730
1013
|
calculateDropTarget(fingerPageY, evt.nativeEvent.pageX);
|
|
731
1014
|
|
|
732
|
-
// Auto-scroll at edges
|
|
733
|
-
|
|
734
|
-
updateAutoScroll(fingerInContainer);
|
|
1015
|
+
// Auto-scroll at edges, from the finger's container-local position
|
|
1016
|
+
updateAutoScroll(fingerLocalY);
|
|
735
1017
|
},
|
|
736
1018
|
onPanResponderRelease: evt => {
|
|
737
1019
|
panResponderActiveRef.current = false;
|
|
@@ -739,7 +1021,9 @@ export function useDragDrop(params) {
|
|
|
739
1021
|
},
|
|
740
1022
|
onPanResponderTerminate: () => {
|
|
741
1023
|
panResponderActiveRef.current = false;
|
|
742
|
-
|
|
1024
|
+
// A terminate (parent scroll steals the gesture, app backgrounds, etc.)
|
|
1025
|
+
// cancels the drop rather than committing at the last hovered target.
|
|
1026
|
+
handleDragEnd(undefined, undefined, true);
|
|
743
1027
|
}
|
|
744
1028
|
})).current;
|
|
745
1029
|
|
|
@@ -748,29 +1032,29 @@ export function useDragDrop(params) {
|
|
|
748
1032
|
return () => {
|
|
749
1033
|
cancelLongPressTimer();
|
|
750
1034
|
cancelAutoExpandTimer();
|
|
1035
|
+
cancelLevelSettleTimer();
|
|
751
1036
|
stopAutoScroll();
|
|
1037
|
+
pendingDragRef.current = false;
|
|
1038
|
+
lastStoreDropTargetRef.current = null;
|
|
752
1039
|
if (isDraggingRef.current) {
|
|
753
1040
|
isDraggingRef.current = false;
|
|
754
|
-
|
|
755
|
-
store.getState().updateDraggedNodeId(null);
|
|
756
|
-
store.getState().updateInvalidDragTargetIds(new Set());
|
|
757
|
-
store.getState().updateDropTarget(null, null);
|
|
1041
|
+
resetDragStoreState();
|
|
758
1042
|
}
|
|
759
1043
|
};
|
|
760
|
-
}, [
|
|
1044
|
+
}, [cancelLongPressTimer, cancelAutoExpandTimer, cancelLevelSettleTimer, stopAutoScroll, resetDragStoreState]);
|
|
761
1045
|
return {
|
|
762
1046
|
panResponder,
|
|
763
1047
|
overlayY,
|
|
764
1048
|
overlayX,
|
|
765
1049
|
isDragging,
|
|
766
1050
|
draggedNode,
|
|
767
|
-
dropTarget,
|
|
768
|
-
effectiveDropLevel,
|
|
769
1051
|
handleNodeTouchStart,
|
|
770
1052
|
handleNodeTouchEnd,
|
|
771
1053
|
cancelLongPressTimer,
|
|
1054
|
+
handleScroll,
|
|
772
1055
|
scrollOffsetRef,
|
|
773
|
-
headerOffsetRef
|
|
1056
|
+
headerOffsetRef,
|
|
1057
|
+
containerHeightRef
|
|
774
1058
|
};
|
|
775
1059
|
}
|
|
776
1060
|
//# sourceMappingURL=useDragDrop.js.map
|