Skip to content

Commit 4a3acfc

Browse files
committed
init TreeDropTargetDelegate
1 parent 6ef333b commit 4a3acfc

File tree

3 files changed

+344
-149
lines changed

3 files changed

+344
-149
lines changed
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
import {Direction, DropTarget, ItemDropTarget, Key, Node, RefObject} from '@react-types/shared';
2+
import {ListDropTargetDelegate} from './ListDropTargetDelegate';
3+
4+
interface TreeDropTargetDelegateOptions {
5+
/**
6+
* The horizontal layout direction.
7+
* @default 'ltr'
8+
*/
9+
direction?: Direction
10+
}
11+
12+
interface TreeCollection<T> extends Iterable<Node<T>> {
13+
getItem(key: Key): Node<T> | null,
14+
getChildren?(key: Key): Iterable<Node<T>>,
15+
getKeyAfter(key: Key): Key | null,
16+
getKeyBefore(key: Key): Key | null
17+
}
18+
19+
interface TreeState<T> {
20+
collection: TreeCollection<T>,
21+
expandedKeys: Set<Key>
22+
}
23+
24+
interface PointerTracking {
25+
lastY: number,
26+
lastX: number,
27+
yDirection: 'up' | 'down' | null,
28+
xDirection: 'left' | 'right' | null,
29+
boundaryContext: {
30+
parentKey: Key,
31+
lastChildKey: Key,
32+
lastSwitchY: number,
33+
lastSwitchX: number,
34+
entryDirection: 'up' | 'down' | null,
35+
preferredTargetIndex?: number
36+
} | null
37+
}
38+
39+
const X_SWITCH_THRESHOLD = 3;
40+
const Y_SWITCH_THRESHOLD = 4;
41+
42+
export class TreeDropTargetDelegate<T> extends ListDropTargetDelegate {
43+
private state: TreeState<T>;
44+
private pointerTracking: PointerTracking = {
45+
lastY: 0,
46+
lastX: 0,
47+
yDirection: null,
48+
xDirection: null,
49+
boundaryContext: null
50+
};
51+
52+
constructor(state: TreeState<T>, ref: RefObject<HTMLElement | null>, options?: TreeDropTargetDelegateOptions) {
53+
super(state.collection as Iterable<Node<unknown>>, ref, {
54+
direction: options?.direction || 'ltr',
55+
orientation: 'vertical',
56+
layout: 'stack'
57+
});
58+
this.state = state;
59+
}
60+
61+
getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget {
62+
let baseTarget = super.getDropTargetFromPoint(x, y, isValidDropTarget);
63+
64+
if (!baseTarget || baseTarget.type === 'root') {
65+
return baseTarget;
66+
}
67+
68+
let target = this.resolveDropTarget(baseTarget, x, y, isValidDropTarget);
69+
70+
return target;
71+
}
72+
73+
private resolveDropTarget(
74+
target: ItemDropTarget,
75+
x: number,
76+
y: number,
77+
isValidDropTarget: (target: DropTarget) => boolean
78+
): ItemDropTarget {
79+
let tracking = this.pointerTracking;
80+
81+
// Calculate movement directions
82+
let deltaY = y - tracking.lastY;
83+
let deltaX = x - tracking.lastX;
84+
let currentYMovement: 'up' | 'down' | null = null;
85+
let currentXMovement: 'left' | 'right' | null = null;
86+
87+
if (Math.abs(deltaY) > 2) {
88+
currentYMovement = deltaY > 0 ? 'down' : 'up';
89+
tracking.yDirection = currentYMovement;
90+
tracking.lastY = y;
91+
}
92+
93+
if (Math.abs(deltaX) > 2) {
94+
currentXMovement = deltaX > 0 ? 'right' : 'left';
95+
tracking.xDirection = currentXMovement;
96+
tracking.lastX = x;
97+
}
98+
99+
let potentialTargets = this.getPotentialTargets(target, isValidDropTarget);
100+
101+
if (potentialTargets.length > 1) {
102+
target = this.selectTarget(potentialTargets, target, x, y, currentYMovement, currentXMovement);
103+
} else {
104+
target = potentialTargets[0];
105+
// Reset boundary context since we're not in a boundary case
106+
tracking.boundaryContext = null;
107+
}
108+
109+
return target;
110+
}
111+
112+
private getPotentialTargets(originalTarget: ItemDropTarget, isValidDropTarget: (target: DropTarget) => boolean): ItemDropTarget[] {
113+
if (originalTarget.dropPosition === 'on') {
114+
return [originalTarget];
115+
}
116+
117+
let target = originalTarget;
118+
119+
// Normalize to 'after'
120+
if (originalTarget.dropPosition === 'before') {
121+
let keyBefore = this.state.collection.getKeyBefore(originalTarget.key);
122+
if (keyBefore == null) {
123+
return [originalTarget];
124+
}
125+
target = {
126+
type: 'item',
127+
key: keyBefore,
128+
dropPosition: 'after'
129+
} as const;
130+
}
131+
132+
let currentItem = this.state.collection.getItem(target.key);
133+
while (currentItem && currentItem?.type !== 'item' && currentItem.nextKey != null) {
134+
target.key = currentItem.nextKey;
135+
currentItem = this.state.collection.getItem(currentItem.nextKey);
136+
}
137+
138+
let potentialTargets = [target];
139+
140+
// If target has children and is expanded, use "before first child"
141+
if (currentItem && currentItem.hasChildNodes && this.state.expandedKeys.has(currentItem.key) && this.state.collection.getChildren) {
142+
let firstChildItemNode: Node<any> | null = null;
143+
for (let child of this.state.collection.getChildren(currentItem.key)) {
144+
if (child.type === 'item') {
145+
firstChildItemNode = child;
146+
break;
147+
}
148+
}
149+
150+
if (firstChildItemNode) {
151+
const beforeFirstChildTarget = {
152+
type: 'item',
153+
key: firstChildItemNode.key,
154+
dropPosition: 'before'
155+
} as const;
156+
157+
if (isValidDropTarget(beforeFirstChildTarget)) {
158+
return [beforeFirstChildTarget];
159+
}
160+
}
161+
}
162+
163+
// Walk up the parent chain to find ancestors that are the last child at their level
164+
let parentKey = currentItem?.parentKey;
165+
let ancestorTargets: ItemDropTarget[] = [];
166+
167+
while (parentKey) {
168+
let parentItem = this.state.collection.getItem(parentKey);
169+
let nextItem = parentItem?.nextKey ? this.state.collection.getItem(parentItem.nextKey) : null;
170+
let isLastChildAtLevel = !nextItem || nextItem.parentKey !== parentKey;
171+
172+
if (isLastChildAtLevel) {
173+
let afterParentTarget = {
174+
type: 'item',
175+
key: parentKey,
176+
dropPosition: 'after'
177+
} as const;
178+
179+
if (isValidDropTarget(afterParentTarget)) {
180+
ancestorTargets.push(afterParentTarget);
181+
}
182+
if (nextItem) {
183+
break;
184+
}
185+
}
186+
187+
parentKey = parentItem?.parentKey;
188+
}
189+
190+
if (ancestorTargets.length > 0) {
191+
potentialTargets.push(...ancestorTargets);
192+
}
193+
194+
// Handle converting "after" to "before next" for non-ambiguous cases
195+
if (potentialTargets.length === 1) {
196+
let nextKey = this.state.collection.getKeyAfter(target.key);
197+
let nextNode = nextKey ? this.state.collection.getItem(nextKey) : null;
198+
if (nextKey != null && nextNode && currentItem && nextNode.level != null && currentItem.level != null && nextNode.level > currentItem.level) {
199+
let beforeTarget = {
200+
type: 'item',
201+
key: nextKey,
202+
dropPosition: 'before'
203+
} as const;
204+
if (isValidDropTarget(beforeTarget)) {
205+
return [beforeTarget];
206+
}
207+
}
208+
}
209+
210+
return potentialTargets;
211+
}
212+
213+
private selectTarget(
214+
potentialTargets: ItemDropTarget[],
215+
originalTarget: ItemDropTarget,
216+
x: number,
217+
y: number,
218+
currentYMovement: 'up' | 'down' | null,
219+
currentXMovement: 'left' | 'right' | null
220+
): ItemDropTarget {
221+
if (potentialTargets.length < 2) {
222+
return potentialTargets[0];
223+
}
224+
225+
let tracking = this.pointerTracking;
226+
let currentItem = this.state.collection.getItem(originalTarget.key);
227+
let parentKey = currentItem?.parentKey;
228+
229+
if (!parentKey) {
230+
return potentialTargets[0];
231+
}
232+
233+
// Case 1: Exactly 2 potential targets - use Y movement only
234+
if (potentialTargets.length === 2) {
235+
// Initialize boundary context if needed
236+
if (!tracking.boundaryContext || tracking.boundaryContext.parentKey !== parentKey) {
237+
let initialTargetIndex = tracking.yDirection === 'up' ? 1 : 0;
238+
239+
tracking.boundaryContext = {
240+
parentKey,
241+
lastChildKey: originalTarget.key,
242+
preferredTargetIndex: initialTargetIndex,
243+
lastSwitchY: y,
244+
lastSwitchX: x,
245+
entryDirection: tracking.yDirection
246+
};
247+
}
248+
249+
let boundaryContext = tracking.boundaryContext;
250+
let distanceFromLastYSwitch = Math.abs(y - boundaryContext.lastSwitchY);
251+
252+
// Toggle between targets based on Y movement
253+
if (distanceFromLastYSwitch > Y_SWITCH_THRESHOLD && currentYMovement) {
254+
let currentIndex = boundaryContext.preferredTargetIndex || 0;
255+
256+
if (currentYMovement === 'down' && currentIndex === 0) {
257+
// Moving down from inner-most, switch to outer-most
258+
boundaryContext.preferredTargetIndex = 1;
259+
boundaryContext.lastSwitchY = y;
260+
} else if (currentYMovement === 'down' && currentIndex === 1) {
261+
// Moving down from outer-most, switch back to inner-most
262+
boundaryContext.preferredTargetIndex = 0;
263+
boundaryContext.lastSwitchY = y;
264+
} else if (currentYMovement === 'up' && currentIndex === 1) {
265+
// Moving up from outer-most, switch to inner-most
266+
boundaryContext.preferredTargetIndex = 0;
267+
boundaryContext.lastSwitchY = y;
268+
} else if (currentYMovement === 'up' && currentIndex === 0) {
269+
// Moving up from inner-most, switch to outer-most
270+
boundaryContext.preferredTargetIndex = 1;
271+
boundaryContext.lastSwitchY = y;
272+
}
273+
}
274+
275+
return potentialTargets[boundaryContext.preferredTargetIndex || 0];
276+
}
277+
278+
// Case 2: More than 2 potential targets - use Y for initial target, then X for switching levels
279+
// Initialize boundary context if needed
280+
if (!tracking.boundaryContext || tracking.boundaryContext.parentKey !== parentKey) {
281+
let initialTargetIndex = 0; // Default to inner-most
282+
if (tracking.yDirection === 'up') {
283+
// If entering from below, start with outer-most
284+
initialTargetIndex = potentialTargets.length - 1;
285+
}
286+
287+
tracking.boundaryContext = {
288+
parentKey,
289+
lastChildKey: originalTarget.key,
290+
preferredTargetIndex: initialTargetIndex,
291+
lastSwitchY: y,
292+
lastSwitchX: x,
293+
entryDirection: tracking.yDirection
294+
};
295+
}
296+
297+
let boundaryContext = tracking.boundaryContext;
298+
let distanceFromLastXSwitch = Math.abs(x - boundaryContext.lastSwitchX);
299+
300+
// X movement controls level selection
301+
if (distanceFromLastXSwitch > X_SWITCH_THRESHOLD && currentXMovement) {
302+
let currentTargetIndex = boundaryContext.preferredTargetIndex || 0;
303+
304+
if (currentXMovement === 'left') {
305+
if (this.direction === 'ltr') {
306+
// LTR: left = move to higher level in tree (increase index)
307+
if (currentTargetIndex < potentialTargets.length - 1) {
308+
boundaryContext.preferredTargetIndex = currentTargetIndex + 1;
309+
boundaryContext.lastSwitchX = x;
310+
}
311+
} else {
312+
// RTL: left = move to lower level in tree (decrease index)
313+
if (currentTargetIndex > 0) {
314+
boundaryContext.preferredTargetIndex = currentTargetIndex - 1;
315+
boundaryContext.lastSwitchX = x;
316+
}
317+
}
318+
} else if (currentXMovement === 'right') {
319+
if (this.direction === 'ltr') {
320+
// LTR: right = move to lower level in tree (decrease index)
321+
if (currentTargetIndex > 0) {
322+
boundaryContext.preferredTargetIndex = currentTargetIndex - 1;
323+
boundaryContext.lastSwitchX = x;
324+
}
325+
} else {
326+
// RTL: right = move to higher level in tree (increase index)
327+
if (currentTargetIndex < potentialTargets.length - 1) {
328+
boundaryContext.preferredTargetIndex = currentTargetIndex + 1;
329+
boundaryContext.lastSwitchX = x;
330+
}
331+
}
332+
}
333+
}
334+
335+
let targetIndex = Math.max(0, Math.min(boundaryContext.preferredTargetIndex || 0, potentialTargets.length - 1));
336+
return potentialTargets[targetIndex];
337+
}
338+
}

packages/@react-aria/dnd/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,6 @@ export {useDraggableCollection} from './useDraggableCollection';
6464
export {useClipboard} from './useClipboard';
6565
export {DragPreview} from './DragPreview';
6666
export {ListDropTargetDelegate} from './ListDropTargetDelegate';
67+
export {TreeDropTargetDelegate} from './TreeDropTargetDelegate';
6768
export {isVirtualDragging} from './DragManager';
6869
export {isDirectoryDropItem, isFileDropItem, isTextDropItem} from './utils';

0 commit comments

Comments
 (0)