atlassian / react-beautiful-dnd

Beautiful and accessible drag and drop for lists with React
https://react-beautiful-dnd.netlify.app
Other
33.39k stars 2.57k forks source link

Not able to drag and drop on mobile using React Portal #2111

Open Merynek opened 3 years ago

Merynek commented 3 years ago

I cant dragging with items on mobile when I use portals. You can check this on your own examples. I found out that you solved this in version 12.0.0-alpha.7 so mby its regression.

Relase: https://github.com/atlassian/react-beautiful-dnd/issues/1317

Issue: https://github.com/atlassian/react-beautiful-dnd/issues/582

Example: https://react-beautiful-dnd.netlify.app/iframe.html?id=portals--using-your-own-portal&viewMode=story

Aloushek commented 3 years ago

I ran into the same issue. After some digging the problem is probably with dissapearing (moved to portal/clone) draggable item after drag start. First touch on drag start scrolls the whole page/list, second touch on draggable item drag the item correctly.

The same problem is on the following stories:

We can simulate this simply on Windows Chrome device emulation, or by opening stories on Android Chrome (iPhone has the same issue).

touchdndbeautiful

Screenshot_2021-03-12-11-22-12-830_com android chrome (2)

Merynek commented 3 years ago

I tried old versions and it works in version: "12.0.0-beta.10" From version "12.0.0-beta.11" it not works so you can look at commits in this version.

Aloushek commented 3 years ago

I checked the version and in 12.0.0-beta.11 was rewritten code for detection of dragging element. It makes sense to me that the new code for detection reopens the fix in 12.0.0-alpha.7 😔 So we're stucked at beta.10

StuffByLiang commented 3 years ago

I can confirm this behaviour works on 12.0.0-beta.10 and not anything above! Thanks @Merynek, I didn't know how else to fix this problem

StuffByLiang commented 3 years ago

Here are the diffs between the versions: https://github.com/atlassian/react-beautiful-dnd/compare/v12.0.0-beta.10...v12.0.0-beta.11

qiaoyuwen commented 3 years ago

Did you find a solution or hack for this bug?

maxsupera commented 3 years ago

same issue here! is the current solution to stick with 12.0.0-beta.10 ?

thanks!

nmauersberg commented 1 year ago

Still having the same issue. Any update would be great! :)

stevenmcountryman commented 1 year ago

@nmauersberg, @maxsupera, and anyone else still looking for a fix, years later:

I fixed the issue by cloning the default touch sensor and adding back in the logic they took out with the 12.0.0-beta.11 change. Having both sets of logic side by side should make all scenarios work:

Steps: Create a file in your repo, called use-touch-sensor.tsx or whatever you want to name it, then paste in the following code: (note this code is 99.99% the original use-touch-sensor.js code from this repo, with some slight modifications)

import * as React from 'react';
import { useCallback, useMemo } from 'use-memo-one';
import type { Position } from 'css-box-model';
import { DraggableId, FluidDragActions, PreDragActions, SensorAPI } from 'react-beautiful-dnd';

type TouchWithForce = Touch & {
  force: number;
};

type Idle = {
  type: 'IDLE';
};

type Pending = {
  type: 'PENDING';
  point: Position;
  actions: PreDragActions;
  longPressTimerId: NodeJS.Timeout;
};

type Dragging = {
  type: 'DRAGGING';
  actions: FluidDragActions;
  hasMoved: boolean;
};

type Phase = Idle | Pending | Dragging;

type PredicateFn<T> = (value: T) => boolean;

function findIndex<T>(list: T[], predicate: PredicateFn<T>): number {
  if (list.findIndex) {
    return list.findIndex(predicate);
  }

  // Using a for loop so that we can exit early
  for (let i = 0; i < list.length; i++) {
    if (predicate(list[i])) {
      return i;
    }
  }
  // Array.prototype.find returns -1 when nothing is found
  return -1;
}

function find<T>(list: T[], predicate: PredicateFn<T>): T {
  if (list.find) {
    return list.find(predicate);
  }
  const index: number = findIndex(list, predicate);
  if (index !== -1) {
    return list[index];
  }
  // Array.prototype.find returns undefined when nothing is found
  return undefined;
}

const supportedPageVisibilityEventName: string = ((): string => {
  const base: string = 'visibilitychange';

  // Server side rendering
  if (typeof document === 'undefined') {
    return base;
  }

  // See https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
  const candidates: string[] = [base, `ms${base}`, `webkit${base}`, `moz${base}`, `o${base}`];

  const supported: string = find(
    candidates,
    (eventName: string): boolean => `on${eventName}` in document
  );

  return supported || base;
})();

const idle: Idle = { type: 'IDLE' };
// 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;

type GetBindingArgs = {
  cancel: () => void;
  completed: () => void;
  getPhase: () => Phase;
};

function getWindowBindings({ cancel, getPhase }: GetBindingArgs): EventBinding[] {
  return [
    // If the orientation of the device changes - kill the drag
    // https://davidwalsh.name/orientation-change
    {
      eventName: 'orientationchange' as keyof HTMLElementEventMap,
      fn: cancel,
    },
    // some devices fire resize if the orientation changes
    {
      eventName: 'resize',
      fn: cancel,
    },
    // 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 (getPhase().type !== 'DRAGGING') {
          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.key === 'Escape') {
          event.preventDefault();
        }
        cancel();
      },
    },
    // Cancel on page visibility change
    {
      eventName: supportedPageVisibilityEventName as keyof HTMLElementEventMap,
      fn: cancel,
    },
  ];
}

// All of the touch events get applied to the drag handle of the touch interaction
// This plays well with the event.target being unmounted during a drag
function getHandleBindings({ cancel, completed, getPhase }: GetBindingArgs): EventBinding[] {
  return [
    {
      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: { capture: false },
      fn: (event: TouchEvent) => {
        const phase: Phase = getPhase();
        // Drag has not yet started and we are waiting for a long press.
        if (phase.type !== 'DRAGGING') {
          cancel();
          return;
        }

        // At this point we are dragging
        phase.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();
        phase.actions.move(point);
      },
    },
    {
      eventName: 'touchend',
      fn: (event: TouchEvent) => {
        const phase: Phase = getPhase();
        // drag had not started yet - do not prevent the default action
        if (phase.type !== 'DRAGGING') {
          cancel();
          return;
        }

        // ending the drag
        event.preventDefault();
        phase.actions.drop({ shouldBlockNextClick: true });
        completed();
      },
    },
    {
      eventName: 'touchcancel',
      fn: (event: TouchEvent) => {
        // drag had not started yet - do not prevent the default action
        if (getPhase().type !== 'DRAGGING') {
          cancel();
          return;
        }

        // already dragging - this event is directly ending a drag
        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' as keyof HTMLElementEventMap,
      fn: (event: TouchEvent) => {
        const phase: Phase = getPhase();

        // needed to use phase.actions
        if (phase.type === 'IDLE') {
          throw Error('invariant');
        }

        // This is not fantastic logic, but it is done to account for
        // and issue with forcepress on iOS
        // Calling event.preventDefault() will currently opt out of scrolling and clicking
        // https://github.com/atlassian/react-beautiful-dnd/issues/1401

        const touch: TouchWithForce = event.touches[0] as TouchWithForce;

        if (!touch) {
          return;
        }

        const isForcePress: boolean = touch.force >= forcePressThreshold;

        if (!isForcePress) {
          return;
        }

        const shouldRespect: boolean = phase.actions.shouldRespectForcePress();

        if (phase.type === 'PENDING') {
          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 (phase.hasMoved) {
            // 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 as keyof HTMLElementEventMap,
      fn: cancel,
    },
    // Not adding a cancel on touchstart as this handler will pick up the initial touchstart event
  ];
}
type EventOptions = {
  passive?: boolean;
  capture?: boolean;
  // sometimes an event might only event want to be bound once
  once?: boolean;
};

type EventBinding = {
  eventName: keyof HTMLElementEventMap;
  fn: EventListenerOrEventListenerObject;
  options?: EventOptions;
};

function getOptions(shared: EventOptions, fromBinding: EventOptions): EventOptions {
  return {
    ...shared,
    ...fromBinding,
  };
}

type UnbindFn = () => void;

function bindEvents(
  el: HTMLElement | Window,
  bindings: EventBinding[],
  sharedOptions?: EventOptions
) {
  const unbindings: UnbindFn[] = bindings.map(
    (binding: EventBinding): UnbindFn => {
      const options: Object = getOptions(sharedOptions, binding.options);

      el.addEventListener(binding.eventName, binding.fn, options);

      return function unbind() {
        el.removeEventListener(binding.eventName, binding.fn, options);
      };
    }
  );

  // Return a function to unbind events
  return function unbindAll() {
    unbindings.forEach((unbind: UnbindFn) => {
      unbind();
    });
  };
}

export default function useTouchSensor(api: SensorAPI) {
  const phaseRef = React.useRef<Phase>(idle);
  const unbindEventsRef = React.useRef<() => void>(() => null);

  const getPhase = useCallback(function getPhase(): Phase {
    return phaseRef.current;
  }, []);

  const setPhase = useCallback(function setPhase(phase: Phase) {
    phaseRef.current = phase;
  }, []);

  const startCaptureBinding = useMemo(
    () => ({
      eventName: 'touchstart' as keyof HTMLElementEventMap,
      fn: function onTouchStart(event: TouchEvent) {
        // Event already used by something else
        if (event.defaultPrevented) {
          return;
        }

        // We 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

        const draggableId: DraggableId = api.findClosestDraggableId(event);

        if (!draggableId) {
          return;
        }

        const actions: PreDragActions = api.tryGetLock(
          draggableId,
          // eslint-disable-next-line no-use-before-define
          stop,
          { sourceEvent: event }
        );

        // could not start a drag
        if (!actions) {
          return;
        }

        const touch: Touch = event.touches[0];
        const { clientX, clientY } = touch;
        const point: Position = {
          x: clientX,
          y: clientY,
        };
        const dragHandleId = api.findClosestDraggableId(event);
        if (!dragHandleId) {
          throw Error('Touch sensor unable to find drag dragHandleId');
        }
        const handle: HTMLElement = document.querySelector(
          `[data-rbd-drag-handle-draggable-id='${dragHandleId}']`
        );
        if (!handle) {
          throw Error('Touch sensor unable to find drag handle');
        }

        // unbind this event handler
        unbindEventsRef.current();

        // eslint-disable-next-line no-use-before-define
        startPendingDrag(actions, point, handle);
      },
    }),
    // not including stop or startPendingDrag as it is not defined initially
    [api]
  );

  const listenForCapture = useCallback(
    function listenForCapture() {
      const options = {
        capture: true,
        passive: false,
      };

      unbindEventsRef.current = bindEvents(window, [startCaptureBinding], options);
    },
    [startCaptureBinding]
  );

  const stop = useCallback(() => {
    const { current } = phaseRef;
    if (current.type === 'IDLE') {
      return;
    }

    // aborting any pending drag
    if (current.type === 'PENDING') {
      clearTimeout(current.longPressTimerId);
    }

    setPhase(idle);
    unbindEventsRef.current();

    listenForCapture();
  }, [listenForCapture, setPhase]);

  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(target: HTMLElement) {
      const options = { capture: true, passive: false };
      const args: GetBindingArgs = {
        cancel,
        completed: stop,
        getPhase,
      };

      // In prior versions of iOS it was required that touch listeners be added
      // to the handle to work correctly (even if the handle got removed in a portal / clone)
      // In the latest version it appears to be the opposite: for reparenting to work
      // the events need to be attached to the window.
      // For now i'll keep these two functions seperate in case we need to swap it back again
      // Old behaviour:
      // https://gist.github.com/parris/dda613e3ae78f14eb2dc9fa0f4bfce3d
      // https://stackoverflow.com/questions/33298828/touch-move-event-dont-fire-after-touch-start-target-is-removed
      const unbindTarget = bindEvents(target, getHandleBindings(args), options);
      const unbindTargetWindow = bindEvents(window, getHandleBindings(args), options);
      const unbindWindow = bindEvents(window, getWindowBindings(args), options);

      unbindEventsRef.current = function unbindAll() {
        unbindTarget();
        unbindTargetWindow();
        unbindWindow();
      };
    },
    [cancel, getPhase, stop]
  );

  const startDragging = useCallback(
    function startDragging() {
      const phase: Phase = getPhase();
      if (phase.type !== 'PENDING') {
        throw Error(`Cannot start dragging from phase ${phase.type}`);
      }

      const actions: FluidDragActions = phase.actions.fluidLift(phase.point);

      setPhase({
        type: 'DRAGGING',
        actions,
        hasMoved: false,
      });
    },
    [getPhase, setPhase]
  );

  const startPendingDrag = useCallback(
    function startPendingDrag(actions: PreDragActions, point: Position, target: HTMLElement) {
      if (getPhase().type !== 'IDLE') {
        throw Error('Expected to move from IDLE to PENDING drag');
      }

      const longPressTimerId = setTimeout(startDragging, timeForLongPress);

      setPhase({
        type: 'PENDING',
        point,
        actions,
        longPressTimerId,
      });

      bindCapturingEvents(target);
    },
    [bindCapturingEvents, getPhase, setPhase, startDragging]
  );

  React.useLayoutEffect(
    function mount() {
      listenForCapture();

      return function unmount() {
        // remove any existing listeners
        unbindEventsRef.current();

        // need to kill any pending drag start timer
        const phase: Phase = getPhase();
        if (phase.type === 'PENDING') {
          clearTimeout(phase.longPressTimerId);
          setPhase(idle);
        }
      };
    },
    [getPhase, listenForCapture, setPhase]
  );

  // 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
  React.useLayoutEffect(function webkitHack() {
    const unbind = bindEvents(window, [
      {
        eventName: 'touchmove',
        // using a new noop function for each usage as a single `removeEventListener()`
        // call will remove all handlers with the same reference
        // https://codesandbox.io/s/removing-multiple-handlers-with-same-reference-fxe15
        fn: () => {},
        options: { capture: false, passive: false },
      },
    ]);

    return unbind;
  }, []);
}

Then import it into wherever your DragDropContext lives and use it like so:

import {
  DragDropContext,
  useKeyboardSensor,
  useMouseSensor,
} from 'react-beautiful-dnd';
import useTouchSensor from './custom-sensors/use-touch-sensor';

...
return (
    <DragDropContext
      enableDefaultSensors={false}
      sensors={[useMouseSensor, useKeyboardSensor, useTouchSensor]}
      ....
     </DragDropContext>
);
....
ferrariRoma commented 3 months ago

@stevenmcountryman You save my day :) really appreciated.

And there were some kinda type errors so I fixed a little bit.

import * as React from 'react';
import { useCallback, useMemo } from 'use-memo-one';
import type { Position } from 'css-box-model';
import {
  DraggableId,
  FluidDragActions,
  PreDragActions,
  SensorAPI,
} from 'react-beautiful-dnd';

type TouchWithForce = Touch & {
  force: number;
};

type Idle = {
  type: 'IDLE';
};

type Pending = {
  type: 'PENDING';
  point: Position;
  actions: PreDragActions;
  longPressTimerId: NodeJS.Timeout;
};

type Dragging = {
  type: 'DRAGGING';
  actions: FluidDragActions;
  hasMoved: boolean;
};

type Phase = Idle | Pending | Dragging;

type PredicateFn<T> = (value: T) => boolean;

function findIndex<T>(list: T[], predicate: PredicateFn<T>): number {
  if (list.findIndex) {
    return list.findIndex(predicate);
  }

  // Using a for loop so that we can exit early
  for (let i = 0; i < list.length; i++) {
    if (predicate(list[i])) {
      return i;
    }
  }
  // Array.prototype.find returns -1 when nothing is found
  return -1;
}

function find<T>(list: T[], predicate: PredicateFn<T>): T | undefined {
  if (list.find) {
    return list.find(predicate);
  }
  const index: number = findIndex(list, predicate);
  if (index !== -1) {
    return list[index];
  }
  // Array.prototype.find returns undefined when nothing is found
  return undefined;
}

const supportedPageVisibilityEventName: string = ((): string => {
  const base: string = 'visibilitychange';

  // Server side rendering
  if (typeof document === 'undefined') {
    return base;
  }

  // See https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
  const candidates: string[] = [
    base,
    `ms${base}`,
    `webkit${base}`,
    `moz${base}`,
    `o${base}`,
  ];

  const supported: string | undefined = find(
    candidates,
    (eventName: string): boolean => `on${eventName}` in document,
  );

  return supported || base;
})();

const idle: Idle = { type: 'IDLE' };
// 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;

type GetBindingArgs = {
  cancel: () => void;
  completed: () => void;
  getPhase: () => Phase;
};

function getWindowBindings({
  cancel,
  getPhase,
}: GetBindingArgs): EventBinding[] {
  return [
    // If the orientation of the device changes - kill the drag
    // https://davidwalsh.name/orientation-change
    {
      eventName: 'orientationchange' as keyof HTMLElementEventMap,
      fn: cancel,
    },
    // some devices fire resize if the orientation changes
    {
      eventName: 'resize',
      fn: cancel,
    },
    // 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 (getPhase().type !== 'DRAGGING') {
          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.key === 'Escape') {
          event.preventDefault();
        }
        cancel();
      },
    },
    // Cancel on page visibility change
    {
      eventName: supportedPageVisibilityEventName as keyof HTMLElementEventMap,
      fn: cancel,
    },
  ];
}

// All of the touch events get applied to the drag handle of the touch interaction
// This plays well with the event.target being unmounted during a drag
function getHandleBindings({
  cancel,
  completed,
  getPhase,
}: GetBindingArgs): EventBinding[] {
  return [
    {
      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: { capture: false },
      fn: (event: TouchEvent) => {
        const phase: Phase = getPhase();
        // Drag has not yet started and we are waiting for a long press.
        if (phase.type !== 'DRAGGING') {
          cancel();
          return;
        }

        // At this point we are dragging
        phase.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();
        phase.actions.move(point);
      },
    },
    {
      eventName: 'touchend',
      fn: (event: TouchEvent) => {
        const phase: Phase = getPhase();
        // drag had not started yet - do not prevent the default action
        if (phase.type !== 'DRAGGING') {
          cancel();
          return;
        }

        // ending the drag
        event.preventDefault();
        phase.actions.drop({ shouldBlockNextClick: true });
        completed();
      },
    },
    {
      eventName: 'touchcancel',
      fn: (event: TouchEvent) => {
        // drag had not started yet - do not prevent the default action
        if (getPhase().type !== 'DRAGGING') {
          cancel();
          return;
        }

        // already dragging - this event is directly ending a drag
        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' as keyof HTMLElementEventMap,
      fn: (event: TouchEvent) => {
        const phase: Phase = getPhase();

        // needed to use phase.actions
        if (phase.type === 'IDLE') {
          throw Error('invariant');
        }

        // This is not fantastic logic, but it is done to account for
        // and issue with forcepress on iOS
        // Calling event.preventDefault() will currently opt out of scrolling and clicking
        // https://github.com/atlassian/react-beautiful-dnd/issues/1401

        const touch: TouchWithForce = event.touches[0] as TouchWithForce;

        if (!touch) {
          return;
        }

        const isForcePress: boolean = touch.force >= forcePressThreshold;

        if (!isForcePress) {
          return;
        }

        const shouldRespect: boolean = phase.actions.shouldRespectForcePress();

        if (phase.type === 'PENDING') {
          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 (phase.hasMoved) {
            // 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 as keyof HTMLElementEventMap,
      fn: cancel,
    },
    // Not adding a cancel on touchstart as this handler will pick up the initial touchstart event
  ];
}
type EventOptions = {
  passive?: boolean;
  capture?: boolean;
  // sometimes an event might only event want to be bound once
  once?: boolean;
};

type NewType = (
  event: KeyboardEvent & TouchEvent & EventListenerOrEventListenerObject,
) => void;

type EventBinding = {
  eventName: keyof HTMLElementEventMap;
  fn: NewType;
  options?: EventOptions;
};

function getOptions(
  shared?: EventOptions,
  fromBinding?: EventOptions,
): EventOptions {
  return {
    ...shared,
    ...fromBinding,
  };
}

type UnbindFn = () => void;

function bindEvents(
  el: HTMLElement | Window,
  bindings: EventBinding[],
  sharedOptions?: EventOptions,
) {
  const unbindings: UnbindFn[] = bindings.map(
    (binding: EventBinding): UnbindFn => {
      const options: Object = getOptions(sharedOptions, binding.options);

      el.addEventListener(
        binding.eventName,
        binding.fn as EventListenerOrEventListenerObject,
        options,
      );

      return function unbind() {
        el.removeEventListener(
          binding.eventName,
          binding.fn as EventListenerOrEventListenerObject,
          options,
        );
      };
    },
  );

  // Return a function to unbind events
  return function unbindAll() {
    unbindings.forEach((unbind: UnbindFn) => {
      unbind();
    });
  };
}

export default function useTouchSensor(api: SensorAPI) {
  const phaseRef = React.useRef<Phase>(idle);
  const unbindEventsRef = React.useRef<() => void>(() => null);

  const getPhase = useCallback(function getPhase(): Phase {
    return phaseRef.current;
  }, []);

  const setPhase = useCallback(function setPhase(phase: Phase) {
    phaseRef.current = phase;
  }, []);

  const startCaptureBinding = useMemo(
    () => ({
      eventName: 'touchstart' as keyof HTMLElementEventMap,
      fn: function onTouchStart(event: TouchEvent) {
        // Event already used by something else
        if (event.defaultPrevented) {
          return;
        }

        // We 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

        const draggableId: DraggableId | null =
          api.findClosestDraggableId(event);

        if (!draggableId) {
          return;
        }

        const actions: PreDragActions | null = api.tryGetLock(
          draggableId,
          // eslint-disable-next-line no-use-before-define
          stop,
          { sourceEvent: event },
        );

        // could not start a drag
        if (!actions) {
          return;
        }

        const touch: Touch = event.touches[0];
        const { clientX, clientY } = touch;
        const point: Position = {
          x: clientX,
          y: clientY,
        };
        const dragHandleId = api.findClosestDraggableId(event);
        if (!dragHandleId) {
          throw Error('Touch sensor unable to find drag dragHandleId');
        }
        const handle: HTMLElement | null = document.querySelector(
          `[data-rbd-drag-handle-draggable-id='${dragHandleId}']`,
        );
        if (!handle) {
          throw Error('Touch sensor unable to find drag handle');
        }

        // unbind this event handler
        unbindEventsRef.current();

        // eslint-disable-next-line no-use-before-define
        startPendingDrag(actions, point, handle);
      },
    }),
    // not including stop or startPendingDrag as it is not defined initially
    [api],
  );

  const listenForCapture = useCallback(
    function listenForCapture() {
      const options = {
        capture: true,
        passive: false,
      };

      unbindEventsRef.current = bindEvents(
        window,
        [startCaptureBinding],
        options,
      );
    },
    [startCaptureBinding],
  );

  const stop = useCallback(() => {
    const { current } = phaseRef;
    if (current.type === 'IDLE') {
      return;
    }

    // aborting any pending drag
    if (current.type === 'PENDING') {
      clearTimeout(current.longPressTimerId);
    }

    setPhase(idle);
    unbindEventsRef.current();

    listenForCapture();
  }, [listenForCapture, setPhase]);

  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(target: HTMLElement) {
      const options = { capture: true, passive: false };
      const args: GetBindingArgs = {
        cancel,
        completed: stop,
        getPhase,
      };

      // In prior versions of iOS it was required that touch listeners be added
      // to the handle to work correctly (even if the handle got removed in a portal / clone)
      // In the latest version it appears to be the opposite: for reparenting to work
      // the events need to be attached to the window.
      // For now i'll keep these two functions seperate in case we need to swap it back again
      // Old behaviour:
      // https://gist.github.com/parris/dda613e3ae78f14eb2dc9fa0f4bfce3d
      // https://stackoverflow.com/questions/33298828/touch-move-event-dont-fire-after-touch-start-target-is-removed
      const unbindTarget = bindEvents(target, getHandleBindings(args), options);
      const unbindTargetWindow = bindEvents(
        window,
        getHandleBindings(args),
        options,
      );
      const unbindWindow = bindEvents(window, getWindowBindings(args), options);

      unbindEventsRef.current = function unbindAll() {
        unbindTarget();
        unbindTargetWindow();
        unbindWindow();
      };
    },
    [cancel, getPhase, stop],
  );

  const startDragging = useCallback(
    function startDragging() {
      const phase: Phase = getPhase();
      if (phase.type !== 'PENDING') {
        throw Error(`Cannot start dragging from phase ${phase.type}`);
      }

      const actions: FluidDragActions = phase.actions.fluidLift(phase.point);

      setPhase({
        type: 'DRAGGING',
        actions,
        hasMoved: false,
      });
    },
    [getPhase, setPhase],
  );

  const startPendingDrag = useCallback(
    function startPendingDrag(
      actions: PreDragActions,
      point: Position,
      target: HTMLElement,
    ) {
      if (getPhase().type !== 'IDLE') {
        throw Error('Expected to move from IDLE to PENDING drag');
      }

      const longPressTimerId = setTimeout(startDragging, timeForLongPress);

      setPhase({
        type: 'PENDING',
        point,
        actions,
        longPressTimerId,
      });

      bindCapturingEvents(target);
    },
    [bindCapturingEvents, getPhase, setPhase, startDragging],
  );

  React.useLayoutEffect(
    function mount() {
      listenForCapture();

      return function unmount() {
        // remove any existing listeners
        unbindEventsRef.current();

        // need to kill any pending drag start timer
        const phase: Phase = getPhase();
        if (phase.type === 'PENDING') {
          clearTimeout(phase.longPressTimerId);
          setPhase(idle);
        }
      };
    },
    [getPhase, listenForCapture, setPhase],
  );

  // 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
  React.useLayoutEffect(function webkitHack() {
    const unbind = bindEvents(window, [
      {
        eventName: 'touchmove',
        // using a new noop function for each usage as a single `removeEventListener()`
        // call will remove all handlers with the same reference
        // https://codesandbox.io/s/removing-multiple-handlers-with-same-reference-fxe15
        fn: () => {},
        options: { capture: false, passive: false },
      },
    ]);

    return unbind;
  }, []);
}
ATheCoder commented 1 month ago

@stevenmcountryman

Why don't you make a PR for this solution?