@hello-pangea/dnd
Version:
Beautiful and accessible drag and drop for lists with React
497 lines (436 loc) • 12.2 kB
text/typescript
import rafSchd from 'raf-schd';
import { useState } from 'react';
import { useCallback, useMemo } from 'use-memo-one';
import type { Position } from 'css-box-model';
import { invariant } from '../../invariant';
import type {
ContextId,
State,
Sensor,
StopDragOptions,
PreDragActions,
FluidDragActions,
SnapDragActions,
DraggableId,
SensorAPI,
TryGetLock,
TryGetLockOptions,
DraggableOptions,
} from '../../types';
import create from './lock';
import type { Lock, LockAPI } from './lock';
import type { Store, Action } from '../../state/store-types';
import canStartDrag from '../../state/can-start-drag';
import {
move as moveAction,
moveUp as moveUpAction,
moveRight as moveRightAction,
moveDown as moveDownAction,
moveLeft as moveLeftAction,
drop as dropAction,
lift as liftAction,
flush,
} from '../../state/action-creators';
import type { LiftArgs as LiftActionArgs } from '../../state/action-creators';
import isDragging from '../../state/is-dragging';
import type {
Registry,
DraggableEntry,
} from '../../state/registry/registry-types';
import useMouseSensor from './sensors/use-mouse-sensor';
import useKeyboardSensor from './sensors/use-keyboard-sensor';
import useTouchSensor from './sensors/use-touch-sensor';
import useValidateSensorHooks from './use-validate-sensor-hooks';
import isEventInInteractiveElement from './is-event-in-interactive-element';
import getBorderBoxCenterPosition from '../get-border-box-center-position';
import { warning } from '../../dev-warning';
import useLayoutEffect from '../use-isomorphic-layout-effect';
import { noop } from '../../empty';
import findClosestDraggableIdFromEvent from './find-closest-draggable-id-from-event';
import findDraggable from '../get-elements/find-draggable';
import bindEvents from '../event-bindings/bind-events';
function preventDefault(event: Event) {
event.preventDefault();
}
type LockPhase = 'PRE_DRAG' | 'DRAGGING' | 'COMPLETED';
interface IsActiveArgs {
expected: LockPhase;
phase: LockPhase;
isLockActive: () => boolean;
shouldWarn: boolean;
}
function isActive({
expected,
phase,
isLockActive,
shouldWarn,
}: IsActiveArgs): boolean {
// lock is no longer active
if (!isLockActive()) {
if (shouldWarn) {
warning(`
Cannot perform action.
The sensor no longer has an action lock.
Tips:
- Throw away your action handlers when forceStop() is called
- Check actions.isActive() if you really need to
`);
}
return false;
}
// wrong phase
if (expected !== phase) {
if (shouldWarn) {
warning(`
Cannot perform action.
The actions you used belong to an outdated phase
Current phase: ${expected}
You called an action from outdated phase: ${phase}
Tips:
- Do not use preDragActions actions after calling preDragActions.lift()
`);
}
return false;
}
return true;
}
interface CanStartArgs {
lockAPI: LockAPI;
registry: Registry;
store: Store;
draggableId: DraggableId;
}
function canStart({
lockAPI,
store,
registry,
draggableId,
}: CanStartArgs): boolean {
// lock is already claimed - cannot start
if (lockAPI.isClaimed()) {
return false;
}
const entry: DraggableEntry | null = registry.draggable.findById(draggableId);
if (!entry) {
warning(`Unable to find draggable with id: ${draggableId}`);
return false;
}
// draggable is not enabled - cannot start
if (!entry.options.isEnabled) {
return false;
}
// Application might now allow dragging right now
if (!canStartDrag(store.getState(), draggableId)) {
return false;
}
return true;
}
interface TryStartArgs {
lockAPI: LockAPI;
contextId: ContextId;
registry: Registry;
store: Store;
draggableId: DraggableId;
forceSensorStop: (() => void) | null;
sourceEvent: Event | null;
}
function tryStart({
lockAPI,
contextId,
store,
registry,
draggableId,
forceSensorStop,
sourceEvent,
}: TryStartArgs): PreDragActions | null {
const shouldStart: boolean = canStart({
lockAPI,
store,
registry,
draggableId,
});
if (!shouldStart) {
return null;
}
const entry: DraggableEntry = registry.draggable.getById(draggableId);
const el: HTMLElement | null = findDraggable(contextId, entry.descriptor.id);
if (!el) {
warning(`Unable to find draggable element with id: ${draggableId}`);
return null;
}
// Do not allow dragging from interactive elements
if (
sourceEvent &&
!entry.options.canDragInteractiveElements &&
isEventInInteractiveElement(el, sourceEvent)
) {
return null;
}
// claiming lock
const lock: Lock = lockAPI.claim(forceSensorStop || noop);
let phase: LockPhase = 'PRE_DRAG';
function getShouldRespectForcePress(): boolean {
// not looking up the entry as it might have been removed in a virtual list
return entry.options.shouldRespectForcePress;
}
function isLockActive(): boolean {
return lockAPI.isActive(lock);
}
function tryDispatch(expected: LockPhase, getAction: () => Action): void {
if (isActive({ expected, phase, isLockActive, shouldWarn: true })) {
store.dispatch(getAction());
}
}
const tryDispatchWhenDragging = tryDispatch.bind(null, 'DRAGGING');
interface LiftArgs {
liftActionArgs: LiftActionArgs;
cleanup: () => void;
actions: any;
}
function lift(args: LiftArgs) {
function completed() {
lockAPI.release();
phase = 'COMPLETED';
}
// Double lift = bad
if (phase !== 'PRE_DRAG') {
completed();
invariant(false, `Cannot lift in phase ${phase}`);
}
store.dispatch(liftAction(args.liftActionArgs));
// We are now in the DRAGGING phase
phase = 'DRAGGING';
function finish(
reason: 'CANCEL' | 'DROP',
options: StopDragOptions = { shouldBlockNextClick: false },
) {
args.cleanup();
// block next click if requested
if (options.shouldBlockNextClick) {
const unbind = bindEvents(window, [
{
eventName: 'click',
fn: preventDefault,
options: {
// only blocking a single click
once: true,
passive: false,
capture: true,
},
},
]);
// Sometimes the click is swallowed, such as when there is reparenting
// The click event (in the message queue) will occur before the next setTimeout expiry
// https://codesandbox.io/s/click-behaviour-pkfk2
setTimeout(unbind);
}
// releasing
completed();
store.dispatch(dropAction({ reason }));
}
return {
isActive: () =>
isActive({
expected: 'DRAGGING',
phase,
isLockActive,
// Do not want to want warnings for boolean checks
shouldWarn: false,
}),
shouldRespectForcePress: getShouldRespectForcePress,
drop: (options?: StopDragOptions) => finish('DROP', options),
cancel: (options?: StopDragOptions) => finish('CANCEL', options),
...args.actions,
};
}
function fluidLift(clientSelection: Position): FluidDragActions {
const move = rafSchd((client: Position) => {
tryDispatchWhenDragging(() => moveAction({ client }));
});
const api = lift({
liftActionArgs: {
id: draggableId,
clientSelection,
movementMode: 'FLUID',
},
cleanup: () => move.cancel(),
actions: { move },
});
return {
...api,
move,
};
}
function snapLift(): SnapDragActions {
const actions = {
moveUp: () => tryDispatchWhenDragging(moveUpAction),
moveRight: () => tryDispatchWhenDragging(moveRightAction),
moveDown: () => tryDispatchWhenDragging(moveDownAction),
moveLeft: () => tryDispatchWhenDragging(moveLeftAction),
};
return lift({
liftActionArgs: {
id: draggableId,
clientSelection: getBorderBoxCenterPosition(el as HTMLElement),
movementMode: 'SNAP',
},
cleanup: noop,
actions,
});
}
function abortPreDrag() {
const shouldRelease: boolean = isActive({
expected: 'PRE_DRAG',
phase,
isLockActive,
shouldWarn: true,
});
if (shouldRelease) {
lockAPI.release();
}
}
const preDrag: PreDragActions = {
isActive: () =>
isActive({
expected: 'PRE_DRAG',
phase,
isLockActive,
// Do not want to want warnings for boolean checks
shouldWarn: false,
}),
shouldRespectForcePress: getShouldRespectForcePress,
fluidLift,
snapLift,
abort: abortPreDrag,
};
return preDrag;
}
interface SensorMarshalArgs {
contextId: ContextId;
registry: Registry;
store: Store;
customSensors: Sensor[] | null;
enableDefaultSensors: boolean;
}
// default sensors are now exported to library consumers
const defaultSensors: Sensor[] = [
useMouseSensor,
useKeyboardSensor,
useTouchSensor,
];
export default function useSensorMarshal({
contextId,
store,
registry,
customSensors,
enableDefaultSensors,
}: SensorMarshalArgs): void {
const useSensors: Sensor[] = [
...(enableDefaultSensors ? defaultSensors : []),
...(customSensors || []),
];
const lockAPI: LockAPI = useState(() => create())[0];
const tryAbandonLock = useCallback(
function tryAbandonLock(previous: State, current: State) {
if (isDragging(previous) && !isDragging(current)) {
lockAPI.tryAbandon();
}
},
[lockAPI],
);
// We need to abort any capturing if there is no longer a drag
useLayoutEffect(
function listenToStore() {
let previous: State = store.getState();
const unsubscribe = store.subscribe(() => {
const current: State = store.getState();
tryAbandonLock(previous, current);
previous = current;
});
// unsubscribe from store when unmounting
return unsubscribe;
},
[lockAPI, store, tryAbandonLock],
);
// abort any lock on unmount
useLayoutEffect(() => {
return lockAPI.tryAbandon;
}, [lockAPI.tryAbandon]);
const canGetLock = useCallback(
(draggableId: DraggableId): boolean => {
return canStart({
lockAPI,
registry,
store,
draggableId,
});
},
[lockAPI, registry, store],
);
const tryGetLock: TryGetLock = useCallback(
(
draggableId: DraggableId,
forceStop?: () => void,
options?: TryGetLockOptions,
): PreDragActions | null =>
tryStart({
lockAPI,
registry,
contextId,
store,
draggableId,
forceSensorStop: forceStop || null,
sourceEvent:
options && options.sourceEvent ? options.sourceEvent : null,
}),
[contextId, lockAPI, registry, store],
);
const findClosestDraggableId = useCallback(
(event: Event): DraggableId | null =>
findClosestDraggableIdFromEvent(contextId, event),
[contextId],
);
const findOptionsForDraggable = useCallback(
(id: DraggableId): DraggableOptions | null => {
const entry: DraggableEntry | null = registry.draggable.findById(id);
return entry ? entry.options : null;
},
[registry.draggable],
);
const tryReleaseLock = useCallback(
function tryReleaseLock() {
if (!lockAPI.isClaimed()) {
return;
}
lockAPI.tryAbandon();
if (store.getState().phase !== 'IDLE') {
store.dispatch(flush());
}
},
[lockAPI, store],
);
const isLockClaimed = useCallback(() => lockAPI.isClaimed(), [lockAPI]);
const api: SensorAPI = useMemo(
() => ({
canGetLock,
tryGetLock,
findClosestDraggableId,
findOptionsForDraggable,
tryReleaseLock,
isLockClaimed,
}),
[
canGetLock,
tryGetLock,
findClosestDraggableId,
findOptionsForDraggable,
tryReleaseLock,
isLockClaimed,
],
);
// Bad ass
useValidateSensorHooks(useSensors);
for (let i = 0; i < useSensors.length; i++) {
useSensors[i](api);
}
}