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