taye/interact.js

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

Summary

Maintainability
C
1 day
Test Coverage
C
70%
import Modification from '@interactjs/modifiers/Modification'
import * as modifiers from '@interactjs/modifiers/base'
import offset from '@interactjs/offset/plugin'
import * as Interact from '@interactjs/types/index'
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' {
  // eslint-disable-next-line no-shadow
  interface PhaseMap {
    resume?: true
    inertiastart?: true
  }
}

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

declare module '@interactjs/core/defaultOptions' {
  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<Interact.DoPhaseArg<Interact.ActionName, 'inertiastart'>, 'iEvent'>
    'interactions:action-inertiastart': Interact.DoPhaseArg<Interact.ActionName, 'inertiastart'>
    'interactions:after-action-inertiastart': Interact.DoPhaseArg<Interact.ActionName, 'inertiastart'>
    'interactions:before-action-resume': Omit<Interact.DoPhaseArg<Interact.ActionName, 'resume'>, 'iEvent'>
    'interactions:action-resume': Interact.DoPhaseArg<Interact.ActionName, 'resume'>
    'interactions:after-action-resume': Interact.DoPhaseArg<Interact.ActionName, 'resume'>
  }
}

function install (scope: Interact.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 = null
  modifierCount = 0
  modifierArg: modifiers.ModifierArg = null

  startCoords: Interact.Point = null
  t0 = 0
  v0 = 0

  te = 0
  targetOffset: Interact.Point = null
  modifiedOffset: Interact.Point = null
  currentOffset: Interact.Point = null

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

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

  start (event: Interact.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 = {
      interaction,
      interactable: interaction.interactable,
      element: interaction.element,
      rect: interaction.rect,
      edges: interaction.edges,
      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: Interact.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 }: Interact.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 }: Interact.DoPhaseArg<Interact.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: Interact.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: Interact.Interaction }) {
  const state = interaction.inertia

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

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

const inertia: Interact.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