react-native-tree-multi-select 3.0.0-beta.2 → 3.0.0-beta.4
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 +85 -25
- package/lib/module/TreeView.js +36 -31
- package/lib/module/TreeView.js.map +1 -1
- package/lib/module/components/CheckboxView.js +8 -4
- package/lib/module/components/CheckboxView.js.map +1 -1
- package/lib/module/components/CustomExpandCollapseIcon.js +2 -2
- package/lib/module/components/CustomExpandCollapseIcon.js.map +1 -1
- package/lib/module/components/DragOverlay.js +17 -5
- package/lib/module/components/DragOverlay.js.map +1 -1
- package/lib/module/components/DropIndicator.js +2 -2
- package/lib/module/components/DropIndicator.js.map +1 -1
- package/lib/module/components/NodeList.js +80 -56
- package/lib/module/components/NodeList.js.map +1 -1
- package/lib/module/constants/treeView.constants.js +3 -0
- package/lib/module/constants/treeView.constants.js.map +1 -1
- package/lib/module/helpers/expandCollapse.helper.js.map +1 -1
- package/lib/module/helpers/moveTreeNode.helper.js +30 -0
- package/lib/module/helpers/moveTreeNode.helper.js.map +1 -1
- package/lib/module/helpers/selectAll.helper.js.map +1 -1
- package/lib/module/helpers/toggleCheckbox.helper.js +44 -61
- package/lib/module/helpers/toggleCheckbox.helper.js.map +1 -1
- package/lib/module/hooks/useDragDrop.js +141 -34
- package/lib/module/hooks/useDragDrop.js.map +1 -1
- package/lib/module/{handlers/ScrollToNodeHandler.js → hooks/useScrollToNode.js} +27 -26
- package/lib/module/hooks/useScrollToNode.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/jest.setup.js +14 -1
- package/lib/module/jest.setup.js.map +1 -1
- package/lib/module/store/treeView.store.js +3 -0
- package/lib/module/store/treeView.store.js.map +1 -1
- package/lib/module/utils/typedMemo.js +3 -3
- package/lib/module/utils/typedMemo.js.map +1 -1
- package/lib/module/utils/useDeepCompareEffect.js +5 -5
- package/lib/module/utils/useDeepCompareEffect.js.map +1 -1
- package/lib/typescript/src/TreeView.d.ts +3 -3
- package/lib/typescript/src/TreeView.d.ts.map +1 -1
- package/lib/typescript/src/components/CheckboxView.d.ts +1 -2
- package/lib/typescript/src/components/CheckboxView.d.ts.map +1 -1
- package/lib/typescript/src/components/CustomExpandCollapseIcon.d.ts +1 -2
- package/lib/typescript/src/components/CustomExpandCollapseIcon.d.ts.map +1 -1
- package/lib/typescript/src/components/DragOverlay.d.ts +1 -0
- package/lib/typescript/src/components/DragOverlay.d.ts.map +1 -1
- package/lib/typescript/src/components/DropIndicator.d.ts +1 -2
- package/lib/typescript/src/components/DropIndicator.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 +2 -0
- package/lib/typescript/src/constants/treeView.constants.d.ts.map +1 -1
- package/lib/typescript/src/helpers/expandCollapse.helper.d.ts +2 -2
- package/lib/typescript/src/helpers/expandCollapse.helper.d.ts.map +1 -1
- package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts.map +1 -1
- package/lib/typescript/src/helpers/selectAll.helper.d.ts +4 -4
- package/lib/typescript/src/helpers/selectAll.helper.d.ts.map +1 -1
- package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts +3 -0
- package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useDragDrop.d.ts +18 -7
- package/lib/typescript/src/hooks/useDragDrop.d.ts.map +1 -1
- package/lib/typescript/src/{handlers/ScrollToNodeHandler.d.ts → hooks/useScrollToNode.d.ts} +13 -15
- package/lib/typescript/src/hooks/useScrollToNode.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +4 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/jest.setup.d.ts +1 -1
- package/lib/typescript/src/jest.setup.d.ts.map +1 -1
- package/lib/typescript/src/store/treeView.store.d.ts +2 -1
- package/lib/typescript/src/store/treeView.store.d.ts.map +1 -1
- package/lib/typescript/src/types/dragDrop.types.d.ts +19 -0
- package/lib/typescript/src/types/dragDrop.types.d.ts.map +1 -1
- package/lib/typescript/src/types/treeView.types.d.ts +149 -35
- package/lib/typescript/src/types/treeView.types.d.ts.map +1 -1
- package/lib/typescript/src/utils/typedMemo.d.ts +1 -1
- package/lib/typescript/src/utils/typedMemo.d.ts.map +1 -1
- package/lib/typescript/src/utils/useDeepCompareEffect.d.ts +2 -2
- package/lib/typescript/src/utils/useDeepCompareEffect.d.ts.map +1 -1
- package/package.json +32 -15
- package/src/TreeView.tsx +57 -35
- package/src/components/CheckboxView.tsx +7 -4
- package/src/components/CustomExpandCollapseIcon.tsx +2 -2
- package/src/components/DragOverlay.tsx +19 -6
- package/src/components/DropIndicator.tsx +2 -2
- package/src/components/NodeList.tsx +90 -58
- package/src/constants/treeView.constants.ts +4 -1
- package/src/helpers/expandCollapse.helper.ts +5 -5
- package/src/helpers/moveTreeNode.helper.ts +33 -0
- package/src/helpers/selectAll.helper.ts +10 -10
- package/src/helpers/toggleCheckbox.helper.ts +57 -69
- package/src/hooks/useDragDrop.ts +182 -46
- package/src/{handlers/ScrollToNodeHandler.tsx → hooks/useScrollToNode.ts} +48 -45
- package/src/index.tsx +9 -0
- package/src/jest.setup.ts +14 -1
- package/src/store/treeView.store.ts +6 -1
- package/src/types/dragDrop.types.ts +21 -0
- package/src/types/treeView.types.ts +157 -41
- package/src/utils/typedMemo.ts +3 -3
- package/src/utils/useDeepCompareEffect.ts +13 -7
- package/lib/module/handlers/ScrollToNodeHandler.js.map +0 -1
- package/lib/typescript/src/handlers/ScrollToNodeHandler.d.ts.map +0 -1
package/src/hooks/useDragDrop.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
MutableRefObject,
|
|
3
|
+
RefObject,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState
|
|
8
|
+
} from "react";
|
|
2
9
|
import {
|
|
3
10
|
Animated,
|
|
4
11
|
PanResponder,
|
|
@@ -7,27 +14,44 @@ import {
|
|
|
7
14
|
import type { FlashList } from "@shopify/flash-list";
|
|
8
15
|
|
|
9
16
|
import type { __FlattenedTreeNode__, TreeNode } from "../types/treeView.types";
|
|
10
|
-
import type { DragEndEvent, DropTarget } from "../types/dragDrop.types";
|
|
17
|
+
import type { DragCancelEvent, DragEndEvent, DragStartEvent, DropTarget } from "../types/dragDrop.types";
|
|
11
18
|
import { getTreeViewStore } from "../store/treeView.store";
|
|
12
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
collapseNodes,
|
|
21
|
+
expandNodes,
|
|
22
|
+
handleToggleExpand,
|
|
23
|
+
initializeNodeMaps,
|
|
24
|
+
recalculateCheckedStates
|
|
25
|
+
} from "../helpers";
|
|
13
26
|
import { moveTreeNode } from "../helpers/moveTreeNode.helper";
|
|
27
|
+
import { listHeaderFooterPadding } from "../constants/treeView.constants";
|
|
14
28
|
|
|
15
29
|
interface UseDragDropParams<ID> {
|
|
16
30
|
storeId: string;
|
|
17
31
|
flattenedNodes: __FlattenedTreeNode__<ID>[];
|
|
18
|
-
flashListRef:
|
|
19
|
-
containerRef:
|
|
32
|
+
flashListRef: RefObject<FlashList<__FlattenedTreeNode__<ID>> | null>;
|
|
33
|
+
containerRef: RefObject<{ measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => void; } | null>;
|
|
20
34
|
dragEnabled: boolean;
|
|
35
|
+
onDragStart?: (event: DragStartEvent<ID>) => void;
|
|
21
36
|
onDragEnd?: (event: DragEndEvent<ID>) => void;
|
|
37
|
+
onDragCancel?: (event: DragCancelEvent<ID>) => void;
|
|
22
38
|
longPressDuration: number;
|
|
23
39
|
autoScrollThreshold: number;
|
|
24
40
|
autoScrollSpeed: number;
|
|
25
|
-
internalDataRef:
|
|
26
|
-
measuredItemHeightRef:
|
|
41
|
+
internalDataRef: MutableRefObject<TreeNode<ID>[] | null>;
|
|
42
|
+
measuredItemHeightRef: MutableRefObject<number>;
|
|
27
43
|
dragOverlayOffset: number;
|
|
28
44
|
autoExpandDelay: number;
|
|
29
45
|
/** Pixels per nesting level, used for magnetic overlay shift. */
|
|
30
46
|
indentationMultiplier: number;
|
|
47
|
+
/** Callback to determine if a drop is allowed on a specific target. */
|
|
48
|
+
canDrop?: (draggedNode: TreeNode<ID>, targetNode: TreeNode<ID>, position: "above" | "below" | "inside") => boolean;
|
|
49
|
+
/** Maximum nesting depth allowed. */
|
|
50
|
+
maxDepth?: number;
|
|
51
|
+
/** Callback to determine if a node can accept children. */
|
|
52
|
+
canNodeHaveChildren?: (node: TreeNode<ID>) => boolean;
|
|
53
|
+
/** Callback to determine if a node can be dragged. */
|
|
54
|
+
canDrag?: (node: TreeNode<ID>) => boolean;
|
|
31
55
|
}
|
|
32
56
|
|
|
33
57
|
interface UseDragDropReturn<ID> {
|
|
@@ -46,8 +70,8 @@ interface UseDragDropReturn<ID> {
|
|
|
46
70
|
) => void;
|
|
47
71
|
handleNodeTouchEnd: () => void;
|
|
48
72
|
cancelLongPressTimer: () => void;
|
|
49
|
-
scrollOffsetRef:
|
|
50
|
-
headerOffsetRef:
|
|
73
|
+
scrollOffsetRef: MutableRefObject<number>;
|
|
74
|
+
headerOffsetRef: MutableRefObject<number>;
|
|
51
75
|
}
|
|
52
76
|
|
|
53
77
|
export function useDragDrop<ID>(
|
|
@@ -59,7 +83,9 @@ export function useDragDrop<ID>(
|
|
|
59
83
|
flashListRef,
|
|
60
84
|
containerRef,
|
|
61
85
|
dragEnabled,
|
|
86
|
+
onDragStart,
|
|
62
87
|
onDragEnd,
|
|
88
|
+
onDragCancel,
|
|
63
89
|
longPressDuration,
|
|
64
90
|
autoScrollThreshold,
|
|
65
91
|
autoScrollSpeed,
|
|
@@ -68,6 +94,10 @@ export function useDragDrop<ID>(
|
|
|
68
94
|
dragOverlayOffset,
|
|
69
95
|
autoExpandDelay,
|
|
70
96
|
indentationMultiplier,
|
|
97
|
+
canDrop: canDropCallback,
|
|
98
|
+
maxDepth,
|
|
99
|
+
canNodeHaveChildren,
|
|
100
|
+
canDrag,
|
|
71
101
|
} = params;
|
|
72
102
|
|
|
73
103
|
// --- Refs for mutable state (no stale closures in PanResponder) ---
|
|
@@ -107,15 +137,42 @@ export function useDragDrop<ID>(
|
|
|
107
137
|
const panResponderActiveRef = useRef(false);
|
|
108
138
|
|
|
109
139
|
// Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
|
|
110
|
-
const prevDropTargetRef = useRef<{ targetIndex: number; position: "above" | "below" | "inside" } | null>(null);
|
|
140
|
+
const prevDropTargetRef = useRef<{ targetIndex: number; position: "above" | "below" | "inside"; } | null>(null);
|
|
141
|
+
|
|
142
|
+
// Depth of the dragged subtree (computed once at drag start, used for maxDepth check)
|
|
143
|
+
const draggedSubtreeDepthRef = useRef(0);
|
|
111
144
|
|
|
112
145
|
// Keep flattenedNodes ref current for PanResponder closures
|
|
113
146
|
const flattenedNodesRef = useRef(flattenedNodes);
|
|
114
147
|
flattenedNodesRef.current = flattenedNodes;
|
|
115
148
|
|
|
116
149
|
// Keep callbacks current
|
|
150
|
+
const onDragStartRef = useRef(onDragStart);
|
|
151
|
+
onDragStartRef.current = onDragStart;
|
|
117
152
|
const onDragEndRef = useRef(onDragEnd);
|
|
118
153
|
onDragEndRef.current = onDragEnd;
|
|
154
|
+
const onDragCancelRef = useRef(onDragCancel);
|
|
155
|
+
onDragCancelRef.current = onDragCancel;
|
|
156
|
+
const canDropRef = useRef(canDropCallback);
|
|
157
|
+
canDropRef.current = canDropCallback;
|
|
158
|
+
const canNodeHaveChildrenRef = useRef(canNodeHaveChildren);
|
|
159
|
+
canNodeHaveChildrenRef.current = canNodeHaveChildren;
|
|
160
|
+
const canDragRef = useRef(canDrag);
|
|
161
|
+
canDragRef.current = canDrag;
|
|
162
|
+
|
|
163
|
+
// Keep config values current for PanResponder closures
|
|
164
|
+
const dragOverlayOffsetRef = useRef(dragOverlayOffset);
|
|
165
|
+
dragOverlayOffsetRef.current = dragOverlayOffset;
|
|
166
|
+
const autoScrollThresholdRef = useRef(autoScrollThreshold);
|
|
167
|
+
autoScrollThresholdRef.current = autoScrollThreshold;
|
|
168
|
+
const autoScrollSpeedParamRef = useRef(autoScrollSpeed);
|
|
169
|
+
autoScrollSpeedParamRef.current = autoScrollSpeed;
|
|
170
|
+
const autoExpandDelayRef = useRef(autoExpandDelay);
|
|
171
|
+
autoExpandDelayRef.current = autoExpandDelay;
|
|
172
|
+
const indentationMultiplierRef = useRef(indentationMultiplier);
|
|
173
|
+
indentationMultiplierRef.current = indentationMultiplier;
|
|
174
|
+
const maxDepthRef = useRef(maxDepth);
|
|
175
|
+
maxDepthRef.current = maxDepth;
|
|
119
176
|
|
|
120
177
|
// --- React state (triggers re-renders only at drag start/end + indicator changes) ---
|
|
121
178
|
const [isDragging, setIsDragging] = useState(false);
|
|
@@ -123,7 +180,7 @@ export function useDragDrop<ID>(
|
|
|
123
180
|
const [dropTarget, setDropTarget] = useState<DropTarget<ID> | null>(null);
|
|
124
181
|
const [effectiveDropLevel, setEffectiveDropLevel] = useState(0);
|
|
125
182
|
|
|
126
|
-
// Ref mirror of dropTarget
|
|
183
|
+
// Ref mirror of dropTarget - avoids nesting Zustand updates inside React state updaters
|
|
127
184
|
const dropTargetRef = useRef<DropTarget<ID> | null>(null);
|
|
128
185
|
|
|
129
186
|
// --- Long press timer ---
|
|
@@ -157,6 +214,22 @@ export function useDragDrop<ID>(
|
|
|
157
214
|
[storeId]
|
|
158
215
|
);
|
|
159
216
|
|
|
217
|
+
// --- Get the maximum depth of a subtree (0 for leaf nodes) ---
|
|
218
|
+
const getSubtreeDepth = useCallback(
|
|
219
|
+
(nodeId: ID): number => {
|
|
220
|
+
const store = getTreeViewStore<ID>(storeId);
|
|
221
|
+
const { nodeMap } = store.getState();
|
|
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;
|
|
229
|
+
},
|
|
230
|
+
[storeId]
|
|
231
|
+
);
|
|
232
|
+
|
|
160
233
|
// --- Initiate drag ---
|
|
161
234
|
const initiateDrag = useCallback(
|
|
162
235
|
(nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
|
|
@@ -187,12 +260,11 @@ export function useDragDrop<ID>(
|
|
|
187
260
|
draggedNodeRef.current = node;
|
|
188
261
|
draggedNodeIdRef.current = nodeId;
|
|
189
262
|
draggedNodeIndexRef.current = nodeIndex;
|
|
263
|
+
draggedSubtreeDepthRef.current = getSubtreeDepth(nodeId);
|
|
190
264
|
|
|
191
|
-
// Use measured item height if available, fall back to
|
|
265
|
+
// Use measured item height if available, fall back to default
|
|
192
266
|
const measured = measuredItemHeightRef.current;
|
|
193
|
-
|
|
194
|
-
(flashListRef.current as any)?.props?.estimatedItemSize ?? 36;
|
|
195
|
-
itemHeightRef.current = measured > 0 ? measured : estimatedSize;
|
|
267
|
+
itemHeightRef.current = measured > 0 ? measured : 36;
|
|
196
268
|
|
|
197
269
|
// Calculate headerOffset dynamically:
|
|
198
270
|
// fingerLocalY = pageY - containerPageY
|
|
@@ -207,9 +279,8 @@ export function useDragDrop<ID>(
|
|
|
207
279
|
|
|
208
280
|
// Delta-based auto-scroll: compute finger's position in the container
|
|
209
281
|
// from the node's known index (avoids unreliable containerPageY).
|
|
210
|
-
// The FlashList header (padding:5 → ~10px) + nodeIndex * itemHeight - scroll + locationY
|
|
211
282
|
const iH = itemHeightRef.current;
|
|
212
|
-
const listHeaderHeight =
|
|
283
|
+
const listHeaderHeight = listHeaderFooterPadding * 2;
|
|
213
284
|
initialFingerPageYRef.current = pageY;
|
|
214
285
|
initialFingerContainerYRef.current =
|
|
215
286
|
listHeaderHeight + nodeIndex * iH - scrollOffsetRef.current + locationY;
|
|
@@ -223,7 +294,7 @@ export function useDragDrop<ID>(
|
|
|
223
294
|
store.getState().updateInvalidDragTargetIds(descendants);
|
|
224
295
|
|
|
225
296
|
// Set overlay initial position (with configurable offset)
|
|
226
|
-
const overlayLocalY = fingerLocalY - locationY +
|
|
297
|
+
const overlayLocalY = fingerLocalY - locationY + dragOverlayOffsetRef.current * itemHeightRef.current;
|
|
227
298
|
overlayY.setValue(overlayLocalY);
|
|
228
299
|
|
|
229
300
|
// Reset magnetic overlay
|
|
@@ -238,6 +309,9 @@ export function useDragDrop<ID>(
|
|
|
238
309
|
setEffectiveDropLevel(node.level ?? 0);
|
|
239
310
|
setDropTarget(null);
|
|
240
311
|
|
|
312
|
+
// Notify consumer that drag has started
|
|
313
|
+
onDragStartRef.current?.({ draggedNodeId: nodeId });
|
|
314
|
+
|
|
241
315
|
// Start auto-scroll loop
|
|
242
316
|
startAutoScrollLoop();
|
|
243
317
|
});
|
|
@@ -249,6 +323,7 @@ export function useDragDrop<ID>(
|
|
|
249
323
|
containerRef,
|
|
250
324
|
flashListRef,
|
|
251
325
|
getDescendantIds,
|
|
326
|
+
getSubtreeDepth,
|
|
252
327
|
overlayY,
|
|
253
328
|
]
|
|
254
329
|
);
|
|
@@ -258,6 +333,12 @@ export function useDragDrop<ID>(
|
|
|
258
333
|
(nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
|
|
259
334
|
if (!dragEnabled) return;
|
|
260
335
|
|
|
336
|
+
// Check if this node can be dragged
|
|
337
|
+
if (canDragRef.current) {
|
|
338
|
+
const node = flattenedNodesRef.current[nodeIndex];
|
|
339
|
+
if (node && !canDragRef.current(node)) return;
|
|
340
|
+
}
|
|
341
|
+
|
|
261
342
|
// Cancel any existing timer
|
|
262
343
|
cancelLongPressTimer();
|
|
263
344
|
|
|
@@ -281,7 +362,7 @@ export function useDragDrop<ID>(
|
|
|
281
362
|
scrollOffsetRef.current + autoScrollSpeedRef.current
|
|
282
363
|
);
|
|
283
364
|
scrollOffsetRef.current = newOffset;
|
|
284
|
-
|
|
365
|
+
flashListRef.current?.scrollToOffset?.({
|
|
285
366
|
offset: newOffset,
|
|
286
367
|
animated: false,
|
|
287
368
|
});
|
|
@@ -302,8 +383,8 @@ export function useDragDrop<ID>(
|
|
|
302
383
|
|
|
303
384
|
const updateAutoScroll = useCallback(
|
|
304
385
|
(fingerInContainer: number) => {
|
|
305
|
-
const threshold =
|
|
306
|
-
const maxSpeed = 8 *
|
|
386
|
+
const threshold = autoScrollThresholdRef.current;
|
|
387
|
+
const maxSpeed = 8 * autoScrollSpeedParamRef.current;
|
|
307
388
|
const containerH = containerHeightRef.current;
|
|
308
389
|
|
|
309
390
|
if (fingerInContainer < threshold) {
|
|
@@ -319,7 +400,7 @@ export function useDragDrop<ID>(
|
|
|
319
400
|
autoScrollSpeedRef.current = 0;
|
|
320
401
|
}
|
|
321
402
|
},
|
|
322
|
-
[
|
|
403
|
+
[]
|
|
323
404
|
);
|
|
324
405
|
|
|
325
406
|
// --- Cancel auto-expand timer ---
|
|
@@ -365,6 +446,26 @@ export function useDragDrop<ID>(
|
|
|
365
446
|
position = "inside";
|
|
366
447
|
}
|
|
367
448
|
|
|
449
|
+
// --- Determine if "inside" drop is allowed for this target ---
|
|
450
|
+
const canDropInsideTarget = (() => {
|
|
451
|
+
// canNodeHaveChildren: structural constraint
|
|
452
|
+
if (canNodeHaveChildrenRef.current && !canNodeHaveChildrenRef.current(targetNode)) {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
// maxDepth: the dragged subtree at (targetLevel + 1) must not exceed maxDepth
|
|
456
|
+
if (maxDepthRef.current !== undefined) {
|
|
457
|
+
const targetLevel = targetNode.level ?? 0;
|
|
458
|
+
const deepest = targetLevel + 1 + draggedSubtreeDepthRef.current;
|
|
459
|
+
if (deepest > maxDepthRef.current) return false;
|
|
460
|
+
}
|
|
461
|
+
return true;
|
|
462
|
+
})();
|
|
463
|
+
|
|
464
|
+
// If "inside" is not allowed, convert to nearest zone
|
|
465
|
+
if (position === "inside" && !canDropInsideTarget) {
|
|
466
|
+
position = positionInItem < 0.5 ? "above" : "below";
|
|
467
|
+
}
|
|
468
|
+
|
|
368
469
|
// --- Horizontal control at level cliffs ---
|
|
369
470
|
// At the boundary between nodes at different depths, the user's
|
|
370
471
|
// horizontal finger position decides the drop level:
|
|
@@ -391,16 +492,17 @@ export function useDragDrop<ID>(
|
|
|
391
492
|
shallowLevel = nextLevel;
|
|
392
493
|
}
|
|
393
494
|
} else if (currentLevel > 0) {
|
|
394
|
-
// Last item in the list
|
|
495
|
+
// Last item in the list - treat as cliff to root level
|
|
395
496
|
isCliff = true;
|
|
396
497
|
shallowLevel = 0;
|
|
397
498
|
}
|
|
398
499
|
|
|
399
500
|
if (isCliff) {
|
|
400
501
|
// Generous threshold: midpoint of the two levels + 2× indent buffer
|
|
502
|
+
const indent = indentationMultiplierRef.current;
|
|
401
503
|
const threshold =
|
|
402
|
-
((currentLevel + shallowLevel) / 2) *
|
|
403
|
-
+
|
|
504
|
+
((currentLevel + shallowLevel) / 2) * indent
|
|
505
|
+
+ indent * 2;
|
|
404
506
|
|
|
405
507
|
if (fingerLocalX < threshold) {
|
|
406
508
|
// User wants the shallow level
|
|
@@ -434,10 +536,11 @@ export function useDragDrop<ID>(
|
|
|
434
536
|
const prevLevel = prevNode?.level ?? 0;
|
|
435
537
|
const currentLevel = targetNode.level ?? 0;
|
|
436
538
|
if (prevNode && prevLevel > currentLevel) {
|
|
437
|
-
// Level cliff above
|
|
539
|
+
// Level cliff above - same generous threshold
|
|
540
|
+
const indent = indentationMultiplierRef.current;
|
|
438
541
|
const threshold =
|
|
439
|
-
((prevLevel + currentLevel) / 2) *
|
|
440
|
-
+
|
|
542
|
+
((prevLevel + currentLevel) / 2) * indent
|
|
543
|
+
+ indent * 2;
|
|
441
544
|
|
|
442
545
|
if (fingerLocalX >= threshold) {
|
|
443
546
|
clampedIndex = clampedIndex - 1;
|
|
@@ -450,7 +553,8 @@ export function useDragDrop<ID>(
|
|
|
450
553
|
// --- Suppress "below" when it's redundant or confusing ---
|
|
451
554
|
// After horizontal control, any remaining "below" that isn't at a
|
|
452
555
|
// cliff is redundant with "above" on the next node → show "inside".
|
|
453
|
-
|
|
556
|
+
// Only convert to "inside" if inside drops are allowed for this target.
|
|
557
|
+
if (position === "below" && canDropInsideTarget) {
|
|
454
558
|
const expandedSet = getTreeViewStore<ID>(storeId).getState().expanded;
|
|
455
559
|
|
|
456
560
|
// (a) Expanded parent: "below" visually sits at the parent/child junction
|
|
@@ -485,10 +589,10 @@ export function useDragDrop<ID>(
|
|
|
485
589
|
const lowerLevel = nodes[lowerIdx]?.level ?? 0;
|
|
486
590
|
|
|
487
591
|
if (upperLevel === lowerLevel) {
|
|
488
|
-
// Same level
|
|
592
|
+
// Same level - pure visual hysteresis, keep previous
|
|
489
593
|
return;
|
|
490
594
|
}
|
|
491
|
-
// Level cliff
|
|
595
|
+
// Level cliff - horizontal control already resolved this,
|
|
492
596
|
// let the result pass through.
|
|
493
597
|
}
|
|
494
598
|
}
|
|
@@ -500,14 +604,29 @@ export function useDragDrop<ID>(
|
|
|
500
604
|
const store = getTreeViewStore<ID>(storeId);
|
|
501
605
|
const { invalidDragTargetIds, draggedNodeId, expanded } =
|
|
502
606
|
store.getState();
|
|
607
|
+
|
|
608
|
+
// maxDepth check for above/below (sibling) positions
|
|
609
|
+
let maxDepthValid = true;
|
|
610
|
+
if (maxDepthRef.current !== undefined && (position === "above" || position === "below")) {
|
|
611
|
+
const targetLevel = targetNode.level ?? 0;
|
|
612
|
+
const deepest = targetLevel + draggedSubtreeDepthRef.current;
|
|
613
|
+
if (deepest > maxDepthRef.current) maxDepthValid = false;
|
|
614
|
+
}
|
|
615
|
+
|
|
503
616
|
const isValid =
|
|
504
617
|
targetNode.id !== draggedNodeId &&
|
|
505
|
-
!invalidDragTargetIds.has(targetNode.id)
|
|
618
|
+
!invalidDragTargetIds.has(targetNode.id) &&
|
|
619
|
+
maxDepthValid &&
|
|
620
|
+
(!canDropRef.current || canDropRef.current(
|
|
621
|
+
draggedNodeRef.current!,
|
|
622
|
+
targetNode,
|
|
623
|
+
position
|
|
624
|
+
));
|
|
506
625
|
|
|
507
626
|
// --- Auto-expand: if hovering "inside" a collapsed expandable node ---
|
|
508
627
|
if (isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
|
|
509
628
|
if (autoExpandTargetRef.current !== targetNode.id) {
|
|
510
|
-
// New hover target
|
|
629
|
+
// New hover target - start timer
|
|
511
630
|
cancelAutoExpandTimer();
|
|
512
631
|
autoExpandTargetRef.current = targetNode.id;
|
|
513
632
|
autoExpandTimerRef.current = setTimeout(() => {
|
|
@@ -515,10 +634,10 @@ export function useDragDrop<ID>(
|
|
|
515
634
|
// Expand the node and track it
|
|
516
635
|
handleToggleExpand(storeId, targetNode.id);
|
|
517
636
|
autoExpandedDuringDragRef.current.add(targetNode.id);
|
|
518
|
-
},
|
|
637
|
+
}, autoExpandDelayRef.current);
|
|
519
638
|
}
|
|
520
639
|
} else {
|
|
521
|
-
// Not hovering inside a collapsed expandable node
|
|
640
|
+
// Not hovering inside a collapsed expandable node - cancel timer
|
|
522
641
|
if (autoExpandTargetRef.current !== null) {
|
|
523
642
|
cancelAutoExpandTimer();
|
|
524
643
|
}
|
|
@@ -547,7 +666,7 @@ export function useDragDrop<ID>(
|
|
|
547
666
|
// then spring to 0 for a smooth "magnetic snap" transition.
|
|
548
667
|
if (prevLevel !== effectiveLevel) {
|
|
549
668
|
overlayX.setValue(
|
|
550
|
-
(prevLevel - effectiveLevel) *
|
|
669
|
+
(prevLevel - effectiveLevel) * indentationMultiplierRef.current
|
|
551
670
|
);
|
|
552
671
|
Animated.spring(overlayX, {
|
|
553
672
|
toValue: 0,
|
|
@@ -599,7 +718,7 @@ export function useDragDrop<ID>(
|
|
|
599
718
|
return newTarget;
|
|
600
719
|
});
|
|
601
720
|
},
|
|
602
|
-
[storeId,
|
|
721
|
+
[storeId, cancelAutoExpandTimer, overlayX]
|
|
603
722
|
);
|
|
604
723
|
|
|
605
724
|
// --- Handle drag end ---
|
|
@@ -673,18 +792,35 @@ export function useDragDrop<ID>(
|
|
|
673
792
|
newTreeData: newData,
|
|
674
793
|
});
|
|
675
794
|
|
|
676
|
-
// Scroll to the dropped node after React processes the expansion
|
|
795
|
+
// Scroll to the dropped node after React processes the expansion,
|
|
796
|
+
// but only if it's outside the visible viewport. An animated
|
|
797
|
+
// scroll would consume the user's next touch (RN stops the
|
|
798
|
+
// animation on tap), so we skip when the node is already visible.
|
|
677
799
|
setTimeout(() => {
|
|
678
800
|
const nodes = flattenedNodesRef.current;
|
|
679
801
|
const idx = nodes.findIndex(n => n.id === droppedNodeId);
|
|
680
|
-
if (idx
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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;
|
|
686
813
|
}
|
|
814
|
+
|
|
815
|
+
flashListRef.current?.scrollToIndex?.({
|
|
816
|
+
index: idx,
|
|
817
|
+
animated: true,
|
|
818
|
+
viewPosition: 0.5,
|
|
819
|
+
});
|
|
687
820
|
}, 100);
|
|
821
|
+
} else if (droppedNodeId !== null) {
|
|
822
|
+
// Drag ended without a valid drop — notify consumer
|
|
823
|
+
onDragCancelRef.current?.({ draggedNodeId: droppedNodeId });
|
|
688
824
|
}
|
|
689
825
|
|
|
690
826
|
// Collapse auto-expanded nodes that aren't ancestors of the drop target
|
|
@@ -777,13 +913,13 @@ export function useDragDrop<ID>(
|
|
|
777
913
|
|
|
778
914
|
// Update overlay position (with configurable offset)
|
|
779
915
|
const overlayLocalY =
|
|
780
|
-
fingerLocalY - grabOffsetYRef.current +
|
|
916
|
+
fingerLocalY - grabOffsetYRef.current + dragOverlayOffsetRef.current * itemHeightRef.current;
|
|
781
917
|
overlayY.setValue(overlayLocalY);
|
|
782
918
|
|
|
783
919
|
// Calculate drop target (horizontal position used at level cliffs)
|
|
784
920
|
calculateDropTarget(fingerPageY, evt.nativeEvent.pageX);
|
|
785
921
|
|
|
786
|
-
// Auto-scroll at edges
|
|
922
|
+
// Auto-scroll at edges - use delta-based position relative to container
|
|
787
923
|
const fingerInContainer =
|
|
788
924
|
initialFingerContainerYRef.current +
|
|
789
925
|
(fingerPageY - initialFingerPageYRef.current);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* useScrollToNode Hook
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Provides an imperative handle to scroll to a specified node within a tree view.
|
|
5
5
|
* The scrolling action is orchestrated via a two-step "milestone" mechanism that ensures the target
|
|
6
6
|
* node is both expanded in the tree and that the rendered list reflects this expansion before the scroll
|
|
7
7
|
* is performed.
|
|
@@ -32,22 +32,23 @@
|
|
|
32
32
|
* in the UI, thus preventing issues with attempting to scroll to an element that does not exist yet.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import
|
|
35
|
+
import {
|
|
36
|
+
useEffect,
|
|
37
|
+
useImperativeHandle,
|
|
38
|
+
useLayoutEffect,
|
|
39
|
+
useRef,
|
|
40
|
+
useState,
|
|
41
|
+
type Dispatch,
|
|
42
|
+
type MutableRefObject,
|
|
43
|
+
type RefObject,
|
|
44
|
+
type SetStateAction,
|
|
45
|
+
} from "react";
|
|
36
46
|
import { expandNodes } from "../helpers/expandCollapse.helper";
|
|
37
47
|
import { useTreeViewStore } from "../store/treeView.store";
|
|
38
48
|
import { useShallow } from "zustand/react/shallow";
|
|
39
49
|
import { type __FlattenedTreeNode__ } from "../types/treeView.types";
|
|
40
|
-
import { typedMemo } from "../utils/typedMemo";
|
|
41
50
|
import { fastIsEqual } from "fast-is-equal";
|
|
42
51
|
|
|
43
|
-
interface Props<ID> {
|
|
44
|
-
storeId: string;
|
|
45
|
-
flashListRef: React.MutableRefObject<any>;
|
|
46
|
-
flattenedFilteredNodes: __FlattenedTreeNode__<ID>[];
|
|
47
|
-
setInitialScrollIndex: React.Dispatch<React.SetStateAction<number>>;
|
|
48
|
-
initialScrollNodeID: ID | undefined;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
52
|
export interface ScrollToNodeParams<ID> {
|
|
52
53
|
nodeId: ID;
|
|
53
54
|
expandScrolledNode?: boolean;
|
|
@@ -57,27 +58,34 @@ export interface ScrollToNodeParams<ID> {
|
|
|
57
58
|
viewPosition?: number;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
export interface ScrollToNodeHandlerRef<ID> {
|
|
62
|
+
scrollToNodeID: (params: ScrollToNodeParams<ID>) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
60
65
|
// Enum representing the two milestones needed before scrolling
|
|
61
66
|
enum ExpandQueueAction {
|
|
62
67
|
EXPANDED,
|
|
63
68
|
RENDERED,
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
interface UseScrollToNodeParams<ID> {
|
|
72
|
+
storeId: string;
|
|
73
|
+
scrollToNodeHandlerRef: RefObject<ScrollToNodeHandlerRef<ID>>;
|
|
74
|
+
flashListRef: MutableRefObject<any>;
|
|
75
|
+
flattenedFilteredNodes: __FlattenedTreeNode__<ID>[];
|
|
76
|
+
setInitialScrollIndex: Dispatch<SetStateAction<number>>;
|
|
77
|
+
initialScrollNodeID: ID | undefined;
|
|
68
78
|
}
|
|
69
79
|
|
|
70
|
-
function
|
|
71
|
-
props: Props<ID>,
|
|
72
|
-
ref: React.ForwardedRef<ScrollToNodeHandlerRef<ID>>
|
|
73
|
-
) {
|
|
80
|
+
export function useScrollToNode<ID>(params: UseScrollToNodeParams<ID>) {
|
|
74
81
|
const {
|
|
75
82
|
storeId,
|
|
83
|
+
scrollToNodeHandlerRef,
|
|
76
84
|
flashListRef,
|
|
77
85
|
flattenedFilteredNodes,
|
|
78
86
|
setInitialScrollIndex,
|
|
79
87
|
initialScrollNodeID
|
|
80
|
-
} =
|
|
88
|
+
} = params;
|
|
81
89
|
|
|
82
90
|
const { expanded, childToParentMap } = useTreeViewStore<ID>(storeId)(useShallow(
|
|
83
91
|
state => ({
|
|
@@ -86,9 +94,16 @@ function _innerScrollToNodeHandler<ID>(
|
|
|
86
94
|
})
|
|
87
95
|
));
|
|
88
96
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
// Ref to store the scroll parameters for the queued action.
|
|
98
|
+
const queuedScrollToNodeParams = useRef<ScrollToNodeParams<ID> | null>(null);
|
|
99
|
+
|
|
100
|
+
// State to track progression: first the expansion is triggered, then the list is rendered.
|
|
101
|
+
const [expandAndScrollToNodeQueue, setExpandAndScrollToNodeQueue]
|
|
102
|
+
= useState<ExpandQueueAction[]>([]);
|
|
103
|
+
|
|
104
|
+
useImperativeHandle(scrollToNodeHandlerRef, () => ({
|
|
105
|
+
scrollToNodeID: (scrollParams: ScrollToNodeParams<ID>) => {
|
|
106
|
+
queuedScrollToNodeParams.current = scrollParams;
|
|
92
107
|
// Mark that expansion is initiated.
|
|
93
108
|
setExpandAndScrollToNodeQueue([ExpandQueueAction.EXPANDED]);
|
|
94
109
|
// Trigger expansion logic (this may update the store and subsequently re-render the list).
|
|
@@ -100,18 +115,11 @@ function _innerScrollToNodeHandler<ID>(
|
|
|
100
115
|
}
|
|
101
116
|
}), [storeId]);
|
|
102
117
|
|
|
103
|
-
|
|
104
|
-
const queuedScrollToNodeParams = React.useRef<ScrollToNodeParams<ID> | null>(null);
|
|
105
|
-
|
|
106
|
-
// State to track progression: first the expansion is triggered, then the list is rendered.
|
|
107
|
-
const [expandAndScrollToNodeQueue, setExpandAndScrollToNodeQueue]
|
|
108
|
-
= React.useState<ExpandQueueAction[]>([]);
|
|
109
|
-
|
|
110
|
-
const latestFlattenedFilteredNodesRef = React.useRef(flattenedFilteredNodes);
|
|
118
|
+
const latestFlattenedFilteredNodesRef = useRef(flattenedFilteredNodes);
|
|
111
119
|
|
|
112
120
|
/* When the rendered node list changes, update the ref.
|
|
113
121
|
If an expansion was triggered, mark that the list is now rendered. */
|
|
114
|
-
|
|
122
|
+
useEffect(() => {
|
|
115
123
|
setExpandAndScrollToNodeQueue(prevQueue => {
|
|
116
124
|
if (prevQueue.includes(ExpandQueueAction.EXPANDED)) {
|
|
117
125
|
latestFlattenedFilteredNodesRef.current = flattenedFilteredNodes;
|
|
@@ -127,7 +135,7 @@ function _innerScrollToNodeHandler<ID>(
|
|
|
127
135
|
|
|
128
136
|
/* Once the target node is expanded and the list is updated (milestones reached),
|
|
129
137
|
perform the scroll using the latest node list. */
|
|
130
|
-
|
|
138
|
+
useLayoutEffect(() => {
|
|
131
139
|
if (queuedScrollToNodeParams.current === null)
|
|
132
140
|
return;
|
|
133
141
|
|
|
@@ -146,12 +154,16 @@ function _innerScrollToNodeHandler<ID>(
|
|
|
146
154
|
parentId = childToParentMap.get(queuedScrollToNodeParams.current.nodeId) as ID;
|
|
147
155
|
}
|
|
148
156
|
|
|
149
|
-
// Ensure if the parent is expanded before proceeding to scroll to the node
|
|
157
|
+
// Ensure if the parent is expanded before proceeding to scroll to the node.
|
|
158
|
+
// This fires transiently during the milestone system — the layout effect runs
|
|
159
|
+
// before the expansion has propagated to the store, then retries on next render.
|
|
160
|
+
/* istanbul ignore next -- async timing guard: expansion not yet propagated to store */
|
|
150
161
|
if (parentId && !expanded.has(parentId))
|
|
151
162
|
return;
|
|
152
163
|
}
|
|
153
164
|
// If node is set to expand
|
|
154
165
|
else {
|
|
166
|
+
/* istanbul ignore next -- async timing guard: node expansion not yet propagated */
|
|
155
167
|
if (!expanded.has(queuedScrollToNodeParams.current.nodeId))
|
|
156
168
|
return;
|
|
157
169
|
}
|
|
@@ -177,6 +189,7 @@ function _innerScrollToNodeHandler<ID>(
|
|
|
177
189
|
viewPosition
|
|
178
190
|
});
|
|
179
191
|
} else {
|
|
192
|
+
/* istanbul ignore next -- __DEV__ is false in test/production */
|
|
180
193
|
if (__DEV__) {
|
|
181
194
|
console.info("Cannot find the item of the mentioned id to scroll in the rendered tree view list data!");
|
|
182
195
|
}
|
|
@@ -193,8 +206,8 @@ function _innerScrollToNodeHandler<ID>(
|
|
|
193
206
|
////////////////////////////// Handle Initial Scroll /////////////////////////////
|
|
194
207
|
/* On first render, if an initial scroll target is provided, determine its index.
|
|
195
208
|
This is done only once. */
|
|
196
|
-
const initialScrollDone =
|
|
197
|
-
|
|
209
|
+
const initialScrollDone = useRef(false);
|
|
210
|
+
useLayoutEffect(() => {
|
|
198
211
|
if (initialScrollDone.current) return;
|
|
199
212
|
|
|
200
213
|
const index = flattenedFilteredNodes.findIndex(
|
|
@@ -209,14 +222,4 @@ function _innerScrollToNodeHandler<ID>(
|
|
|
209
222
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
210
223
|
}, [flattenedFilteredNodes, initialScrollNodeID]);
|
|
211
224
|
/////////////////////////////////////////////////////////////////////////////////
|
|
212
|
-
|
|
213
|
-
return null;
|
|
214
225
|
}
|
|
215
|
-
|
|
216
|
-
const _ScrollToNodeHandler = React.forwardRef(_innerScrollToNodeHandler) as <ID>(
|
|
217
|
-
props: Props<ID> & { ref?: React.ForwardedRef<ScrollToNodeHandlerRef<ID>>; }
|
|
218
|
-
) => ReturnType<typeof _innerScrollToNodeHandler>;
|
|
219
|
-
|
|
220
|
-
export const ScrollToNodeHandler = typedMemo<
|
|
221
|
-
typeof _ScrollToNodeHandler
|
|
222
|
-
>(_ScrollToNodeHandler);
|