taye/interact.js

View on GitHub
packages/@interactjs/core/interactions.ts

Summary

Maintainability
C
1 day
Test Coverage
import browser from '@interactjs/utils/browser'
import domObjects from '@interactjs/utils/domObjects'
import { nodeContains } from '@interactjs/utils/domUtils'
import * as pointerUtils from '@interactjs/utils/pointerUtils'

import type { Scope, SignalArgs, Plugin } from '@interactjs/core/scope'
import type { ActionName, Listener } from '@interactjs/core/types'

/* eslint-disable import/no-duplicates -- for typescript module augmentations */
import './interactablePreventDefault'
import interactablePreventDefault from './interactablePreventDefault'
import InteractionBase from './Interaction'
/* eslint-enable import/no-duplicates */
import type { SearchDetails } from './interactionFinder'
import finder from './interactionFinder'

declare module '@interactjs/core/scope' {
  interface Scope {
    Interaction: typeof InteractionBase
    interactions: {
      new: <T extends ActionName>(options: any) => InteractionBase<T>
      list: Array<InteractionBase<ActionName>>
      listeners: { [type: string]: Listener }
      docEvents: Array<{ type: string; listener: Listener }>
      pointerMoveTolerance: number
    }
    prevTouchTime: number
  }

  interface SignalArgs {
    'interactions:find': {
      interaction: InteractionBase
      searchDetails: SearchDetails
    }
  }
}

const methodNames = [
  'pointerDown',
  'pointerMove',
  'pointerUp',
  'updatePointer',
  'removePointer',
  'windowBlur',
]

function install(scope: Scope) {
  const listeners = {} as any

  for (const method of methodNames) {
    listeners[method] = doOnInteractions(method, scope)
  }

  const pEventTypes = browser.pEventTypes
  let docEvents: typeof scope.interactions.docEvents

  if (domObjects.PointerEvent) {
    docEvents = [
      { type: pEventTypes.down, listener: releasePointersOnRemovedEls },
      { type: pEventTypes.down, listener: listeners.pointerDown },
      { type: pEventTypes.move, listener: listeners.pointerMove },
      { type: pEventTypes.up, listener: listeners.pointerUp },
      { type: pEventTypes.cancel, listener: listeners.pointerUp },
    ]
  } else {
    docEvents = [
      { type: 'mousedown', listener: listeners.pointerDown },
      { type: 'mousemove', listener: listeners.pointerMove },
      { type: 'mouseup', listener: listeners.pointerUp },

      { type: 'touchstart', listener: releasePointersOnRemovedEls },
      { type: 'touchstart', listener: listeners.pointerDown },
      { type: 'touchmove', listener: listeners.pointerMove },
      { type: 'touchend', listener: listeners.pointerUp },
      { type: 'touchcancel', listener: listeners.pointerUp },
    ]
  }

  docEvents.push({
    type: 'blur',
    listener(event) {
      for (const interaction of scope.interactions.list) {
        interaction.documentBlur(event)
      }
    },
  })

  // for ignoring browser's simulated mouse events
  scope.prevTouchTime = 0

  scope.Interaction = class<T extends ActionName> extends InteractionBase<T> {
    get pointerMoveTolerance() {
      return scope.interactions.pointerMoveTolerance
    }

    set pointerMoveTolerance(value) {
      scope.interactions.pointerMoveTolerance = value
    }

    _now() {
      return scope.now()
    }
  }

  scope.interactions = {
    // all active and idle interactions
    list: [],
    new<T extends ActionName>(options: { pointerType?: string; scopeFire?: Scope['fire'] }) {
      options.scopeFire = (name, arg) => scope.fire(name, arg)

      const interaction = new scope.Interaction<T>(options as Required<typeof options>)

      scope.interactions.list.push(interaction)
      return interaction
    },
    listeners,
    docEvents,
    pointerMoveTolerance: 1,
  }

  function releasePointersOnRemovedEls() {
    // for all inactive touch interactions with pointers down
    for (const interaction of scope.interactions.list) {
      if (!interaction.pointerIsDown || interaction.pointerType !== 'touch' || interaction._interacting) {
        continue
      }

      // if a pointer is down on an element that is no longer in the DOM tree
      for (const pointer of interaction.pointers) {
        if (!scope.documents.some(({ doc }) => nodeContains(doc, pointer.downTarget))) {
          // remove the pointer from the interaction
          interaction.removePointer(pointer.pointer, pointer.event)
        }
      }
    }
  }

  scope.usePlugin(interactablePreventDefault)
}

function doOnInteractions(method: string, scope: Scope) {
  return function (event: Event) {
    const interactions = scope.interactions.list

    const pointerType = pointerUtils.getPointerType(event)
    const [eventTarget, curEventTarget] = pointerUtils.getEventTargets(event)
    const matches: any[] = [] // [ [pointer, interaction], ...]

    if (/^touch/.test(event.type)) {
      scope.prevTouchTime = scope.now()

      // @ts-expect-error
      for (const changedTouch of event.changedTouches) {
        const pointer = changedTouch
        const pointerId = pointerUtils.getPointerId(pointer)
        const searchDetails: SearchDetails = {
          pointer,
          pointerId,
          pointerType,
          eventType: event.type,
          eventTarget,
          curEventTarget,
          scope,
        }
        const interaction = getInteraction(searchDetails)

        matches.push([
          searchDetails.pointer,
          searchDetails.eventTarget,
          searchDetails.curEventTarget,
          interaction,
        ])
      }
    } else {
      let invalidPointer = false

      if (!browser.supportsPointerEvent && /mouse/.test(event.type)) {
        // ignore mouse events while touch interactions are active
        for (let i = 0; i < interactions.length && !invalidPointer; i++) {
          invalidPointer = interactions[i].pointerType !== 'mouse' && interactions[i].pointerIsDown
        }

        // try to ignore mouse events that are simulated by the browser
        // after a touch event
        invalidPointer =
          invalidPointer ||
          scope.now() - scope.prevTouchTime < 500 ||
          // on iOS and Firefox Mobile, MouseEvent.timeStamp is zero if simulated
          event.timeStamp === 0
      }

      if (!invalidPointer) {
        const searchDetails = {
          pointer: event as PointerEvent,
          pointerId: pointerUtils.getPointerId(event as PointerEvent),
          pointerType,
          eventType: event.type,
          curEventTarget,
          eventTarget,
          scope,
        }

        const interaction = getInteraction(searchDetails)

        matches.push([
          searchDetails.pointer,
          searchDetails.eventTarget,
          searchDetails.curEventTarget,
          interaction,
        ])
      }
    }

    // eslint-disable-next-line no-shadow
    for (const [pointer, eventTarget, curEventTarget, interaction] of matches) {
      interaction[method](pointer, event, eventTarget, curEventTarget)
    }
  }
}

function getInteraction(searchDetails: SearchDetails) {
  const { pointerType, scope } = searchDetails

  const foundInteraction = finder.search(searchDetails)
  const signalArg = { interaction: foundInteraction, searchDetails }

  scope.fire('interactions:find', signalArg)

  return signalArg.interaction || scope.interactions.new({ pointerType })
}

function onDocSignal<T extends 'scope:add-document' | 'scope:remove-document'>(
  { doc, scope, options }: SignalArgs[T],
  eventMethodName: 'add' | 'remove',
) {
  const {
    interactions: { docEvents },
    events,
  } = scope
  const eventMethod = events[eventMethodName]

  if (scope.browser.isIOS && !options.events) {
    options.events = { passive: false }
  }

  // delegate event listener
  for (const eventType in events.delegatedEvents) {
    eventMethod(doc, eventType, events.delegateListener)
    eventMethod(doc, eventType, events.delegateUseCapture, true)
  }

  const eventOptions = options && options.events

  for (const { type, listener } of docEvents) {
    eventMethod(doc, type, listener, eventOptions)
  }
}

const interactions: Plugin = {
  id: 'core/interactions',
  install,
  listeners: {
    'scope:add-document': (arg) => onDocSignal(arg, 'add'),
    'scope:remove-document': (arg) => onDocSignal(arg, 'remove'),
    'interactable:unset': ({ interactable }, scope) => {
      // Stop and destroy related interactions when an Interactable is unset
      for (let i = scope.interactions.list.length - 1; i >= 0; i--) {
        const interaction = scope.interactions.list[i]

        if (interaction.interactable !== interactable) {
          continue
        }

        interaction.stop()
        scope.fire('interactions:destroy', { interaction })
        interaction.destroy()

        if (scope.interactions.list.length > 2) {
          scope.interactions.list.splice(i, 1)
        }
      }
    },
  },
  onDocSignal,
  doOnInteractions,
  methodNames,
}

export default interactions