taye/interact.js

View on GitHub
packages/@interactjs/inertia/plugin.ts

Summary

Maintainability
C
1 day
Test Coverage
import type { Interaction, DoPhaseArg } from '@interactjs/core/Interaction'
import type { Scope, SignalArgs, Plugin } from '@interactjs/core/scope'
import type { ActionName, Point, PointerEventType } from '@interactjs/core/types'
/* eslint-disable import/no-duplicates -- for typescript module augmentations */
import '@interactjs/modifiers/base'
import '@interactjs/offset/plugin'
import * as modifiers from '@interactjs/modifiers/base'
import { Modification } from '@interactjs/modifiers/Modification'
import type { ModifierArg } from '@interactjs/modifiers/types'
import offset from '@interactjs/offset/plugin'
/* eslint-enable import/no-duplicates */
import * as dom from '@interactjs/utils/domUtils'
import hypot from '@interactjs/utils/hypot'
import is from '@interactjs/utils/is'
import { copyCoords } from '@interactjs/utils/pointerUtils'
import raf from '@interactjs/utils/raf'

declare module '@interactjs/core/InteractEvent' {
  interface PhaseMap {
    resume?: true
    inertiastart?: true
  }
}

declare module '@interactjs/core/Interaction' {
  interface Interaction {
    inertia?: InertiaState
  }
}

declare module '@interactjs/core/options' {
  interface PerActionDefaults {
    inertia?: {
      enabled?: boolean
      resistance?: number // the lambda in exponential decay
      minSpeed?: number // target speed must be above this for inertia to start
      endSpeed?: number // the speed at which inertia is slow enough to stop
      allowResume?: true // allow resuming an action in inertia phase
      smoothEndDuration?: number // animate to snap/restrict endOnly if there's no inertia
    }
  }
}

declare module '@interactjs/core/scope' {
  interface SignalArgs {
    'interactions:before-action-inertiastart': Omit<DoPhaseArg<ActionName, 'inertiastart'>, 'iEvent'>
    'interactions:action-inertiastart': DoPhaseArg<ActionName, 'inertiastart'>
    'interactions:after-action-inertiastart': DoPhaseArg<ActionName, 'inertiastart'>
    'interactions:before-action-resume': Omit<DoPhaseArg<ActionName, 'resume'>, 'iEvent'>
    'interactions:action-resume': DoPhaseArg<ActionName, 'resume'>
    'interactions:after-action-resume': DoPhaseArg<ActionName, 'resume'>
  }
}

function install(scope: Scope) {
  const { defaults } = scope

  scope.usePlugin(offset)
  scope.usePlugin(modifiers.default)
  scope.actions.phases.inertiastart = true
  scope.actions.phases.resume = true

  defaults.perAction.inertia = {
    enabled: false,
    resistance: 10, // the lambda in exponential decay
    minSpeed: 100, // target speed must be above this for inertia to start
    endSpeed: 10, // the speed at which inertia is slow enough to stop
    allowResume: true, // allow resuming an action in inertia phase
    smoothEndDuration: 300, // animate to snap/restrict endOnly if there's no inertia
  }
}

export class InertiaState {
  active = false
  isModified = false
  smoothEnd = false
  allowResume = false

  modification!: Modification
  modifierCount = 0
  modifierArg!: ModifierArg

  startCoords!: Point
  t0 = 0
  v0 = 0

  te = 0
  targetOffset!: Point
  modifiedOffset!: Point
  currentOffset!: Point

  lambda_v0? = 0 // eslint-disable-line camelcase
  one_ve_v0? = 0 // eslint-disable-line camelcase
  timeout!: number
  readonly interaction: Interaction

  constructor(interaction: Interaction) {
    this.interaction = interaction
  }

  start(event: PointerEventType) {
    const { interaction } = this
    const options = getOptions(interaction)

    if (!options || !options.enabled) {
      return false
    }

    const { client: velocityClient } = interaction.coords.velocity
    const pointerSpeed = hypot(velocityClient.x, velocityClient.y)
    const modification = this.modification || (this.modification = new Modification(interaction))

    modification.copyFrom(interaction.modification)

    this.t0 = interaction._now()
    this.allowResume = options.allowResume
    this.v0 = pointerSpeed
    this.currentOffset = { x: 0, y: 0 }
    this.startCoords = interaction.coords.cur.page

    this.modifierArg = modification.fillArg({
      pageCoords: this.startCoords,
      preEnd: true,
      phase: 'inertiastart',
    })

    const thrown =
      this.t0 - interaction.coords.cur.timeStamp < 50 &&
      pointerSpeed > options.minSpeed &&
      pointerSpeed > options.endSpeed

    if (thrown) {
      this.startInertia()
    } else {
      modification.result = modification.setAll(this.modifierArg)

      if (!modification.result.changed) {
        return false
      }

      this.startSmoothEnd()
    }

    // force modification change
    interaction.modification.result.rect = null

    // bring inertiastart event to the target coords
    interaction.offsetBy(this.targetOffset)
    interaction._doPhase({
      interaction,
      event,
      phase: 'inertiastart',
    })
    interaction.offsetBy({ x: -this.targetOffset.x, y: -this.targetOffset.y })
    // force modification change
    interaction.modification.result.rect = null

    this.active = true
    interaction.simulation = this

    return true
  }

  startInertia() {
    const startVelocity = this.interaction.coords.velocity.client
    const options = getOptions(this.interaction)
    const lambda = options.resistance
    const inertiaDur = -Math.log(options.endSpeed / this.v0) / lambda

    this.targetOffset = {
      x: (startVelocity.x - inertiaDur) / lambda,
      y: (startVelocity.y - inertiaDur) / lambda,
    }

    this.te = inertiaDur
    this.lambda_v0 = lambda / this.v0
    this.one_ve_v0 = 1 - options.endSpeed / this.v0

    const { modification, modifierArg } = this

    modifierArg.pageCoords = {
      x: this.startCoords.x + this.targetOffset.x,
      y: this.startCoords.y + this.targetOffset.y,
    }

    modification.result = modification.setAll(modifierArg)

    if (modification.result.changed) {
      this.isModified = true
      this.modifiedOffset = {
        x: this.targetOffset.x + modification.result.delta.x,
        y: this.targetOffset.y + modification.result.delta.y,
      }
    }

    this.onNextFrame(() => this.inertiaTick())
  }

  startSmoothEnd() {
    this.smoothEnd = true
    this.isModified = true
    this.targetOffset = {
      x: this.modification.result.delta.x,
      y: this.modification.result.delta.y,
    }

    this.onNextFrame(() => this.smoothEndTick())
  }

  onNextFrame(tickFn: () => void) {
    this.timeout = raf.request(() => {
      if (this.active) {
        tickFn()
      }
    })
  }

  inertiaTick() {
    const { interaction } = this
    const options = getOptions(interaction)
    const lambda = options.resistance
    const t = (interaction._now() - this.t0) / 1000

    if (t < this.te) {
      const progress = 1 - (Math.exp(-lambda * t) - this.lambda_v0) / this.one_ve_v0
      let newOffset: Point

      if (this.isModified) {
        newOffset = getQuadraticCurvePoint(
          0,
          0,
          this.targetOffset.x,
          this.targetOffset.y,
          this.modifiedOffset.x,
          this.modifiedOffset.y,
          progress,
        )
      } else {
        newOffset = {
          x: this.targetOffset.x * progress,
          y: this.targetOffset.y * progress,
        }
      }

      const delta = { x: newOffset.x - this.currentOffset.x, y: newOffset.y - this.currentOffset.y }

      this.currentOffset.x += delta.x
      this.currentOffset.y += delta.y

      interaction.offsetBy(delta)
      interaction.move()

      this.onNextFrame(() => this.inertiaTick())
    } else {
      interaction.offsetBy({
        x: this.modifiedOffset.x - this.currentOffset.x,
        y: this.modifiedOffset.y - this.currentOffset.y,
      })

      this.end()
    }
  }

  smoothEndTick() {
    const { interaction } = this
    const t = interaction._now() - this.t0
    const { smoothEndDuration: duration } = getOptions(interaction)

    if (t < duration) {
      const newOffset = {
        x: easeOutQuad(t, 0, this.targetOffset.x, duration),
        y: easeOutQuad(t, 0, this.targetOffset.y, duration),
      }
      const delta = {
        x: newOffset.x - this.currentOffset.x,
        y: newOffset.y - this.currentOffset.y,
      }

      this.currentOffset.x += delta.x
      this.currentOffset.y += delta.y

      interaction.offsetBy(delta)
      interaction.move({ skipModifiers: this.modifierCount })

      this.onNextFrame(() => this.smoothEndTick())
    } else {
      interaction.offsetBy({
        x: this.targetOffset.x - this.currentOffset.x,
        y: this.targetOffset.y - this.currentOffset.y,
      })

      this.end()
    }
  }

  resume({ pointer, event, eventTarget }: SignalArgs['interactions:down']) {
    const { interaction } = this

    // undo inertia changes to interaction coords
    interaction.offsetBy({
      x: -this.currentOffset.x,
      y: -this.currentOffset.y,
    })

    // update pointer at pointer down position
    interaction.updatePointer(pointer, event, eventTarget, true)

    // fire resume signals and event
    interaction._doPhase({
      interaction,
      event,
      phase: 'resume',
    })
    copyCoords(interaction.coords.prev, interaction.coords.cur)

    this.stop()
  }

  end() {
    this.interaction.move()
    this.interaction.end()
    this.stop()
  }

  stop() {
    this.active = this.smoothEnd = false
    this.interaction.simulation = null
    raf.cancel(this.timeout)
  }
}

function start({ interaction, event }: DoPhaseArg<ActionName, 'end'>) {
  if (!interaction._interacting || interaction.simulation) {
    return null
  }

  const started = interaction.inertia.start(event)

  // prevent action end if inertia or smoothEnd
  return started ? false : null
}

// Check if the down event hits the current inertia target
// control should be return to the user
function resume(arg: SignalArgs['interactions:down']) {
  const { interaction, eventTarget } = arg
  const state = interaction.inertia

  if (!state.active) return

  let element = eventTarget as Node

  // climb up the DOM tree from the event target
  while (is.element(element)) {
    // if interaction element is the current inertia target element
    if (element === interaction.element) {
      state.resume(arg)
      break
    }

    element = dom.parentNode(element)
  }
}

function stop({ interaction }: { interaction: Interaction }) {
  const state = interaction.inertia

  if (state.active) {
    state.stop()
  }
}

function getOptions({ interactable, prepared }: Interaction) {
  return interactable && interactable.options && prepared.name && interactable.options[prepared.name].inertia
}

const inertia: Plugin = {
  id: 'inertia',
  before: ['modifiers', 'actions'],
  install,
  listeners: {
    'interactions:new': ({ interaction }) => {
      interaction.inertia = new InertiaState(interaction)
    },

    'interactions:before-action-end': start,
    'interactions:down': resume,
    'interactions:stop': stop,

    'interactions:before-action-resume': (arg) => {
      const { modification } = arg.interaction

      modification.stop(arg)
      modification.start(arg, arg.interaction.coords.cur.page)
      modification.applyToInteraction(arg)
    },

    'interactions:before-action-inertiastart': (arg) => arg.interaction.modification.setAndApply(arg),
    'interactions:action-resume': modifiers.addEventModifiers,
    'interactions:action-inertiastart': modifiers.addEventModifiers,
    'interactions:after-action-inertiastart': (arg) =>
      arg.interaction.modification.restoreInteractionCoords(arg),
    'interactions:after-action-resume': (arg) => arg.interaction.modification.restoreInteractionCoords(arg),
  },
}

// http://stackoverflow.com/a/5634528/2280888
function _getQBezierValue(t: number, p1: number, p2: number, p3: number) {
  const iT = 1 - t
  return iT * iT * p1 + 2 * iT * t * p2 + t * t * p3
}

function getQuadraticCurvePoint(
  startX: number,
  startY: number,
  cpX: number,
  cpY: number,
  endX: number,
  endY: number,
  position: number,
) {
  return {
    x: _getQBezierValue(position, startX, cpX, endX),
    y: _getQBezierValue(position, startY, cpY, endY),
  }
}

// http://gizma.com/easing/
function easeOutQuad(t: number, b: number, c: number, d: number) {
  t /= d
  return -c * t * (t - 2) + b
}

export default inertia