react-beautiful-dnd
Version:
Beautiful, accessible drag and drop for lists with React.js
305 lines (255 loc) • 8.16 kB
Flow
// @flow
/* eslint-disable no-use-before-define */
import stopEvent from '../util/stop-event';
import createScheduler from '../util/create-scheduler';
import isSloppyClickThresholdExceeded from '../util/is-sloppy-click-threshold-exceeded';
import getWindowFromRef from '../../get-window-from-ref';
import type {
Position,
HTMLElement,
} from '../../../types';
import type {
Callbacks,
Props,
} from '../drag-handle-types';
import type { TouchSensor } from './sensor-types';
type State = {
isDragging: boolean,
hasMoved: boolean,
preventClick: boolean,
longPressTimerId: ?number,
pending: ?Position,
}
type TouchWithForce = Touch & {
force: number
}
export const timeForLongPress: number = 200;
export const forcePressThreshold: number = 0.15;
const noop = (): void => { };
const initial: State = {
isDragging: false,
pending: null,
hasMoved: false,
preventClick: false,
longPressTimerId: null,
};
export default (callbacks: Callbacks, getDraggableRef: () => ?HTMLElement): TouchSensor => {
let state: State = initial;
const setState = (partial: Object): void => {
state = {
...state,
...partial,
};
};
const isDragging = (): boolean => state.isDragging;
const isCapturing = (): boolean =>
Boolean(state.pending || state.isDragging || state.longPressTimerId);
const schedule = createScheduler(callbacks, isDragging);
const startDragging = () => {
// Drag can start from either a timeout or user movement
// so we need to clear the timeout
if (state.longPressTimerId) {
clearTimeout(state.longPressTimerId);
}
const pending: ?Position = state.pending;
if (!state.pending) {
console.error('cannot start a touch drag without a pending position');
kill();
return;
}
setState({
isDragging: true,
// has not moved from original position yet
hasMoved: false,
// no longer relevant
pending: null,
longPressTimerId: null,
});
callbacks.onLift({
client: pending,
// not allowing container scrolling for touch movements at this stage
isScrollAllowed: false,
});
};
const stopDragging = (fn?: Function = noop) => {
unbindWindowEvents();
setState({
...initial,
preventClick: true,
});
fn();
};
const startPendingDrag = (event: TouchEvent) => {
const touch: Touch = event.touches[0];
const { clientX, clientY } = touch;
const point: Position = {
x: clientX,
y: clientY,
};
const longPressTimerId: number = setTimeout(startDragging, timeForLongPress);
setState({
longPressTimerId,
pending: point,
isDragging: false,
hasMoved: false,
});
bindWindowEvents();
};
const stopPendingDrag = () => {
clearTimeout(state.longPressTimerId);
unbindWindowEvents();
setState(initial);
};
const kill = (fn?: Function = noop) => {
if (state.pending) {
stopPendingDrag();
return;
}
stopDragging(fn);
};
const cancel = () => {
kill(callbacks.onCancel);
};
const windowBindings = {
touchmove: (event: TouchEvent) => {
const { clientX, clientY } = event.touches[0];
const point: Position = {
x: clientX,
y: clientY,
};
// event already stopped in onTouchMove but being cautious
stopEvent(event);
// record that a movement has occurred
if (!state.hasMoved) {
setState({
hasMoved: true,
});
}
if (state.pending) {
if (!isSloppyClickThresholdExceeded(state.pending, point)) {
return;
}
// User is probably attempting to scroll. However, because we have opted
// out of native scrolling the best option is to start a drag rather than
// end the pending drag and do nothing.
// More information: https://github.com/atlassian/react-beautiful-dnd/issues/11#issuecomment-343288990
startDragging();
return;
}
// already dragging
schedule.move(point);
},
touchend: (event: TouchEvent) => {
if (state.pending) {
stopPendingDrag();
// not stopping the event as this can be registered as a tap
return;
}
// already dragging
stopDragging(callbacks.onDrop);
stopEvent(event);
},
touchcancel: () => {
if (state.pending) {
stopPendingDrag();
return;
}
cancel();
},
touchstart: () => {
// this will also intercept the initial touchstart
// This should never happen - but just being super safe
if (isDragging()) {
console.error('touch start fired while already dragging');
cancel();
}
},
// If the orientation of the device changes - kill the drag
// https://davidwalsh.name/orientation-change
orientationchange: cancel,
// some devices fire resize if the orientation changes
resize: cancel,
// A window scroll will cancel a pending or current drag.
// This should not happen as we are calling preventDefault in touchmove,
// but just being extra safe
scroll: cancel,
// Long press can bring up a context menu
// need to opt out of this behavior
contextmenu: stopEvent,
// On some devices it is possible to have a touch interface with a keyboard.
// On any keyboard event we cancel a touch drag
keydown: 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
touchforcechange: (event: TouchEvent) => {
// force push action will no longer fire after a touchmove
if (state.hasMoved) {
return;
}
const touch: TouchWithForce = (event.touches[0] : any);
if (touch.force >= forcePressThreshold) {
cancel();
}
},
};
const eventKeys = Object.keys(windowBindings);
const bindWindowEvents = () => {
const win: HTMLElement = getWindowFromRef(getDraggableRef());
eventKeys.forEach((eventKey: string) => {
const fn: Function = windowBindings[eventKey];
if (eventKey === '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
win.addEventListener(eventKey, fn, { passive: false });
return;
}
win.addEventListener(eventKey, fn);
});
};
const unbindWindowEvents = () => {
const win: HTMLElement = getWindowFromRef(getDraggableRef());
eventKeys.forEach((eventKey: string) =>
win.removeEventListener(eventKey, windowBindings[eventKey]));
};
// entry point
const onTouchStart = (event: TouchEvent, props: Props) => {
if (!props.canLift) {
return;
}
if (isCapturing()) {
console.error('should not be able to perform a touch start while a drag or pending drag is occurring');
cancel();
return;
}
// Not stopping the event as we want to allow force press and other events to occur
// Scrolling is stopped by onTouchMove
startPendingDrag(event);
};
const onTouchMove = (event: TouchEvent) => {
// Need to call preventDefault() on the *first* touchmove
// in order to prevent native scrolling from occurring.
// Adding the global event handler for touchmove misses the first event
// https://twitter.com/alexandereardon/status/927671336435990528
if (isCapturing()) {
event.preventDefault();
}
// Not calling stopPropigation as we want the events to bubble to the global event handler
};
const onClick = (event: MouseEvent) => {
if (!state.preventClick) {
return;
}
stopEvent(event);
setState(initial);
};
const sensor: TouchSensor = {
onTouchStart,
onTouchMove,
onClick,
kill,
isCapturing,
isDragging,
};
return sensor;
};