UNPKG

react-beautiful-dnd

Version:

Beautiful, accessible drag and drop for lists with React.js

404 lines (351 loc) 11.4 kB
// @flow /* eslint-disable no-use-before-define */ import createScheduler from '../util/create-scheduler'; import createPostDragEventPreventer, { type EventPreventer } from '../util/create-post-drag-event-preventer'; import createEventMarshal, { type EventMarshal } from '../util/create-event-marshal'; import { bindEvents, unbindEvents } from '../util/bind-events'; import * as keyCodes from '../../key-codes'; import supportedPageVisibilityEventName from '../util/supported-page-visibility-event-name'; import type { EventBinding } from '../util/event-types'; import type { Position, } from '../../../types'; import type { TouchSensor, CreateSensorArgs } from './sensor-types'; type State = { isDragging: boolean, hasMoved: boolean, longPressTimerId: ?TimeoutID, pending: ?Position, } type TouchWithForce = Touch & { force: number } type WebkitHack = {| preventTouchMove: () => void, releaseTouchMove: () => void, |} export const timeForLongPress: number = 150; export const forcePressThreshold: number = 0.15; const touchStartMarshal: EventMarshal = createEventMarshal(); const noop = (): void => { }; // Webkit does not allow event.preventDefault() in dynamically added handlers // So we add an always listening event handler to get around this :( // webkit bug: https://bugs.webkit.org/show_bug.cgi?id=184250 const webkitHack: WebkitHack = (() => { const stub: WebkitHack = { preventTouchMove: noop, releaseTouchMove: noop, }; // Do nothing when server side rendering if (typeof window === 'undefined') { return stub; } // Device has no touch support - no point adding the touch listener if (!('ontouchstart' in window)) { return stub; } // Not adding any user agent testing as everything pretends to be webkit let isBlocking: boolean = false; // Adding a persistent event handler window.addEventListener('touchmove', (event: TouchEvent) => { // We let the event go through as normal as nothing // is blocking the touchmove if (!isBlocking) { return; } // Our event handler would have worked correctly if the browser // was not webkit based, or an older version of webkit. if (event.defaultPrevented) { return; } // Okay, now we need to step in and fix things event.preventDefault(); // Forcing this to be non-passive so we can get every touchmove // Not activating in the capture phase like the dynamic touchmove we add. // Technically it would not matter if we did this in the capture phase }, { passive: false, capture: false }); const preventTouchMove = () => { isBlocking = true; }; const releaseTouchMove = () => { isBlocking = false; }; return { preventTouchMove, releaseTouchMove }; })(); const initial: State = { isDragging: false, pending: null, hasMoved: false, longPressTimerId: null, }; export default ({ callbacks, getWindow, canStartCapturing, }: CreateSensorArgs): 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); const postDragEventPreventer: EventPreventer = createPostDragEventPreventer(getWindow); const startDragging = () => { const pending: ?Position = state.pending; if (!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, autoScrollMode: 'FLUID', }); }; const stopDragging = (fn?: Function = noop) => { schedule.cancel(); touchStartMarshal.reset(); webkitHack.releaseTouchMove(); unbindWindowEvents(); postDragEventPreventer.preventNext(); setState(initial); fn(); }; const startPendingDrag = (event: TouchEvent) => { const touch: Touch = event.touches[0]; const { clientX, clientY } = touch; const point: Position = { x: clientX, y: clientY, }; const longPressTimerId: TimeoutID = setTimeout(startDragging, timeForLongPress); setState({ longPressTimerId, pending: point, isDragging: false, hasMoved: false, }); bindWindowEvents(); }; const stopPendingDrag = () => { if (state.longPressTimerId) { clearTimeout(state.longPressTimerId); } schedule.cancel(); touchStartMarshal.reset(); webkitHack.releaseTouchMove(); unbindWindowEvents(); setState(initial); }; const kill = (fn?: Function = noop) => { if (state.pending) { stopPendingDrag(); return; } stopDragging(fn); }; const unmount = () => { kill(); postDragEventPreventer.abort(); }; const cancel = () => { kill(callbacks.onCancel); }; const windowBindings: 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 options: { passive: false }, fn: (event: TouchEvent) => { // Drag has not yet started and we are waiting for a long press. if (!state.isDragging) { stopPendingDrag(); return; } // At this point we are dragging if (!state.hasMoved) { setState({ hasMoved: true, }); } const { clientX, clientY } = event.touches[0]; const point: Position = { x: clientX, y: 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 (!state.isDragging) { stopPendingDrag(); return; } // already dragging - this event is directly ending a drag event.preventDefault(); stopDragging(callbacks.onDrop); }, }, { eventName: 'touchcancel', fn: (event: TouchEvent) => { // drag had not started yet - do not prevent the default action if (!state.isDragging) { stopPendingDrag(); return; } // already dragging - this event is directly ending a drag event.preventDefault(); stopDragging(callbacks.onCancel); }, }, // 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 (state.pending) { stopPendingDrag(); 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 (!state.isDragging) { 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 { eventName: 'touchforcechange', fn: (event: TouchEvent) => { // A force push action will no longer fire after a touchmove if (state.hasMoved) { // This is being super safe. While this situation should not occur we // are still expressing that we want to opt out of force pressing event.preventDefault(); return; } // A drag could be pending or has already started but no movement has occurred const touch: TouchWithForce = (event.touches[0] : any); if (touch.force >= forcePressThreshold) { // this is an indirect cancel so we do not preventDefault // we also want to allow the force press to occur cancel(); } }, }, // Cancel on page visibility change { eventName: supportedPageVisibilityEventName, fn: cancel, }, ]; const bindWindowEvents = () => { bindEvents(getWindow(), windowBindings, { capture: true }); }; const unbindWindowEvents = () => { unbindEvents(getWindow(), windowBindings, { capture: true }); }; // entry point const onTouchStart = (event: TouchEvent) => { if (touchStartMarshal.isHandled()) { return; } if (!canStartCapturing(event)) { return; } if (isCapturing()) { console.error('should not be able to perform a touch start while a drag or pending drag is occurring'); cancel(); 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(); // A webkit only hack to prevent touch move events webkitHack.preventTouchMove(); startPendingDrag(event); }; const sensor: TouchSensor = { onTouchStart, kill, isCapturing, isDragging, unmount, }; return sensor; };