react-native-tree-multi-select 3.0.0-beta.3 → 3.0.0-beta.5
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 +100 -30
- 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 +78 -58
- 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 +43 -60
- package/lib/module/helpers/toggleCheckbox.helper.js.map +1 -1
- package/lib/module/hooks/useDragDrop.js +146 -65
- 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 +24 -8
- 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 +10 -0
- package/lib/typescript/src/types/dragDrop.types.d.ts.map +1 -1
- package/lib/typescript/src/types/treeView.types.d.ts +79 -41
- 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 +87 -60
- 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 +56 -68
- package/src/hooks/useDragDrop.ts +190 -80
- package/src/{handlers/ScrollToNodeHandler.tsx → hooks/useScrollToNode.ts} +48 -45
- package/src/index.tsx +11 -0
- package/src/jest.setup.ts +14 -1
- package/src/store/treeView.store.ts +6 -1
- package/src/types/dragDrop.types.ts +12 -0
- package/src/types/treeView.types.ts +87 -43
- 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
|
@@ -1,5 +1,56 @@
|
|
|
1
|
+
import type { CheckboxValueType, TreeNode } from "../types/treeView.types";
|
|
1
2
|
import { getTreeViewStore } from "../store/treeView.store";
|
|
2
3
|
|
|
4
|
+
/** Derive the tri-state checkbox value from checked and indeterminate booleans. */
|
|
5
|
+
export function getCheckboxValue(
|
|
6
|
+
isChecked: boolean,
|
|
7
|
+
isIndeterminate: boolean
|
|
8
|
+
): CheckboxValueType {
|
|
9
|
+
if (isIndeterminate) return "indeterminate";
|
|
10
|
+
return isChecked;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Update a parent node's checked/indeterminate state based on its children.
|
|
15
|
+
* Mutates tempChecked and tempIndeterminate in-place.
|
|
16
|
+
*/
|
|
17
|
+
function updateParentCheckState<ID>(
|
|
18
|
+
parentId: ID,
|
|
19
|
+
children: TreeNode<ID>[],
|
|
20
|
+
tempChecked: Set<ID>,
|
|
21
|
+
tempIndeterminate: Set<ID>,
|
|
22
|
+
): void {
|
|
23
|
+
let allChecked = true;
|
|
24
|
+
let anyCheckedOrIndeterminate = false;
|
|
25
|
+
|
|
26
|
+
for (const child of children) {
|
|
27
|
+
const isChecked = tempChecked.has(child.id);
|
|
28
|
+
const isIndeterminate = tempIndeterminate.has(child.id);
|
|
29
|
+
|
|
30
|
+
if (isChecked) {
|
|
31
|
+
anyCheckedOrIndeterminate = true;
|
|
32
|
+
} else if (isIndeterminate) {
|
|
33
|
+
anyCheckedOrIndeterminate = true;
|
|
34
|
+
allChecked = false;
|
|
35
|
+
} else {
|
|
36
|
+
allChecked = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!allChecked && anyCheckedOrIndeterminate) break;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (allChecked) {
|
|
43
|
+
tempChecked.add(parentId);
|
|
44
|
+
tempIndeterminate.delete(parentId);
|
|
45
|
+
} else if (anyCheckedOrIndeterminate) {
|
|
46
|
+
tempChecked.delete(parentId);
|
|
47
|
+
tempIndeterminate.add(parentId);
|
|
48
|
+
} else {
|
|
49
|
+
tempChecked.delete(parentId);
|
|
50
|
+
tempIndeterminate.delete(parentId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
3
54
|
/**
|
|
4
55
|
* Function to toggle checkbox state for a tree structure.
|
|
5
56
|
* It sets the checked and indeterminate state for all affected nodes in the tree after an action to check/uncheck is made.
|
|
@@ -106,7 +157,7 @@ export function toggleCheckboxes<ID>(
|
|
|
106
157
|
while (stack.length > 0) {
|
|
107
158
|
const nodeId = stack.pop()!;
|
|
108
159
|
const node = nodeMap.get(nodeId);
|
|
109
|
-
if (!node) continue;
|
|
160
|
+
if (!node) continue;
|
|
110
161
|
|
|
111
162
|
if (childrenChecked) {
|
|
112
163
|
tempChecked.add(nodeId);
|
|
@@ -157,43 +208,8 @@ export function toggleCheckboxes<ID>(
|
|
|
157
208
|
*/
|
|
158
209
|
function updateNodeState(nodeId: ID) {
|
|
159
210
|
const node = nodeMap.get(nodeId);
|
|
160
|
-
if (!node
|
|
161
|
-
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
let allChildrenChecked = true;
|
|
166
|
-
let anyChildCheckedOrIndeterminate = false;
|
|
167
|
-
|
|
168
|
-
for (const child of node.children) {
|
|
169
|
-
const isChecked = tempChecked.has(child.id);
|
|
170
|
-
const isIndeterminate = tempIndeterminate.has(child.id);
|
|
171
|
-
|
|
172
|
-
if (isChecked) {
|
|
173
|
-
anyChildCheckedOrIndeterminate = true;
|
|
174
|
-
} else if (isIndeterminate) {
|
|
175
|
-
anyChildCheckedOrIndeterminate = true;
|
|
176
|
-
allChildrenChecked = false;
|
|
177
|
-
} else {
|
|
178
|
-
allChildrenChecked = false;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// If both conditions are met, we can break early.
|
|
182
|
-
if (!allChildrenChecked && anyChildCheckedOrIndeterminate) {
|
|
183
|
-
break;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (allChildrenChecked) {
|
|
188
|
-
tempChecked.add(nodeId);
|
|
189
|
-
tempIndeterminate.delete(nodeId);
|
|
190
|
-
} else if (anyChildCheckedOrIndeterminate) {
|
|
191
|
-
tempChecked.delete(nodeId);
|
|
192
|
-
tempIndeterminate.add(nodeId);
|
|
193
|
-
} else {
|
|
194
|
-
tempChecked.delete(nodeId);
|
|
195
|
-
tempIndeterminate.delete(nodeId);
|
|
196
|
-
}
|
|
211
|
+
if (!node?.children?.length) return;
|
|
212
|
+
updateParentCheckState(nodeId, node.children, tempChecked, tempIndeterminate);
|
|
197
213
|
}
|
|
198
214
|
|
|
199
215
|
// Update the state object with the new checked and indeterminate sets.
|
|
@@ -260,37 +276,9 @@ export function recalculateCheckedStates<ID>(storeId: string) {
|
|
|
260
276
|
// Update each parent based on its children's current state
|
|
261
277
|
for (const parentId of parentNodes) {
|
|
262
278
|
const node = nodeMap.get(parentId);
|
|
279
|
+
/* istanbul ignore next -- parentNodes is built from nodeMap entries with children above; same nodeMap, same iteration - unreachable unless nodeMap mutates between loops */
|
|
263
280
|
if (!node?.children?.length) continue;
|
|
264
|
-
|
|
265
|
-
let allChecked = true;
|
|
266
|
-
let anyCheckedOrIndeterminate = false;
|
|
267
|
-
|
|
268
|
-
for (const child of node.children) {
|
|
269
|
-
const isChecked = tempChecked.has(child.id);
|
|
270
|
-
const isIndeterminate = tempIndeterminate.has(child.id);
|
|
271
|
-
|
|
272
|
-
if (isChecked) {
|
|
273
|
-
anyCheckedOrIndeterminate = true;
|
|
274
|
-
} else if (isIndeterminate) {
|
|
275
|
-
anyCheckedOrIndeterminate = true;
|
|
276
|
-
allChecked = false;
|
|
277
|
-
} else {
|
|
278
|
-
allChecked = false;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (!allChecked && anyCheckedOrIndeterminate) break;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if (allChecked) {
|
|
285
|
-
tempChecked.add(parentId);
|
|
286
|
-
tempIndeterminate.delete(parentId);
|
|
287
|
-
} else if (anyCheckedOrIndeterminate) {
|
|
288
|
-
tempChecked.delete(parentId);
|
|
289
|
-
tempIndeterminate.add(parentId);
|
|
290
|
-
} else {
|
|
291
|
-
tempChecked.delete(parentId);
|
|
292
|
-
tempIndeterminate.delete(parentId);
|
|
293
|
-
}
|
|
281
|
+
updateParentCheckState(parentId, node.children, tempChecked, tempIndeterminate);
|
|
294
282
|
}
|
|
295
283
|
|
|
296
284
|
updateChecked(tempChecked);
|
package/src/hooks/useDragDrop.ts
CHANGED
|
@@ -1,33 +1,67 @@
|
|
|
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,
|
|
12
|
+
Platform,
|
|
5
13
|
type PanResponderInstance,
|
|
6
14
|
} from "react-native";
|
|
7
15
|
import type { FlashList } from "@shopify/flash-list";
|
|
8
16
|
|
|
9
|
-
import type { __FlattenedTreeNode__, TreeNode } from "../types/treeView.types";
|
|
10
|
-
import type {
|
|
17
|
+
import type { __FlattenedTreeNode__, TreeNode, DropAutoScrollOptions } from "../types/treeView.types";
|
|
18
|
+
import type { ScrollToNodeHandlerRef } from "./useScrollToNode";
|
|
19
|
+
import type { DragCancelEvent, DragEndEvent, DragStartEvent, DropTarget } from "../types/dragDrop.types";
|
|
11
20
|
import { getTreeViewStore } from "../store/treeView.store";
|
|
12
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
collapseNodes,
|
|
23
|
+
expandNodes,
|
|
24
|
+
handleToggleExpand,
|
|
25
|
+
initializeNodeMaps,
|
|
26
|
+
recalculateCheckedStates
|
|
27
|
+
} from "../helpers";
|
|
13
28
|
import { moveTreeNode } from "../helpers/moveTreeNode.helper";
|
|
29
|
+
import { listHeaderFooterPadding } from "../constants/treeView.constants";
|
|
30
|
+
|
|
31
|
+
// Android reports locationY slightly differently, causing the overlay
|
|
32
|
+
// to appear ~1 item height closer to the finger than on iOS.
|
|
33
|
+
const PLATFORM_OVERLAY_Y_CORRECTION = Platform.OS === "android" ? -2 : 0;
|
|
14
34
|
|
|
15
35
|
interface UseDragDropParams<ID> {
|
|
16
36
|
storeId: string;
|
|
17
37
|
flattenedNodes: __FlattenedTreeNode__<ID>[];
|
|
18
|
-
flashListRef:
|
|
19
|
-
containerRef:
|
|
38
|
+
flashListRef: RefObject<FlashList<__FlattenedTreeNode__<ID>> | null>;
|
|
39
|
+
containerRef: RefObject<{ measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => void; } | null>;
|
|
20
40
|
dragEnabled: boolean;
|
|
41
|
+
onDragStart?: (event: DragStartEvent<ID>) => void;
|
|
21
42
|
onDragEnd?: (event: DragEndEvent<ID>) => void;
|
|
43
|
+
onDragCancel?: (event: DragCancelEvent<ID>) => void;
|
|
22
44
|
longPressDuration: number;
|
|
23
45
|
autoScrollThreshold: number;
|
|
24
46
|
autoScrollSpeed: number;
|
|
25
|
-
internalDataRef:
|
|
26
|
-
measuredItemHeightRef:
|
|
47
|
+
internalDataRef: MutableRefObject<TreeNode<ID>[] | null>;
|
|
48
|
+
measuredItemHeightRef: MutableRefObject<number>;
|
|
27
49
|
dragOverlayOffset: number;
|
|
28
50
|
autoExpandDelay: number;
|
|
29
51
|
/** Pixels per nesting level, used for magnetic overlay shift. */
|
|
30
52
|
indentationMultiplier: number;
|
|
53
|
+
/** Callback to determine if a drop is allowed on a specific target. */
|
|
54
|
+
canDrop?: (draggedNode: TreeNode<ID>, targetNode: TreeNode<ID>, position: "above" | "below" | "inside") => boolean;
|
|
55
|
+
/** Maximum nesting depth allowed. */
|
|
56
|
+
maxDepth?: number;
|
|
57
|
+
/** Callback to determine if a node can accept children. */
|
|
58
|
+
canNodeHaveChildren?: (node: TreeNode<ID>) => boolean;
|
|
59
|
+
/** Ref for scrolling to a node after drop. */
|
|
60
|
+
scrollToNodeHandlerRef: RefObject<ScrollToNodeHandlerRef<ID> | null>;
|
|
61
|
+
/** Auto-scroll configuration for after drop. */
|
|
62
|
+
autoScrollToDroppedNode?: boolean | DropAutoScrollOptions;
|
|
63
|
+
/** Callback to determine if a node can be dragged. */
|
|
64
|
+
canDrag?: (node: TreeNode<ID>) => boolean;
|
|
31
65
|
}
|
|
32
66
|
|
|
33
67
|
interface UseDragDropReturn<ID> {
|
|
@@ -46,8 +80,8 @@ interface UseDragDropReturn<ID> {
|
|
|
46
80
|
) => void;
|
|
47
81
|
handleNodeTouchEnd: () => void;
|
|
48
82
|
cancelLongPressTimer: () => void;
|
|
49
|
-
scrollOffsetRef:
|
|
50
|
-
headerOffsetRef:
|
|
83
|
+
scrollOffsetRef: MutableRefObject<number>;
|
|
84
|
+
headerOffsetRef: MutableRefObject<number>;
|
|
51
85
|
}
|
|
52
86
|
|
|
53
87
|
export function useDragDrop<ID>(
|
|
@@ -59,7 +93,9 @@ export function useDragDrop<ID>(
|
|
|
59
93
|
flashListRef,
|
|
60
94
|
containerRef,
|
|
61
95
|
dragEnabled,
|
|
96
|
+
onDragStart,
|
|
62
97
|
onDragEnd,
|
|
98
|
+
onDragCancel,
|
|
63
99
|
longPressDuration,
|
|
64
100
|
autoScrollThreshold,
|
|
65
101
|
autoScrollSpeed,
|
|
@@ -68,6 +104,12 @@ export function useDragDrop<ID>(
|
|
|
68
104
|
dragOverlayOffset,
|
|
69
105
|
autoExpandDelay,
|
|
70
106
|
indentationMultiplier,
|
|
107
|
+
canDrop: canDropCallback,
|
|
108
|
+
maxDepth,
|
|
109
|
+
canNodeHaveChildren,
|
|
110
|
+
canDrag,
|
|
111
|
+
scrollToNodeHandlerRef,
|
|
112
|
+
autoScrollToDroppedNode,
|
|
71
113
|
} = params;
|
|
72
114
|
|
|
73
115
|
// --- Refs for mutable state (no stale closures in PanResponder) ---
|
|
@@ -80,6 +122,7 @@ export function useDragDrop<ID>(
|
|
|
80
122
|
|
|
81
123
|
const containerPageXRef = useRef(0);
|
|
82
124
|
const containerPageYRef = useRef(0);
|
|
125
|
+
const containerWidthRef = useRef(0);
|
|
83
126
|
const containerHeightRef = useRef(0);
|
|
84
127
|
const grabOffsetYRef = useRef(0);
|
|
85
128
|
const scrollOffsetRef = useRef(0);
|
|
@@ -109,13 +152,40 @@ export function useDragDrop<ID>(
|
|
|
109
152
|
// Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
|
|
110
153
|
const prevDropTargetRef = useRef<{ targetIndex: number; position: "above" | "below" | "inside"; } | null>(null);
|
|
111
154
|
|
|
155
|
+
// Depth of the dragged subtree (computed once at drag start, used for maxDepth check)
|
|
156
|
+
const draggedSubtreeDepthRef = useRef(0);
|
|
157
|
+
|
|
112
158
|
// Keep flattenedNodes ref current for PanResponder closures
|
|
113
159
|
const flattenedNodesRef = useRef(flattenedNodes);
|
|
114
160
|
flattenedNodesRef.current = flattenedNodes;
|
|
115
161
|
|
|
116
162
|
// Keep callbacks current
|
|
163
|
+
const onDragStartRef = useRef(onDragStart);
|
|
164
|
+
onDragStartRef.current = onDragStart;
|
|
117
165
|
const onDragEndRef = useRef(onDragEnd);
|
|
118
166
|
onDragEndRef.current = onDragEnd;
|
|
167
|
+
const onDragCancelRef = useRef(onDragCancel);
|
|
168
|
+
onDragCancelRef.current = onDragCancel;
|
|
169
|
+
const canDropRef = useRef(canDropCallback);
|
|
170
|
+
canDropRef.current = canDropCallback;
|
|
171
|
+
const canNodeHaveChildrenRef = useRef(canNodeHaveChildren);
|
|
172
|
+
canNodeHaveChildrenRef.current = canNodeHaveChildren;
|
|
173
|
+
const canDragRef = useRef(canDrag);
|
|
174
|
+
canDragRef.current = canDrag;
|
|
175
|
+
|
|
176
|
+
// Keep config values current for PanResponder closures
|
|
177
|
+
const dragOverlayOffsetRef = useRef(dragOverlayOffset);
|
|
178
|
+
dragOverlayOffsetRef.current = dragOverlayOffset;
|
|
179
|
+
const autoScrollThresholdRef = useRef(autoScrollThreshold);
|
|
180
|
+
autoScrollThresholdRef.current = autoScrollThreshold;
|
|
181
|
+
const autoScrollSpeedParamRef = useRef(autoScrollSpeed);
|
|
182
|
+
autoScrollSpeedParamRef.current = autoScrollSpeed;
|
|
183
|
+
const autoExpandDelayRef = useRef(autoExpandDelay);
|
|
184
|
+
autoExpandDelayRef.current = autoExpandDelay;
|
|
185
|
+
const indentationMultiplierRef = useRef(indentationMultiplier);
|
|
186
|
+
indentationMultiplierRef.current = indentationMultiplier;
|
|
187
|
+
const maxDepthRef = useRef(maxDepth);
|
|
188
|
+
maxDepthRef.current = maxDepth;
|
|
119
189
|
|
|
120
190
|
// --- React state (triggers re-renders only at drag start/end + indicator changes) ---
|
|
121
191
|
const [isDragging, setIsDragging] = useState(false);
|
|
@@ -157,6 +227,22 @@ export function useDragDrop<ID>(
|
|
|
157
227
|
[storeId]
|
|
158
228
|
);
|
|
159
229
|
|
|
230
|
+
// --- Get the maximum depth of a subtree (0 for leaf nodes) ---
|
|
231
|
+
const getSubtreeDepth = useCallback(
|
|
232
|
+
(nodeId: ID): number => {
|
|
233
|
+
const store = getTreeViewStore<ID>(storeId);
|
|
234
|
+
const { nodeMap } = store.getState();
|
|
235
|
+
const node = nodeMap.get(nodeId);
|
|
236
|
+
if (!node?.children?.length) return 0;
|
|
237
|
+
let max = 0;
|
|
238
|
+
for (const child of node.children) {
|
|
239
|
+
max = Math.max(max, 1 + getSubtreeDepth(child.id));
|
|
240
|
+
}
|
|
241
|
+
return max;
|
|
242
|
+
},
|
|
243
|
+
[storeId]
|
|
244
|
+
);
|
|
245
|
+
|
|
160
246
|
// --- Initiate drag ---
|
|
161
247
|
const initiateDrag = useCallback(
|
|
162
248
|
(nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
|
|
@@ -165,9 +251,10 @@ export function useDragDrop<ID>(
|
|
|
165
251
|
const container = containerRef.current;
|
|
166
252
|
if (!container) return;
|
|
167
253
|
|
|
168
|
-
container.measureInWindow((x, y,
|
|
254
|
+
container.measureInWindow((x, y, w, h) => {
|
|
169
255
|
containerPageXRef.current = x;
|
|
170
256
|
containerPageYRef.current = y;
|
|
257
|
+
containerWidthRef.current = w;
|
|
171
258
|
containerHeightRef.current = h;
|
|
172
259
|
|
|
173
260
|
// Find the node in flattened list
|
|
@@ -187,12 +274,11 @@ export function useDragDrop<ID>(
|
|
|
187
274
|
draggedNodeRef.current = node;
|
|
188
275
|
draggedNodeIdRef.current = nodeId;
|
|
189
276
|
draggedNodeIndexRef.current = nodeIndex;
|
|
277
|
+
draggedSubtreeDepthRef.current = getSubtreeDepth(nodeId);
|
|
190
278
|
|
|
191
|
-
// Use measured item height if available, fall back to
|
|
279
|
+
// Use measured item height if available, fall back to default
|
|
192
280
|
const measured = measuredItemHeightRef.current;
|
|
193
|
-
|
|
194
|
-
(flashListRef.current as any)?.props?.estimatedItemSize ?? 36;
|
|
195
|
-
itemHeightRef.current = measured > 0 ? measured : estimatedSize;
|
|
281
|
+
itemHeightRef.current = measured > 0 ? measured : 36;
|
|
196
282
|
|
|
197
283
|
// Calculate headerOffset dynamically:
|
|
198
284
|
// fingerLocalY = pageY - containerPageY
|
|
@@ -207,9 +293,8 @@ export function useDragDrop<ID>(
|
|
|
207
293
|
|
|
208
294
|
// Delta-based auto-scroll: compute finger's position in the container
|
|
209
295
|
// from the node's known index (avoids unreliable containerPageY).
|
|
210
|
-
// The FlashList header (padding:5 → ~10px) + nodeIndex * itemHeight - scroll + locationY
|
|
211
296
|
const iH = itemHeightRef.current;
|
|
212
|
-
const listHeaderHeight =
|
|
297
|
+
const listHeaderHeight = listHeaderFooterPadding * 2;
|
|
213
298
|
initialFingerPageYRef.current = pageY;
|
|
214
299
|
initialFingerContainerYRef.current =
|
|
215
300
|
listHeaderHeight + nodeIndex * iH - scrollOffsetRef.current + locationY;
|
|
@@ -223,7 +308,7 @@ export function useDragDrop<ID>(
|
|
|
223
308
|
store.getState().updateInvalidDragTargetIds(descendants);
|
|
224
309
|
|
|
225
310
|
// Set overlay initial position (with configurable offset)
|
|
226
|
-
const overlayLocalY = fingerLocalY - locationY +
|
|
311
|
+
const overlayLocalY = fingerLocalY - locationY + (dragOverlayOffsetRef.current + PLATFORM_OVERLAY_Y_CORRECTION) * itemHeightRef.current;
|
|
227
312
|
overlayY.setValue(overlayLocalY);
|
|
228
313
|
|
|
229
314
|
// Reset magnetic overlay
|
|
@@ -238,6 +323,9 @@ export function useDragDrop<ID>(
|
|
|
238
323
|
setEffectiveDropLevel(node.level ?? 0);
|
|
239
324
|
setDropTarget(null);
|
|
240
325
|
|
|
326
|
+
// Notify consumer that drag has started
|
|
327
|
+
onDragStartRef.current?.({ draggedNodeId: nodeId });
|
|
328
|
+
|
|
241
329
|
// Start auto-scroll loop
|
|
242
330
|
startAutoScrollLoop();
|
|
243
331
|
});
|
|
@@ -249,6 +337,7 @@ export function useDragDrop<ID>(
|
|
|
249
337
|
containerRef,
|
|
250
338
|
flashListRef,
|
|
251
339
|
getDescendantIds,
|
|
340
|
+
getSubtreeDepth,
|
|
252
341
|
overlayY,
|
|
253
342
|
]
|
|
254
343
|
);
|
|
@@ -258,6 +347,12 @@ export function useDragDrop<ID>(
|
|
|
258
347
|
(nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
|
|
259
348
|
if (!dragEnabled) return;
|
|
260
349
|
|
|
350
|
+
// Check if this node can be dragged
|
|
351
|
+
if (canDragRef.current) {
|
|
352
|
+
const node = flattenedNodesRef.current[nodeIndex];
|
|
353
|
+
if (node && !canDragRef.current(node)) return;
|
|
354
|
+
}
|
|
355
|
+
|
|
261
356
|
// Cancel any existing timer
|
|
262
357
|
cancelLongPressTimer();
|
|
263
358
|
|
|
@@ -281,7 +376,7 @@ export function useDragDrop<ID>(
|
|
|
281
376
|
scrollOffsetRef.current + autoScrollSpeedRef.current
|
|
282
377
|
);
|
|
283
378
|
scrollOffsetRef.current = newOffset;
|
|
284
|
-
|
|
379
|
+
flashListRef.current?.scrollToOffset?.({
|
|
285
380
|
offset: newOffset,
|
|
286
381
|
animated: false,
|
|
287
382
|
});
|
|
@@ -302,8 +397,8 @@ export function useDragDrop<ID>(
|
|
|
302
397
|
|
|
303
398
|
const updateAutoScroll = useCallback(
|
|
304
399
|
(fingerInContainer: number) => {
|
|
305
|
-
const threshold =
|
|
306
|
-
const maxSpeed = 8 *
|
|
400
|
+
const threshold = autoScrollThresholdRef.current;
|
|
401
|
+
const maxSpeed = 8 * autoScrollSpeedParamRef.current;
|
|
307
402
|
const containerH = containerHeightRef.current;
|
|
308
403
|
|
|
309
404
|
if (fingerInContainer < threshold) {
|
|
@@ -319,7 +414,7 @@ export function useDragDrop<ID>(
|
|
|
319
414
|
autoScrollSpeedRef.current = 0;
|
|
320
415
|
}
|
|
321
416
|
},
|
|
322
|
-
[
|
|
417
|
+
[]
|
|
323
418
|
);
|
|
324
419
|
|
|
325
420
|
// --- Cancel auto-expand timer ---
|
|
@@ -357,14 +452,34 @@ export function useDragDrop<ID>(
|
|
|
357
452
|
const positionInItem =
|
|
358
453
|
(adjustedContentY - clampedIndex * iH) / iH;
|
|
359
454
|
let position: "above" | "below" | "inside";
|
|
360
|
-
if (positionInItem < 0.
|
|
455
|
+
if (positionInItem < 0.25) {
|
|
361
456
|
position = "above";
|
|
362
|
-
} else if (positionInItem > 0.
|
|
457
|
+
} else if (positionInItem > 0.75) {
|
|
363
458
|
position = "below";
|
|
364
459
|
} else {
|
|
365
460
|
position = "inside";
|
|
366
461
|
}
|
|
367
462
|
|
|
463
|
+
// --- Determine if "inside" drop is allowed for this target ---
|
|
464
|
+
const canDropInsideTarget = (() => {
|
|
465
|
+
// canNodeHaveChildren: structural constraint
|
|
466
|
+
if (canNodeHaveChildrenRef.current && !canNodeHaveChildrenRef.current(targetNode)) {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
// maxDepth: the dragged subtree at (targetLevel + 1) must not exceed maxDepth
|
|
470
|
+
if (maxDepthRef.current !== undefined) {
|
|
471
|
+
const targetLevel = targetNode.level ?? 0;
|
|
472
|
+
const deepest = targetLevel + 1 + draggedSubtreeDepthRef.current;
|
|
473
|
+
if (deepest > maxDepthRef.current) return false;
|
|
474
|
+
}
|
|
475
|
+
return true;
|
|
476
|
+
})();
|
|
477
|
+
|
|
478
|
+
// If "inside" is not allowed, convert to nearest zone
|
|
479
|
+
if (position === "inside" && !canDropInsideTarget) {
|
|
480
|
+
position = positionInItem < 0.5 ? "above" : "below";
|
|
481
|
+
}
|
|
482
|
+
|
|
368
483
|
// --- Horizontal control at level cliffs ---
|
|
369
484
|
// At the boundary between nodes at different depths, the user's
|
|
370
485
|
// horizontal finger position decides the drop level:
|
|
@@ -378,7 +493,7 @@ export function useDragDrop<ID>(
|
|
|
378
493
|
let logicalPosition: "above" | "below" | "inside" | null = null;
|
|
379
494
|
let visualDropLevel: number | null = null;
|
|
380
495
|
|
|
381
|
-
if (position === "below"
|
|
496
|
+
if (position === "below") {
|
|
382
497
|
const currentLevel = targetNode.level ?? 0;
|
|
383
498
|
let isCliff = false;
|
|
384
499
|
let shallowLevel = 0;
|
|
@@ -397,10 +512,9 @@ export function useDragDrop<ID>(
|
|
|
397
512
|
}
|
|
398
513
|
|
|
399
514
|
if (isCliff) {
|
|
400
|
-
//
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
+ indentationMultiplier * 2;
|
|
515
|
+
// Midpoint of the item's visible content area
|
|
516
|
+
const itemLeftEdge = currentLevel * indentationMultiplierRef.current;
|
|
517
|
+
const threshold = itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
|
|
404
518
|
|
|
405
519
|
if (fingerLocalX < threshold) {
|
|
406
520
|
// User wants the shallow level
|
|
@@ -434,10 +548,8 @@ export function useDragDrop<ID>(
|
|
|
434
548
|
const prevLevel = prevNode?.level ?? 0;
|
|
435
549
|
const currentLevel = targetNode.level ?? 0;
|
|
436
550
|
if (prevNode && prevLevel > currentLevel) {
|
|
437
|
-
|
|
438
|
-
const threshold =
|
|
439
|
-
((prevLevel + currentLevel) / 2) * indentationMultiplier
|
|
440
|
-
+ indentationMultiplier * 2;
|
|
551
|
+
const itemLeftEdge = prevLevel * indentationMultiplierRef.current;
|
|
552
|
+
const threshold = itemLeftEdge + (containerWidthRef.current - itemLeftEdge) * 0.3;
|
|
441
553
|
|
|
442
554
|
if (fingerLocalX >= threshold) {
|
|
443
555
|
clampedIndex = clampedIndex - 1;
|
|
@@ -447,25 +559,15 @@ export function useDragDrop<ID>(
|
|
|
447
559
|
}
|
|
448
560
|
}
|
|
449
561
|
|
|
450
|
-
// --- Suppress "below" when it's
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
|
|
562
|
+
// --- Suppress "below" when it's semantically confusing ---
|
|
563
|
+
// For expanded parents, "below" visually sits at the parent/child
|
|
564
|
+
// junction but semantically inserts as a sibling after the entire
|
|
565
|
+
// subtree. Convert to "inside" which is clearer.
|
|
566
|
+
if (position === "below" && canDropInsideTarget) {
|
|
454
567
|
const expandedSet = getTreeViewStore<ID>(storeId).getState().expanded;
|
|
455
|
-
|
|
456
|
-
// (a) Expanded parent: "below" visually sits at the parent/child junction
|
|
457
|
-
// but semantically inserts as a sibling after the entire subtree.
|
|
458
568
|
if (targetNode.children?.length && expandedSet.has(targetNode.id)) {
|
|
459
569
|
position = "inside";
|
|
460
570
|
}
|
|
461
|
-
// (b) No level cliff below: convert to "inside" so the highlight
|
|
462
|
-
// covers the full bottom of the node.
|
|
463
|
-
else if (clampedIndex < nodes.length - 1) {
|
|
464
|
-
const nextNode = nodes[clampedIndex + 1];
|
|
465
|
-
if (nextNode && (nextNode.level ?? 0) >= (targetNode.level ?? 0)) {
|
|
466
|
-
position = "inside";
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
571
|
}
|
|
470
572
|
|
|
471
573
|
// --- Hysteresis: prevent flicker between "below N" and "above N+1" ---
|
|
@@ -500,9 +602,24 @@ export function useDragDrop<ID>(
|
|
|
500
602
|
const store = getTreeViewStore<ID>(storeId);
|
|
501
603
|
const { invalidDragTargetIds, draggedNodeId, expanded } =
|
|
502
604
|
store.getState();
|
|
605
|
+
|
|
606
|
+
// maxDepth check for above/below (sibling) positions
|
|
607
|
+
let maxDepthValid = true;
|
|
608
|
+
if (maxDepthRef.current !== undefined && (position === "above" || position === "below")) {
|
|
609
|
+
const targetLevel = targetNode.level ?? 0;
|
|
610
|
+
const deepest = targetLevel + draggedSubtreeDepthRef.current;
|
|
611
|
+
if (deepest > maxDepthRef.current) maxDepthValid = false;
|
|
612
|
+
}
|
|
613
|
+
|
|
503
614
|
const isValid =
|
|
504
615
|
targetNode.id !== draggedNodeId &&
|
|
505
|
-
!invalidDragTargetIds.has(targetNode.id)
|
|
616
|
+
!invalidDragTargetIds.has(targetNode.id) &&
|
|
617
|
+
maxDepthValid &&
|
|
618
|
+
(!canDropRef.current || canDropRef.current(
|
|
619
|
+
draggedNodeRef.current!,
|
|
620
|
+
targetNode,
|
|
621
|
+
position
|
|
622
|
+
));
|
|
506
623
|
|
|
507
624
|
// --- Auto-expand: if hovering "inside" a collapsed expandable node ---
|
|
508
625
|
if (isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
|
|
@@ -513,9 +630,9 @@ export function useDragDrop<ID>(
|
|
|
513
630
|
autoExpandTimerRef.current = setTimeout(() => {
|
|
514
631
|
autoExpandTimerRef.current = null;
|
|
515
632
|
// Expand the node and track it
|
|
516
|
-
|
|
633
|
+
expandNodes(storeId, [targetNode.id]);
|
|
517
634
|
autoExpandedDuringDragRef.current.add(targetNode.id);
|
|
518
|
-
},
|
|
635
|
+
}, autoExpandDelayRef.current);
|
|
519
636
|
}
|
|
520
637
|
} else {
|
|
521
638
|
// Not hovering inside a collapsed expandable node - cancel timer
|
|
@@ -547,7 +664,7 @@ export function useDragDrop<ID>(
|
|
|
547
664
|
// then spring to 0 for a smooth "magnetic snap" transition.
|
|
548
665
|
if (prevLevel !== effectiveLevel) {
|
|
549
666
|
overlayX.setValue(
|
|
550
|
-
(prevLevel - effectiveLevel) *
|
|
667
|
+
(prevLevel - effectiveLevel) * indentationMultiplierRef.current
|
|
551
668
|
);
|
|
552
669
|
Animated.spring(overlayX, {
|
|
553
670
|
toValue: 0,
|
|
@@ -599,7 +716,7 @@ export function useDragDrop<ID>(
|
|
|
599
716
|
return newTarget;
|
|
600
717
|
});
|
|
601
718
|
},
|
|
602
|
-
[storeId,
|
|
719
|
+
[storeId, cancelAutoExpandTimer, overlayX]
|
|
603
720
|
);
|
|
604
721
|
|
|
605
722
|
// --- Handle drag end ---
|
|
@@ -673,32 +790,25 @@ export function useDragDrop<ID>(
|
|
|
673
790
|
newTreeData: newData,
|
|
674
791
|
});
|
|
675
792
|
|
|
676
|
-
//
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
flashListRef.current?.scrollToIndex?.({
|
|
697
|
-
index: idx,
|
|
698
|
-
animated: true,
|
|
699
|
-
viewPosition: 0.5,
|
|
700
|
-
});
|
|
701
|
-
}, 100);
|
|
793
|
+
// Auto-scroll to the dropped node unless disabled by the user.
|
|
794
|
+
const scrollOpts = autoScrollToDroppedNode;
|
|
795
|
+
const scrollEnabled = scrollOpts === undefined || scrollOpts === true
|
|
796
|
+
|| (typeof scrollOpts === "object" && scrollOpts.enabled !== false);
|
|
797
|
+
|
|
798
|
+
if (scrollEnabled) {
|
|
799
|
+
const custom = typeof scrollOpts === "object" ? scrollOpts : {};
|
|
800
|
+
setTimeout(() => {
|
|
801
|
+
scrollToNodeHandlerRef.current?.scrollToNodeID({
|
|
802
|
+
nodeId: droppedNodeId,
|
|
803
|
+
animated: custom.animated ?? true,
|
|
804
|
+
viewPosition: custom.viewPosition ?? 0.5,
|
|
805
|
+
viewOffset: custom.viewOffset,
|
|
806
|
+
});
|
|
807
|
+
}, 0);
|
|
808
|
+
}
|
|
809
|
+
} else if (droppedNodeId !== null) {
|
|
810
|
+
// Drag ended without a valid drop - notify consumer
|
|
811
|
+
onDragCancelRef.current?.({ draggedNodeId: droppedNodeId });
|
|
702
812
|
}
|
|
703
813
|
|
|
704
814
|
// Collapse auto-expanded nodes that aren't ancestors of the drop target
|
|
@@ -791,7 +901,7 @@ export function useDragDrop<ID>(
|
|
|
791
901
|
|
|
792
902
|
// Update overlay position (with configurable offset)
|
|
793
903
|
const overlayLocalY =
|
|
794
|
-
fingerLocalY - grabOffsetYRef.current +
|
|
904
|
+
fingerLocalY - grabOffsetYRef.current + (dragOverlayOffsetRef.current + PLATFORM_OVERLAY_Y_CORRECTION) * itemHeightRef.current;
|
|
795
905
|
overlayY.setValue(overlayLocalY);
|
|
796
906
|
|
|
797
907
|
// Calculate drop target (horizontal position used at level cliffs)
|