UNPKG

react-aria-components

Version:

A library of styleable components built using React Aria

305 lines (260 loc) • 10.4 kB
import {Direction, DropTarget, DropTargetDelegate, ItemDropTarget, Key, Node, RootDropTarget} from '@react-types/shared'; interface TreeCollection<T> extends Iterable<Node<T>> { getItem(key: Key): Node<T> | null, getChildren?(key: Key): Iterable<Node<T>>, getKeyAfter(key: Key): Key | null, getKeyBefore(key: Key): Key | null } interface TreeState<T> { collection: TreeCollection<T>, expandedKeys: Set<Key> } interface PointerTracking { lastY: number, lastX: number, yDirection: 'up' | 'down' | null, xDirection: 'left' | 'right' | null, boundaryContext: { parentKey: Key, lastSwitchY: number, lastSwitchX: number, preferredTargetIndex?: number } | null } const X_SWITCH_THRESHOLD = 10; const Y_SWITCH_THRESHOLD = 5; export class TreeDropTargetDelegate<T> { private delegate: DropTargetDelegate | null = null; private state: TreeState<T> | null = null; private direction: Direction = 'ltr'; private pointerTracking: PointerTracking = { lastY: 0, lastX: 0, yDirection: null, xDirection: null, boundaryContext: null }; setup(delegate: DropTargetDelegate, state: TreeState<T>, direction: Direction): void { this.delegate = delegate; this.state = state; this.direction = direction; } getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget | null { let baseTarget = this.delegate!.getDropTargetFromPoint(x, y, isValidDropTarget); if (!baseTarget || baseTarget.type === 'root') { return baseTarget; } return this.resolveDropTarget(baseTarget, x, y, isValidDropTarget); } private resolveDropTarget( target: ItemDropTarget, x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean ): RootDropTarget | ItemDropTarget | null { let tracking = this.pointerTracking; // Calculate movement directions let deltaY = y - tracking.lastY; let deltaX = x - tracking.lastX; let currentYMovement: 'up' | 'down' | null = tracking.yDirection; let currentXMovement: 'left' | 'right' | null = tracking.xDirection; if (Math.abs(deltaY) > Y_SWITCH_THRESHOLD) { currentYMovement = deltaY > 0 ? 'down' : 'up'; tracking.yDirection = currentYMovement; tracking.lastY = y; } if (Math.abs(deltaX) > X_SWITCH_THRESHOLD) { currentXMovement = deltaX > 0 ? 'right' : 'left'; tracking.xDirection = currentXMovement; tracking.lastX = x; } // Normalize to 'after' if (target.dropPosition === 'before') { let keyBefore = this.state!.collection.getKeyBefore(target.key); if (keyBefore != null) { target = { type: 'item', key: keyBefore, dropPosition: 'after' } as const; } } let potentialTargets = this.getPotentialTargets(target, isValidDropTarget); if (potentialTargets.length === 0) { return {type: 'root'}; } let resolvedItemTarget: ItemDropTarget; if (potentialTargets.length > 1) { resolvedItemTarget = this.selectTarget(potentialTargets, target, x, y, currentYMovement, currentXMovement); } else { resolvedItemTarget = potentialTargets[0]; // Reset boundary context since we're not in a boundary case tracking.boundaryContext = null; } return resolvedItemTarget; } // Returns potential targets for an ambiguous drop position (e.g. after the last child of a parent, or after the parent itself) // Ordered by level, from innermost to outermost. private getPotentialTargets(originalTarget: ItemDropTarget, isValidDropTarget: (target: DropTarget) => boolean): ItemDropTarget[] { if (originalTarget.dropPosition === 'on') { return [originalTarget]; } let target = originalTarget; let collection = this.state!.collection; let currentItem = collection.getItem(target.key); while (currentItem && currentItem?.type !== 'item' && currentItem.nextKey != null) { target.key = currentItem.nextKey; currentItem = collection.getItem(currentItem.nextKey); } let potentialTargets = [target]; // If target has children and is expanded, use "before first child" if (currentItem && currentItem.hasChildNodes && this.state!.expandedKeys.has(currentItem.key) && collection.getChildren && target.dropPosition === 'after') { let firstChildItemNode: Node<T> | null = null; for (let child of collection.getChildren(currentItem.key)) { if (child.type === 'item') { firstChildItemNode = child; break; } } if (firstChildItemNode) { const beforeFirstChildTarget = { type: 'item', key: firstChildItemNode.key, dropPosition: 'before' } as const; if (isValidDropTarget(beforeFirstChildTarget)) { return [beforeFirstChildTarget]; } else { return []; } } } if (currentItem?.nextKey != null) { return [originalTarget]; } // Walk up the parent chain to find ancestors that are the last child at their level let parentKey = currentItem?.parentKey; let ancestorTargets: ItemDropTarget[] = []; while (parentKey) { let parentItem = collection.getItem(parentKey); let nextItem = parentItem?.nextKey ? collection.getItem(parentItem.nextKey) : null; let isLastChildAtLevel = !nextItem || nextItem.parentKey !== parentKey; if (isLastChildAtLevel) { let afterParentTarget = { type: 'item', key: parentKey, dropPosition: 'after' } as const; if (isValidDropTarget(afterParentTarget)) { ancestorTargets.push(afterParentTarget); } if (nextItem) { break; } } parentKey = parentItem?.parentKey; } if (ancestorTargets.length > 0) { potentialTargets.push(...ancestorTargets); } // Handle converting "after" to "before next" for non-ambiguous cases if (potentialTargets.length === 1) { let nextKey = collection.getKeyAfter(target.key); let nextNode = nextKey ? collection.getItem(nextKey) : null; if (nextKey != null && nextNode && currentItem && nextNode.level != null && currentItem.level != null && nextNode.level > currentItem.level) { let beforeTarget = { type: 'item', key: nextKey, dropPosition: 'before' } as const; if (isValidDropTarget(beforeTarget)) { return [beforeTarget]; } } } return potentialTargets.filter(isValidDropTarget); } private selectTarget( potentialTargets: ItemDropTarget[], originalTarget: ItemDropTarget, x: number, y: number, currentYMovement: 'up' | 'down' | null, currentXMovement: 'left' | 'right' | null ): ItemDropTarget { if (potentialTargets.length < 2) { return potentialTargets[0]; } let tracking = this.pointerTracking; let currentItem = this.state!.collection.getItem(originalTarget.key); let parentKey = currentItem?.parentKey; if (!parentKey) { return potentialTargets[0]; } // More than 1 potential target - use Y for initial target, then X for switching levels // Initialize boundary context if needed if (!tracking.boundaryContext || tracking.boundaryContext.parentKey !== parentKey) { // If entering from below, start with outer-most let initialTargetIndex = tracking.yDirection === 'up' ? potentialTargets.length - 1 : 0; tracking.boundaryContext = { parentKey, preferredTargetIndex: initialTargetIndex, lastSwitchY: y, lastSwitchX: x }; } let boundaryContext = tracking.boundaryContext; let distanceFromLastXSwitch = Math.abs(x - boundaryContext.lastSwitchX); let distanceFromLastYSwitch = Math.abs(y - boundaryContext.lastSwitchY); // Switch between targets based on Y movement if (distanceFromLastYSwitch > Y_SWITCH_THRESHOLD && currentYMovement) { let currentIndex = boundaryContext.preferredTargetIndex || 0; if (currentYMovement === 'down' && currentIndex === 0) { // Moving down from inner-most, switch to outer-most boundaryContext.preferredTargetIndex = potentialTargets.length - 1; } else if (currentYMovement === 'up' && currentIndex === potentialTargets.length - 1) { // Moving up from outer-most, switch to inner-most boundaryContext.preferredTargetIndex = 0; } // Reset x tracking so that moving diagonally doesn't cause flickering. tracking.xDirection = null; } // X movement controls level selection if (distanceFromLastXSwitch > X_SWITCH_THRESHOLD && currentXMovement) { let currentTargetIndex = boundaryContext.preferredTargetIndex || 0; if (currentXMovement === 'left') { if (this.direction === 'ltr') { // LTR: left = move to higher level in tree (increase index) if (currentTargetIndex < potentialTargets.length - 1) { boundaryContext.preferredTargetIndex = currentTargetIndex + 1; boundaryContext.lastSwitchX = x; } } else { // RTL: left = move to lower level in tree (decrease index) if (currentTargetIndex > 0) { boundaryContext.preferredTargetIndex = currentTargetIndex - 1; boundaryContext.lastSwitchX = x; } } } else if (currentXMovement === 'right') { if (this.direction === 'ltr') { // LTR: right = move to lower level in tree (decrease index) if (currentTargetIndex > 0) { boundaryContext.preferredTargetIndex = currentTargetIndex - 1; boundaryContext.lastSwitchX = x; } } else { // RTL: right = move to higher level in tree (increase index) if (currentTargetIndex < potentialTargets.length - 1) { boundaryContext.preferredTargetIndex = currentTargetIndex + 1; boundaryContext.lastSwitchX = x; } } } // Reset y tracking so that moving diagonally doesn't cause flickering. tracking.yDirection = null; } let targetIndex = Math.max(0, Math.min(boundaryContext.preferredTargetIndex || 0, potentialTargets.length - 1)); return potentialTargets[targetIndex]; } }