react-beautiful-dnd-next
Version:
Beautiful and accessible drag and drop for lists with React
404 lines (357 loc) • 12.5 kB
JavaScript
// @flow
import type { Position } from 'css-box-model';
import { useRef } from 'react';
import invariant from 'tiny-invariant';
import { useMemo, useCallback } from 'use-memo-one';
import type { EventBinding } from '../util/event-types';
import createEventMarshal, {
type EventMarshal,
} from '../util/create-event-marshal';
import type { Callbacks } from '../drag-handle-types';
import { bindEvents, unbindEvents } from '../util/bind-events';
import createScheduler from '../util/create-scheduler';
import * as keyCodes from '../../key-codes';
import supportedPageVisibilityEventName from '../util/supported-page-visibility-event-name';
import createPostDragEventPreventer, {
type EventPreventer,
} from '../util/create-post-drag-event-preventer';
import useLayoutEffect from '../../use-isomorphic-layout-effect';
export type Args = {|
callbacks: Callbacks,
getDraggableRef: () => ?HTMLElement,
getWindow: () => HTMLElement,
canStartCapturing: (event: Event) => boolean,
getShouldRespectForcePress: () => boolean,
onCaptureStart: (abort: () => void) => void,
onCaptureEnd: () => void,
|};
export type OnTouchStart = (event: TouchEvent) => void;
type PendingDrag = {|
longPressTimerId: TimeoutID,
point: Position,
|};
type TouchWithForce = Touch & {
force: number,
};
// Decreased from 150 as a work around for an issue for forcepress on iOS
// https://github.com/atlassian/react-beautiful-dnd/issues/1401
export const timeForLongPress: number = 120;
export const forcePressThreshold: number = 0.15;
const touchStartMarshal: EventMarshal = createEventMarshal();
const noop = (): void => {};
export default function useTouchSensor(args: Args): OnTouchStart {
const {
callbacks,
getWindow,
canStartCapturing,
getShouldRespectForcePress,
onCaptureStart,
onCaptureEnd,
} = args;
const pendingRef = useRef<?PendingDrag>(null);
const isDraggingRef = useRef<boolean>(false);
const hasMovedRef = useRef<boolean>(false);
const unbindWindowEventsRef = useRef<() => void>(noop);
const getIsCapturing = useCallback(
() => Boolean(pendingRef.current || isDraggingRef.current),
[],
);
const postDragClickPreventer: EventPreventer = useMemo(
() => createPostDragEventPreventer(getWindow),
[getWindow],
);
const schedule = useMemo(() => {
invariant(
!getIsCapturing(),
'Should not recreate scheduler while capturing',
);
return createScheduler(callbacks);
}, [callbacks, getIsCapturing]);
const stop = useCallback(() => {
if (!getIsCapturing()) {
return;
}
schedule.cancel();
unbindWindowEventsRef.current();
touchStartMarshal.reset();
hasMovedRef.current = false;
onCaptureEnd();
// if dragging - prevent the next click
if (isDraggingRef.current) {
postDragClickPreventer.preventNext();
isDraggingRef.current = false;
return;
}
const pending: ?PendingDrag = pendingRef.current;
invariant(pending, 'Expected a pending drag');
clearTimeout(pending.longPressTimerId);
pendingRef.current = null;
}, [getIsCapturing, onCaptureEnd, postDragClickPreventer, schedule]);
const cancel = useCallback(() => {
const wasDragging: boolean = isDraggingRef.current;
stop();
if (wasDragging) {
callbacks.onCancel();
}
}, [callbacks, stop]);
const windowBindings: EventBinding[] = useMemo(() => {
invariant(
!getIsCapturing(),
'Should not recreate window bindings while capturing',
);
const bindings: EventBinding[] = [
{
eventName: 'touchmove',
// Opting out of passive touchmove (default) so as to prevent scrolling while moving
// Not worried about performance as effect of move is throttled in requestAnimationFrame
// Using `capture: false` due to a recent horrible firefox bug: https://twitter.com/alexandereardon/status/1125904207184187393
options: { passive: false, capture: false },
fn: (event: TouchEvent) => {
// Drag has not yet started and we are waiting for a long press.
if (!isDraggingRef.current) {
stop();
return;
}
// At this point we are dragging
if (!hasMovedRef.current) {
hasMovedRef.current = true;
}
const touch: ?Touch = event.touches[0];
if (!touch) {
return;
}
const point: Position = {
x: touch.clientX,
y: touch.clientY,
};
// We need to prevent the default event in order to block native scrolling
// Also because we are using it as part of a drag we prevent the default action
// as a sign that we are using the event
event.preventDefault();
schedule.move(point);
},
},
{
eventName: 'touchend',
fn: (event: TouchEvent) => {
// drag had not started yet - do not prevent the default action
if (!isDraggingRef.current) {
stop();
return;
}
// already dragging - this event is directly ending a drag
event.preventDefault();
stop();
callbacks.onDrop();
},
},
{
eventName: 'touchcancel',
fn: (event: TouchEvent) => {
// drag had not started yet - do not prevent the default action
if (!isDraggingRef.current) {
stop();
return;
}
// already dragging - this event is directly ending a drag
event.preventDefault();
cancel();
},
},
// another touch start should not happen without a
// touchend or touchcancel. However, just being super safe
{
eventName: 'touchstart',
fn: cancel,
},
// If the orientation of the device changes - kill the drag
// https://davidwalsh.name/orientation-change
{
eventName: 'orientationchange',
fn: cancel,
},
// some devices fire resize if the orientation changes
{
eventName: 'resize',
fn: cancel,
},
// ## Passive: true
// For scroll events we are okay with eventual consistency.
// Passive scroll listeners is the default behavior for mobile
// but we are being really clear here
// ## Capture: false
// Scroll events on elements do not bubble, but they go through the capture phase
// https://twitter.com/alexandereardon/status/985994224867819520
// Using capture: false here as we want to avoid intercepting droppable scroll requests
{
eventName: 'scroll',
options: { passive: true, capture: false },
fn: () => {
// stop a pending drag
if (pendingRef.current) {
stop();
return;
}
schedule.windowScrollMove();
},
},
// Long press can bring up a context menu
// need to opt out of this behavior
{
eventName: 'contextmenu',
fn: (event: Event) => {
// always opting out of context menu events
event.preventDefault();
},
},
// On some devices it is possible to have a touch interface with a keyboard.
// On any keyboard event we cancel a touch drag
{
eventName: 'keydown',
fn: (event: KeyboardEvent) => {
if (!isDraggingRef.current) {
cancel();
return;
}
// direct cancel: we are preventing the default action
// indirect cancel: we are not preventing the default action
// escape is a direct cancel
if (event.keyCode === keyCodes.escape) {
event.preventDefault();
}
cancel();
},
},
// Need to opt out of dragging if the user is a force press
// Only for webkit which has decided to introduce its own custom way of doing things
// https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html
// NOTE: this function is back-ported from the `virtual` branch
{
eventName: 'touchforcechange',
fn: (event: TouchEvent) => {
const touch: TouchWithForce = (event.touches[0]: any);
const isForcePress: boolean = touch.force >= forcePressThreshold;
if (!isForcePress) {
return;
}
const shouldRespect: boolean = getShouldRespectForcePress();
if (pendingRef.current) {
if (shouldRespect) {
cancel();
}
// If not respecting we just let the event go through
// It will not have an impact on the browser until
// there has been a sufficient time ellapsed
return;
}
// DRAGGING
if (shouldRespect) {
if (hasMovedRef.current) {
// After the user has moved we do not allow the dragging item to be force pressed
// This prevents strange behaviour such as a link preview opening mid drag
event.preventDefault();
return;
}
// indirect cancel
cancel();
return;
}
// not respecting during a drag
event.preventDefault();
},
},
// Cancel on page visibility change
{
eventName: supportedPageVisibilityEventName,
fn: cancel,
},
];
return bindings;
}, [
callbacks,
cancel,
getIsCapturing,
getShouldRespectForcePress,
schedule,
stop,
]);
const bindWindowEvents = useCallback(() => {
const win: HTMLElement = getWindow();
const options = { capture: true };
// setting up our unbind before we bind
unbindWindowEventsRef.current = () =>
unbindEvents(win, windowBindings, options);
bindEvents(win, windowBindings, options);
}, [getWindow, windowBindings]);
const startDragging = useCallback(() => {
const pending: ?PendingDrag = pendingRef.current;
invariant(pending, 'Cannot start a drag without a pending drag');
isDraggingRef.current = true;
pendingRef.current = null;
hasMovedRef.current = false;
callbacks.onLift({
clientSelection: pending.point,
movementMode: 'FLUID',
});
}, [callbacks]);
const startPendingDrag = useCallback(
(event: TouchEvent) => {
invariant(!pendingRef.current, 'Expected there to be no pending drag');
const touch: Touch = event.touches[0];
const { clientX, clientY } = touch;
const point: Position = {
x: clientX,
y: clientY,
};
const longPressTimerId: TimeoutID = setTimeout(
startDragging,
timeForLongPress,
);
const pending: PendingDrag = {
point,
longPressTimerId,
};
pendingRef.current = pending;
onCaptureStart(stop);
bindWindowEvents();
},
[bindWindowEvents, onCaptureStart, startDragging, stop],
);
const onTouchStart = (event: TouchEvent) => {
if (touchStartMarshal.isHandled()) {
return;
}
invariant(
!getIsCapturing(),
'Should not be able to perform a touch start while a drag or pending drag is occurring',
);
// We do not need to prevent the event on a dropping draggable as
// the touchstart event will not fire due to pointer-events: none
// https://codesandbox.io/s/oxo0o775rz
if (!canStartCapturing(event)) {
return;
}
// We need to stop parents from responding to this event - which may cause a double lift
// We also need to NOT call event.preventDefault() so as to maintain as much standard
// browser interactions as possible.
// This includes navigation on anchors which we want to preserve
touchStartMarshal.handle();
startPendingDrag(event);
};
// This is needed for safari
// Simply adding a non capture, non passive 'touchmove' listener.
// This forces event.preventDefault() in dynamically added
// touchmove event handlers to actually work
// https://github.com/atlassian/react-beautiful-dnd/issues/1374
useLayoutEffect(function webkitHack() {
const unbind = bindEvents(window, [
{
eventName: 'touchmove',
fn: noop,
options: { capture: false, passive: false },
},
]);
return unbind;
}, []);
return onTouchStart;
}