react-native-drax 0.11.0-alpha.2 → 1.1.0
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/LICENSE.md +1 -1
- package/README.md +390 -227
- package/lib/module/DebugOverlay.js +121 -0
- package/lib/module/DebugOverlay.js.map +1 -0
- package/lib/module/Drax.js +36 -0
- package/lib/module/Drax.js.map +1 -0
- package/lib/module/DraxContext.js +6 -0
- package/lib/module/DraxContext.js.map +1 -0
- package/lib/module/DraxHandle.js +47 -0
- package/lib/module/DraxHandle.js.map +1 -0
- package/lib/module/DraxHandleContext.js +11 -0
- package/lib/module/DraxHandleContext.js.map +1 -0
- package/lib/module/DraxList.js +108 -0
- package/lib/module/DraxList.js.map +1 -0
- package/lib/module/DraxProvider.js +203 -0
- package/lib/module/DraxProvider.js.map +1 -0
- package/lib/module/DraxScrollView.js +167 -0
- package/lib/module/DraxScrollView.js.map +1 -0
- package/lib/module/DraxSubprovider.js +21 -0
- package/lib/module/DraxSubprovider.js.map +1 -0
- package/lib/module/DraxView.js +348 -0
- package/lib/module/DraxView.js.map +1 -0
- package/lib/module/HoverLayer.js +152 -0
- package/lib/module/HoverLayer.js.map +1 -0
- package/lib/module/SortableBoardContainer.js +386 -0
- package/lib/module/SortableBoardContainer.js.map +1 -0
- package/lib/module/SortableBoardContext.js +6 -0
- package/lib/module/SortableBoardContext.js.map +1 -0
- package/lib/module/SortableContainer.js +571 -0
- package/lib/module/SortableContainer.js.map +1 -0
- package/lib/module/SortableItem.js +226 -0
- package/lib/module/SortableItem.js.map +1 -0
- package/lib/module/SortableItemContext.js +38 -0
- package/lib/module/SortableItemContext.js.map +1 -0
- package/lib/module/compat/detectVersion.js +19 -0
- package/lib/module/compat/detectVersion.js.map +1 -0
- package/lib/module/compat/index.js +5 -0
- package/lib/module/compat/index.js.map +1 -0
- package/lib/module/compat/types.js +4 -0
- package/lib/module/compat/types.js.map +1 -0
- package/lib/module/compat/useDraxPanGesture.js +94 -0
- package/lib/module/compat/useDraxPanGesture.js.map +1 -0
- package/lib/module/hooks/index.js +5 -0
- package/lib/module/hooks/index.js.map +1 -0
- package/lib/module/hooks/useCallbackDispatch.js +688 -0
- package/lib/module/hooks/useCallbackDispatch.js.map +1 -0
- package/lib/module/hooks/useDragGesture.js +240 -0
- package/lib/module/hooks/useDragGesture.js.map +1 -0
- package/lib/module/hooks/useDraxContext.js +12 -0
- package/lib/module/hooks/useDraxContext.js.map +1 -0
- package/lib/module/hooks/useDraxId.js +13 -0
- package/lib/module/hooks/useDraxId.js.map +1 -0
- package/lib/module/hooks/useDraxMethods.js +73 -0
- package/lib/module/hooks/useDraxMethods.js.map +1 -0
- package/lib/module/hooks/useDraxScrollHandler.js +97 -0
- package/lib/module/hooks/useDraxScrollHandler.js.map +1 -0
- package/lib/module/hooks/useSortableBoard.js +37 -0
- package/lib/module/hooks/useSortableBoard.js.map +1 -0
- package/lib/module/hooks/useSortableList.js +988 -0
- package/lib/module/hooks/useSortableList.js.map +1 -0
- package/lib/module/hooks/useSpatialIndex.js +283 -0
- package/lib/module/hooks/useSpatialIndex.js.map +1 -0
- package/lib/module/hooks/useViewStyles.js +158 -0
- package/lib/module/hooks/useViewStyles.js.map +1 -0
- package/lib/module/hooks/useWebScrollFreeze.js +52 -0
- package/lib/module/hooks/useWebScrollFreeze.js.map +1 -0
- package/lib/module/index.js +37 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/math.js +294 -0
- package/lib/module/math.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/params.js +88 -0
- package/lib/module/params.js.map +1 -0
- package/lib/module/types.js +215 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/DebugOverlay.d.ts +17 -0
- package/lib/typescript/src/DebugOverlay.d.ts.map +1 -0
- package/lib/typescript/src/Drax.d.ts +28 -0
- package/lib/typescript/src/Drax.d.ts.map +1 -0
- package/lib/typescript/src/DraxContext.d.ts +3 -0
- package/lib/typescript/src/DraxContext.d.ts.map +1 -0
- package/lib/typescript/src/DraxHandle.d.ts +25 -0
- package/lib/typescript/src/DraxHandle.d.ts.map +1 -0
- package/lib/typescript/src/DraxHandleContext.d.ts +12 -0
- package/lib/typescript/src/DraxHandleContext.d.ts.map +1 -0
- package/lib/typescript/src/DraxList.d.ts +66 -0
- package/lib/typescript/src/DraxList.d.ts.map +1 -0
- package/lib/typescript/src/DraxProvider.d.ts +4 -0
- package/lib/typescript/src/DraxProvider.d.ts.map +1 -0
- package/lib/typescript/src/DraxScrollView.d.ts +7 -0
- package/lib/typescript/src/DraxScrollView.d.ts.map +1 -0
- package/lib/typescript/src/DraxSubprovider.d.ts +4 -0
- package/lib/typescript/src/DraxSubprovider.d.ts.map +1 -0
- package/lib/typescript/src/DraxView.d.ts +4 -0
- package/lib/typescript/src/DraxView.d.ts.map +1 -0
- package/lib/typescript/src/HoverLayer.d.ts +38 -0
- package/lib/typescript/src/HoverLayer.d.ts.map +1 -0
- package/lib/typescript/src/SortableBoardContainer.d.ts +11 -0
- package/lib/typescript/src/SortableBoardContainer.d.ts.map +1 -0
- package/lib/typescript/src/SortableBoardContext.d.ts +4 -0
- package/lib/typescript/src/SortableBoardContext.d.ts.map +1 -0
- package/lib/typescript/src/SortableContainer.d.ts +13 -0
- package/lib/typescript/src/SortableContainer.d.ts.map +1 -0
- package/lib/typescript/src/SortableItem.d.ts +14 -0
- package/lib/typescript/src/SortableItem.d.ts.map +1 -0
- package/lib/typescript/src/SortableItemContext.d.ts +37 -0
- package/lib/typescript/src/SortableItemContext.d.ts.map +1 -0
- package/lib/typescript/src/compat/detectVersion.d.ts +2 -0
- package/lib/typescript/src/compat/detectVersion.d.ts.map +1 -0
- package/lib/typescript/src/compat/index.d.ts +4 -0
- package/lib/typescript/src/compat/index.d.ts.map +1 -0
- package/lib/typescript/src/compat/types.d.ts +33 -0
- package/lib/typescript/src/compat/types.d.ts.map +1 -0
- package/lib/typescript/src/compat/useDraxPanGesture.d.ts +8 -0
- package/lib/typescript/src/compat/useDraxPanGesture.d.ts.map +1 -0
- package/lib/typescript/src/hooks/index.d.ts +3 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useCallbackDispatch.d.ts +40 -0
- package/lib/typescript/src/hooks/useCallbackDispatch.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useDragGesture.d.ts +17 -0
- package/lib/typescript/src/hooks/useDragGesture.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useDraxContext.d.ts +2 -0
- package/lib/typescript/src/hooks/useDraxContext.d.ts.map +1 -0
- package/{build → lib/typescript/src}/hooks/useDraxId.d.ts +1 -0
- package/lib/typescript/src/hooks/useDraxId.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useDraxMethods.d.ts +13 -0
- package/lib/typescript/src/hooks/useDraxMethods.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useDraxScrollHandler.d.ts +27 -0
- package/lib/typescript/src/hooks/useDraxScrollHandler.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useSortableBoard.d.ts +10 -0
- package/lib/typescript/src/hooks/useSortableBoard.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useSortableList.d.ts +11 -0
- package/lib/typescript/src/hooks/useSortableList.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useSpatialIndex.d.ts +22 -0
- package/lib/typescript/src/hooks/useSpatialIndex.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useViewStyles.d.ts +183 -0
- package/lib/typescript/src/hooks/useViewStyles.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useWebScrollFreeze.d.ts +14 -0
- package/lib/typescript/src/hooks/useWebScrollFreeze.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +25 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/math.d.ts +76 -0
- package/lib/typescript/src/math.d.ts.map +1 -0
- package/{build → lib/typescript/src}/params.d.ts +13 -9
- package/lib/typescript/src/params.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +756 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +164 -34
- package/src/DebugOverlay.tsx +140 -0
- package/src/Drax.ts +33 -0
- package/src/DraxContext.ts +8 -0
- package/src/DraxHandle.tsx +52 -0
- package/src/DraxHandleContext.ts +15 -0
- package/src/DraxList.tsx +181 -0
- package/src/DraxProvider.tsx +224 -0
- package/src/DraxScrollView.tsx +180 -0
- package/src/DraxSubprovider.tsx +22 -0
- package/src/DraxView.tsx +430 -0
- package/src/HoverLayer.tsx +167 -0
- package/src/SortableBoardContainer.tsx +439 -0
- package/src/SortableBoardContext.ts +6 -0
- package/src/SortableContainer.tsx +650 -0
- package/src/SortableItem.tsx +264 -0
- package/src/SortableItemContext.ts +46 -0
- package/src/compat/detectVersion.ts +17 -0
- package/src/compat/index.ts +7 -0
- package/src/compat/types.ts +35 -0
- package/src/compat/useDraxPanGesture.ts +112 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useCallbackDispatch.tsx +830 -0
- package/src/hooks/useDragGesture.ts +273 -0
- package/src/hooks/useDraxContext.ts +11 -0
- package/src/hooks/useDraxId.ts +11 -0
- package/src/hooks/useDraxMethods.ts +71 -0
- package/src/hooks/useDraxScrollHandler.ts +121 -0
- package/src/hooks/useSortableBoard.ts +44 -0
- package/src/hooks/useSortableList.ts +1063 -0
- package/src/hooks/useSpatialIndex.ts +336 -0
- package/src/hooks/useViewStyles.ts +180 -0
- package/src/hooks/useWebScrollFreeze.ts +60 -0
- package/src/index.ts +111 -0
- package/src/math.ts +333 -0
- package/src/params.ts +74 -0
- package/src/types.ts +933 -0
- package/.editorconfig +0 -15
- package/.eslintrc.js +0 -4
- package/.prettierrc +0 -16
- package/CHANGELOG.md +0 -270
- package/CODE-OF-CONDUCT.md +0 -85
- package/CONTRIBUTING.md +0 -15
- package/FUNDING.yml +0 -4
- package/build/AllHoverViews.d.ts +0 -0
- package/build/AllHoverViews.js +0 -30
- package/build/DraxContext.d.ts +0 -2
- package/build/DraxContext.js +0 -6
- package/build/DraxList.d.ts +0 -8
- package/build/DraxList.js +0 -512
- package/build/DraxListItem.d.ts +0 -7
- package/build/DraxListItem.js +0 -121
- package/build/DraxProvider.d.ts +0 -2
- package/build/DraxProvider.js +0 -704
- package/build/DraxScrollView.d.ts +0 -6
- package/build/DraxScrollView.js +0 -136
- package/build/DraxSubprovider.d.ts +0 -3
- package/build/DraxSubprovider.js +0 -18
- package/build/DraxView.d.ts +0 -8
- package/build/DraxView.js +0 -93
- package/build/HoverView.d.ts +0 -8
- package/build/HoverView.js +0 -40
- package/build/PanGestureDetector.d.ts +0 -3
- package/build/PanGestureDetector.js +0 -49
- package/build/hooks/index.d.ts +0 -4
- package/build/hooks/index.js +0 -11
- package/build/hooks/useContent.d.ts +0 -23
- package/build/hooks/useContent.js +0 -212
- package/build/hooks/useDraxContext.d.ts +0 -1
- package/build/hooks/useDraxContext.js +0 -13
- package/build/hooks/useDraxId.js +0 -13
- package/build/hooks/useDraxProtocol.d.ts +0 -5
- package/build/hooks/useDraxProtocol.js +0 -32
- package/build/hooks/useDraxRegistry.d.ts +0 -78
- package/build/hooks/useDraxRegistry.js +0 -714
- package/build/hooks/useDraxScrollHandler.d.ts +0 -25
- package/build/hooks/useDraxScrollHandler.js +0 -89
- package/build/hooks/useDraxState.d.ts +0 -10
- package/build/hooks/useDraxState.js +0 -132
- package/build/hooks/useMeasurements.d.ts +0 -9
- package/build/hooks/useMeasurements.js +0 -119
- package/build/hooks/useStatus.d.ts +0 -11
- package/build/hooks/useStatus.js +0 -96
- package/build/index.d.ts +0 -9
- package/build/index.js +0 -33
- package/build/math.d.ts +0 -22
- package/build/math.js +0 -68
- package/build/params.js +0 -27
- package/build/transform.d.ts +0 -11
- package/build/transform.js +0 -59
- package/build/types.d.ts +0 -807
- package/build/types.js +0 -46
- package/docs/concept.md +0 -79
- package/docs/images/color-drag-drop.gif +0 -0
- package/docs/images/deck-cards.gif +0 -0
- package/docs/images/drag-drop-events.jpg +0 -0
- package/docs/images/knight-moves.gif +0 -0
- package/docs/images/reorderable-list.gif +0 -0
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useSharedValue } from 'react-native-reanimated';
|
|
5
|
+
import { runOnUI } from 'react-native-worklets';
|
|
6
|
+
import { defaultAutoScrollBackThreshold, defaultAutoScrollForwardThreshold, defaultAutoScrollJumpRatio, defaultListItemLongPressDelay } from "../params.js";
|
|
7
|
+
import { packGrid } from "../math.js";
|
|
8
|
+
import { DraxSnapbackTargetPreset } from "../types.js";
|
|
9
|
+
import { useDraxId } from "./useDraxId.js";
|
|
10
|
+
|
|
11
|
+
/** Stable identity — avoids FlatList cell unmounting on data reorder. */
|
|
12
|
+
function useStableKeyExtractor() {
|
|
13
|
+
return useCallback((_item, index) => `__drax_${index}`, []);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Core hook for list-agnostic sortable reordering.
|
|
18
|
+
*
|
|
19
|
+
* During drag, order changes are tracked in a ref (no React re-render)
|
|
20
|
+
* and items are visually repositioned via shift transforms (SharedValues).
|
|
21
|
+
* The data reorder is committed to state only on drop, while the hover
|
|
22
|
+
* view covers any layout transition.
|
|
23
|
+
*/
|
|
24
|
+
export const useSortableList = options => {
|
|
25
|
+
const {
|
|
26
|
+
data: rawData,
|
|
27
|
+
keyExtractor,
|
|
28
|
+
onReorder,
|
|
29
|
+
horizontal = false,
|
|
30
|
+
numColumns = 1,
|
|
31
|
+
reorderStrategy = 'insert',
|
|
32
|
+
longPressDelay = defaultListItemLongPressDelay,
|
|
33
|
+
lockToMainAxis = false,
|
|
34
|
+
autoScrollJumpRatio = defaultAutoScrollJumpRatio,
|
|
35
|
+
autoScrollBackThreshold = defaultAutoScrollBackThreshold,
|
|
36
|
+
autoScrollForwardThreshold = defaultAutoScrollForwardThreshold,
|
|
37
|
+
animationConfig = 'default',
|
|
38
|
+
getItemSpan,
|
|
39
|
+
inactiveItemStyle,
|
|
40
|
+
itemEntering,
|
|
41
|
+
itemExiting,
|
|
42
|
+
onDragStart,
|
|
43
|
+
onDragPositionChange,
|
|
44
|
+
onDragEnd
|
|
45
|
+
} = options;
|
|
46
|
+
const id = useDraxId(options.id);
|
|
47
|
+
|
|
48
|
+
// ── Fixed items tracking ────────────────────────────────────────────
|
|
49
|
+
const fixedKeys = useRef(new Set());
|
|
50
|
+
|
|
51
|
+
// ── SharedValues (UI-thread state) ────────────────────────────────
|
|
52
|
+
const draggedItem = useSharedValue(undefined);
|
|
53
|
+
const dropTargetPositionSV = useSharedValue({
|
|
54
|
+
x: 0,
|
|
55
|
+
y: 0
|
|
56
|
+
});
|
|
57
|
+
const dropTargetVisibleSV = useSharedValue(false);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Per-item shift transforms keyed by item key.
|
|
61
|
+
* Written from JS thread during drag, read on UI thread via useAnimatedStyle.
|
|
62
|
+
*/
|
|
63
|
+
const shiftsRef = useSharedValue({});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* When true, SortableItem applies shifts with duration 0 (instant).
|
|
67
|
+
* Set during reorder commit so items don't animate from old shift→0
|
|
68
|
+
* while the FlatList re-renders (which would cause a double-offset flash).
|
|
69
|
+
* Reset at the start of the next drag session.
|
|
70
|
+
*/
|
|
71
|
+
const instantClearSV = useSharedValue(false);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* When false, SortableItem ignores all shifts (treats them as 0).
|
|
75
|
+
* Written SYNCHRONOUSLY from useLayoutEffect (direct JSI write) when
|
|
76
|
+
* rawData changes, so the animated style picks it up in the same UI
|
|
77
|
+
* frame as the Fabric commit. This prevents the 1-frame blink where
|
|
78
|
+
* cells show new content but the animated style still has stale shifts.
|
|
79
|
+
*/
|
|
80
|
+
const shiftsValidSV = useSharedValue(true);
|
|
81
|
+
|
|
82
|
+
// ── JS-thread state ───────────────────────────────────────────────
|
|
83
|
+
const [originalIndexes, setOriginalIndexes] = useState([]);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Buffered FlatList data. Only updated on external data changes, NOT on
|
|
87
|
+
* accepted reorders. This ensures FlatList never re-renders on reorder
|
|
88
|
+
* commit, eliminating the Fabric-vs-Reanimated race that caused the blink.
|
|
89
|
+
*/
|
|
90
|
+
const [stableData, setStableData] = useState(rawData);
|
|
91
|
+
|
|
92
|
+
// Always-current rawData for deferred flushVisualOrder
|
|
93
|
+
const rawDataRef = useRef(rawData);
|
|
94
|
+
rawDataRef.current = rawData;
|
|
95
|
+
const itemMeasurements = useRef(new Map());
|
|
96
|
+
const containerMeasurementsRef = useRef(undefined);
|
|
97
|
+
const contentSizeRef = useRef(undefined);
|
|
98
|
+
const scrollPosition = useSharedValue({
|
|
99
|
+
x: 0,
|
|
100
|
+
y: 0
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── Drag tracking (refs, no re-render) ────────────────────────────
|
|
104
|
+
const draggedDisplayIndexRef = useRef(undefined);
|
|
105
|
+
const dragStartIndexRef = useRef(undefined);
|
|
106
|
+
/**
|
|
107
|
+
* Pending reorder during drag. Tracks the desired display order
|
|
108
|
+
* as indices into rawData. Updated by moveDraggedItem (ref, not state).
|
|
109
|
+
*/
|
|
110
|
+
const pendingOrderRef = useRef([]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Committed visual order — the pending order from the last completed drag.
|
|
114
|
+
* FlatList data is NOT changed on reorder; items are positioned entirely
|
|
115
|
+
* via shifts. This ref allows the next drag to start from the visual state.
|
|
116
|
+
* Empty means FlatList data matches the visual order (identity).
|
|
117
|
+
*/
|
|
118
|
+
const committedOrderRef = useRef([]);
|
|
119
|
+
/** Shifts corresponding to the committed visual order (for cancel revert). */
|
|
120
|
+
const committedShiftsRef = useRef({});
|
|
121
|
+
/** Item keys in committed visual order — detects when parent data matches. */
|
|
122
|
+
const committedKeyOrderRef = useRef([]);
|
|
123
|
+
/** Cross-container: phantom slot for incoming items */
|
|
124
|
+
const phantomRef = useRef(undefined);
|
|
125
|
+
/** Cross-container: off-screen shifts for transferred items */
|
|
126
|
+
const ghostShiftsRef = useRef({});
|
|
127
|
+
/** When true, the next useLayoutEffect RESET skips the sync shiftsValidSV=false
|
|
128
|
+
* write. Set by board-path finalizeDrag which keeps the hover visible to cover
|
|
129
|
+
* the transition — the sync write would prematurely zero shifts on other items. */
|
|
130
|
+
const skipShiftsInvalidationRef = useRef(false);
|
|
131
|
+
|
|
132
|
+
// ── Handle data changes ──────────────────────────────────────────────
|
|
133
|
+
// With permanent shifts, FlatList data is NOT changed on reorder.
|
|
134
|
+
// When rawData changes (parent updated state after onReorder, or external
|
|
135
|
+
// data change), check if it matches the committed visual order. If so,
|
|
136
|
+
// clear shifts (items now at correct FlatList positions). Otherwise reset.
|
|
137
|
+
useLayoutEffect(() => {
|
|
138
|
+
// Always keep originalIndexes as identity — permanent shifts handle visual order.
|
|
139
|
+
setOriginalIndexes(prev => {
|
|
140
|
+
const isIdentity = prev.length === rawData.length && prev.every((v, i) => v === i);
|
|
141
|
+
if (isIdentity) return prev;
|
|
142
|
+
return rawData.length > 0 ? [...Array(rawData.length).keys()] : [];
|
|
143
|
+
});
|
|
144
|
+
const committedKeys = committedKeyOrderRef.current;
|
|
145
|
+
if (committedKeys.length > 0 && committedKeys.length === rawData.length) {
|
|
146
|
+
// Check if new data order matches committed visual order by keys.
|
|
147
|
+
let matches = true;
|
|
148
|
+
for (let i = 0; i < rawData.length; i++) {
|
|
149
|
+
const item = rawData[i];
|
|
150
|
+
if (item === undefined || keyExtractor(item, i) !== committedKeys[i]) {
|
|
151
|
+
matches = false;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (matches) {
|
|
156
|
+
// Parent accepted our reorder — stableData stays unchanged.
|
|
157
|
+
// FlatList keeps rendering the original data order; permanent shifts
|
|
158
|
+
// handle the visual reorder. No Fabric commit → no race → no blink.
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// External data change or initial mount — update stableData and reset.
|
|
164
|
+
setStableData(rawData);
|
|
165
|
+
committedOrderRef.current = [];
|
|
166
|
+
committedKeyOrderRef.current = [];
|
|
167
|
+
committedShiftsRef.current = {};
|
|
168
|
+
ghostShiftsRef.current = {};
|
|
169
|
+
if (skipShiftsInvalidationRef.current) {
|
|
170
|
+
// Board-path reorder: hover covers the transition.
|
|
171
|
+
skipShiftsInvalidationRef.current = false;
|
|
172
|
+
instantClearSV.value = true;
|
|
173
|
+
shiftsRef.value = {};
|
|
174
|
+
} else {
|
|
175
|
+
// External data change: invalidate shifts immediately so the animated
|
|
176
|
+
// style reads zero shifts in the same frame as the Fabric commit.
|
|
177
|
+
shiftsValidSV.value = false;
|
|
178
|
+
runOnUI(() => {
|
|
179
|
+
'worklet';
|
|
180
|
+
|
|
181
|
+
instantClearSV.value = true;
|
|
182
|
+
shiftsRef.value = {};
|
|
183
|
+
shiftsValidSV.value = true;
|
|
184
|
+
})();
|
|
185
|
+
}
|
|
186
|
+
}, [rawData, keyExtractor, shiftsRef, instantClearSV, shiftsValidSV]);
|
|
187
|
+
|
|
188
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
const getMeasurementByOriginalIndex = originalIndex => {
|
|
191
|
+
const item = stableData[originalIndex];
|
|
192
|
+
if (item === undefined) return undefined;
|
|
193
|
+
const key = keyExtractor(item, originalIndex);
|
|
194
|
+
return itemMeasurements.current.get(key);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Alias for internal use
|
|
198
|
+
const getMeasForOrigIdx = getMeasurementByOriginalIndex;
|
|
199
|
+
|
|
200
|
+
/** Get the span for an item at the given original data index */
|
|
201
|
+
const getSpanForOrigIdx = origIdx => {
|
|
202
|
+
if (!getItemSpan) return {
|
|
203
|
+
colSpan: 1,
|
|
204
|
+
rowSpan: 1
|
|
205
|
+
};
|
|
206
|
+
const item = stableData[origIdx];
|
|
207
|
+
if (item === undefined) return {
|
|
208
|
+
colSpan: 1,
|
|
209
|
+
rowSpan: 1
|
|
210
|
+
};
|
|
211
|
+
return getItemSpan(item, origIdx);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Derive grid geometry (cell size + gaps) from current measurements.
|
|
216
|
+
* Only used when getItemSpan is provided and numColumns > 1.
|
|
217
|
+
*/
|
|
218
|
+
const deriveGridGeometry = () => {
|
|
219
|
+
if (!getItemSpan || originalIndexes.length === 0) return undefined;
|
|
220
|
+
const firstOrigIdx = originalIndexes[0];
|
|
221
|
+
const startMeas = firstOrigIdx !== undefined ? getMeasForOrigIdx(firstOrigIdx) : undefined;
|
|
222
|
+
if (!startMeas) return undefined;
|
|
223
|
+
|
|
224
|
+
// Pack original order to know grid positions for gap derivation
|
|
225
|
+
const origPacking = packGrid(originalIndexes.length, numColumns, displayIdx => getSpanForOrigIdx(originalIndexes[displayIdx]));
|
|
226
|
+
|
|
227
|
+
// Find cell dimensions from measurements of items with span 1
|
|
228
|
+
let cellWidth;
|
|
229
|
+
let cellHeight;
|
|
230
|
+
for (let i = 0; i < originalIndexes.length; i++) {
|
|
231
|
+
const origIdx = originalIndexes[i];
|
|
232
|
+
const span = getSpanForOrigIdx(origIdx);
|
|
233
|
+
const meas = getMeasForOrigIdx(origIdx);
|
|
234
|
+
if (!meas) continue;
|
|
235
|
+
if (span.colSpan === 1 && cellWidth === undefined) cellWidth = meas.width;
|
|
236
|
+
if (span.rowSpan === 1 && cellHeight === undefined) cellHeight = meas.height;
|
|
237
|
+
if (cellWidth !== undefined && cellHeight !== undefined) break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Fallback: derive from first item divided by its span
|
|
241
|
+
if (cellWidth === undefined || cellHeight === undefined) {
|
|
242
|
+
const firstSpan = getSpanForOrigIdx(firstOrigIdx);
|
|
243
|
+
if (cellWidth === undefined) cellWidth = startMeas.width / firstSpan.colSpan;
|
|
244
|
+
if (cellHeight === undefined) cellHeight = startMeas.height / firstSpan.rowSpan;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Derive column gap from two items at different grid columns
|
|
248
|
+
let colGap = 0;
|
|
249
|
+
for (let i = 0; i < origPacking.positions.length && colGap === 0; i++) {
|
|
250
|
+
for (let j = i + 1; j < origPacking.positions.length; j++) {
|
|
251
|
+
const pi = origPacking.positions[i];
|
|
252
|
+
const pj = origPacking.positions[j];
|
|
253
|
+
if (pi.col !== pj.col) {
|
|
254
|
+
const mi = getMeasForOrigIdx(originalIndexes[i]);
|
|
255
|
+
const mj = getMeasForOrigIdx(originalIndexes[j]);
|
|
256
|
+
if (mi && mj) {
|
|
257
|
+
const colDiff = Math.abs(pj.col - pi.col);
|
|
258
|
+
const xDiff = Math.abs(mj.x - mi.x);
|
|
259
|
+
colGap = xDiff / colDiff - cellWidth;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Derive row gap from two items at different grid rows
|
|
267
|
+
let rowGap = 0;
|
|
268
|
+
for (let i = 0; i < origPacking.positions.length && rowGap === 0; i++) {
|
|
269
|
+
for (let j = i + 1; j < origPacking.positions.length; j++) {
|
|
270
|
+
const pi = origPacking.positions[i];
|
|
271
|
+
const pj = origPacking.positions[j];
|
|
272
|
+
if (pi.row !== pj.row) {
|
|
273
|
+
const mi = getMeasForOrigIdx(originalIndexes[i]);
|
|
274
|
+
const mj = getMeasForOrigIdx(originalIndexes[j]);
|
|
275
|
+
if (mi && mj) {
|
|
276
|
+
const rowDiff = Math.abs(pj.row - pi.row);
|
|
277
|
+
const yDiff = Math.abs(mj.y - mi.y);
|
|
278
|
+
rowGap = yDiff / rowDiff - cellHeight;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
cellWidth,
|
|
286
|
+
cellHeight,
|
|
287
|
+
colGap: Math.max(colGap, 0),
|
|
288
|
+
rowGap: Math.max(rowGap, 0),
|
|
289
|
+
startX: startMeas.x,
|
|
290
|
+
startY: startMeas.y
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// ── Shift application (merges ghost shifts for cross-container) ──
|
|
295
|
+
|
|
296
|
+
const applyShifts = shifts => {
|
|
297
|
+
if (!shifts) return;
|
|
298
|
+
const ghosts = ghostShiftsRef.current;
|
|
299
|
+
if (Object.keys(ghosts).length > 0) {
|
|
300
|
+
shiftsRef.value = {
|
|
301
|
+
...shifts,
|
|
302
|
+
...ghosts
|
|
303
|
+
};
|
|
304
|
+
} else {
|
|
305
|
+
shiftsRef.value = shifts;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// ── Shift computation ─────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Compute the gap between items from current FlatList measurements.
|
|
313
|
+
* Uses the first two items in originalIndexes to detect separator/padding.
|
|
314
|
+
*/
|
|
315
|
+
const computeItemGap = () => {
|
|
316
|
+
if (originalIndexes.length < 2) return 0;
|
|
317
|
+
const meas0 = getMeasForOrigIdx(originalIndexes[0]);
|
|
318
|
+
const meas1 = getMeasForOrigIdx(originalIndexes[1]);
|
|
319
|
+
if (!meas0 || !meas1) return 0;
|
|
320
|
+
if (numColumns > 1) {
|
|
321
|
+
// Grid: gap between rows (check items in different rows)
|
|
322
|
+
const firstRowEnd = Math.min(numColumns, originalIndexes.length);
|
|
323
|
+
if (originalIndexes.length > firstRowEnd) {
|
|
324
|
+
const lastInRow0 = getMeasForOrigIdx(originalIndexes[firstRowEnd - 1]);
|
|
325
|
+
const firstInRow1 = getMeasForOrigIdx(originalIndexes[firstRowEnd]);
|
|
326
|
+
if (lastInRow0 && firstInRow1) {
|
|
327
|
+
return horizontal ? firstInRow1.x - (lastInRow0.x + lastInRow0.width) : firstInRow1.y - (lastInRow0.y + lastInRow0.height);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// List: gap = nextItem.y - (thisItem.y + thisItem.height)
|
|
334
|
+
return horizontal ? meas1.x - (meas0.x + meas0.width) : meas1.y - (meas0.y + meas0.height);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Compute shifts for items in the given order. Returns a map of
|
|
339
|
+
* item key → {x, y} shift, or undefined if measurements are missing.
|
|
340
|
+
*
|
|
341
|
+
* @param order Array of original data indices in desired display order
|
|
342
|
+
* @param skipIndex Optional display index to skip (dragged item during drag)
|
|
343
|
+
*/
|
|
344
|
+
const computeShiftsForOrder = (order, skipIndex, phantom) => {
|
|
345
|
+
if (order.length === 0) return undefined;
|
|
346
|
+
const measurements = order.map(origIdx => getMeasForOrigIdx(origIdx));
|
|
347
|
+
const missingShiftIdx = measurements.findIndex(m => !m);
|
|
348
|
+
if (missingShiftIdx >= 0) {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
const gap = computeItemGap();
|
|
352
|
+
const firstOrigIdx = originalIndexes[0];
|
|
353
|
+
const startMeas = firstOrigIdx !== undefined ? getMeasForOrigIdx(firstOrigIdx) : undefined;
|
|
354
|
+
if (!startMeas) {
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
const targetPositions = new Map();
|
|
358
|
+
if (numColumns <= 1) {
|
|
359
|
+
let cursor = horizontal ? startMeas.x : startMeas.y;
|
|
360
|
+
let displaySlot = 0;
|
|
361
|
+
for (let i = 0; i < order.length; i++) {
|
|
362
|
+
// Reserve space for phantom before laying out this item
|
|
363
|
+
if (phantom && displaySlot === phantom.atDisplayIndex) {
|
|
364
|
+
cursor += (horizontal ? phantom.width : phantom.height) + gap;
|
|
365
|
+
displaySlot++;
|
|
366
|
+
}
|
|
367
|
+
const meas = measurements[i];
|
|
368
|
+
if (horizontal) {
|
|
369
|
+
targetPositions.set(i, {
|
|
370
|
+
x: cursor,
|
|
371
|
+
y: startMeas.y
|
|
372
|
+
});
|
|
373
|
+
cursor += meas.width + gap;
|
|
374
|
+
} else {
|
|
375
|
+
targetPositions.set(i, {
|
|
376
|
+
x: startMeas.x,
|
|
377
|
+
y: cursor
|
|
378
|
+
});
|
|
379
|
+
cursor += meas.height + gap;
|
|
380
|
+
}
|
|
381
|
+
displaySlot++;
|
|
382
|
+
}
|
|
383
|
+
} else if (getItemSpan) {
|
|
384
|
+
// ── Mixed-size grid: bin-pack items into a 2D occupancy grid ──
|
|
385
|
+
const geo = deriveGridGeometry();
|
|
386
|
+
if (!geo) return undefined;
|
|
387
|
+
const packing = packGrid(order.length, numColumns, displayIdx => getSpanForOrigIdx(order[displayIdx]));
|
|
388
|
+
for (let i = 0; i < order.length; i++) {
|
|
389
|
+
const gp = packing.positions[i];
|
|
390
|
+
targetPositions.set(i, {
|
|
391
|
+
x: geo.startX + gp.col * (geo.cellWidth + geo.colGap),
|
|
392
|
+
y: geo.startY + gp.row * (geo.cellHeight + geo.rowGap)
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
} else {
|
|
396
|
+
// ── Uniform grid: col = i % numColumns ──
|
|
397
|
+
let cursorY = startMeas.y;
|
|
398
|
+
const colXPositions = [];
|
|
399
|
+
for (let c = 0; c < numColumns && c < originalIndexes.length; c++) {
|
|
400
|
+
const colMeas = getMeasForOrigIdx(originalIndexes[c]);
|
|
401
|
+
colXPositions.push(colMeas ? colMeas.x : 0);
|
|
402
|
+
}
|
|
403
|
+
for (let i = 0; i < order.length; i++) {
|
|
404
|
+
const col = i % numColumns;
|
|
405
|
+
targetPositions.set(i, {
|
|
406
|
+
x: colXPositions[col] ?? 0,
|
|
407
|
+
y: cursorY
|
|
408
|
+
});
|
|
409
|
+
if (col === numColumns - 1 || i === order.length - 1) {
|
|
410
|
+
const rowStart = i - col;
|
|
411
|
+
let rowHeight = 0;
|
|
412
|
+
for (let j = rowStart; j <= i; j++) {
|
|
413
|
+
rowHeight = Math.max(rowHeight, measurements[j].height);
|
|
414
|
+
}
|
|
415
|
+
cursorY += rowHeight + gap;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const newShifts = {};
|
|
420
|
+
for (let i = 0; i < order.length; i++) {
|
|
421
|
+
if (skipIndex !== undefined && i === skipIndex) continue;
|
|
422
|
+
const origIdx = order[i];
|
|
423
|
+
const item = stableData[origIdx];
|
|
424
|
+
if (item === undefined) continue;
|
|
425
|
+
const key = keyExtractor(item, origIdx);
|
|
426
|
+
const currentMeas = getMeasForOrigIdx(origIdx);
|
|
427
|
+
if (!currentMeas) continue;
|
|
428
|
+
const target = targetPositions.get(i);
|
|
429
|
+
if (!target) continue;
|
|
430
|
+
const dx = target.x - currentMeas.x;
|
|
431
|
+
const dy = target.y - currentMeas.y;
|
|
432
|
+
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
|
|
433
|
+
newShifts[key] = {
|
|
434
|
+
x: dx,
|
|
435
|
+
y: dy
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return newShifts;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/** Compute and apply shifts during drag (skips the invisible dragged item). */
|
|
443
|
+
const computeShifts = () => {
|
|
444
|
+
const shifts = computeShiftsForOrder(pendingOrderRef.current, draggedDisplayIndexRef.current, phantomRef.current ?? undefined);
|
|
445
|
+
applyShifts(shifts);
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// ── Reorder during drag (ref-only, no state) ─────────────────────
|
|
449
|
+
|
|
450
|
+
const moveDraggedItem = toDisplayIndex => {
|
|
451
|
+
const fromIdx = draggedDisplayIndexRef.current;
|
|
452
|
+
if (fromIdx === undefined || fromIdx === toDisplayIndex) return;
|
|
453
|
+
|
|
454
|
+
// Don't move to a fixed item's position
|
|
455
|
+
const prev = pendingOrderRef.current;
|
|
456
|
+
if (prev.length === 0) return;
|
|
457
|
+
const targetOrigIdx = prev[toDisplayIndex];
|
|
458
|
+
if (targetOrigIdx !== undefined) {
|
|
459
|
+
const targetItem = stableData[targetOrigIdx];
|
|
460
|
+
if (targetItem !== undefined) {
|
|
461
|
+
const targetKey = keyExtractor(targetItem, targetOrigIdx);
|
|
462
|
+
if (fixedKeys.current.has(targetKey)) return;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
let newOrder;
|
|
466
|
+
if (reorderStrategy === 'swap') {
|
|
467
|
+
newOrder = [...prev];
|
|
468
|
+
const temp = newOrder[fromIdx];
|
|
469
|
+
newOrder[fromIdx] = newOrder[toDisplayIndex];
|
|
470
|
+
newOrder[toDisplayIndex] = temp;
|
|
471
|
+
} else {
|
|
472
|
+
newOrder = [...prev];
|
|
473
|
+
const [removed] = newOrder.splice(fromIdx, 1);
|
|
474
|
+
if (removed !== undefined) {
|
|
475
|
+
newOrder.splice(toDisplayIndex, 0, removed);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
pendingOrderRef.current = newOrder;
|
|
479
|
+
draggedDisplayIndexRef.current = toDisplayIndex;
|
|
480
|
+
|
|
481
|
+
// Update visual shifts
|
|
482
|
+
computeShifts();
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// ── Commit visual order (permanent shifts, no FlatList data change) ──
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Store the current pending order as the committed visual order.
|
|
489
|
+
* Called after drag ends — FlatList data is NOT changed. Items stay
|
|
490
|
+
* at their FlatList positions and shifts provide the visual reorder.
|
|
491
|
+
*/
|
|
492
|
+
const commitVisualOrder = () => {
|
|
493
|
+
const pending = pendingOrderRef.current;
|
|
494
|
+
if (pending.length === 0) return;
|
|
495
|
+
committedOrderRef.current = [...pending];
|
|
496
|
+
committedKeyOrderRef.current = pending.map(origIdx => {
|
|
497
|
+
const item = stableData[origIdx];
|
|
498
|
+
return item !== undefined ? keyExtractor(item, origIdx) : '';
|
|
499
|
+
});
|
|
500
|
+
// Store final shifts (all items including formerly-dragged) for cancel revert.
|
|
501
|
+
const finalShifts = computeShiftsForOrder(pending);
|
|
502
|
+
committedShiftsRef.current = finalShifts ?? {};
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Flush permanent shifts: update stableData to match rawData and clear
|
|
507
|
+
* shifts. Called after a delay so both the Fabric commit and the shift
|
|
508
|
+
* clearing are processed on the same UI frame — no visual blink because
|
|
509
|
+
* items are already at the correct visual positions via permanent shifts.
|
|
510
|
+
* Restores touch hit testing (FlatList cells at correct Yoga positions).
|
|
511
|
+
*/
|
|
512
|
+
/** Flag: next stableData change should clear shifts via runOnUI */
|
|
513
|
+
const pendingShiftFlushRef = useRef(false);
|
|
514
|
+
const flushVisualOrder = () => {
|
|
515
|
+
const currentRawData = rawDataRef.current;
|
|
516
|
+
committedOrderRef.current = [];
|
|
517
|
+
committedKeyOrderRef.current = [];
|
|
518
|
+
committedShiftsRef.current = {};
|
|
519
|
+
pendingShiftFlushRef.current = true;
|
|
520
|
+
setStableData(currentRawData);
|
|
521
|
+
// Shift clearing happens in the useLayoutEffect below — NOT here.
|
|
522
|
+
// This ensures it's queued during the same React commit as the
|
|
523
|
+
// Fabric update, so both land on the same UI frame.
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// When flushVisualOrder updates stableData, clear shifts during the
|
|
527
|
+
// same commit phase. The runOnUI worklet and the Fabric commit are
|
|
528
|
+
// both queued from this useLayoutEffect — processed on the same
|
|
529
|
+
// UI frame, so items transition from permanent-shift positions to
|
|
530
|
+
// new FlatList positions atomically. No blink.
|
|
531
|
+
useLayoutEffect(() => {
|
|
532
|
+
if (pendingShiftFlushRef.current) {
|
|
533
|
+
pendingShiftFlushRef.current = false;
|
|
534
|
+
runOnUI(() => {
|
|
535
|
+
'worklet';
|
|
536
|
+
|
|
537
|
+
instantClearSV.value = true;
|
|
538
|
+
shiftsRef.value = {};
|
|
539
|
+
})();
|
|
540
|
+
}
|
|
541
|
+
}, [stableData, instantClearSV, shiftsRef]);
|
|
542
|
+
|
|
543
|
+
// ── Drag state methods ─────────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
const setDraggedItem = index => {
|
|
546
|
+
draggedItem.value = index;
|
|
547
|
+
};
|
|
548
|
+
const resetDraggedItem = () => {
|
|
549
|
+
draggedItem.value = -1;
|
|
550
|
+
draggedDisplayIndexRef.current = undefined;
|
|
551
|
+
dragStartIndexRef.current = undefined;
|
|
552
|
+
pendingOrderRef.current = [];
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Initialize pending order from current originalIndexes at drag start.
|
|
557
|
+
*/
|
|
558
|
+
const initPendingOrder = () => {
|
|
559
|
+
// Start from the committed visual order (what the user sees),
|
|
560
|
+
// NOT originalIndexes (always identity with permanent shifts).
|
|
561
|
+
const committed = committedOrderRef.current;
|
|
562
|
+
pendingOrderRef.current = committed.length > 0 ? [...committed] : [...originalIndexes];
|
|
563
|
+
instantClearSV.value = false;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Cancel drag without reorder — clears shifts instantly and makes item visible.
|
|
568
|
+
* Used when the drag ends but no reorder happened (item snaps back to origin).
|
|
569
|
+
*/
|
|
570
|
+
const cancelDrag = () => {
|
|
571
|
+
instantClearSV.value = true;
|
|
572
|
+
// Revert to committed shifts from the previous drag (if any).
|
|
573
|
+
// If no previous drag, clears to empty (identity positions).
|
|
574
|
+
shiftsRef.value = committedShiftsRef.current;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// ── Cross-container phantom slot methods ────────────────────────────
|
|
578
|
+
|
|
579
|
+
const setPhantomSlot = (atDisplayIndex, width, height) => {
|
|
580
|
+
if (pendingOrderRef.current.length === 0) {
|
|
581
|
+
const committed = committedOrderRef.current;
|
|
582
|
+
pendingOrderRef.current = committed.length > 0 ? [...committed] : [...originalIndexes];
|
|
583
|
+
}
|
|
584
|
+
instantClearSV.value = false;
|
|
585
|
+
phantomRef.current = {
|
|
586
|
+
atDisplayIndex,
|
|
587
|
+
width,
|
|
588
|
+
height
|
|
589
|
+
};
|
|
590
|
+
const shifts = computeShiftsForOrder(pendingOrderRef.current, undefined, phantomRef.current);
|
|
591
|
+
applyShifts(shifts);
|
|
592
|
+
};
|
|
593
|
+
const clearPhantomSlot = () => {
|
|
594
|
+
phantomRef.current = undefined;
|
|
595
|
+
instantClearSV.value = false;
|
|
596
|
+
const shifts = computeShiftsForOrder(pendingOrderRef.current);
|
|
597
|
+
if (shifts !== undefined) {
|
|
598
|
+
applyShifts(shifts);
|
|
599
|
+
} else {
|
|
600
|
+
shiftsRef.value = committedShiftsRef.current;
|
|
601
|
+
}
|
|
602
|
+
pendingOrderRef.current = [];
|
|
603
|
+
};
|
|
604
|
+
const ejectDraggedItem = () => {
|
|
605
|
+
const dragIdx = draggedDisplayIndexRef.current;
|
|
606
|
+
if (dragIdx === undefined) return;
|
|
607
|
+
const pending = pendingOrderRef.current;
|
|
608
|
+
if (pending.length === 0 || dragIdx >= pending.length) return;
|
|
609
|
+
const newOrder = [...pending];
|
|
610
|
+
newOrder.splice(dragIdx, 1);
|
|
611
|
+
pendingOrderRef.current = newOrder;
|
|
612
|
+
instantClearSV.value = false;
|
|
613
|
+
applyShifts(computeShiftsForOrder(newOrder));
|
|
614
|
+
draggedDisplayIndexRef.current = undefined;
|
|
615
|
+
};
|
|
616
|
+
const reinjectDraggedItem = (displayIndex, originalIndex) => {
|
|
617
|
+
const pending = pendingOrderRef.current;
|
|
618
|
+
if (pending.length === 0) {
|
|
619
|
+
const committed = committedOrderRef.current;
|
|
620
|
+
pendingOrderRef.current = committed.length > 0 ? [...committed] : [...originalIndexes];
|
|
621
|
+
}
|
|
622
|
+
const newOrder = [...pendingOrderRef.current];
|
|
623
|
+
newOrder.splice(displayIndex, 0, originalIndex);
|
|
624
|
+
pendingOrderRef.current = newOrder;
|
|
625
|
+
draggedDisplayIndexRef.current = displayIndex;
|
|
626
|
+
instantClearSV.value = false;
|
|
627
|
+
computeShifts();
|
|
628
|
+
};
|
|
629
|
+
const getPhantomSnapTarget = () => {
|
|
630
|
+
const containerMeasurements = containerMeasurementsRef.current;
|
|
631
|
+
if (!containerMeasurements) return DraxSnapbackTargetPreset.Default;
|
|
632
|
+
const phantom = phantomRef.current;
|
|
633
|
+
if (!phantom) return DraxSnapbackTargetPreset.Default;
|
|
634
|
+
const pending = pendingOrderRef.current;
|
|
635
|
+
if (pending.length === 0) {
|
|
636
|
+
return {
|
|
637
|
+
x: containerMeasurements.x - scrollPosition.value.x,
|
|
638
|
+
y: containerMeasurements.y - scrollPosition.value.y
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
const gap = computeItemGap();
|
|
642
|
+
const firstOrigIdx = originalIndexes[0];
|
|
643
|
+
const startMeas = firstOrigIdx !== undefined ? getMeasForOrigIdx(firstOrigIdx) : undefined;
|
|
644
|
+
if (!startMeas) return DraxSnapbackTargetPreset.Default;
|
|
645
|
+
let cursor = horizontal ? startMeas.x : startMeas.y;
|
|
646
|
+
let displaySlot = 0;
|
|
647
|
+
for (let i = 0; i < pending.length; i++) {
|
|
648
|
+
if (displaySlot === phantom.atDisplayIndex) {
|
|
649
|
+
const phantomPos = horizontal ? {
|
|
650
|
+
x: cursor,
|
|
651
|
+
y: startMeas.y
|
|
652
|
+
} : {
|
|
653
|
+
x: startMeas.x,
|
|
654
|
+
y: cursor
|
|
655
|
+
};
|
|
656
|
+
return {
|
|
657
|
+
x: containerMeasurements.x + phantomPos.x - scrollPosition.value.x,
|
|
658
|
+
y: containerMeasurements.y + phantomPos.y - scrollPosition.value.y
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
const meas = getMeasForOrigIdx(pending[i]);
|
|
662
|
+
if (!meas) return DraxSnapbackTargetPreset.Default;
|
|
663
|
+
cursor += (horizontal ? meas.width : meas.height) + gap;
|
|
664
|
+
displaySlot++;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Phantom at end
|
|
668
|
+
const phantomPos = horizontal ? {
|
|
669
|
+
x: cursor,
|
|
670
|
+
y: startMeas.y
|
|
671
|
+
} : {
|
|
672
|
+
x: startMeas.x,
|
|
673
|
+
y: cursor
|
|
674
|
+
};
|
|
675
|
+
return {
|
|
676
|
+
x: containerMeasurements.x + phantomPos.x - scrollPosition.value.x,
|
|
677
|
+
y: containerMeasurements.y + phantomPos.y - scrollPosition.value.y
|
|
678
|
+
};
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Compute the display slot (index) from a container-local content position.
|
|
683
|
+
* Used when dragging over empty space (no receiver hit) to determine
|
|
684
|
+
* which slot the dragged item should occupy.
|
|
685
|
+
*/
|
|
686
|
+
const getSlotFromPosition = contentPos => {
|
|
687
|
+
const pending = pendingOrderRef.current;
|
|
688
|
+
if (pending.length === 0) return 0;
|
|
689
|
+
|
|
690
|
+
// Use ORIGINAL layout positions for stable slot boundaries.
|
|
691
|
+
// Key insight: slot boundaries must NOT shift when items are reordered
|
|
692
|
+
// during drag. Using pending-order measurements causes oscillation:
|
|
693
|
+
// move changes boundaries → same position maps to new slot → another
|
|
694
|
+
// move → boundaries shift again → gap keeps running from the finger.
|
|
695
|
+
// Original layout positions are fixed throughout the drag.
|
|
696
|
+
const measurements = originalIndexes.map(origIdx => getMeasForOrigIdx(origIdx));
|
|
697
|
+
const missingIdx = measurements.findIndex(m => !m);
|
|
698
|
+
if (missingIdx >= 0) {
|
|
699
|
+
return draggedDisplayIndexRef.current ?? 0;
|
|
700
|
+
}
|
|
701
|
+
const gap = computeItemGap();
|
|
702
|
+
|
|
703
|
+
// Use the shorter of measurements and pending to avoid out-of-bounds access.
|
|
704
|
+
const itemCount = Math.min(measurements.length, pending.length);
|
|
705
|
+
if (itemCount === 0) return 0;
|
|
706
|
+
if (numColumns <= 1) {
|
|
707
|
+
// Single-column list — find which slot the position falls in.
|
|
708
|
+
// Boundary is at the gap midpoint between adjacent items, making
|
|
709
|
+
// forward and backward equally responsive (distance = size/2 + gap/2).
|
|
710
|
+
// Using item centers (50%) would be asymmetric: the hover center
|
|
711
|
+
// starts AT the forward boundary but a full item-height from the
|
|
712
|
+
// backward boundary, making forward too sensitive and backward too sluggish.
|
|
713
|
+
const firstMeas = measurements[0];
|
|
714
|
+
if (!firstMeas) return 0;
|
|
715
|
+
let cursor = horizontal ? firstMeas.x : firstMeas.y;
|
|
716
|
+
for (let i = 0; i < itemCount; i++) {
|
|
717
|
+
const meas = measurements[i];
|
|
718
|
+
if (!meas) continue;
|
|
719
|
+
const size = horizontal ? meas.width : meas.height;
|
|
720
|
+
const boundary = cursor + size + gap / 2; // midpoint of gap after item
|
|
721
|
+
const pos = horizontal ? contentPos.x : contentPos.y;
|
|
722
|
+
if (pos < boundary) return i;
|
|
723
|
+
cursor += size + gap;
|
|
724
|
+
}
|
|
725
|
+
return itemCount - 1;
|
|
726
|
+
} else if (getItemSpan) {
|
|
727
|
+
// ── Mixed-size grid: map finger to cell, then to display index ──
|
|
728
|
+
const geo = deriveGridGeometry();
|
|
729
|
+
if (!geo) return draggedDisplayIndexRef.current ?? 0;
|
|
730
|
+
|
|
731
|
+
// Pack original order (stable positions during drag)
|
|
732
|
+
const origPacking = packGrid(itemCount, numColumns, displayIdx => getSpanForOrigIdx(originalIndexes[displayIdx]));
|
|
733
|
+
|
|
734
|
+
// Find which grid cell the finger is in
|
|
735
|
+
const cellCol = Math.max(0, Math.min(Math.floor((contentPos.x - geo.startX + geo.colGap / 2) / (geo.cellWidth + geo.colGap)), numColumns - 1));
|
|
736
|
+
const cellRow = Math.max(0, Math.floor((contentPos.y - geo.startY + geo.rowGap / 2) / (geo.cellHeight + geo.rowGap)));
|
|
737
|
+
|
|
738
|
+
// Build cell → display index map (all cells each item occupies)
|
|
739
|
+
const cellOwner = new Map();
|
|
740
|
+
for (let i = 0; i < origPacking.positions.length && i < itemCount; i++) {
|
|
741
|
+
const pos = origPacking.positions[i];
|
|
742
|
+
const span = getSpanForOrigIdx(originalIndexes[i]);
|
|
743
|
+
for (let r = 0; r < span.rowSpan; r++) {
|
|
744
|
+
for (let c = 0; c < span.colSpan; c++) {
|
|
745
|
+
cellOwner.set(`${pos.row + r},${pos.col + c}`, i);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Direct cell hit
|
|
751
|
+
const owner = cellOwner.get(`${cellRow},${cellCol}`);
|
|
752
|
+
if (owner !== undefined) return Math.min(owner, pending.length - 1);
|
|
753
|
+
|
|
754
|
+
// Empty cell — find nearest item by center distance
|
|
755
|
+
let minDist = Infinity;
|
|
756
|
+
let nearest = 0;
|
|
757
|
+
for (let i = 0; i < origPacking.positions.length && i < itemCount; i++) {
|
|
758
|
+
const meas = measurements[i];
|
|
759
|
+
if (!meas) continue;
|
|
760
|
+
const cx = meas.x + meas.width / 2;
|
|
761
|
+
const cy = meas.y + meas.height / 2;
|
|
762
|
+
const dist = Math.abs(contentPos.x - cx) + Math.abs(contentPos.y - cy);
|
|
763
|
+
if (dist < minDist) {
|
|
764
|
+
minDist = dist;
|
|
765
|
+
nearest = i;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return Math.min(nearest, pending.length - 1);
|
|
769
|
+
} else {
|
|
770
|
+
// ── Uniform grid — find row then column ──
|
|
771
|
+
const firstMeas = measurements[0];
|
|
772
|
+
if (!firstMeas) return 0;
|
|
773
|
+
let cursorY = firstMeas.y;
|
|
774
|
+
|
|
775
|
+
// Find row — use full row boundary (not center) so the bottom
|
|
776
|
+
// half of a row doesn't spill into the next row.
|
|
777
|
+
let targetRow = 0;
|
|
778
|
+
const totalRows = Math.ceil(itemCount / numColumns);
|
|
779
|
+
for (let row = 0; row < totalRows; row++) {
|
|
780
|
+
const rowStart = row * numColumns;
|
|
781
|
+
const rowEnd = Math.min(rowStart + numColumns, itemCount);
|
|
782
|
+
let rowHeight = 0;
|
|
783
|
+
for (let col = rowStart; col < rowEnd; col++) {
|
|
784
|
+
const colMeas = measurements[col];
|
|
785
|
+
if (colMeas) rowHeight = Math.max(rowHeight, colMeas.height);
|
|
786
|
+
}
|
|
787
|
+
if (contentPos.y < cursorY + rowHeight + gap / 2) {
|
|
788
|
+
targetRow = row;
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
cursorY += rowHeight + gap;
|
|
792
|
+
targetRow = row;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Find column within row — use gap midpoint for symmetric sensitivity
|
|
796
|
+
const colXPositions = [];
|
|
797
|
+
for (let c = 0; c < numColumns && c < originalIndexes.length; c++) {
|
|
798
|
+
const origIdx = originalIndexes[c];
|
|
799
|
+
const colMeas = origIdx !== undefined ? getMeasForOrigIdx(origIdx) : undefined;
|
|
800
|
+
colXPositions.push(colMeas ? colMeas.x : 0);
|
|
801
|
+
}
|
|
802
|
+
const firstMeasWidth = firstMeas.width;
|
|
803
|
+
const colGap = numColumns >= 2 && colXPositions.length >= 2 ? (colXPositions[1] ?? 0) - ((colXPositions[0] ?? 0) + firstMeasWidth) : 0;
|
|
804
|
+
let targetCol = 0;
|
|
805
|
+
for (let c = 0; c < numColumns; c++) {
|
|
806
|
+
const colX = colXPositions[c] ?? 0;
|
|
807
|
+
const colMeas = measurements[Math.min(c, measurements.length - 1)];
|
|
808
|
+
if (!colMeas) break;
|
|
809
|
+
const colBoundary = colX + colMeas.width + colGap / 2;
|
|
810
|
+
if (contentPos.x < colBoundary) {
|
|
811
|
+
targetCol = c;
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
targetCol = c;
|
|
815
|
+
}
|
|
816
|
+
return Math.min(targetRow * numColumns + targetCol, pending.length - 1);
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
// ── Snapback target ─────────────────────────────────────────────────
|
|
821
|
+
|
|
822
|
+
const getSnapbackTarget = () => {
|
|
823
|
+
const containerMeasurements = containerMeasurementsRef.current;
|
|
824
|
+
if (!containerMeasurements) return DraxSnapbackTargetPreset.Default;
|
|
825
|
+
const displayIdx = draggedDisplayIndexRef.current;
|
|
826
|
+
if (displayIdx === undefined) return DraxSnapbackTargetPreset.Default;
|
|
827
|
+
const pending = pendingOrderRef.current;
|
|
828
|
+
if (pending.length === 0) return DraxSnapbackTargetPreset.Default;
|
|
829
|
+
|
|
830
|
+
// Compute the target position for the dragged item by laying out
|
|
831
|
+
// items in the pending order and accumulating dimensions.
|
|
832
|
+
// This is the same logic as computeShifts but we only need the
|
|
833
|
+
// position at displayIdx.
|
|
834
|
+
const measurements = pending.map(origIdx => getMeasForOrigIdx(origIdx));
|
|
835
|
+
if (measurements.some(m => !m)) return DraxSnapbackTargetPreset.Default;
|
|
836
|
+
let targetPos;
|
|
837
|
+
const gap = computeItemGap();
|
|
838
|
+
|
|
839
|
+
// Use FlatList's actual starting position, not pending[0] which
|
|
840
|
+
// may be the dragged item at the wrong FlatList slot.
|
|
841
|
+
const snapFirstOrigIdx = originalIndexes[0];
|
|
842
|
+
const snapStartMeas = snapFirstOrigIdx !== undefined ? getMeasForOrigIdx(snapFirstOrigIdx) : undefined;
|
|
843
|
+
if (!snapStartMeas) return DraxSnapbackTargetPreset.Default;
|
|
844
|
+
if (numColumns <= 1) {
|
|
845
|
+
// Single-column list
|
|
846
|
+
let cursor = horizontal ? snapStartMeas.x : snapStartMeas.y;
|
|
847
|
+
for (let i = 0; i < displayIdx; i++) {
|
|
848
|
+
const meas = measurements[i];
|
|
849
|
+
cursor += (horizontal ? meas.width : meas.height) + gap;
|
|
850
|
+
}
|
|
851
|
+
targetPos = horizontal ? {
|
|
852
|
+
x: cursor,
|
|
853
|
+
y: snapStartMeas.y
|
|
854
|
+
} : {
|
|
855
|
+
x: snapStartMeas.x,
|
|
856
|
+
y: cursor
|
|
857
|
+
};
|
|
858
|
+
} else if (getItemSpan) {
|
|
859
|
+
// Mixed-size grid — pack items and find target position
|
|
860
|
+
const geo = deriveGridGeometry();
|
|
861
|
+
if (!geo) return DraxSnapbackTargetPreset.Default;
|
|
862
|
+
const packing = packGrid(pending.length, numColumns, di => getSpanForOrigIdx(pending[di]));
|
|
863
|
+
const gp = packing.positions[displayIdx];
|
|
864
|
+
if (!gp) return DraxSnapbackTargetPreset.Default;
|
|
865
|
+
targetPos = {
|
|
866
|
+
x: geo.startX + gp.col * (geo.cellWidth + geo.colGap),
|
|
867
|
+
y: geo.startY + gp.row * (geo.cellHeight + geo.rowGap)
|
|
868
|
+
};
|
|
869
|
+
} else {
|
|
870
|
+
// Uniform grid
|
|
871
|
+
let cursorY = snapStartMeas.y;
|
|
872
|
+
const targetRow = Math.floor(displayIdx / numColumns);
|
|
873
|
+
const targetCol = displayIdx % numColumns;
|
|
874
|
+
for (let row = 0; row < targetRow; row++) {
|
|
875
|
+
const rowStart = row * numColumns;
|
|
876
|
+
const rowEnd = Math.min(rowStart + numColumns, pending.length);
|
|
877
|
+
let rowHeight = 0;
|
|
878
|
+
for (let col = rowStart; col < rowEnd; col++) {
|
|
879
|
+
rowHeight = Math.max(rowHeight, measurements[col].height);
|
|
880
|
+
}
|
|
881
|
+
cursorY += rowHeight + gap;
|
|
882
|
+
}
|
|
883
|
+
const colMeas = getMeasForOrigIdx(originalIndexes[targetCol]);
|
|
884
|
+
targetPos = {
|
|
885
|
+
x: colMeas ? colMeas.x : 0,
|
|
886
|
+
y: cursorY
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
return {
|
|
890
|
+
x: containerMeasurements.x + targetPos.x - scrollPosition.value.x,
|
|
891
|
+
y: containerMeasurements.y + targetPos.y - scrollPosition.value.y
|
|
892
|
+
};
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// ── Scroll event handlers ─────────────────────────────────────────
|
|
896
|
+
|
|
897
|
+
const onScroll = event => {
|
|
898
|
+
runOnUI(_event => {
|
|
899
|
+
'worklet';
|
|
900
|
+
|
|
901
|
+
scrollPosition.value = {
|
|
902
|
+
x: _event.contentOffset.x,
|
|
903
|
+
y: _event.contentOffset.y
|
|
904
|
+
};
|
|
905
|
+
})(event.nativeEvent);
|
|
906
|
+
};
|
|
907
|
+
const onContentSizeChange = (width, height) => {
|
|
908
|
+
contentSizeRef.current = {
|
|
909
|
+
x: width,
|
|
910
|
+
y: height
|
|
911
|
+
};
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
// ── Build the internal object ─────────────────────────────────────
|
|
915
|
+
const internal = {
|
|
916
|
+
id,
|
|
917
|
+
horizontal,
|
|
918
|
+
numColumns,
|
|
919
|
+
reorderStrategy,
|
|
920
|
+
longPressDelay,
|
|
921
|
+
lockToMainAxis,
|
|
922
|
+
animationConfig,
|
|
923
|
+
getItemSpan,
|
|
924
|
+
inactiveItemStyle,
|
|
925
|
+
itemEntering,
|
|
926
|
+
itemExiting,
|
|
927
|
+
fixedKeys,
|
|
928
|
+
draggedItem,
|
|
929
|
+
itemMeasurements,
|
|
930
|
+
originalIndexes,
|
|
931
|
+
keyExtractor,
|
|
932
|
+
data: stableData,
|
|
933
|
+
rawData: stableData,
|
|
934
|
+
moveDraggedItem,
|
|
935
|
+
getSnapbackTarget,
|
|
936
|
+
setDraggedItem,
|
|
937
|
+
resetDraggedItem,
|
|
938
|
+
scrollPosition,
|
|
939
|
+
containerMeasurementsRef,
|
|
940
|
+
contentSizeRef,
|
|
941
|
+
autoScrollJumpRatio,
|
|
942
|
+
autoScrollBackThreshold,
|
|
943
|
+
autoScrollForwardThreshold,
|
|
944
|
+
onDragStart,
|
|
945
|
+
onDragPositionChange,
|
|
946
|
+
onDragEnd,
|
|
947
|
+
onReorder,
|
|
948
|
+
getMeasurementByOriginalIndex,
|
|
949
|
+
dropTargetPositionSV,
|
|
950
|
+
dropTargetVisibleSV,
|
|
951
|
+
onItemSnapEnd: undefined,
|
|
952
|
+
draggedDisplayIndexRef,
|
|
953
|
+
dragStartIndexRef,
|
|
954
|
+
shiftsRef,
|
|
955
|
+
instantClearSV,
|
|
956
|
+
shiftsValidSV,
|
|
957
|
+
initPendingOrder,
|
|
958
|
+
commitVisualOrder,
|
|
959
|
+
flushVisualOrder,
|
|
960
|
+
computeShiftsForOrder,
|
|
961
|
+
committedOrderRef,
|
|
962
|
+
pendingOrderRef,
|
|
963
|
+
cancelDrag,
|
|
964
|
+
getSlotFromPosition,
|
|
965
|
+
phantomRef,
|
|
966
|
+
setPhantomSlot,
|
|
967
|
+
clearPhantomSlot,
|
|
968
|
+
ejectDraggedItem,
|
|
969
|
+
reinjectDraggedItem,
|
|
970
|
+
getPhantomSnapTarget,
|
|
971
|
+
ghostShiftsRef,
|
|
972
|
+
committedShiftsRef,
|
|
973
|
+
skipShiftsInvalidationRef
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
// Stable index-based keyExtractor prevents FlatList from unmounting cells
|
|
977
|
+
// when data reorders. Cells stay at their FlatList index and React updates
|
|
978
|
+
// content in place (no unmount/remount), eliminating the multi-frame blink.
|
|
979
|
+
const stableKeyExtractor = useStableKeyExtractor();
|
|
980
|
+
return {
|
|
981
|
+
data: stableData,
|
|
982
|
+
onScroll,
|
|
983
|
+
onContentSizeChange,
|
|
984
|
+
stableKeyExtractor,
|
|
985
|
+
_internal: internal
|
|
986
|
+
};
|
|
987
|
+
};
|
|
988
|
+
//# sourceMappingURL=useSortableList.js.map
|