react-native-tree-multi-select 3.0.0-beta.4 → 3.0.0-beta.6
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 +64 -30
- 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 +83 -31
- 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 +6 -13
- 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 +486 -216
- package/lib/module/hooks/useDragDrop.js.map +1 -1
- package/lib/module/hooks/useScrollToNode.js +18 -1
- 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 +30 -7
- 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 +3 -3
- 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 +78 -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 +84 -29
- package/src/constants/treeView.constants.ts +6 -1
- package/src/helpers/moveTreeNode.helper.ts +160 -43
- package/src/helpers/toggleCheckbox.helper.ts +6 -13
- package/src/helpers/treeNode.helper.ts +52 -1
- package/src/hooks/useDragDrop.ts +597 -250
- package/src/hooks/useScrollToNode.ts +22 -1
- package/src/index.tsx +5 -1
- package/src/store/treeView.store.ts +6 -0
- package/src/types/dragDrop.types.ts +25 -13
- package/src/types/treeView.types.ts +82 -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,11 +1,36 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import { Animated, PanResponder } 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 {
|
|
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;
|
|
9
34
|
export function useDragDrop(params) {
|
|
10
35
|
const {
|
|
11
36
|
storeId,
|
|
@@ -19,39 +44,60 @@ export function useDragDrop(params) {
|
|
|
19
44
|
longPressDuration,
|
|
20
45
|
autoScrollThreshold,
|
|
21
46
|
autoScrollSpeed,
|
|
22
|
-
internalDataRef,
|
|
23
47
|
measuredItemHeightRef,
|
|
48
|
+
contentHeightRef,
|
|
49
|
+
itemHeightsRef,
|
|
24
50
|
dragOverlayOffset,
|
|
51
|
+
overlayYCorrection,
|
|
25
52
|
autoExpandDelay,
|
|
53
|
+
autoExpand = true,
|
|
54
|
+
magneticSnap = true,
|
|
26
55
|
indentationMultiplier,
|
|
27
56
|
canDrop: canDropCallback,
|
|
28
57
|
maxDepth,
|
|
29
58
|
canNodeHaveChildren,
|
|
30
|
-
canDrag
|
|
59
|
+
canDrag,
|
|
60
|
+
scrollToNodeHandlerRef,
|
|
61
|
+
autoScrollToDroppedNode
|
|
31
62
|
} = params;
|
|
32
63
|
|
|
33
64
|
// --- Refs for mutable state (no stale closures in PanResponder) ---
|
|
34
65
|
const isDraggingRef = useRef(false);
|
|
35
66
|
const draggedNodeRef = useRef(null);
|
|
36
|
-
|
|
37
|
-
|
|
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);
|
|
38
70
|
const longPressTimerRef = useRef(null);
|
|
39
71
|
const containerPageXRef = useRef(0);
|
|
40
72
|
const containerPageYRef = useRef(0);
|
|
73
|
+
const containerWidthRef = useRef(0);
|
|
41
74
|
const containerHeightRef = useRef(0);
|
|
42
75
|
const grabOffsetYRef = useRef(0);
|
|
43
76
|
const scrollOffsetRef = useRef(0);
|
|
44
77
|
const headerOffsetRef = useRef(0);
|
|
45
|
-
const itemHeightRef = useRef(
|
|
78
|
+
const itemHeightRef = useRef(defaultItemHeight);
|
|
46
79
|
const overlayY = useRef(new Animated.Value(0)).current;
|
|
47
80
|
const overlayX = useRef(new Animated.Value(0)).current;
|
|
48
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);
|
|
49
86
|
const autoScrollRAFRef = useRef(null);
|
|
50
87
|
const autoScrollSpeedRef = useRef(0);
|
|
51
88
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
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);
|
|
55
101
|
|
|
56
102
|
// Auto-expand timer for hovering over collapsed nodes
|
|
57
103
|
const autoExpandTimerRef = useRef(null);
|
|
@@ -62,8 +108,16 @@ export function useDragDrop(params) {
|
|
|
62
108
|
// Tracks whether the PanResponder has captured the current gesture
|
|
63
109
|
const panResponderActiveRef = useRef(false);
|
|
64
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
|
+
|
|
65
116
|
// Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
|
|
66
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);
|
|
67
121
|
|
|
68
122
|
// Depth of the dragged subtree (computed once at drag start, used for maxDepth check)
|
|
69
123
|
const draggedSubtreeDepthRef = useRef(0);
|
|
@@ -89,32 +143,49 @@ export function useDragDrop(params) {
|
|
|
89
143
|
// Keep config values current for PanResponder closures
|
|
90
144
|
const dragOverlayOffsetRef = useRef(dragOverlayOffset);
|
|
91
145
|
dragOverlayOffsetRef.current = dragOverlayOffset;
|
|
146
|
+
const overlayYCorrectionRef = useRef(overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION);
|
|
147
|
+
overlayYCorrectionRef.current = overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION;
|
|
92
148
|
const autoScrollThresholdRef = useRef(autoScrollThreshold);
|
|
93
149
|
autoScrollThresholdRef.current = autoScrollThreshold;
|
|
94
150
|
const autoScrollSpeedParamRef = useRef(autoScrollSpeed);
|
|
95
151
|
autoScrollSpeedParamRef.current = autoScrollSpeed;
|
|
96
152
|
const autoExpandDelayRef = useRef(autoExpandDelay);
|
|
97
153
|
autoExpandDelayRef.current = autoExpandDelay;
|
|
154
|
+
const autoExpandRef = useRef(autoExpand);
|
|
155
|
+
autoExpandRef.current = autoExpand;
|
|
156
|
+
const magneticSnapRef = useRef(magneticSnap);
|
|
157
|
+
magneticSnapRef.current = magneticSnap;
|
|
98
158
|
const indentationMultiplierRef = useRef(indentationMultiplier);
|
|
99
159
|
indentationMultiplierRef.current = indentationMultiplier;
|
|
100
160
|
const maxDepthRef = useRef(maxDepth);
|
|
101
161
|
maxDepthRef.current = maxDepth;
|
|
102
162
|
|
|
103
|
-
// --- React state (triggers re-renders only at drag start/end +
|
|
163
|
+
// --- React state (triggers re-renders only at drag start/end + level changes) ---
|
|
104
164
|
const [isDragging, setIsDragging] = useState(false);
|
|
105
165
|
const [draggedNode, setDraggedNode] = useState(null);
|
|
106
|
-
const [dropTarget, setDropTarget] = useState(null);
|
|
107
|
-
const [effectiveDropLevel, setEffectiveDropLevel] = useState(0);
|
|
108
166
|
|
|
109
|
-
//
|
|
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.
|
|
110
172
|
const dropTargetRef = useRef(null);
|
|
111
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
|
+
|
|
112
179
|
// --- Long press timer ---
|
|
113
180
|
const cancelLongPressTimer = useCallback(() => {
|
|
114
181
|
if (longPressTimerRef.current) {
|
|
115
182
|
clearTimeout(longPressTimerRef.current);
|
|
116
183
|
longPressTimerRef.current = null;
|
|
117
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;
|
|
118
189
|
}, []);
|
|
119
190
|
|
|
120
191
|
// --- Get all descendant IDs of a node ---
|
|
@@ -140,27 +211,41 @@ export function useDragDrop(params) {
|
|
|
140
211
|
|
|
141
212
|
// --- Get the maximum depth of a subtree (0 for leaf nodes) ---
|
|
142
213
|
const getSubtreeDepth = useCallback(nodeId => {
|
|
143
|
-
const store = getTreeViewStore(storeId);
|
|
144
214
|
const {
|
|
145
215
|
nodeMap
|
|
146
|
-
} =
|
|
147
|
-
|
|
148
|
-
if (!node?.children?.length) return 0;
|
|
149
|
-
let max = 0;
|
|
150
|
-
for (const child of node.children) {
|
|
151
|
-
max = Math.max(max, 1 + getSubtreeDepth(child.id));
|
|
152
|
-
}
|
|
153
|
-
return max;
|
|
216
|
+
} = getTreeViewStore(storeId).getState();
|
|
217
|
+
return getSubtreeDepthFromMap(nodeMap, nodeId);
|
|
154
218
|
}, [storeId]);
|
|
155
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
|
+
|
|
156
232
|
// --- Initiate drag ---
|
|
157
233
|
const initiateDrag = useCallback((nodeId, pageY, locationY, nodeIndex) => {
|
|
158
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;
|
|
159
238
|
const container = containerRef.current;
|
|
160
239
|
if (!container) return;
|
|
161
|
-
|
|
240
|
+
pendingDragRef.current = true;
|
|
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;
|
|
162
246
|
containerPageXRef.current = x;
|
|
163
247
|
containerPageYRef.current = y;
|
|
248
|
+
containerWidthRef.current = w;
|
|
164
249
|
containerHeightRef.current = h;
|
|
165
250
|
|
|
166
251
|
// Find the node in flattened list
|
|
@@ -168,25 +253,25 @@ export function useDragDrop(params) {
|
|
|
168
253
|
const node = nodes[nodeIndex];
|
|
169
254
|
if (!node) return;
|
|
170
255
|
|
|
171
|
-
// Collapse node if expanded
|
|
256
|
+
// Collapse node if expanded (restored if the drag is cancelled)
|
|
172
257
|
const store = getTreeViewStore(storeId);
|
|
173
258
|
const {
|
|
174
259
|
expanded
|
|
175
260
|
} = store.getState();
|
|
261
|
+
wasDraggedNodeExpandedRef.current = false;
|
|
176
262
|
if (expanded.has(nodeId) && node.children?.length) {
|
|
177
263
|
handleToggleExpand(storeId, nodeId);
|
|
264
|
+
wasDraggedNodeExpandedRef.current = true;
|
|
178
265
|
}
|
|
179
266
|
|
|
180
267
|
// Store grab metadata
|
|
181
268
|
grabOffsetYRef.current = locationY;
|
|
182
269
|
draggedNodeRef.current = node;
|
|
183
|
-
draggedNodeIdRef.current = nodeId;
|
|
184
|
-
draggedNodeIndexRef.current = nodeIndex;
|
|
185
270
|
draggedSubtreeDepthRef.current = getSubtreeDepth(nodeId);
|
|
186
271
|
|
|
187
272
|
// Use measured item height if available, fall back to default
|
|
188
273
|
const measured = measuredItemHeightRef.current;
|
|
189
|
-
itemHeightRef.current = measured > 0 ? measured :
|
|
274
|
+
itemHeightRef.current = measured > 0 ? measured : defaultItemHeight;
|
|
190
275
|
|
|
191
276
|
// Calculate headerOffset dynamically:
|
|
192
277
|
// fingerLocalY = pageY - containerPageY
|
|
@@ -194,13 +279,7 @@ export function useDragDrop(params) {
|
|
|
194
279
|
// So: headerOffset = fingerLocalY + scrollOffset - grabOffsetY - nodeIndex * itemHeight
|
|
195
280
|
const fingerLocalY = pageY - containerPageYRef.current;
|
|
196
281
|
headerOffsetRef.current = fingerLocalY + scrollOffsetRef.current - locationY - nodeIndex * itemHeightRef.current;
|
|
197
|
-
|
|
198
|
-
// Delta-based auto-scroll: compute finger's position in the container
|
|
199
|
-
// from the node's known index (avoids unreliable containerPageY).
|
|
200
|
-
const iH = itemHeightRef.current;
|
|
201
|
-
const listHeaderHeight = listHeaderFooterPadding * 2;
|
|
202
|
-
initialFingerPageYRef.current = pageY;
|
|
203
|
-
initialFingerContainerYRef.current = listHeaderHeight + nodeIndex * iH - scrollOffsetRef.current + locationY;
|
|
282
|
+
lastFingerPageYRef.current = pageY;
|
|
204
283
|
|
|
205
284
|
// Compute invalid targets (self + descendants)
|
|
206
285
|
const descendants = getDescendantIds(nodeId);
|
|
@@ -211,26 +290,27 @@ export function useDragDrop(params) {
|
|
|
211
290
|
store.getState().updateInvalidDragTargetIds(descendants);
|
|
212
291
|
|
|
213
292
|
// Set overlay initial position (with configurable offset)
|
|
214
|
-
|
|
215
|
-
overlayY.setValue(overlayLocalY);
|
|
293
|
+
overlayY.setValue(computeOverlayLocalY(fingerLocalY));
|
|
216
294
|
|
|
217
295
|
// Reset magnetic overlay
|
|
218
296
|
overlayX.setValue(0);
|
|
219
297
|
prevEffectiveLevelRef.current = node.level ?? 0;
|
|
298
|
+
cancelLevelSettleTimer();
|
|
220
299
|
|
|
221
300
|
// Set React state
|
|
222
301
|
isDraggingRef.current = true;
|
|
223
302
|
autoExpandedDuringDragRef.current.clear();
|
|
224
303
|
setIsDragging(true);
|
|
225
304
|
setDraggedNode(node);
|
|
226
|
-
setEffectiveDropLevel(node.level ?? 0);
|
|
227
|
-
setDropTarget(null);
|
|
228
305
|
|
|
229
306
|
// Notify consumer that drag has started
|
|
230
307
|
onDragStartRef.current?.({
|
|
231
308
|
draggedNodeId: nodeId
|
|
232
309
|
});
|
|
233
310
|
|
|
311
|
+
// Announce for screen readers (no-op when no assistive tech is active).
|
|
312
|
+
AccessibilityInfo.announceForAccessibility?.(`Picked up ${node.name}`);
|
|
313
|
+
|
|
234
314
|
// Start auto-scroll loop
|
|
235
315
|
startAutoScrollLoop();
|
|
236
316
|
});
|
|
@@ -241,6 +321,15 @@ export function useDragDrop(params) {
|
|
|
241
321
|
// --- Handle node touch start (long press detection) ---
|
|
242
322
|
const handleNodeTouchStart = useCallback((nodeId, pageY, locationY, nodeIndex) => {
|
|
243
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;
|
|
244
333
|
|
|
245
334
|
// Check if this node can be dragged
|
|
246
335
|
if (canDragRef.current) {
|
|
@@ -256,24 +345,63 @@ export function useDragDrop(params) {
|
|
|
256
345
|
longPressTimerRef.current = null;
|
|
257
346
|
initiateDrag(nodeId, pageY, locationY, nodeIndex);
|
|
258
347
|
}, longPressDuration);
|
|
259
|
-
}, [dragEnabled, longPressDuration, cancelLongPressTimer, initiateDrag]);
|
|
348
|
+
}, [dragEnabled, storeId, longPressDuration, cancelLongPressTimer, initiateDrag]);
|
|
260
349
|
|
|
261
350
|
// --- Auto-scroll ---
|
|
262
351
|
const startAutoScrollLoop = useCallback(() => {
|
|
263
|
-
|
|
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 => {
|
|
264
363
|
if (!isDraggingRef.current) return;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
}
|
|
272
400
|
}
|
|
273
401
|
autoScrollRAFRef.current = requestAnimationFrame(loop);
|
|
274
402
|
};
|
|
275
403
|
autoScrollRAFRef.current = requestAnimationFrame(loop);
|
|
276
|
-
}, [flashListRef]);
|
|
404
|
+
}, [flashListRef, contentHeightRef]);
|
|
277
405
|
const stopAutoScroll = useCallback(() => {
|
|
278
406
|
if (autoScrollRAFRef.current !== null) {
|
|
279
407
|
cancelAnimationFrame(autoScrollRAFRef.current);
|
|
@@ -283,16 +411,21 @@ export function useDragDrop(params) {
|
|
|
283
411
|
}, []);
|
|
284
412
|
const updateAutoScroll = useCallback(fingerInContainer => {
|
|
285
413
|
const threshold = autoScrollThresholdRef.current;
|
|
286
|
-
|
|
414
|
+
// px/second; the RAF loop converts to distance via elapsed time.
|
|
415
|
+
const maxSpeed = MAX_AUTO_SCROLL_SPEED * autoScrollSpeedParamRef.current;
|
|
287
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.
|
|
288
421
|
if (fingerInContainer < threshold) {
|
|
289
422
|
// Scroll up
|
|
290
423
|
const ratio = 1 - Math.max(0, fingerInContainer) / threshold;
|
|
291
|
-
autoScrollSpeedRef.current = -maxSpeed * ratio;
|
|
424
|
+
autoScrollSpeedRef.current = -maxSpeed * Math.sqrt(ratio);
|
|
292
425
|
} else if (fingerInContainer > containerH - threshold) {
|
|
293
426
|
// Scroll down
|
|
294
427
|
const ratio = 1 - Math.max(0, containerH - fingerInContainer) / threshold;
|
|
295
|
-
autoScrollSpeedRef.current = maxSpeed * ratio;
|
|
428
|
+
autoScrollSpeedRef.current = maxSpeed * Math.sqrt(ratio);
|
|
296
429
|
} else {
|
|
297
430
|
autoScrollSpeedRef.current = 0;
|
|
298
431
|
}
|
|
@@ -307,25 +440,121 @@ export function useDragDrop(params) {
|
|
|
307
440
|
autoExpandTargetRef.current = null;
|
|
308
441
|
}, []);
|
|
309
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
|
+
|
|
310
460
|
// --- Calculate drop target ---
|
|
311
461
|
const calculateDropTarget = useCallback((fingerPageY, fingerPageX) => {
|
|
312
462
|
const nodes = flattenedNodesRef.current;
|
|
313
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();
|
|
314
486
|
const fingerLocalY = fingerPageY - containerPageYRef.current;
|
|
315
487
|
const fingerContentY = fingerLocalY + scrollOffsetRef.current;
|
|
316
488
|
const adjustedContentY = fingerContentY - headerOffsetRef.current;
|
|
317
489
|
const iH = itemHeightRef.current;
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
}
|
|
320
533
|
let targetNode = nodes[clampedIndex];
|
|
321
534
|
if (!targetNode) return;
|
|
322
535
|
|
|
323
|
-
// Determine zone within item
|
|
324
|
-
|
|
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
|
+
}
|
|
325
554
|
let position;
|
|
326
|
-
if (positionInItem <
|
|
555
|
+
if (positionInItem < aboveBound) {
|
|
327
556
|
position = "above";
|
|
328
|
-
} else if (positionInItem >
|
|
557
|
+
} else if (positionInItem > belowBound) {
|
|
329
558
|
position = "below";
|
|
330
559
|
} else {
|
|
331
560
|
position = "inside";
|
|
@@ -363,7 +592,7 @@ export function useDragDrop(params) {
|
|
|
363
592
|
let logicalTargetId = null;
|
|
364
593
|
let logicalPosition = null;
|
|
365
594
|
let visualDropLevel = null;
|
|
366
|
-
if (position === "below"
|
|
595
|
+
if (position === "below") {
|
|
367
596
|
const currentLevel = targetNode.level ?? 0;
|
|
368
597
|
let isCliff = false;
|
|
369
598
|
let shallowLevel = 0;
|
|
@@ -380,10 +609,7 @@ export function useDragDrop(params) {
|
|
|
380
609
|
shallowLevel = 0;
|
|
381
610
|
}
|
|
382
611
|
if (isCliff) {
|
|
383
|
-
|
|
384
|
-
const indent = indentationMultiplierRef.current;
|
|
385
|
-
const threshold = (currentLevel + shallowLevel) / 2 * indent + indent * 2;
|
|
386
|
-
if (fingerLocalX < threshold) {
|
|
612
|
+
if (fingerLeftOfLevelThreshold(currentLevel, fingerLocalX)) {
|
|
387
613
|
// User wants the shallow level
|
|
388
614
|
if (clampedIndex < nodes.length - 1) {
|
|
389
615
|
// Non-last item: switch to "above" on the next (shallower) node
|
|
@@ -393,9 +619,6 @@ export function useDragDrop(params) {
|
|
|
393
619
|
position = "above";
|
|
394
620
|
} else {
|
|
395
621
|
// Last item: find ancestor at shallow level, target it with "below"
|
|
396
|
-
const {
|
|
397
|
-
childToParentMap
|
|
398
|
-
} = getTreeViewStore(storeId).getState();
|
|
399
622
|
let ancestorId = targetNode.id;
|
|
400
623
|
let walkLevel = currentLevel;
|
|
401
624
|
while (walkLevel > shallowLevel) {
|
|
@@ -417,10 +640,7 @@ export function useDragDrop(params) {
|
|
|
417
640
|
const prevLevel = prevNode?.level ?? 0;
|
|
418
641
|
const currentLevel = targetNode.level ?? 0;
|
|
419
642
|
if (prevNode && prevLevel > currentLevel) {
|
|
420
|
-
|
|
421
|
-
const indent = indentationMultiplierRef.current;
|
|
422
|
-
const threshold = (prevLevel + currentLevel) / 2 * indent + indent * 2;
|
|
423
|
-
if (fingerLocalX >= threshold) {
|
|
643
|
+
if (!fingerLeftOfLevelThreshold(prevLevel, fingerLocalX)) {
|
|
424
644
|
clampedIndex = clampedIndex - 1;
|
|
425
645
|
targetNode = prevNode;
|
|
426
646
|
position = "below";
|
|
@@ -428,26 +648,14 @@ export function useDragDrop(params) {
|
|
|
428
648
|
}
|
|
429
649
|
}
|
|
430
650
|
|
|
431
|
-
// --- Suppress "below" when it's
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
651
|
+
// --- Suppress "below" when it's semantically confusing ---
|
|
652
|
+
// For expanded parents, "below" visually sits at the parent/child
|
|
653
|
+
// junction but semantically inserts as a sibling after the entire
|
|
654
|
+
// subtree. Convert to "inside" which is clearer.
|
|
435
655
|
if (position === "below" && canDropInsideTarget) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
// (a) Expanded parent: "below" visually sits at the parent/child junction
|
|
439
|
-
// but semantically inserts as a sibling after the entire subtree.
|
|
440
|
-
if (targetNode.children?.length && expandedSet.has(targetNode.id)) {
|
|
656
|
+
if (targetNode.children?.length && expanded.has(targetNode.id)) {
|
|
441
657
|
position = "inside";
|
|
442
658
|
}
|
|
443
|
-
// (b) No level cliff below: convert to "inside" so the highlight
|
|
444
|
-
// covers the full bottom of the node.
|
|
445
|
-
else if (clampedIndex < nodes.length - 1) {
|
|
446
|
-
const nextNode = nodes[clampedIndex + 1];
|
|
447
|
-
if (nextNode && (nextNode.level ?? 0) >= (targetNode.level ?? 0)) {
|
|
448
|
-
position = "inside";
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
659
|
}
|
|
452
660
|
|
|
453
661
|
// --- Hysteresis: prevent flicker between "below N" and "above N+1" ---
|
|
@@ -473,27 +681,29 @@ export function useDragDrop(params) {
|
|
|
473
681
|
targetIndex: clampedIndex,
|
|
474
682
|
position
|
|
475
683
|
};
|
|
476
|
-
const indicatorTop = fingerLocalY - grabOffsetYRef.current;
|
|
477
684
|
|
|
478
|
-
// Validity check
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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;
|
|
485
693
|
|
|
486
694
|
// maxDepth check for above/below (sibling) positions
|
|
487
695
|
let maxDepthValid = true;
|
|
488
|
-
if (maxDepthRef.current !== undefined && (
|
|
489
|
-
|
|
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;
|
|
490
700
|
const deepest = targetLevel + draggedSubtreeDepthRef.current;
|
|
491
701
|
if (deepest > maxDepthRef.current) maxDepthValid = false;
|
|
492
702
|
}
|
|
493
|
-
const isValid =
|
|
703
|
+
const isValid = effectiveTargetId !== draggedNodeId && !invalidDragTargetIds.has(effectiveTargetId) && maxDepthValid && (!canDropRef.current || canDropRef.current(draggedNodeRef.current, effectiveTargetNode, effectivePosition));
|
|
494
704
|
|
|
495
705
|
// --- Auto-expand: if hovering "inside" a collapsed expandable node ---
|
|
496
|
-
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)) {
|
|
497
707
|
if (autoExpandTargetRef.current !== targetNode.id) {
|
|
498
708
|
// New hover target - start timer
|
|
499
709
|
cancelAutoExpandTimer();
|
|
@@ -501,7 +711,7 @@ export function useDragDrop(params) {
|
|
|
501
711
|
autoExpandTimerRef.current = setTimeout(() => {
|
|
502
712
|
autoExpandTimerRef.current = null;
|
|
503
713
|
// Expand the node and track it
|
|
504
|
-
|
|
714
|
+
expandNodes(storeId, [targetNode.id]);
|
|
505
715
|
autoExpandedDuringDragRef.current.add(targetNode.id);
|
|
506
716
|
}, autoExpandDelayRef.current);
|
|
507
717
|
}
|
|
@@ -520,41 +730,79 @@ export function useDragDrop(params) {
|
|
|
520
730
|
// the effective level comes from the visual drop level, not the target node.
|
|
521
731
|
const effectiveLevel = isValid ? visualDropLevel !== null ? visualDropLevel // "below" ancestor → sibling at that level
|
|
522
732
|
: position === "inside" ? (targetNode.level ?? 0) + 1 : targetNode.level ?? 0 : draggedLevel;
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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) {
|
|
533
747
|
Animated.spring(overlayX, {
|
|
534
|
-
toValue:
|
|
748
|
+
toValue: targetX,
|
|
535
749
|
useNativeDriver: true,
|
|
536
750
|
speed: 40,
|
|
537
751
|
bounciness: 4
|
|
538
752
|
}).start();
|
|
753
|
+
} else {
|
|
754
|
+
overlayX.setValue(targetX);
|
|
539
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);
|
|
540
778
|
}
|
|
541
779
|
const newTarget = {
|
|
542
780
|
targetNodeId: targetNode.id,
|
|
543
781
|
targetIndex: clampedIndex,
|
|
544
782
|
position,
|
|
545
|
-
isValid
|
|
546
|
-
targetLevel: targetNode.level ?? 0,
|
|
547
|
-
indicatorTop
|
|
783
|
+
isValid
|
|
548
784
|
};
|
|
549
785
|
|
|
550
|
-
// Update the store so each Node can render its own indicator
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
+
};
|
|
555
803
|
}
|
|
556
804
|
|
|
557
|
-
// Keep ref in sync (
|
|
805
|
+
// Keep the commit ref in sync (read by handleDragEnd).
|
|
558
806
|
// When a logical target exists (e.g. ancestor at a cliff), use it
|
|
559
807
|
// for the actual move while the visual indicator stays on the current node.
|
|
560
808
|
if (logicalTargetId !== null && logicalPosition !== null) {
|
|
@@ -566,24 +814,22 @@ export function useDragDrop(params) {
|
|
|
566
814
|
} else {
|
|
567
815
|
dropTargetRef.current = newTarget;
|
|
568
816
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
return prevTarget;
|
|
572
|
-
}
|
|
573
|
-
return newTarget;
|
|
574
|
-
});
|
|
575
|
-
}, [storeId, cancelAutoExpandTimer, overlayX]);
|
|
817
|
+
}, [storeId, cancelAutoExpandTimer, cancelLevelSettleTimer, fingerLeftOfLevelThreshold, overlayX, itemHeightsRef]);
|
|
818
|
+
calculateDropTargetRef.current = calculateDropTarget;
|
|
576
819
|
|
|
577
820
|
// --- Handle drag end ---
|
|
578
|
-
const handleDragEnd = useCallback((fingerPageY, fingerPageX) => {
|
|
821
|
+
const handleDragEnd = useCallback((fingerPageY, fingerPageX, cancel = false) => {
|
|
579
822
|
stopAutoScroll();
|
|
580
823
|
cancelLongPressTimer();
|
|
581
824
|
cancelAutoExpandTimer();
|
|
582
|
-
prevDropTargetRef.current = null;
|
|
583
825
|
if (!isDraggingRef.current) return;
|
|
584
826
|
isDraggingRef.current = false;
|
|
585
827
|
|
|
586
|
-
// 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.
|
|
587
833
|
if (fingerPageY !== undefined) {
|
|
588
834
|
calculateDropTarget(fingerPageY, fingerPageX ?? 0);
|
|
589
835
|
}
|
|
@@ -592,90 +838,99 @@ export function useDragDrop(params) {
|
|
|
592
838
|
// Without this, the timer fires after drag ends and toggles the target back to collapsed.
|
|
593
839
|
cancelAutoExpandTimer();
|
|
594
840
|
|
|
595
|
-
// Read
|
|
596
|
-
//
|
|
597
|
-
// We use the current dropTarget state via a callback
|
|
598
|
-
// 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).
|
|
599
843
|
const currentTarget = dropTargetRef.current;
|
|
600
|
-
const droppedNodeId =
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
internalDataRef.current = newData;
|
|
625
|
-
|
|
626
|
-
// 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().
|
|
627
868
|
onDragEndRef.current?.({
|
|
628
869
|
draggedNodeId: droppedNodeId,
|
|
629
870
|
targetNodeId: currentTarget.targetNodeId,
|
|
630
871
|
position: currentTarget.position,
|
|
631
|
-
|
|
872
|
+
previousParentId: prevPosition?.parentId ?? null,
|
|
873
|
+
previousIndex: prevPosition?.index ?? -1,
|
|
874
|
+
newParentId: newPosition?.parentId ?? null,
|
|
875
|
+
newIndex: newPosition?.index ?? -1
|
|
632
876
|
});
|
|
633
877
|
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
//
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
878
|
+
// Announce the result for screen readers (no-op without assistive tech).
|
|
879
|
+
AccessibilityInfo.announceForAccessibility?.(`Moved ${draggedNodeRef.current?.name ?? "node"}`);
|
|
880
|
+
|
|
881
|
+
// Auto-scroll to the dropped node unless disabled by the user.
|
|
882
|
+
const scrollOpts = autoScrollToDroppedNode;
|
|
883
|
+
const scrollEnabled = scrollOpts === undefined || scrollOpts === true || typeof scrollOpts === "object" && scrollOpts.enabled !== false;
|
|
884
|
+
if (scrollEnabled) {
|
|
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;
|
|
651
900
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
}
|
|
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
|
+
}
|
|
906
|
+
}
|
|
658
907
|
} else if (droppedNodeId !== null) {
|
|
659
|
-
// 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
|
+
}
|
|
660
913
|
onDragCancelRef.current?.({
|
|
661
914
|
draggedNodeId: droppedNodeId
|
|
662
915
|
});
|
|
916
|
+
AccessibilityInfo.announceForAccessibility?.(`Cancelled moving ${draggedNodeRef.current?.name ?? "node"}`);
|
|
663
917
|
}
|
|
664
918
|
|
|
665
919
|
// Collapse auto-expanded nodes that aren't ancestors of the drop target
|
|
666
920
|
if (autoExpandedDuringDragRef.current.size > 0) {
|
|
667
|
-
|
|
921
|
+
// Re-read: the maps were rebuilt if a move committed above.
|
|
668
922
|
const {
|
|
669
|
-
childToParentMap
|
|
670
|
-
} =
|
|
923
|
+
childToParentMap: postMoveParentMap
|
|
924
|
+
} = store.getState();
|
|
671
925
|
|
|
672
|
-
// 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.
|
|
673
928
|
const ancestorIds = new Set();
|
|
674
|
-
if (currentTarget?.isValid) {
|
|
929
|
+
if (!cancel && currentTarget?.isValid) {
|
|
675
930
|
let walkId = currentTarget.targetNodeId;
|
|
676
931
|
while (walkId !== undefined) {
|
|
677
932
|
ancestorIds.add(walkId);
|
|
678
|
-
walkId =
|
|
933
|
+
walkId = postMoveParentMap.get(walkId);
|
|
679
934
|
}
|
|
680
935
|
}
|
|
681
936
|
|
|
@@ -693,29 +948,38 @@ export function useDragDrop(params) {
|
|
|
693
948
|
}
|
|
694
949
|
|
|
695
950
|
// Clear drag state
|
|
696
|
-
|
|
697
|
-
store2.getState().updateDraggedNodeId(null);
|
|
698
|
-
store2.getState().updateInvalidDragTargetIds(new Set());
|
|
699
|
-
store2.getState().updateDropTarget(null, null);
|
|
951
|
+
resetDragStoreState();
|
|
700
952
|
|
|
701
953
|
// Reset all refs
|
|
702
954
|
overlayX.setValue(0);
|
|
703
955
|
prevEffectiveLevelRef.current = null;
|
|
956
|
+
cancelLevelSettleTimer();
|
|
957
|
+
prevDropTargetRef.current = null;
|
|
704
958
|
dropTargetRef.current = null;
|
|
959
|
+
lastStoreDropTargetRef.current = null;
|
|
705
960
|
draggedNodeRef.current = null;
|
|
706
|
-
|
|
707
|
-
draggedNodeIndexRef.current = -1;
|
|
708
|
-
setDropTarget(null);
|
|
961
|
+
wasDraggedNodeExpandedRef.current = false;
|
|
709
962
|
setIsDragging(false);
|
|
710
963
|
setDraggedNode(null);
|
|
711
964
|
},
|
|
712
965
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
713
|
-
[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]);
|
|
714
976
|
|
|
715
977
|
// --- Handle node touch end ---
|
|
716
978
|
// If the PanResponder never captured the gesture (no movement after long
|
|
717
979
|
// press fired), end the drag here so the node doesn't stay "lifted".
|
|
718
980
|
const handleNodeTouchEnd = useCallback(() => {
|
|
981
|
+
// cancelLongPressTimer also aborts any drag still awaiting its
|
|
982
|
+
// measureInWindow callback (clears pendingDragRef).
|
|
719
983
|
cancelLongPressTimer();
|
|
720
984
|
if (isDraggingRef.current && !panResponderActiveRef.current) {
|
|
721
985
|
handleDragEnd();
|
|
@@ -731,21 +995,25 @@ export function useDragDrop(params) {
|
|
|
731
995
|
onPanResponderGrant: () => {
|
|
732
996
|
panResponderActiveRef.current = true;
|
|
733
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,
|
|
734
1002
|
onPanResponderMove: evt => {
|
|
735
1003
|
if (!isDraggingRef.current) return;
|
|
736
1004
|
const fingerPageY = evt.nativeEvent.pageY;
|
|
1005
|
+
lastFingerPageYRef.current = fingerPageY;
|
|
1006
|
+
lastFingerPageXRef.current = evt.nativeEvent.pageX;
|
|
737
1007
|
const fingerLocalY = fingerPageY - containerPageYRef.current;
|
|
738
1008
|
|
|
739
1009
|
// Update overlay position (with configurable offset)
|
|
740
|
-
|
|
741
|
-
overlayY.setValue(overlayLocalY);
|
|
1010
|
+
overlayY.setValue(computeOverlayLocalY(fingerLocalY));
|
|
742
1011
|
|
|
743
1012
|
// Calculate drop target (horizontal position used at level cliffs)
|
|
744
1013
|
calculateDropTarget(fingerPageY, evt.nativeEvent.pageX);
|
|
745
1014
|
|
|
746
|
-
// Auto-scroll at edges
|
|
747
|
-
|
|
748
|
-
updateAutoScroll(fingerInContainer);
|
|
1015
|
+
// Auto-scroll at edges, from the finger's container-local position
|
|
1016
|
+
updateAutoScroll(fingerLocalY);
|
|
749
1017
|
},
|
|
750
1018
|
onPanResponderRelease: evt => {
|
|
751
1019
|
panResponderActiveRef.current = false;
|
|
@@ -753,7 +1021,9 @@ export function useDragDrop(params) {
|
|
|
753
1021
|
},
|
|
754
1022
|
onPanResponderTerminate: () => {
|
|
755
1023
|
panResponderActiveRef.current = false;
|
|
756
|
-
|
|
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);
|
|
757
1027
|
}
|
|
758
1028
|
})).current;
|
|
759
1029
|
|
|
@@ -762,29 +1032,29 @@ export function useDragDrop(params) {
|
|
|
762
1032
|
return () => {
|
|
763
1033
|
cancelLongPressTimer();
|
|
764
1034
|
cancelAutoExpandTimer();
|
|
1035
|
+
cancelLevelSettleTimer();
|
|
765
1036
|
stopAutoScroll();
|
|
1037
|
+
pendingDragRef.current = false;
|
|
1038
|
+
lastStoreDropTargetRef.current = null;
|
|
766
1039
|
if (isDraggingRef.current) {
|
|
767
1040
|
isDraggingRef.current = false;
|
|
768
|
-
|
|
769
|
-
store.getState().updateDraggedNodeId(null);
|
|
770
|
-
store.getState().updateInvalidDragTargetIds(new Set());
|
|
771
|
-
store.getState().updateDropTarget(null, null);
|
|
1041
|
+
resetDragStoreState();
|
|
772
1042
|
}
|
|
773
1043
|
};
|
|
774
|
-
}, [
|
|
1044
|
+
}, [cancelLongPressTimer, cancelAutoExpandTimer, cancelLevelSettleTimer, stopAutoScroll, resetDragStoreState]);
|
|
775
1045
|
return {
|
|
776
1046
|
panResponder,
|
|
777
1047
|
overlayY,
|
|
778
1048
|
overlayX,
|
|
779
1049
|
isDragging,
|
|
780
1050
|
draggedNode,
|
|
781
|
-
dropTarget,
|
|
782
|
-
effectiveDropLevel,
|
|
783
1051
|
handleNodeTouchStart,
|
|
784
1052
|
handleNodeTouchEnd,
|
|
785
1053
|
cancelLongPressTimer,
|
|
1054
|
+
handleScroll,
|
|
786
1055
|
scrollOffsetRef,
|
|
787
|
-
headerOffsetRef
|
|
1056
|
+
headerOffsetRef,
|
|
1057
|
+
containerHeightRef
|
|
788
1058
|
};
|
|
789
1059
|
}
|
|
790
1060
|
//# sourceMappingURL=useDragDrop.js.map
|