@hello-pangea/dnd
Version:
Beautiful and accessible drag and drop for lists with React
391 lines (335 loc) • 10 kB
text/typescript
import { useRef } from 'react';
import { useCallback, useMemo } from 'use-memo-one';
import type { Position } from 'css-box-model';
import { invariant } from '../../../invariant';
import type {
PreDragActions,
FluidDragActions,
DraggableId,
SensorAPI,
DraggableOptions,
} from '../../../types';
import type {
AnyEventBinding,
EventBinding,
EventOptions,
MouseEventBinding,
} from '../../event-bindings/event-types';
import bindEvents from '../../event-bindings/bind-events';
import * as keyCodes from '../../key-codes';
import preventStandardKeyEvents from './util/prevent-standard-key-events';
import supportedPageVisibilityEventName from './util/supported-page-visibility-event-name';
import useLayoutEffect from '../../use-isomorphic-layout-effect';
import { noop } from '../../../empty';
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button
export const primaryButton = 0;
export const sloppyClickThreshold = 5;
function isSloppyClickThresholdExceeded(
original: Position,
current: Position,
): boolean {
return (
Math.abs(current.x - original.x) >= sloppyClickThreshold ||
Math.abs(current.y - original.y) >= sloppyClickThreshold
);
}
interface Idle {
type: 'IDLE';
}
interface Pending {
type: 'PENDING';
point: Position;
actions: PreDragActions;
}
interface Dragging {
type: 'DRAGGING';
actions: FluidDragActions;
}
type Phase = Idle | Pending | Dragging;
const idle: Idle = { type: 'IDLE' };
interface GetCaptureArgs {
cancel: () => void;
completed: () => void;
getPhase: () => Phase;
setPhase: (phase: Phase) => void;
}
function getCaptureBindings({
cancel,
completed,
getPhase,
setPhase,
}: GetCaptureArgs): AnyEventBinding[] {
return [
{
eventName: 'mousemove',
fn: (event: MouseEvent) => {
const { button, clientX, clientY } = event;
if (button !== primaryButton) {
return;
}
const point: Position = {
x: clientX,
y: clientY,
};
const phase: Phase = getPhase();
// Already dragging
if (phase.type === 'DRAGGING') {
// preventing default as we are using this event
event.preventDefault();
phase.actions.move(point);
return;
}
// There should be a pending drag at this point
invariant(phase.type === 'PENDING', 'Cannot be IDLE');
const pending: Position = phase.point;
// threshold not yet exceeded
if (!isSloppyClickThresholdExceeded(pending, point)) {
return;
}
// preventing default as we are using this event
event.preventDefault();
// Lifting at the current point to prevent the draggable item from
// jumping by the sloppyClickThreshold
const actions: FluidDragActions = phase.actions.fluidLift(point);
setPhase({
type: 'DRAGGING',
actions,
});
},
},
{
eventName: 'mouseup',
fn: (event: MouseEvent) => {
const phase: Phase = getPhase();
if (phase.type !== 'DRAGGING') {
cancel();
return;
}
// preventing default as we are using this event
event.preventDefault();
phase.actions.drop({ shouldBlockNextClick: true });
completed();
},
},
{
eventName: 'mousedown',
fn: (event: MouseEvent) => {
// this can happen during a drag when the user clicks a button
// other than the primary mouse button
if (getPhase().type === 'DRAGGING') {
event.preventDefault();
}
cancel();
},
},
{
eventName: 'keydown',
fn: (event: KeyboardEvent) => {
const phase: Phase = getPhase();
// Abort if any keystrokes while a drag is pending
if (phase.type === 'PENDING') {
cancel();
return;
}
// cancelling a drag
if (event.keyCode === keyCodes.escape) {
event.preventDefault();
cancel();
return;
}
preventStandardKeyEvents(event);
},
},
{
eventName: 'resize',
fn: cancel,
},
{
eventName: 'scroll',
// kill a pending drag if there is a window scroll
options: { passive: true, capture: false },
fn: () => {
if (getPhase().type === 'PENDING') {
cancel();
}
},
},
// Need to opt out of dragging if the user is a force press
// Only for safari which has decided to introduce its own custom way of doing things
// https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html
{
eventName: 'webkitmouseforcedown',
// it is considered a indirect cancel so we do not
// prevent default in any situation.
fn: (event: Event) => {
const phase: Phase = getPhase();
invariant(phase.type !== 'IDLE', 'Unexpected phase');
if (phase.actions.shouldRespectForcePress()) {
cancel();
return;
}
// This technically doesn't do anything.
// It won't do anything if `webkitmouseforcewillbegin` is prevented.
// But it is a good signal that we want to opt out of this
event.preventDefault();
},
},
// Cancel on page visibility change
{
eventName: supportedPageVisibilityEventName,
fn: cancel,
},
];
}
export default function useMouseSensor(api: SensorAPI) {
const phaseRef = useRef<Phase>(idle);
const unbindEventsRef = useRef<() => void>(noop);
const startCaptureBinding: MouseEventBinding = useMemo(
() => ({
eventName: 'mousedown',
fn: function onMouseDown(event: MouseEvent) {
// Event already used
if (event.defaultPrevented) {
return;
}
// only starting a drag if dragging with the primary mouse button
if (event.button !== primaryButton) {
return;
}
// Do not start a drag if any modifier key is pressed
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
return;
}
const draggableId: DraggableId | null =
api.findClosestDraggableId(event);
if (!draggableId) {
return;
}
const actions: PreDragActions | null = api.tryGetLock(
draggableId,
// stop is defined later
// eslint-disable-next-line @typescript-eslint/no-use-before-define
stop,
{ sourceEvent: event },
);
if (!actions) {
return;
}
// consuming the event
event.preventDefault();
const point: Position = {
x: event.clientX,
y: event.clientY,
};
// unbind this listener
unbindEventsRef.current();
// using this function before it is defined as their is a circular usage pattern
// eslint-disable-next-line @typescript-eslint/no-use-before-define
startPendingDrag(actions, point);
},
}),
// not including startPendingDrag as it is not defined initially
// eslint-disable-next-line react-hooks/exhaustive-deps
[api],
);
const preventForcePressBinding: EventBinding = useMemo(
() => ({
eventName: 'webkitmouseforcewillbegin',
fn: (event: Event) => {
if (event.defaultPrevented) {
return;
}
const id: DraggableId | null = api.findClosestDraggableId(event);
if (!id) {
return;
}
const options: DraggableOptions | null =
api.findOptionsForDraggable(id);
if (!options) {
return;
}
if (options.shouldRespectForcePress) {
return;
}
if (!api.canGetLock(id)) {
return;
}
event.preventDefault();
},
}),
[api],
);
const listenForCapture = useCallback(
function listenForCapture() {
const options: EventOptions = {
passive: false,
capture: true,
};
unbindEventsRef.current = bindEvents(
window,
[preventForcePressBinding, startCaptureBinding],
options,
);
},
[preventForcePressBinding, startCaptureBinding],
);
const stop = useCallback(() => {
const current: Phase = phaseRef.current;
if (current.type === 'IDLE') {
return;
}
phaseRef.current = idle;
unbindEventsRef.current();
listenForCapture();
}, [listenForCapture]);
const cancel = useCallback(() => {
const phase: Phase = phaseRef.current;
stop();
if (phase.type === 'DRAGGING') {
phase.actions.cancel({ shouldBlockNextClick: true });
}
if (phase.type === 'PENDING') {
phase.actions.abort();
}
}, [stop]);
const bindCapturingEvents = useCallback(
function bindCapturingEvents() {
const options = { capture: true, passive: false };
const bindings: AnyEventBinding[] = getCaptureBindings({
cancel,
completed: stop,
getPhase: () => phaseRef.current,
setPhase: (phase: Phase) => {
phaseRef.current = phase;
},
});
unbindEventsRef.current = bindEvents(window, bindings, options);
},
[cancel, stop],
);
const startPendingDrag = useCallback(
function startPendingDrag(actions: PreDragActions, point: Position) {
invariant(
phaseRef.current.type === 'IDLE',
'Expected to move from IDLE to PENDING drag',
);
phaseRef.current = {
type: 'PENDING',
point,
actions,
};
bindCapturingEvents();
},
[bindCapturingEvents],
);
useLayoutEffect(
function mount() {
listenForCapture();
// kill any pending window events when unmounting
return function unmount() {
unbindEventsRef.current();
};
},
[listenForCapture],
);
}