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
package/src/hooks/useDragDrop.ts
CHANGED
|
@@ -7,24 +7,58 @@ import {
|
|
|
7
7
|
useState
|
|
8
8
|
} from "react";
|
|
9
9
|
import {
|
|
10
|
+
AccessibilityInfo,
|
|
10
11
|
Animated,
|
|
11
12
|
PanResponder,
|
|
13
|
+
Platform,
|
|
14
|
+
type NativeScrollEvent,
|
|
15
|
+
type NativeSyntheticEvent,
|
|
12
16
|
type PanResponderInstance,
|
|
13
17
|
} from "react-native";
|
|
14
18
|
import type { FlashList } from "@shopify/flash-list";
|
|
15
19
|
|
|
16
|
-
import type { __FlattenedTreeNode__, TreeNode } from "../types/treeView.types";
|
|
17
|
-
import type {
|
|
20
|
+
import type { __FlattenedTreeNode__, TreeNode, DropAutoScrollOptions } from "../types/treeView.types";
|
|
21
|
+
import type { ScrollToNodeHandlerRef } from "./useScrollToNode";
|
|
22
|
+
import type { DragCancelEvent, DragEndEvent, DragStartEvent, DropPosition, DropTarget } from "../types/dragDrop.types";
|
|
18
23
|
import { getTreeViewStore } from "../store/treeView.store";
|
|
19
24
|
import {
|
|
20
25
|
collapseNodes,
|
|
21
26
|
expandNodes,
|
|
27
|
+
getSubtreeDepthFromMap,
|
|
22
28
|
handleToggleExpand,
|
|
23
|
-
initializeNodeMaps,
|
|
24
|
-
recalculateCheckedStates
|
|
25
29
|
} from "../helpers";
|
|
26
|
-
import {
|
|
27
|
-
|
|
30
|
+
import {
|
|
31
|
+
applyMoveToStore,
|
|
32
|
+
findNodePosition,
|
|
33
|
+
findNodePositionFromMaps,
|
|
34
|
+
moveTreeNode,
|
|
35
|
+
} from "../helpers/moveTreeNode.helper";
|
|
36
|
+
import { scrollMovedNodeIntoView } from "./useScrollToNode";
|
|
37
|
+
import { defaultItemHeight } from "../constants/treeView.constants";
|
|
38
|
+
|
|
39
|
+
// Android reports touch `locationY` slightly differently from iOS, which makes the
|
|
40
|
+
// drag overlay sit ~2 item-heights closer to the finger. This empirical correction
|
|
41
|
+
// (in item-height units, added to dragOverlayOffset) compensates for that. Consumers
|
|
42
|
+
// can override it per-instance via DragAndDropOptions.overlayYCorrection.
|
|
43
|
+
const DEFAULT_OVERLAY_Y_CORRECTION = Platform.OS === "android" ? -2 : 0;
|
|
44
|
+
|
|
45
|
+
// Auto-scroll speed at the very edge in px/SECOND, before the proximity ramp
|
|
46
|
+
// and the consumer's `autoScrollSpeed` multiplier are applied. Time-based (not
|
|
47
|
+
// px/frame) so 60Hz and 120Hz displays scroll at the same real-world speed.
|
|
48
|
+
const MAX_AUTO_SCROLL_SPEED = 1200;
|
|
49
|
+
// During auto-scroll, recompute the drop target at most this often. Every scroll
|
|
50
|
+
// frame moves rows under the finger, but recomputing per frame costs store
|
|
51
|
+
// writes + node re-renders on the JS thread (stuttering the scroll itself), and
|
|
52
|
+
// ~10 indicator updates/sec reads calmer than 60 anyway.
|
|
53
|
+
const AUTO_SCROLL_RECALC_INTERVAL_MS = 100;
|
|
54
|
+
// How long (ms) a candidate drop level must hold before the overlay springs to
|
|
55
|
+
// its indentation. Prevents the indent from chasing every row the finger merely
|
|
56
|
+
// passes through while dragging vertically.
|
|
57
|
+
const LEVEL_SETTLE_MS = 120;
|
|
58
|
+
// How far (fraction of row height) the active zone's boundaries extend outward
|
|
59
|
+
// while the finger stays on the same row. Finger tremor at a zone edge would
|
|
60
|
+
// otherwise flip above/inside/below (and the overlay indent) every few frames.
|
|
61
|
+
const ZONE_STICKINESS = 0.08;
|
|
28
62
|
|
|
29
63
|
interface UseDragDropParams<ID> {
|
|
30
64
|
storeId: string;
|
|
@@ -38,18 +72,33 @@ interface UseDragDropParams<ID> {
|
|
|
38
72
|
longPressDuration: number;
|
|
39
73
|
autoScrollThreshold: number;
|
|
40
74
|
autoScrollSpeed: number;
|
|
41
|
-
internalDataRef: MutableRefObject<TreeNode<ID>[] | null>;
|
|
42
75
|
measuredItemHeightRef: MutableRefObject<number>;
|
|
76
|
+
/** Live total content height of the list (from FlashList onContentSizeChange),
|
|
77
|
+
* used to clamp auto-scroll so the offset never runs past the end of the list. */
|
|
78
|
+
contentHeightRef: MutableRefObject<number>;
|
|
79
|
+
/** Measured row heights keyed by stable node id. Enables accurate drop
|
|
80
|
+
* targeting for variable-height rows when the whole list is rendered. */
|
|
81
|
+
itemHeightsRef: MutableRefObject<Map<ID, number>>;
|
|
43
82
|
dragOverlayOffset: number;
|
|
83
|
+
/** Optional override (item-height units) for the platform overlay-Y correction. */
|
|
84
|
+
overlayYCorrection?: number;
|
|
44
85
|
autoExpandDelay: number;
|
|
86
|
+
/** Whether hovering "inside" a collapsed node auto-expands it. Default: true. */
|
|
87
|
+
autoExpand?: boolean;
|
|
88
|
+
/** Whether the overlay springs ("magnetic snap") when the drop level changes. Default: true. */
|
|
89
|
+
magneticSnap?: boolean;
|
|
45
90
|
/** Pixels per nesting level, used for magnetic overlay shift. */
|
|
46
91
|
indentationMultiplier: number;
|
|
47
92
|
/** Callback to determine if a drop is allowed on a specific target. */
|
|
48
|
-
canDrop?: (draggedNode: TreeNode<ID>, targetNode: TreeNode<ID>, position:
|
|
93
|
+
canDrop?: (draggedNode: TreeNode<ID>, targetNode: TreeNode<ID>, position: DropPosition) => boolean;
|
|
49
94
|
/** Maximum nesting depth allowed. */
|
|
50
95
|
maxDepth?: number;
|
|
51
96
|
/** Callback to determine if a node can accept children. */
|
|
52
97
|
canNodeHaveChildren?: (node: TreeNode<ID>) => boolean;
|
|
98
|
+
/** Ref for scrolling to a node after drop. */
|
|
99
|
+
scrollToNodeHandlerRef: RefObject<ScrollToNodeHandlerRef<ID> | null>;
|
|
100
|
+
/** Auto-scroll configuration for after drop. */
|
|
101
|
+
autoScrollToDroppedNode?: boolean | DropAutoScrollOptions;
|
|
53
102
|
/** Callback to determine if a node can be dragged. */
|
|
54
103
|
canDrag?: (node: TreeNode<ID>) => boolean;
|
|
55
104
|
}
|
|
@@ -60,8 +109,6 @@ interface UseDragDropReturn<ID> {
|
|
|
60
109
|
overlayX: Animated.Value;
|
|
61
110
|
isDragging: boolean;
|
|
62
111
|
draggedNode: __FlattenedTreeNode__<ID> | null;
|
|
63
|
-
dropTarget: DropTarget<ID> | null;
|
|
64
|
-
effectiveDropLevel: number;
|
|
65
112
|
handleNodeTouchStart: (
|
|
66
113
|
nodeId: ID,
|
|
67
114
|
pageY: number,
|
|
@@ -70,8 +117,17 @@ interface UseDragDropReturn<ID> {
|
|
|
70
117
|
) => void;
|
|
71
118
|
handleNodeTouchEnd: () => void;
|
|
72
119
|
cancelLongPressTimer: () => void;
|
|
120
|
+
/** onScroll handler for the host list. Owns the scroll-offset bookkeeping:
|
|
121
|
+
* real scroll events are ignored while a drag is active (the auto-scroll
|
|
122
|
+
* RAF loop is the sole writer of the offset then - lagging events would
|
|
123
|
+
* fight its accumulated value and make the offset oscillate), and any
|
|
124
|
+
* scroll cancels a pending long-press. */
|
|
125
|
+
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
|
73
126
|
scrollOffsetRef: MutableRefObject<number>;
|
|
74
127
|
headerOffsetRef: MutableRefObject<number>;
|
|
128
|
+
/** Live container height; kept fresh via the container's onLayout so the
|
|
129
|
+
* auto-scroll edge/clamp math survives a mid-session resize. */
|
|
130
|
+
containerHeightRef: MutableRefObject<number>;
|
|
75
131
|
}
|
|
76
132
|
|
|
77
133
|
export function useDragDrop<ID>(
|
|
@@ -89,43 +145,67 @@ export function useDragDrop<ID>(
|
|
|
89
145
|
longPressDuration,
|
|
90
146
|
autoScrollThreshold,
|
|
91
147
|
autoScrollSpeed,
|
|
92
|
-
internalDataRef,
|
|
93
148
|
measuredItemHeightRef,
|
|
149
|
+
contentHeightRef,
|
|
150
|
+
itemHeightsRef,
|
|
94
151
|
dragOverlayOffset,
|
|
152
|
+
overlayYCorrection,
|
|
95
153
|
autoExpandDelay,
|
|
154
|
+
autoExpand = true,
|
|
155
|
+
magneticSnap = true,
|
|
96
156
|
indentationMultiplier,
|
|
97
157
|
canDrop: canDropCallback,
|
|
98
158
|
maxDepth,
|
|
99
159
|
canNodeHaveChildren,
|
|
100
160
|
canDrag,
|
|
161
|
+
scrollToNodeHandlerRef,
|
|
162
|
+
autoScrollToDroppedNode,
|
|
101
163
|
} = params;
|
|
102
164
|
|
|
103
165
|
// --- Refs for mutable state (no stale closures in PanResponder) ---
|
|
104
166
|
const isDraggingRef = useRef(false);
|
|
105
167
|
const draggedNodeRef = useRef<__FlattenedTreeNode__<ID> | null>(null);
|
|
106
|
-
|
|
107
|
-
|
|
168
|
+
// Whether the dragged node was expanded before drag start force-collapsed it,
|
|
169
|
+
// so a cancelled drag can restore the expansion (a cancel must not mutate state).
|
|
170
|
+
const wasDraggedNodeExpandedRef = useRef(false);
|
|
108
171
|
|
|
109
172
|
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
110
173
|
|
|
111
174
|
const containerPageXRef = useRef(0);
|
|
112
175
|
const containerPageYRef = useRef(0);
|
|
176
|
+
const containerWidthRef = useRef(0);
|
|
113
177
|
const containerHeightRef = useRef(0);
|
|
114
178
|
const grabOffsetYRef = useRef(0);
|
|
115
179
|
const scrollOffsetRef = useRef(0);
|
|
116
180
|
const headerOffsetRef = useRef(0);
|
|
117
|
-
const itemHeightRef = useRef(
|
|
181
|
+
const itemHeightRef = useRef(defaultItemHeight);
|
|
118
182
|
|
|
119
183
|
const overlayY = useRef(new Animated.Value(0)).current;
|
|
120
184
|
const overlayX = useRef(new Animated.Value(0)).current;
|
|
121
185
|
const prevEffectiveLevelRef = useRef<number | null>(null);
|
|
186
|
+
// Settle-debounce for the overlay indent: candidate level + the timer that
|
|
187
|
+
// springs the overlay to it once it has held for LEVEL_SETTLE_MS.
|
|
188
|
+
const pendingLevelRef = useRef<number | null>(null);
|
|
189
|
+
const levelSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
122
190
|
|
|
123
191
|
const autoScrollRAFRef = useRef<number | null>(null);
|
|
124
192
|
const autoScrollSpeedRef = useRef(0);
|
|
125
193
|
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
const
|
|
194
|
+
// Last known finger position, so the auto-scroll loop can recompute the drop
|
|
195
|
+
// target for a stationary finger while rows scroll underneath it.
|
|
196
|
+
const lastFingerPageYRef = useRef(0);
|
|
197
|
+
const lastFingerPageXRef = useRef(0);
|
|
198
|
+
// calculateDropTarget is defined below startAutoScrollLoop; the RAF loop reads
|
|
199
|
+
// it through this ref to avoid a use-before-declaration in the dep array.
|
|
200
|
+
const calculateDropTargetRef = useRef<(fingerPageY: number, fingerPageX: number) => void>(() => { });
|
|
201
|
+
|
|
202
|
+
// Caches the "every current row is measured" gate so calculateDropTarget
|
|
203
|
+
// doesn't scan the full flattened list on every pan frame. Invalidated when
|
|
204
|
+
// the flattened list identity or the measured-heights count changes.
|
|
205
|
+
const allHeightsMeasuredRef = useRef<{
|
|
206
|
+
size: number;
|
|
207
|
+
value: boolean;
|
|
208
|
+
} | null>(null);
|
|
129
209
|
|
|
130
210
|
// Auto-expand timer for hovering over collapsed nodes
|
|
131
211
|
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -136,8 +216,16 @@ export function useDragDrop<ID>(
|
|
|
136
216
|
// Tracks whether the PanResponder has captured the current gesture
|
|
137
217
|
const panResponderActiveRef = useRef(false);
|
|
138
218
|
|
|
219
|
+
// True between the long-press firing and the async measureInWindow callback
|
|
220
|
+
// resolving. Lets a finger-lift in that window abort the pending drag so the
|
|
221
|
+
// node is never left "lifted" with no finger down.
|
|
222
|
+
const pendingDragRef = useRef(false);
|
|
223
|
+
|
|
139
224
|
// Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
|
|
140
|
-
const prevDropTargetRef = useRef<{ targetIndex: number; position:
|
|
225
|
+
const prevDropTargetRef = useRef<{ targetIndex: number; position: DropPosition; } | null>(null);
|
|
226
|
+
// Flattened-list identity seen by the last calculateDropTarget call; when it
|
|
227
|
+
// changes mid-drag the index-based hysteresis state above is invalidated.
|
|
228
|
+
const lastCalcNodesRef = useRef<unknown>(null);
|
|
141
229
|
|
|
142
230
|
// Depth of the dragged subtree (computed once at drag start, used for maxDepth check)
|
|
143
231
|
const draggedSubtreeDepthRef = useRef(0);
|
|
@@ -163,32 +251,53 @@ export function useDragDrop<ID>(
|
|
|
163
251
|
// Keep config values current for PanResponder closures
|
|
164
252
|
const dragOverlayOffsetRef = useRef(dragOverlayOffset);
|
|
165
253
|
dragOverlayOffsetRef.current = dragOverlayOffset;
|
|
254
|
+
const overlayYCorrectionRef = useRef(overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION);
|
|
255
|
+
overlayYCorrectionRef.current = overlayYCorrection ?? DEFAULT_OVERLAY_Y_CORRECTION;
|
|
166
256
|
const autoScrollThresholdRef = useRef(autoScrollThreshold);
|
|
167
257
|
autoScrollThresholdRef.current = autoScrollThreshold;
|
|
168
258
|
const autoScrollSpeedParamRef = useRef(autoScrollSpeed);
|
|
169
259
|
autoScrollSpeedParamRef.current = autoScrollSpeed;
|
|
170
260
|
const autoExpandDelayRef = useRef(autoExpandDelay);
|
|
171
261
|
autoExpandDelayRef.current = autoExpandDelay;
|
|
262
|
+
const autoExpandRef = useRef(autoExpand);
|
|
263
|
+
autoExpandRef.current = autoExpand;
|
|
264
|
+
const magneticSnapRef = useRef(magneticSnap);
|
|
265
|
+
magneticSnapRef.current = magneticSnap;
|
|
172
266
|
const indentationMultiplierRef = useRef(indentationMultiplier);
|
|
173
267
|
indentationMultiplierRef.current = indentationMultiplier;
|
|
174
268
|
const maxDepthRef = useRef(maxDepth);
|
|
175
269
|
maxDepthRef.current = maxDepth;
|
|
176
270
|
|
|
177
|
-
// --- React state (triggers re-renders only at drag start/end +
|
|
271
|
+
// --- React state (triggers re-renders only at drag start/end + level changes) ---
|
|
178
272
|
const [isDragging, setIsDragging] = useState(false);
|
|
179
273
|
const [draggedNode, setDraggedNode] = useState<__FlattenedTreeNode__<ID> | null>(null);
|
|
180
|
-
const [dropTarget, setDropTarget] = useState<DropTarget<ID> | null>(null);
|
|
181
|
-
const [effectiveDropLevel, setEffectiveDropLevel] = useState(0);
|
|
182
274
|
|
|
183
|
-
//
|
|
275
|
+
// The current drop target lives only in a ref (read by handleDragEnd at commit
|
|
276
|
+
// time). It is deliberately NOT React state: calculateDropTarget runs every pan
|
|
277
|
+
// frame, so a per-frame setState would re-render the whole list for nothing -
|
|
278
|
+
// the per-node drop indicator is driven by the store fields (dropTargetNodeId /
|
|
279
|
+
// dropPosition / dropLevel), which are throttled via lastStoreDropTargetRef.
|
|
184
280
|
const dropTargetRef = useRef<DropTarget<ID> | null>(null);
|
|
185
281
|
|
|
282
|
+
// Last value written to the store's drop target. Guards the per-frame Zustand
|
|
283
|
+
// write so updateDropTarget only fires when the indicator actually changes
|
|
284
|
+
// (otherwise every mounted Node's selector would re-run on every pan frame).
|
|
285
|
+
const lastStoreDropTargetRef = useRef<{
|
|
286
|
+
nodeId: ID | null;
|
|
287
|
+
position: DropPosition | null;
|
|
288
|
+
level: number | null;
|
|
289
|
+
} | null>(null);
|
|
290
|
+
|
|
186
291
|
// --- Long press timer ---
|
|
187
292
|
const cancelLongPressTimer = useCallback(() => {
|
|
188
293
|
if (longPressTimerRef.current) {
|
|
189
294
|
clearTimeout(longPressTimerRef.current);
|
|
190
295
|
longPressTimerRef.current = null;
|
|
191
296
|
}
|
|
297
|
+
// Also abort a drag still awaiting its async measureInWindow callback. This
|
|
298
|
+
// makes scrolling (which cancels the long-press) during that window abort the
|
|
299
|
+
// drag cleanly, instead of letting it start later with stale finger coords.
|
|
300
|
+
pendingDragRef.current = false;
|
|
192
301
|
}, []);
|
|
193
302
|
|
|
194
303
|
// --- Get all descendant IDs of a node ---
|
|
@@ -217,30 +326,55 @@ export function useDragDrop<ID>(
|
|
|
217
326
|
// --- Get the maximum depth of a subtree (0 for leaf nodes) ---
|
|
218
327
|
const getSubtreeDepth = useCallback(
|
|
219
328
|
(nodeId: ID): number => {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
const node = nodeMap.get(nodeId);
|
|
223
|
-
if (!node?.children?.length) return 0;
|
|
224
|
-
let max = 0;
|
|
225
|
-
for (const child of node.children) {
|
|
226
|
-
max = Math.max(max, 1 + getSubtreeDepth(child.id));
|
|
227
|
-
}
|
|
228
|
-
return max;
|
|
329
|
+
const { nodeMap } = getTreeViewStore<ID>(storeId).getState();
|
|
330
|
+
return getSubtreeDepthFromMap(nodeMap, nodeId);
|
|
229
331
|
},
|
|
230
332
|
[storeId]
|
|
231
333
|
);
|
|
232
334
|
|
|
335
|
+
// --- Overlay Y for a container-local finger Y (grab point + offsets) ---
|
|
336
|
+
const computeOverlayLocalY = useCallback((fingerLocalY: number) =>
|
|
337
|
+
fingerLocalY
|
|
338
|
+
- grabOffsetYRef.current
|
|
339
|
+
+ (dragOverlayOffsetRef.current + overlayYCorrectionRef.current)
|
|
340
|
+
* itemHeightRef.current,
|
|
341
|
+
[]
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// --- Level-cliff horizontal control: is the finger left of the threshold
|
|
345
|
+
// that selects the shallower level? (30% into the visible content area
|
|
346
|
+
// of a row indented at `level`.) ---
|
|
347
|
+
const fingerLeftOfLevelThreshold = useCallback(
|
|
348
|
+
(level: number, fingerLocalX: number) => {
|
|
349
|
+
const itemLeftEdge = level * indentationMultiplierRef.current;
|
|
350
|
+
const threshold =
|
|
351
|
+
itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
|
|
352
|
+
return fingerLocalX < threshold;
|
|
353
|
+
},
|
|
354
|
+
[]
|
|
355
|
+
);
|
|
356
|
+
|
|
233
357
|
// --- Initiate drag ---
|
|
234
358
|
const initiateDrag = useCallback(
|
|
235
359
|
(nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
|
|
236
360
|
if (!dragEnabled) return;
|
|
361
|
+
// Never start a second drag on top of one already running or pending
|
|
362
|
+
// (e.g. a competing long-press from a second finger).
|
|
363
|
+
if (isDraggingRef.current || pendingDragRef.current) return;
|
|
237
364
|
|
|
238
365
|
const container = containerRef.current;
|
|
239
366
|
if (!container) return;
|
|
240
367
|
|
|
241
|
-
|
|
368
|
+
pendingDragRef.current = true;
|
|
369
|
+
container.measureInWindow((x, y, w, h) => {
|
|
370
|
+
// Finger lifted (or drag cancelled) before the measurement resolved -
|
|
371
|
+
// abort so we don't strand a drag with no finger down.
|
|
372
|
+
if (!pendingDragRef.current) return;
|
|
373
|
+
pendingDragRef.current = false;
|
|
374
|
+
|
|
242
375
|
containerPageXRef.current = x;
|
|
243
376
|
containerPageYRef.current = y;
|
|
377
|
+
containerWidthRef.current = w;
|
|
244
378
|
containerHeightRef.current = h;
|
|
245
379
|
|
|
246
380
|
// Find the node in flattened list
|
|
@@ -248,23 +382,23 @@ export function useDragDrop<ID>(
|
|
|
248
382
|
const node = nodes[nodeIndex];
|
|
249
383
|
if (!node) return;
|
|
250
384
|
|
|
251
|
-
// Collapse node if expanded
|
|
385
|
+
// Collapse node if expanded (restored if the drag is cancelled)
|
|
252
386
|
const store = getTreeViewStore<ID>(storeId);
|
|
253
387
|
const { expanded } = store.getState();
|
|
388
|
+
wasDraggedNodeExpandedRef.current = false;
|
|
254
389
|
if (expanded.has(nodeId) && node.children?.length) {
|
|
255
390
|
handleToggleExpand(storeId, nodeId);
|
|
391
|
+
wasDraggedNodeExpandedRef.current = true;
|
|
256
392
|
}
|
|
257
393
|
|
|
258
394
|
// Store grab metadata
|
|
259
395
|
grabOffsetYRef.current = locationY;
|
|
260
396
|
draggedNodeRef.current = node;
|
|
261
|
-
draggedNodeIdRef.current = nodeId;
|
|
262
|
-
draggedNodeIndexRef.current = nodeIndex;
|
|
263
397
|
draggedSubtreeDepthRef.current = getSubtreeDepth(nodeId);
|
|
264
398
|
|
|
265
399
|
// Use measured item height if available, fall back to default
|
|
266
400
|
const measured = measuredItemHeightRef.current;
|
|
267
|
-
itemHeightRef.current = measured > 0 ? measured :
|
|
401
|
+
itemHeightRef.current = measured > 0 ? measured : defaultItemHeight;
|
|
268
402
|
|
|
269
403
|
// Calculate headerOffset dynamically:
|
|
270
404
|
// fingerLocalY = pageY - containerPageY
|
|
@@ -277,13 +411,7 @@ export function useDragDrop<ID>(
|
|
|
277
411
|
locationY -
|
|
278
412
|
nodeIndex * itemHeightRef.current;
|
|
279
413
|
|
|
280
|
-
|
|
281
|
-
// from the node's known index (avoids unreliable containerPageY).
|
|
282
|
-
const iH = itemHeightRef.current;
|
|
283
|
-
const listHeaderHeight = listHeaderFooterPadding * 2;
|
|
284
|
-
initialFingerPageYRef.current = pageY;
|
|
285
|
-
initialFingerContainerYRef.current =
|
|
286
|
-
listHeaderHeight + nodeIndex * iH - scrollOffsetRef.current + locationY;
|
|
414
|
+
lastFingerPageYRef.current = pageY;
|
|
287
415
|
|
|
288
416
|
// Compute invalid targets (self + descendants)
|
|
289
417
|
const descendants = getDescendantIds(nodeId);
|
|
@@ -294,24 +422,25 @@ export function useDragDrop<ID>(
|
|
|
294
422
|
store.getState().updateInvalidDragTargetIds(descendants);
|
|
295
423
|
|
|
296
424
|
// Set overlay initial position (with configurable offset)
|
|
297
|
-
|
|
298
|
-
overlayY.setValue(overlayLocalY);
|
|
425
|
+
overlayY.setValue(computeOverlayLocalY(fingerLocalY));
|
|
299
426
|
|
|
300
427
|
// Reset magnetic overlay
|
|
301
428
|
overlayX.setValue(0);
|
|
302
429
|
prevEffectiveLevelRef.current = node.level ?? 0;
|
|
430
|
+
cancelLevelSettleTimer();
|
|
303
431
|
|
|
304
432
|
// Set React state
|
|
305
433
|
isDraggingRef.current = true;
|
|
306
434
|
autoExpandedDuringDragRef.current.clear();
|
|
307
435
|
setIsDragging(true);
|
|
308
436
|
setDraggedNode(node);
|
|
309
|
-
setEffectiveDropLevel(node.level ?? 0);
|
|
310
|
-
setDropTarget(null);
|
|
311
437
|
|
|
312
438
|
// Notify consumer that drag has started
|
|
313
439
|
onDragStartRef.current?.({ draggedNodeId: nodeId });
|
|
314
440
|
|
|
441
|
+
// Announce for screen readers (no-op when no assistive tech is active).
|
|
442
|
+
AccessibilityInfo.announceForAccessibility?.(`Picked up ${node.name}`);
|
|
443
|
+
|
|
315
444
|
// Start auto-scroll loop
|
|
316
445
|
startAutoScrollLoop();
|
|
317
446
|
});
|
|
@@ -332,6 +461,15 @@ export function useDragDrop<ID>(
|
|
|
332
461
|
const handleNodeTouchStart = useCallback(
|
|
333
462
|
(nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
|
|
334
463
|
if (!dragEnabled) return;
|
|
464
|
+
// Ignore touches that land while a drag is already running or pending.
|
|
465
|
+
// Without this, a second finger on another row could arm a competing
|
|
466
|
+
// long-press that overwrites the in-flight drag's state.
|
|
467
|
+
if (isDraggingRef.current || pendingDragRef.current) return;
|
|
468
|
+
|
|
469
|
+
// Drag is disabled while a search filter is active: drop targets are
|
|
470
|
+
// computed against the filtered list but the move applies to the full
|
|
471
|
+
// tree, so a drop could land next to siblings hidden by the filter.
|
|
472
|
+
if (getTreeViewStore<ID>(storeId).getState().searchText) return;
|
|
335
473
|
|
|
336
474
|
// Check if this node can be dragged
|
|
337
475
|
if (canDragRef.current) {
|
|
@@ -348,30 +486,75 @@ export function useDragDrop<ID>(
|
|
|
348
486
|
initiateDrag(nodeId, pageY, locationY, nodeIndex);
|
|
349
487
|
}, longPressDuration);
|
|
350
488
|
},
|
|
351
|
-
[dragEnabled, longPressDuration, cancelLongPressTimer, initiateDrag]
|
|
489
|
+
[dragEnabled, storeId, longPressDuration, cancelLongPressTimer, initiateDrag]
|
|
352
490
|
);
|
|
353
491
|
|
|
354
492
|
// --- Auto-scroll ---
|
|
355
493
|
const startAutoScrollLoop = useCallback(() => {
|
|
356
|
-
|
|
494
|
+
// Idempotent: cancel any loop already in flight so a re-entry can never
|
|
495
|
+
// orphan a RAF handle (which would run a second, untracked scroll loop).
|
|
496
|
+
if (autoScrollRAFRef.current !== null) {
|
|
497
|
+
cancelAnimationFrame(autoScrollRAFRef.current);
|
|
498
|
+
autoScrollRAFRef.current = null;
|
|
499
|
+
}
|
|
500
|
+
// Time-based stepping: RAF cadence varies with display refresh rate, so
|
|
501
|
+
// distance is derived from elapsed time, not frame count.
|
|
502
|
+
let lastTs: number | null = null;
|
|
503
|
+
let lastRecalcTs = 0;
|
|
504
|
+
const loop = (ts?: number) => {
|
|
357
505
|
if (!isDraggingRef.current) return;
|
|
358
506
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
507
|
+
// Jest's RAF mock invokes the callback without a timestamp.
|
|
508
|
+
const now = typeof ts === "number" ? ts : Date.now();
|
|
509
|
+
// First frame establishes the baseline; cap dt so a long hiccup
|
|
510
|
+
// (background frame drop) can't produce one huge scroll jump.
|
|
511
|
+
const dtMs = lastTs === null ? 0 : Math.min(now - lastTs, 64);
|
|
512
|
+
lastTs = now;
|
|
513
|
+
|
|
514
|
+
if (autoScrollSpeedRef.current !== 0 && dtMs > 0) {
|
|
515
|
+
// Clamp to the scrollable range so dragging at the bottom edge can't
|
|
516
|
+
// grow the offset past the end (which would corrupt drop-index math).
|
|
517
|
+
// Leave the upper bound open until the content height is measured.
|
|
518
|
+
const contentH = contentHeightRef.current;
|
|
519
|
+
const maxOffset = contentH > 0
|
|
520
|
+
? Math.max(0, contentH - containerHeightRef.current)
|
|
521
|
+
: Number.POSITIVE_INFINITY;
|
|
522
|
+
const newOffset = Math.min(
|
|
523
|
+
maxOffset,
|
|
524
|
+
Math.max(0, scrollOffsetRef.current
|
|
525
|
+
+ (autoScrollSpeedRef.current * dtMs) / 1000)
|
|
363
526
|
);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
527
|
+
|
|
528
|
+
// Pinned at a boundary: nothing moved, so skip the scroll command
|
|
529
|
+
// and the drop-target recompute entirely.
|
|
530
|
+
if (newOffset !== scrollOffsetRef.current) {
|
|
531
|
+
// The RAF loop is the sole writer of scrollOffsetRef during a
|
|
532
|
+
// drag (NodeList's onScroll stands down) - the accumulated value
|
|
533
|
+
// is the commanded position and the native list converges to it.
|
|
534
|
+
scrollOffsetRef.current = newOffset;
|
|
535
|
+
flashListRef.current?.scrollToOffset?.({
|
|
536
|
+
offset: newOffset,
|
|
537
|
+
animated: false,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// Rows are moving under a (possibly stationary) finger; recompute
|
|
541
|
+
// the drop target so the indicator tracks the auto-scroll instead
|
|
542
|
+
// of staying frozen on the last row the finger moved over.
|
|
543
|
+
// Time-throttled - see AUTO_SCROLL_RECALC_INTERVAL_MS.
|
|
544
|
+
if (now - lastRecalcTs >= AUTO_SCROLL_RECALC_INTERVAL_MS) {
|
|
545
|
+
lastRecalcTs = now;
|
|
546
|
+
calculateDropTargetRef.current(
|
|
547
|
+
lastFingerPageYRef.current,
|
|
548
|
+
lastFingerPageXRef.current
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
369
552
|
}
|
|
370
553
|
|
|
371
554
|
autoScrollRAFRef.current = requestAnimationFrame(loop);
|
|
372
555
|
};
|
|
373
556
|
autoScrollRAFRef.current = requestAnimationFrame(loop);
|
|
374
|
-
}, [flashListRef]);
|
|
557
|
+
}, [flashListRef, contentHeightRef]);
|
|
375
558
|
|
|
376
559
|
const stopAutoScroll = useCallback(() => {
|
|
377
560
|
if (autoScrollRAFRef.current !== null) {
|
|
@@ -384,18 +567,22 @@ export function useDragDrop<ID>(
|
|
|
384
567
|
const updateAutoScroll = useCallback(
|
|
385
568
|
(fingerInContainer: number) => {
|
|
386
569
|
const threshold = autoScrollThresholdRef.current;
|
|
387
|
-
|
|
570
|
+
// px/second; the RAF loop converts to distance via elapsed time.
|
|
571
|
+
const maxSpeed = MAX_AUTO_SCROLL_SPEED * autoScrollSpeedParamRef.current;
|
|
388
572
|
const containerH = containerHeightRef.current;
|
|
389
573
|
|
|
574
|
+
// Ease the proximity ramp (sqrt) so speed builds up fast even for a
|
|
575
|
+
// shallow entry into the threshold zone - the screen edge often stops
|
|
576
|
+
// the finger before it reaches the very edge of the list.
|
|
390
577
|
if (fingerInContainer < threshold) {
|
|
391
578
|
// Scroll up
|
|
392
579
|
const ratio = 1 - Math.max(0, fingerInContainer) / threshold;
|
|
393
|
-
autoScrollSpeedRef.current = -maxSpeed * ratio;
|
|
580
|
+
autoScrollSpeedRef.current = -maxSpeed * Math.sqrt(ratio);
|
|
394
581
|
} else if (fingerInContainer > containerH - threshold) {
|
|
395
582
|
// Scroll down
|
|
396
583
|
const ratio =
|
|
397
584
|
1 - Math.max(0, containerH - fingerInContainer) / threshold;
|
|
398
|
-
autoScrollSpeedRef.current = maxSpeed * ratio;
|
|
585
|
+
autoScrollSpeedRef.current = maxSpeed * Math.sqrt(ratio);
|
|
399
586
|
} else {
|
|
400
587
|
autoScrollSpeedRef.current = 0;
|
|
401
588
|
}
|
|
@@ -412,12 +599,51 @@ export function useDragDrop<ID>(
|
|
|
412
599
|
autoExpandTargetRef.current = null;
|
|
413
600
|
}, []);
|
|
414
601
|
|
|
602
|
+
// --- Cancel the overlay-indent settle timer + its pending level ---
|
|
603
|
+
const cancelLevelSettleTimer = useCallback(() => {
|
|
604
|
+
if (levelSettleTimerRef.current) {
|
|
605
|
+
clearTimeout(levelSettleTimerRef.current);
|
|
606
|
+
levelSettleTimerRef.current = null;
|
|
607
|
+
}
|
|
608
|
+
pendingLevelRef.current = null;
|
|
609
|
+
}, []);
|
|
610
|
+
|
|
611
|
+
// --- Clear the store's drag fields (shared by drag end and unmount) ---
|
|
612
|
+
const resetDragStoreState = useCallback(() => {
|
|
613
|
+
const state = getTreeViewStore<ID>(storeId).getState();
|
|
614
|
+
state.updateDraggedNodeId(null);
|
|
615
|
+
state.updateInvalidDragTargetIds(new Set());
|
|
616
|
+
state.updateDropTarget(null, null);
|
|
617
|
+
}, [storeId]);
|
|
618
|
+
|
|
415
619
|
// --- Calculate drop target ---
|
|
416
620
|
const calculateDropTarget = useCallback(
|
|
417
621
|
(fingerPageY: number, fingerPageX: number) => {
|
|
418
622
|
const nodes = flattenedNodesRef.current;
|
|
419
623
|
if (nodes.length === 0) return;
|
|
420
624
|
|
|
625
|
+
// The flattened list changed mid-drag (e.g. auto-expand inserted the
|
|
626
|
+
// hovered parent's children). Index-based hysteresis/stickiness state
|
|
627
|
+
// refers to rows of the OLD list - drop it so it can't stick the zone
|
|
628
|
+
// or gap decision to whatever row now happens to hold that index. The
|
|
629
|
+
// measured-heights gate cache is keyed to the old list too.
|
|
630
|
+
if (lastCalcNodesRef.current !== nodes) {
|
|
631
|
+
lastCalcNodesRef.current = nodes;
|
|
632
|
+
prevDropTargetRef.current = null;
|
|
633
|
+
allHeightsMeasuredRef.current = null;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Single store snapshot per frame (the store can't change within this
|
|
637
|
+
// synchronous pass) - avoids re-reading getState() several times.
|
|
638
|
+
const {
|
|
639
|
+
childToParentMap,
|
|
640
|
+
expanded,
|
|
641
|
+
invalidDragTargetIds,
|
|
642
|
+
draggedNodeId,
|
|
643
|
+
nodeMap,
|
|
644
|
+
updateDropTarget,
|
|
645
|
+
} = getTreeViewStore<ID>(storeId).getState();
|
|
646
|
+
|
|
421
647
|
const fingerLocalY =
|
|
422
648
|
fingerPageY - containerPageYRef.current;
|
|
423
649
|
const fingerContentY =
|
|
@@ -426,21 +652,71 @@ export function useDragDrop<ID>(
|
|
|
426
652
|
fingerContentY - headerOffsetRef.current;
|
|
427
653
|
const iH = itemHeightRef.current;
|
|
428
654
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
655
|
+
// Resolve which row the finger is over. Uniform math (O(1)) is used
|
|
656
|
+
// unless every CURRENT row has been measured (small, fully-rendered
|
|
657
|
+
// lists), in which case a cumulative walk supports variable row heights.
|
|
658
|
+
// The gate checks per-node-id coverage (not map size) so stale heights
|
|
659
|
+
// left by a previous, longer list can never satisfy it. FlashList
|
|
660
|
+
// virtualization can't measure off-screen rows, so large lists fall back
|
|
661
|
+
// to the uniform estimate.
|
|
662
|
+
const heights = itemHeightsRef.current;
|
|
663
|
+
// The full-list scan is cached per measured count (and invalidated on
|
|
664
|
+
// list-identity change above) so it doesn't run on every pan frame.
|
|
665
|
+
const cachedGate = allHeightsMeasuredRef.current;
|
|
666
|
+
let allMeasured: boolean;
|
|
667
|
+
if (cachedGate && cachedGate.size === heights.size) {
|
|
668
|
+
allMeasured = cachedGate.value;
|
|
669
|
+
} else {
|
|
670
|
+
allMeasured = nodes.every((n) => heights.has(n.id));
|
|
671
|
+
allHeightsMeasuredRef.current = { size: heights.size, value: allMeasured };
|
|
672
|
+
}
|
|
673
|
+
let clampedIndex: number;
|
|
674
|
+
let itemTop: number;
|
|
675
|
+
let itemHeight: number;
|
|
676
|
+
if (allMeasured) {
|
|
677
|
+
let top = 0;
|
|
678
|
+
let idx = 0;
|
|
679
|
+
while (idx < nodes.length - 1) {
|
|
680
|
+
const h = heights.get(nodes[idx]!.id) ?? iH;
|
|
681
|
+
if (adjustedContentY < top + h) break;
|
|
682
|
+
top += h;
|
|
683
|
+
idx++;
|
|
684
|
+
}
|
|
685
|
+
clampedIndex = idx;
|
|
686
|
+
itemTop = top;
|
|
687
|
+
itemHeight = heights.get(nodes[idx]!.id) ?? iH;
|
|
688
|
+
} else {
|
|
689
|
+
const rawIndex = Math.floor(adjustedContentY / iH);
|
|
690
|
+
clampedIndex = Math.max(0, Math.min(rawIndex, nodes.length - 1));
|
|
691
|
+
itemTop = clampedIndex * iH;
|
|
692
|
+
itemHeight = iH;
|
|
693
|
+
}
|
|
434
694
|
let targetNode = nodes[clampedIndex];
|
|
435
695
|
if (!targetNode) return;
|
|
436
696
|
|
|
437
|
-
// Determine zone within item
|
|
697
|
+
// Determine zone within item. Sticky zones: while a zone is active for
|
|
698
|
+
// this same row, its boundaries shift outward so natural finger tremor
|
|
699
|
+
// at a zone edge can't flip the position (and with it the indicator
|
|
700
|
+
// and the overlay's indent) back and forth every few frames.
|
|
438
701
|
const positionInItem =
|
|
439
|
-
(adjustedContentY -
|
|
440
|
-
let
|
|
441
|
-
|
|
702
|
+
(adjustedContentY - itemTop) / itemHeight;
|
|
703
|
+
let aboveBound = 0.25;
|
|
704
|
+
let belowBound = 0.75;
|
|
705
|
+
const prevZone = prevDropTargetRef.current;
|
|
706
|
+
if (prevZone && prevZone.targetIndex === clampedIndex) {
|
|
707
|
+
if (prevZone.position === "above") {
|
|
708
|
+
aboveBound += ZONE_STICKINESS;
|
|
709
|
+
} else if (prevZone.position === "below") {
|
|
710
|
+
belowBound -= ZONE_STICKINESS;
|
|
711
|
+
} else {
|
|
712
|
+
aboveBound -= ZONE_STICKINESS;
|
|
713
|
+
belowBound += ZONE_STICKINESS;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
let position: DropPosition;
|
|
717
|
+
if (positionInItem < aboveBound) {
|
|
442
718
|
position = "above";
|
|
443
|
-
} else if (positionInItem >
|
|
719
|
+
} else if (positionInItem > belowBound) {
|
|
444
720
|
position = "below";
|
|
445
721
|
} else {
|
|
446
722
|
position = "inside";
|
|
@@ -476,10 +752,10 @@ export function useDragDrop<ID>(
|
|
|
476
752
|
// logicalTargetId/logicalPosition: when the visual indicator node differs
|
|
477
753
|
// from the actual moveTreeNode target (e.g., ancestor at a shallower level).
|
|
478
754
|
let logicalTargetId: ID | null = null;
|
|
479
|
-
let logicalPosition:
|
|
755
|
+
let logicalPosition: DropPosition | null = null;
|
|
480
756
|
let visualDropLevel: number | null = null;
|
|
481
757
|
|
|
482
|
-
if (position === "below"
|
|
758
|
+
if (position === "below") {
|
|
483
759
|
const currentLevel = targetNode.level ?? 0;
|
|
484
760
|
let isCliff = false;
|
|
485
761
|
let shallowLevel = 0;
|
|
@@ -498,13 +774,7 @@ export function useDragDrop<ID>(
|
|
|
498
774
|
}
|
|
499
775
|
|
|
500
776
|
if (isCliff) {
|
|
501
|
-
|
|
502
|
-
const indent = indentationMultiplierRef.current;
|
|
503
|
-
const threshold =
|
|
504
|
-
((currentLevel + shallowLevel) / 2) * indent
|
|
505
|
-
+ indent * 2;
|
|
506
|
-
|
|
507
|
-
if (fingerLocalX < threshold) {
|
|
777
|
+
if (fingerLeftOfLevelThreshold(currentLevel, fingerLocalX)) {
|
|
508
778
|
// User wants the shallow level
|
|
509
779
|
if (clampedIndex < nodes.length - 1) {
|
|
510
780
|
// Non-last item: switch to "above" on the next (shallower) node
|
|
@@ -514,7 +784,6 @@ export function useDragDrop<ID>(
|
|
|
514
784
|
position = "above";
|
|
515
785
|
} else {
|
|
516
786
|
// Last item: find ancestor at shallow level, target it with "below"
|
|
517
|
-
const { childToParentMap } = getTreeViewStore<ID>(storeId).getState();
|
|
518
787
|
let ancestorId = targetNode.id;
|
|
519
788
|
let walkLevel = currentLevel;
|
|
520
789
|
while (walkLevel > shallowLevel) {
|
|
@@ -536,13 +805,7 @@ export function useDragDrop<ID>(
|
|
|
536
805
|
const prevLevel = prevNode?.level ?? 0;
|
|
537
806
|
const currentLevel = targetNode.level ?? 0;
|
|
538
807
|
if (prevNode && prevLevel > currentLevel) {
|
|
539
|
-
|
|
540
|
-
const indent = indentationMultiplierRef.current;
|
|
541
|
-
const threshold =
|
|
542
|
-
((prevLevel + currentLevel) / 2) * indent
|
|
543
|
-
+ indent * 2;
|
|
544
|
-
|
|
545
|
-
if (fingerLocalX >= threshold) {
|
|
808
|
+
if (!fingerLeftOfLevelThreshold(prevLevel, fingerLocalX)) {
|
|
546
809
|
clampedIndex = clampedIndex - 1;
|
|
547
810
|
targetNode = prevNode;
|
|
548
811
|
position = "below";
|
|
@@ -550,26 +813,14 @@ export function useDragDrop<ID>(
|
|
|
550
813
|
}
|
|
551
814
|
}
|
|
552
815
|
|
|
553
|
-
// --- Suppress "below" when it's
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
816
|
+
// --- Suppress "below" when it's semantically confusing ---
|
|
817
|
+
// For expanded parents, "below" visually sits at the parent/child
|
|
818
|
+
// junction but semantically inserts as a sibling after the entire
|
|
819
|
+
// subtree. Convert to "inside" which is clearer.
|
|
557
820
|
if (position === "below" && canDropInsideTarget) {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
// (a) Expanded parent: "below" visually sits at the parent/child junction
|
|
561
|
-
// but semantically inserts as a sibling after the entire subtree.
|
|
562
|
-
if (targetNode.children?.length && expandedSet.has(targetNode.id)) {
|
|
821
|
+
if (targetNode.children?.length && expanded.has(targetNode.id)) {
|
|
563
822
|
position = "inside";
|
|
564
823
|
}
|
|
565
|
-
// (b) No level cliff below: convert to "inside" so the highlight
|
|
566
|
-
// covers the full bottom of the node.
|
|
567
|
-
else if (clampedIndex < nodes.length - 1) {
|
|
568
|
-
const nextNode = nodes[clampedIndex + 1];
|
|
569
|
-
if (nextNode && (nextNode.level ?? 0) >= (targetNode.level ?? 0)) {
|
|
570
|
-
position = "inside";
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
824
|
}
|
|
574
825
|
|
|
575
826
|
// --- Hysteresis: prevent flicker between "below N" and "above N+1" ---
|
|
@@ -598,33 +849,40 @@ export function useDragDrop<ID>(
|
|
|
598
849
|
}
|
|
599
850
|
prevDropTargetRef.current = { targetIndex: clampedIndex, position };
|
|
600
851
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
//
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
852
|
+
// Validity check (store snapshot taken once at the top of this frame).
|
|
853
|
+
// The actual move uses the LOGICAL target when a cliff override is active
|
|
854
|
+
// (the visual indicator stays on targetNode). Validate canDrop / maxDepth /
|
|
855
|
+
// invalid-target against this effective target so consumer rules can't be
|
|
856
|
+
// bypassed by the visual/logical split at level cliffs.
|
|
857
|
+
const effectiveTargetId = logicalTargetId ?? targetNode.id;
|
|
858
|
+
const effectivePosition = logicalPosition ?? position;
|
|
859
|
+
const effectiveTargetNode =
|
|
860
|
+
logicalTargetId !== null
|
|
861
|
+
? (nodeMap.get(effectiveTargetId) ?? targetNode)
|
|
862
|
+
: targetNode;
|
|
607
863
|
|
|
608
864
|
// maxDepth check for above/below (sibling) positions
|
|
609
865
|
let maxDepthValid = true;
|
|
610
|
-
if (maxDepthRef.current !== undefined && (
|
|
611
|
-
|
|
866
|
+
if (maxDepthRef.current !== undefined && (effectivePosition === "above" || effectivePosition === "below")) {
|
|
867
|
+
// At a cliff the sibling lands at visualDropLevel; otherwise the
|
|
868
|
+
// effective target's own level.
|
|
869
|
+
const targetLevel = visualDropLevel ?? (effectiveTargetNode.level ?? 0);
|
|
612
870
|
const deepest = targetLevel + draggedSubtreeDepthRef.current;
|
|
613
871
|
if (deepest > maxDepthRef.current) maxDepthValid = false;
|
|
614
872
|
}
|
|
615
873
|
|
|
616
874
|
const isValid =
|
|
617
|
-
|
|
618
|
-
!invalidDragTargetIds.has(
|
|
875
|
+
effectiveTargetId !== draggedNodeId &&
|
|
876
|
+
!invalidDragTargetIds.has(effectiveTargetId) &&
|
|
619
877
|
maxDepthValid &&
|
|
620
878
|
(!canDropRef.current || canDropRef.current(
|
|
621
879
|
draggedNodeRef.current!,
|
|
622
|
-
|
|
623
|
-
|
|
880
|
+
effectiveTargetNode,
|
|
881
|
+
effectivePosition
|
|
624
882
|
));
|
|
625
883
|
|
|
626
884
|
// --- Auto-expand: if hovering "inside" a collapsed expandable node ---
|
|
627
|
-
if (isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
|
|
885
|
+
if (autoExpandRef.current && isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
|
|
628
886
|
if (autoExpandTargetRef.current !== targetNode.id) {
|
|
629
887
|
// New hover target - start timer
|
|
630
888
|
cancelAutoExpandTimer();
|
|
@@ -632,7 +890,7 @@ export function useDragDrop<ID>(
|
|
|
632
890
|
autoExpandTimerRef.current = setTimeout(() => {
|
|
633
891
|
autoExpandTimerRef.current = null;
|
|
634
892
|
// Expand the node and track it
|
|
635
|
-
|
|
893
|
+
expandNodes(storeId, [targetNode.id]);
|
|
636
894
|
autoExpandedDuringDragRef.current.add(targetNode.id);
|
|
637
895
|
}, autoExpandDelayRef.current);
|
|
638
896
|
}
|
|
@@ -656,25 +914,54 @@ export function useDragDrop<ID>(
|
|
|
656
914
|
? (targetNode.level ?? 0) + 1
|
|
657
915
|
: (targetNode.level ?? 0))
|
|
658
916
|
: draggedLevel;
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
917
|
+
// The overlay's content keeps the dragged node's original indentation
|
|
918
|
+
// for the whole drag (no re-render); the indent shift is expressed
|
|
919
|
+
// purely as a translateX toward the effective level. Springs retarget
|
|
920
|
+
// mid-flight, so rapid level changes glide instead of jumping - the
|
|
921
|
+
// old scheme (re-render padding + counteracting spring-to-0) split the
|
|
922
|
+
// move across React and the native driver and flickered when the two
|
|
923
|
+
// landed on different frames.
|
|
924
|
+
// A new level must additionally hold for LEVEL_SETTLE_MS before the
|
|
925
|
+
// overlay moves, so the indent doesn't chase every row the finger
|
|
926
|
+
// merely passes through.
|
|
927
|
+
const applyLevel = (nextLevel: number) => {
|
|
928
|
+
prevEffectiveLevelRef.current = nextLevel;
|
|
929
|
+
const targetX =
|
|
930
|
+
(nextLevel - (draggedNodeRef.current?.level ?? 0)) *
|
|
931
|
+
indentationMultiplierRef.current;
|
|
932
|
+
if (magneticSnapRef.current) {
|
|
671
933
|
Animated.spring(overlayX, {
|
|
672
|
-
toValue:
|
|
934
|
+
toValue: targetX,
|
|
673
935
|
useNativeDriver: true,
|
|
674
936
|
speed: 40,
|
|
675
937
|
bounciness: 4,
|
|
676
938
|
}).start();
|
|
939
|
+
} else {
|
|
940
|
+
overlayX.setValue(targetX);
|
|
677
941
|
}
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
if (autoScrollSpeedRef.current !== 0) {
|
|
945
|
+
// Auto-scroll is streaming rows of arbitrary depth under a
|
|
946
|
+
// stationary finger; chasing their levels would dart the overlay
|
|
947
|
+
// sideways and back. Hold the current indent (and drop any pending
|
|
948
|
+
// shift) until the scroll settles - the drop indicator itself
|
|
949
|
+
// keeps tracking via the store.
|
|
950
|
+
cancelLevelSettleTimer();
|
|
951
|
+
} else if (effectiveLevel === prevEffectiveLevelRef.current) {
|
|
952
|
+
// Back at the settled level - drop any pending level the finger
|
|
953
|
+
// only transited through.
|
|
954
|
+
cancelLevelSettleTimer();
|
|
955
|
+
} else if (pendingLevelRef.current !== effectiveLevel) {
|
|
956
|
+
// New candidate level - (re)start the settle timer.
|
|
957
|
+
cancelLevelSettleTimer();
|
|
958
|
+
pendingLevelRef.current = effectiveLevel;
|
|
959
|
+
levelSettleTimerRef.current = setTimeout(() => {
|
|
960
|
+
levelSettleTimerRef.current = null;
|
|
961
|
+
const settled = pendingLevelRef.current;
|
|
962
|
+
pendingLevelRef.current = null;
|
|
963
|
+
if (settled !== null && isDraggingRef.current) applyLevel(settled);
|
|
964
|
+
}, LEVEL_SETTLE_MS);
|
|
678
965
|
}
|
|
679
966
|
|
|
680
967
|
const newTarget: DropTarget<ID> = {
|
|
@@ -682,18 +969,33 @@ export function useDragDrop<ID>(
|
|
|
682
969
|
targetIndex: clampedIndex,
|
|
683
970
|
position,
|
|
684
971
|
isValid,
|
|
685
|
-
targetLevel: targetNode.level ?? 0,
|
|
686
|
-
indicatorTop,
|
|
687
972
|
};
|
|
688
973
|
|
|
689
|
-
// Update the store so each Node can render its own indicator
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
974
|
+
// Update the store so each Node can render its own indicator.
|
|
975
|
+
// The indicator always tracks the VISUAL target/position (what the user
|
|
976
|
+
// sees), even when the actual move target is a logical ancestor.
|
|
977
|
+
// Guard the write so it only fires when the indicator actually changes -
|
|
978
|
+
// calculateDropTarget runs every pan frame and each store write re-runs
|
|
979
|
+
// every mounted Node's selector.
|
|
980
|
+
const nextDropNodeId = isValid ? targetNode.id : null;
|
|
981
|
+
const nextDropPosition = isValid ? position : null;
|
|
982
|
+
const nextDropLevel = isValid ? visualDropLevel : null;
|
|
983
|
+
const lastStore = lastStoreDropTargetRef.current;
|
|
984
|
+
if (
|
|
985
|
+
!lastStore ||
|
|
986
|
+
lastStore.nodeId !== nextDropNodeId ||
|
|
987
|
+
lastStore.position !== nextDropPosition ||
|
|
988
|
+
lastStore.level !== nextDropLevel
|
|
989
|
+
) {
|
|
990
|
+
updateDropTarget(nextDropNodeId, nextDropPosition, nextDropLevel);
|
|
991
|
+
lastStoreDropTargetRef.current = {
|
|
992
|
+
nodeId: nextDropNodeId,
|
|
993
|
+
position: nextDropPosition,
|
|
994
|
+
level: nextDropLevel,
|
|
995
|
+
};
|
|
694
996
|
}
|
|
695
997
|
|
|
696
|
-
// Keep ref in sync (
|
|
998
|
+
// Keep the commit ref in sync (read by handleDragEnd).
|
|
697
999
|
// When a logical target exists (e.g. ancestor at a cliff), use it
|
|
698
1000
|
// for the actual move while the visual indicator stays on the current node.
|
|
699
1001
|
if (logicalTargetId !== null && logicalPosition !== null) {
|
|
@@ -705,34 +1007,33 @@ export function useDragDrop<ID>(
|
|
|
705
1007
|
} else {
|
|
706
1008
|
dropTargetRef.current = newTarget;
|
|
707
1009
|
}
|
|
708
|
-
|
|
709
|
-
setDropTarget((prevTarget) => {
|
|
710
|
-
if (
|
|
711
|
-
prevTarget?.targetNodeId === newTarget.targetNodeId &&
|
|
712
|
-
prevTarget?.position === newTarget.position &&
|
|
713
|
-
prevTarget?.isValid === newTarget.isValid &&
|
|
714
|
-
prevTarget?.indicatorTop === newTarget.indicatorTop
|
|
715
|
-
) {
|
|
716
|
-
return prevTarget;
|
|
717
|
-
}
|
|
718
|
-
return newTarget;
|
|
719
|
-
});
|
|
720
1010
|
},
|
|
721
|
-
[
|
|
1011
|
+
[
|
|
1012
|
+
storeId,
|
|
1013
|
+
cancelAutoExpandTimer,
|
|
1014
|
+
cancelLevelSettleTimer,
|
|
1015
|
+
fingerLeftOfLevelThreshold,
|
|
1016
|
+
overlayX,
|
|
1017
|
+
itemHeightsRef,
|
|
1018
|
+
]
|
|
722
1019
|
);
|
|
1020
|
+
calculateDropTargetRef.current = calculateDropTarget;
|
|
723
1021
|
|
|
724
1022
|
// --- Handle drag end ---
|
|
725
1023
|
const handleDragEnd = useCallback(
|
|
726
|
-
(fingerPageY?: number, fingerPageX?: number) => {
|
|
1024
|
+
(fingerPageY?: number, fingerPageX?: number, cancel: boolean = false) => {
|
|
727
1025
|
stopAutoScroll();
|
|
728
1026
|
cancelLongPressTimer();
|
|
729
1027
|
cancelAutoExpandTimer();
|
|
730
|
-
prevDropTargetRef.current = null;
|
|
731
1028
|
|
|
732
1029
|
if (!isDraggingRef.current) return;
|
|
733
1030
|
isDraggingRef.current = false;
|
|
734
1031
|
|
|
735
|
-
// Recalculate drop target at final position if we have coords
|
|
1032
|
+
// Recalculate drop target at final position if we have coords. Hysteresis
|
|
1033
|
+
// (prevDropTargetRef) is intentionally NOT cleared first, so this final
|
|
1034
|
+
// commit frame resolves to the same target the user last saw the indicator
|
|
1035
|
+
// on (clearing it here would let the release snap to the other side of an
|
|
1036
|
+
// ambiguous same-level boundary). It is reset in the ref-cleanup below.
|
|
736
1037
|
if (fingerPageY !== undefined) {
|
|
737
1038
|
calculateDropTarget(fingerPageY, fingerPageX ?? 0);
|
|
738
1039
|
}
|
|
@@ -741,100 +1042,126 @@ export function useDragDrop<ID>(
|
|
|
741
1042
|
// Without this, the timer fires after drag ends and toggles the target back to collapsed.
|
|
742
1043
|
cancelAutoExpandTimer();
|
|
743
1044
|
|
|
744
|
-
// Read
|
|
745
|
-
//
|
|
746
|
-
// We use the current dropTarget state via a callback
|
|
747
|
-
// Read drop target from ref (avoids nesting Zustand updates inside React state updaters)
|
|
1045
|
+
// Read the final drop target from the ref (the per-frame calculation
|
|
1046
|
+
// keeps it current; there is no React state to wait on).
|
|
748
1047
|
const currentTarget = dropTargetRef.current;
|
|
749
|
-
const droppedNodeId =
|
|
1048
|
+
const droppedNodeId = draggedNodeRef.current?.id ?? null;
|
|
1049
|
+
|
|
1050
|
+
const store = getTreeViewStore<ID>(storeId);
|
|
1051
|
+
const { initialTreeViewData: currentData, nodeMap, childToParentMap } =
|
|
1052
|
+
store.getState();
|
|
1053
|
+
// Capture the node's position before the move for the MoveResult delta
|
|
1054
|
+
// (the maps still describe the pre-move tree here).
|
|
1055
|
+
const prevPosition = droppedNodeId !== null
|
|
1056
|
+
? findNodePositionFromMaps(currentData, nodeMap, childToParentMap, droppedNodeId)
|
|
1057
|
+
: null;
|
|
1058
|
+
// Compute the move up front so an invalid move (moveTreeNode returns the
|
|
1059
|
+
// same reference) or a positional no-op (node re-dropped where it already
|
|
1060
|
+
// sits) is treated as a cancel rather than a spurious onDragEnd.
|
|
1061
|
+
const newData = (!cancel && currentTarget?.isValid && droppedNodeId !== null)
|
|
1062
|
+
? moveTreeNode(currentData, droppedNodeId, currentTarget.targetNodeId, currentTarget.position)
|
|
1063
|
+
: currentData;
|
|
1064
|
+
const newPosition = (newData !== currentData && droppedNodeId !== null)
|
|
1065
|
+
? findNodePosition(newData, droppedNodeId)
|
|
1066
|
+
: null;
|
|
1067
|
+
const isNoOpMove =
|
|
1068
|
+
newPosition !== null &&
|
|
1069
|
+
prevPosition !== null &&
|
|
1070
|
+
newPosition.parentId === prevPosition.parentId &&
|
|
1071
|
+
newPosition.index === prevPosition.index;
|
|
750
1072
|
|
|
751
1073
|
if (
|
|
1074
|
+
!cancel &&
|
|
752
1075
|
currentTarget?.isValid &&
|
|
753
|
-
droppedNodeId !== null
|
|
1076
|
+
droppedNodeId !== null &&
|
|
1077
|
+
newData !== currentData &&
|
|
1078
|
+
!isNoOpMove
|
|
754
1079
|
) {
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
droppedNodeId,
|
|
761
|
-
currentTarget.targetNodeId,
|
|
762
|
-
currentTarget.position
|
|
1080
|
+
// Commit the move to the store (preserves checked/expanded;
|
|
1081
|
+
// shared with the programmatic moveNode path).
|
|
1082
|
+
applyMoveToStore(
|
|
1083
|
+
storeId, newData, droppedNodeId,
|
|
1084
|
+
currentTarget.targetNodeId, currentTarget.position
|
|
763
1085
|
);
|
|
764
1086
|
|
|
765
|
-
//
|
|
766
|
-
store
|
|
767
|
-
|
|
768
|
-
.updateInitialTreeViewData(newData);
|
|
769
|
-
initializeNodeMaps(storeId, newData);
|
|
770
|
-
|
|
771
|
-
// Recalculate checked/indeterminate states for all parents
|
|
772
|
-
// since the tree structure changed
|
|
773
|
-
recalculateCheckedStates<ID>(storeId);
|
|
774
|
-
|
|
775
|
-
// If dropped "inside" a node, expand it so the dropped node is visible
|
|
776
|
-
if (currentTarget.position === "inside") {
|
|
777
|
-
expandNodes(storeId, [currentTarget.targetNodeId]);
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// Expand ancestors of the dropped node so it's visible
|
|
781
|
-
expandNodes(storeId, [droppedNodeId], true);
|
|
782
|
-
|
|
783
|
-
// Set internal data ref to prevent useDeepCompareEffect
|
|
784
|
-
// from reinitializing
|
|
785
|
-
internalDataRef.current = newData;
|
|
786
|
-
|
|
787
|
-
// Notify consumer
|
|
1087
|
+
// Notify the consumer with a lightweight move delta. The reordered
|
|
1088
|
+
// tree lives in the store; TreeView's wrapped onDragEnd captures it
|
|
1089
|
+
// for the reinit-skip, and consumers can read it via getTreeData().
|
|
788
1090
|
onDragEndRef.current?.({
|
|
789
1091
|
draggedNodeId: droppedNodeId,
|
|
790
1092
|
targetNodeId: currentTarget.targetNodeId,
|
|
791
1093
|
position: currentTarget.position,
|
|
792
|
-
|
|
1094
|
+
previousParentId: prevPosition?.parentId ?? null,
|
|
1095
|
+
previousIndex: prevPosition?.index ?? -1,
|
|
1096
|
+
newParentId: newPosition?.parentId ?? null,
|
|
1097
|
+
newIndex: newPosition?.index ?? -1,
|
|
793
1098
|
});
|
|
794
1099
|
|
|
795
|
-
//
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
setTimeout(() => {
|
|
800
|
-
const nodes = flattenedNodesRef.current;
|
|
801
|
-
const idx = nodes.findIndex(n => n.id === droppedNodeId);
|
|
802
|
-
if (idx < 0) return;
|
|
803
|
-
|
|
804
|
-
const itemH = itemHeightRef.current;
|
|
805
|
-
const scrollTop = scrollOffsetRef.current;
|
|
806
|
-
const containerH = containerHeightRef.current;
|
|
807
|
-
const estimatedTop = idx * itemH;
|
|
808
|
-
const estimatedBottom = estimatedTop + itemH;
|
|
809
|
-
|
|
810
|
-
// Already in view → no scroll needed
|
|
811
|
-
if (estimatedTop >= scrollTop && estimatedBottom <= scrollTop + containerH) {
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
1100
|
+
// Announce the result for screen readers (no-op without assistive tech).
|
|
1101
|
+
AccessibilityInfo.announceForAccessibility?.(
|
|
1102
|
+
`Moved ${draggedNodeRef.current?.name ?? "node"}`
|
|
1103
|
+
);
|
|
814
1104
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1105
|
+
// Auto-scroll to the dropped node unless disabled by the user.
|
|
1106
|
+
const scrollOpts = autoScrollToDroppedNode;
|
|
1107
|
+
const scrollEnabled = scrollOpts === undefined || scrollOpts === true
|
|
1108
|
+
|| (typeof scrollOpts === "object" && scrollOpts.enabled !== false);
|
|
1109
|
+
|
|
1110
|
+
if (scrollEnabled) {
|
|
1111
|
+
// The drop lands under the finger, so the node is almost always
|
|
1112
|
+
// already on-screen; scrolling anyway would yank the list (worst
|
|
1113
|
+
// when dropping near the very bottom, where centering the node
|
|
1114
|
+
// scrolls the list back up). Estimate where the dropped row ends
|
|
1115
|
+
// up in the post-move flattened list and only scroll when it
|
|
1116
|
+
// actually sits outside the viewport.
|
|
1117
|
+
const preMoveNodes = flattenedNodesRef.current;
|
|
1118
|
+
const draggedIdx = preMoveNodes.findIndex(
|
|
1119
|
+
(n) => n.id === droppedNodeId
|
|
1120
|
+
);
|
|
1121
|
+
const iH = itemHeightRef.current;
|
|
1122
|
+
let finalIndex = currentTarget.targetIndex;
|
|
1123
|
+
// "below"/"inside" both land the node right after the target row.
|
|
1124
|
+
if (currentTarget.position !== "above") finalIndex += 1;
|
|
1125
|
+
// Removing the dragged row from above the target shifts rows up one.
|
|
1126
|
+
if (draggedIdx !== -1 && draggedIdx < currentTarget.targetIndex) {
|
|
1127
|
+
finalIndex -= 1;
|
|
1128
|
+
}
|
|
1129
|
+
const rowTop =
|
|
1130
|
+
headerOffsetRef.current + finalIndex * iH - scrollOffsetRef.current;
|
|
1131
|
+
const isOnScreen =
|
|
1132
|
+
rowTop >= 0 && rowTop + iH <= containerHeightRef.current;
|
|
1133
|
+
|
|
1134
|
+
if (!isOnScreen) {
|
|
1135
|
+
scrollMovedNodeIntoView(
|
|
1136
|
+
scrollToNodeHandlerRef, droppedNodeId, scrollOpts
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
821
1140
|
} else if (droppedNodeId !== null) {
|
|
822
|
-
// Drag ended without a valid drop
|
|
1141
|
+
// Drag ended without a valid drop - a cancel must not mutate state,
|
|
1142
|
+
// so restore the expansion that drag start force-collapsed.
|
|
1143
|
+
if (wasDraggedNodeExpandedRef.current) {
|
|
1144
|
+
expandNodes(storeId, [droppedNodeId]);
|
|
1145
|
+
}
|
|
823
1146
|
onDragCancelRef.current?.({ draggedNodeId: droppedNodeId });
|
|
1147
|
+
AccessibilityInfo.announceForAccessibility?.(
|
|
1148
|
+
`Cancelled moving ${draggedNodeRef.current?.name ?? "node"}`
|
|
1149
|
+
);
|
|
824
1150
|
}
|
|
825
1151
|
|
|
826
1152
|
// Collapse auto-expanded nodes that aren't ancestors of the drop target
|
|
827
1153
|
if (autoExpandedDuringDragRef.current.size > 0) {
|
|
828
|
-
|
|
829
|
-
const { childToParentMap } =
|
|
1154
|
+
// Re-read: the maps were rebuilt if a move committed above.
|
|
1155
|
+
const { childToParentMap: postMoveParentMap } = store.getState();
|
|
830
1156
|
|
|
831
|
-
// Collect ancestors of the drop target (keep these expanded)
|
|
1157
|
+
// Collect ancestors of the drop target (keep these expanded).
|
|
1158
|
+
// On cancel, retain none so every auto-expanded node collapses back.
|
|
832
1159
|
const ancestorIds = new Set<ID>();
|
|
833
|
-
if (currentTarget?.isValid) {
|
|
1160
|
+
if (!cancel && currentTarget?.isValid) {
|
|
834
1161
|
let walkId: ID | undefined = currentTarget.targetNodeId;
|
|
835
1162
|
while (walkId !== undefined) {
|
|
836
1163
|
ancestorIds.add(walkId);
|
|
837
|
-
walkId =
|
|
1164
|
+
walkId = postMoveParentMap.get(walkId);
|
|
838
1165
|
}
|
|
839
1166
|
}
|
|
840
1167
|
|
|
@@ -852,20 +1179,18 @@ export function useDragDrop<ID>(
|
|
|
852
1179
|
}
|
|
853
1180
|
|
|
854
1181
|
// Clear drag state
|
|
855
|
-
|
|
856
|
-
store2.getState().updateDraggedNodeId(null);
|
|
857
|
-
store2.getState().updateInvalidDragTargetIds(new Set());
|
|
858
|
-
store2.getState().updateDropTarget(null, null);
|
|
1182
|
+
resetDragStoreState();
|
|
859
1183
|
|
|
860
1184
|
// Reset all refs
|
|
861
1185
|
overlayX.setValue(0);
|
|
862
1186
|
prevEffectiveLevelRef.current = null;
|
|
1187
|
+
cancelLevelSettleTimer();
|
|
1188
|
+
prevDropTargetRef.current = null;
|
|
863
1189
|
dropTargetRef.current = null;
|
|
1190
|
+
lastStoreDropTargetRef.current = null;
|
|
864
1191
|
draggedNodeRef.current = null;
|
|
865
|
-
|
|
866
|
-
draggedNodeIndexRef.current = -1;
|
|
1192
|
+
wasDraggedNodeExpandedRef.current = false;
|
|
867
1193
|
|
|
868
|
-
setDropTarget(null);
|
|
869
1194
|
setIsDragging(false);
|
|
870
1195
|
setDraggedNode(null);
|
|
871
1196
|
},
|
|
@@ -876,14 +1201,26 @@ export function useDragDrop<ID>(
|
|
|
876
1201
|
cancelLongPressTimer,
|
|
877
1202
|
cancelAutoExpandTimer,
|
|
878
1203
|
calculateDropTarget,
|
|
879
|
-
internalDataRef,
|
|
880
1204
|
]
|
|
881
1205
|
);
|
|
882
1206
|
|
|
1207
|
+
// --- onScroll for the host list (see UseDragDropReturn.handleScroll) ---
|
|
1208
|
+
const handleScroll = useCallback((
|
|
1209
|
+
event: NativeSyntheticEvent<NativeScrollEvent>
|
|
1210
|
+
) => {
|
|
1211
|
+
if (!isDraggingRef.current) {
|
|
1212
|
+
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
|
1213
|
+
}
|
|
1214
|
+
// Scrolling means this touch isn't a long-press
|
|
1215
|
+
cancelLongPressTimer();
|
|
1216
|
+
}, [cancelLongPressTimer]);
|
|
1217
|
+
|
|
883
1218
|
// --- Handle node touch end ---
|
|
884
1219
|
// If the PanResponder never captured the gesture (no movement after long
|
|
885
1220
|
// press fired), end the drag here so the node doesn't stay "lifted".
|
|
886
1221
|
const handleNodeTouchEnd = useCallback(() => {
|
|
1222
|
+
// cancelLongPressTimer also aborts any drag still awaiting its
|
|
1223
|
+
// measureInWindow callback (clears pendingDragRef).
|
|
887
1224
|
cancelLongPressTimer();
|
|
888
1225
|
if (isDraggingRef.current && !panResponderActiveRef.current) {
|
|
889
1226
|
handleDragEnd();
|
|
@@ -904,26 +1241,28 @@ export function useDragDrop<ID>(
|
|
|
904
1241
|
panResponderActiveRef.current = true;
|
|
905
1242
|
},
|
|
906
1243
|
|
|
1244
|
+
// While a drag is active, refuse to hand the gesture to an ancestor
|
|
1245
|
+
// responder (e.g. a parent ScrollView or swipe navigator) so a slight
|
|
1246
|
+
// horizontal drift can't terminate the drag mid-flight.
|
|
1247
|
+
onPanResponderTerminationRequest: () => !isDraggingRef.current,
|
|
1248
|
+
|
|
907
1249
|
onPanResponderMove: (evt) => {
|
|
908
1250
|
if (!isDraggingRef.current) return;
|
|
909
1251
|
|
|
910
1252
|
const fingerPageY = evt.nativeEvent.pageY;
|
|
1253
|
+
lastFingerPageYRef.current = fingerPageY;
|
|
1254
|
+
lastFingerPageXRef.current = evt.nativeEvent.pageX;
|
|
911
1255
|
const fingerLocalY =
|
|
912
1256
|
fingerPageY - containerPageYRef.current;
|
|
913
1257
|
|
|
914
1258
|
// Update overlay position (with configurable offset)
|
|
915
|
-
|
|
916
|
-
fingerLocalY - grabOffsetYRef.current + dragOverlayOffsetRef.current * itemHeightRef.current;
|
|
917
|
-
overlayY.setValue(overlayLocalY);
|
|
1259
|
+
overlayY.setValue(computeOverlayLocalY(fingerLocalY));
|
|
918
1260
|
|
|
919
1261
|
// Calculate drop target (horizontal position used at level cliffs)
|
|
920
1262
|
calculateDropTarget(fingerPageY, evt.nativeEvent.pageX);
|
|
921
1263
|
|
|
922
|
-
// Auto-scroll at edges
|
|
923
|
-
|
|
924
|
-
initialFingerContainerYRef.current +
|
|
925
|
-
(fingerPageY - initialFingerPageYRef.current);
|
|
926
|
-
updateAutoScroll(fingerInContainer);
|
|
1264
|
+
// Auto-scroll at edges, from the finger's container-local position
|
|
1265
|
+
updateAutoScroll(fingerLocalY);
|
|
927
1266
|
},
|
|
928
1267
|
|
|
929
1268
|
onPanResponderRelease: (evt) => {
|
|
@@ -933,7 +1272,9 @@ export function useDragDrop<ID>(
|
|
|
933
1272
|
|
|
934
1273
|
onPanResponderTerminate: () => {
|
|
935
1274
|
panResponderActiveRef.current = false;
|
|
936
|
-
|
|
1275
|
+
// A terminate (parent scroll steals the gesture, app backgrounds, etc.)
|
|
1276
|
+
// cancels the drop rather than committing at the last hovered target.
|
|
1277
|
+
handleDragEnd(undefined, undefined, true);
|
|
937
1278
|
},
|
|
938
1279
|
})
|
|
939
1280
|
).current;
|
|
@@ -943,16 +1284,22 @@ export function useDragDrop<ID>(
|
|
|
943
1284
|
return () => {
|
|
944
1285
|
cancelLongPressTimer();
|
|
945
1286
|
cancelAutoExpandTimer();
|
|
1287
|
+
cancelLevelSettleTimer();
|
|
946
1288
|
stopAutoScroll();
|
|
1289
|
+
pendingDragRef.current = false;
|
|
1290
|
+
lastStoreDropTargetRef.current = null;
|
|
947
1291
|
if (isDraggingRef.current) {
|
|
948
1292
|
isDraggingRef.current = false;
|
|
949
|
-
|
|
950
|
-
store.getState().updateDraggedNodeId(null);
|
|
951
|
-
store.getState().updateInvalidDragTargetIds(new Set());
|
|
952
|
-
store.getState().updateDropTarget(null, null);
|
|
1293
|
+
resetDragStoreState();
|
|
953
1294
|
}
|
|
954
1295
|
};
|
|
955
|
-
}, [
|
|
1296
|
+
}, [
|
|
1297
|
+
cancelLongPressTimer,
|
|
1298
|
+
cancelAutoExpandTimer,
|
|
1299
|
+
cancelLevelSettleTimer,
|
|
1300
|
+
stopAutoScroll,
|
|
1301
|
+
resetDragStoreState,
|
|
1302
|
+
]);
|
|
956
1303
|
|
|
957
1304
|
return {
|
|
958
1305
|
panResponder,
|
|
@@ -960,12 +1307,12 @@ export function useDragDrop<ID>(
|
|
|
960
1307
|
overlayX,
|
|
961
1308
|
isDragging,
|
|
962
1309
|
draggedNode,
|
|
963
|
-
dropTarget,
|
|
964
|
-
effectiveDropLevel,
|
|
965
1310
|
handleNodeTouchStart,
|
|
966
1311
|
handleNodeTouchEnd,
|
|
967
1312
|
cancelLongPressTimer,
|
|
1313
|
+
handleScroll,
|
|
968
1314
|
scrollOffsetRef,
|
|
969
1315
|
headerOffsetRef,
|
|
1316
|
+
containerHeightRef,
|
|
970
1317
|
};
|
|
971
1318
|
}
|