tweenable.js

import assign from 'object-assign'
import * as easingFunctions from './easing-functions'

// CONSTANTS
const DEFAULT_EASING = 'linear'
const DEFAULT_DURATION = 500
const UPDATE_TIME = 1000 / 60
const root = typeof window !== 'undefined' ? window : global

const AFTER_TWEEN = 'afterTween'
const AFTER_TWEEN_END = 'afterTweenEnd'
const BEFORE_TWEEN = 'beforeTween'
const TWEEN_CREATED = 'tweenCreated'
const TYPE_FUNCTION = 'function'
const TYPE_STRING = 'string'

// requestAnimationFrame() shim by Paul Irish (modified for Shifty)
// http://paulirish.com/2011/requestanimationframe-for-smart-animating/
let scheduleFunction =
  root.requestAnimationFrame ||
  root.webkitRequestAnimationFrame ||
  root.oRequestAnimationFrame ||
  root.msRequestAnimationFrame ||
  (root.mozCancelRequestAnimationFrame && root.mozRequestAnimationFrame) ||
  setTimeout

const noop = () => {}

let listHead = null
let listTail = null

// Strictly used for testing
export const resetList = () => (listHead = listTail = null)
export const getListHead = () => listHead
export const getListTail = () => listTail

const formulas = assign({}, easingFunctions)

/**
 * Calculates the interpolated tween values of an Object for a given
 * timestamp.
 * @param {number} forPosition The position to compute the state for.
 * @param {Object} currentState Current state properties.
 * @param {Object} originalState: The original state properties the Object is
 * tweening from.
 * @param {Object} targetState: The destination state properties the Object
 * is tweening to.
 * @param {number} duration: The length of the tween in milliseconds.
 * @param {number} timestamp: The UNIX epoch time at which the tween began.
 * @param {Object<string|Function>} easing: This Object's keys must correspond
 * to the keys in targetState.
 * @returns {Object}
 * @private
 */
export const tweenProps = ((
  normalizedPosition,
  key,
  easingObjectProp,
  easingFn,
  start
) => (
  forPosition,
  currentState,
  originalState,
  targetState,
  duration,
  timestamp,
  easing
) => {
  normalizedPosition =
    forPosition < timestamp ? 0 : (forPosition - timestamp) / duration

  for (key in currentState) {
    easingObjectProp = easing[key]
    easingFn = easingObjectProp.call
      ? easingObjectProp
      : formulas[easingObjectProp]
    start = originalState[key]

    currentState[key] =
      start + (targetState[key] - start) * easingFn(normalizedPosition)
  }

  return currentState
})()

const processTween = ((
  duration,
  timestamp,
  endTime,
  timeToCompute,
  hasEnded,
  offset,
  currentState,
  targetState,
  delay
) => (tween, currentTime) => {
  duration = tween._duration
  timestamp = tween._timestamp
  currentState = tween._currentState
  targetState = tween._targetState
  delay = tween._delay

  endTime = timestamp + delay + duration
  timeToCompute = currentTime > endTime ? endTime : currentTime
  hasEnded = timeToCompute >= endTime
  offset = duration - (endTime - timeToCompute)

  if (hasEnded) {
    tween._render(targetState, tween._data, offset)
    tween.stop(true)
  } else {
    tween._applyFilter(BEFORE_TWEEN)

    // If the animation has not yet reached the start point (e.g., there was
    // delay that has not yet completed), just interpolate the starting
    // position of the tween.
    if (timeToCompute < timestamp + delay) {
      timestamp = duration = timeToCompute = 1
    } else {
      timestamp += delay
    }

    tweenProps(
      timeToCompute,
      currentState,
      tween._originalState,
      targetState,
      duration,
      timestamp,
      tween._easing
    )

    tween._applyFilter(AFTER_TWEEN)
    tween._render(currentState, tween._data, offset)
  }
})()

export const processTweens = (currentTime, currentTween, nextTweenToProcess) =>
  /**
   * Process all tweens currently managed by Shifty for the current tick. This
   * does not perform any timing or update scheduling; it is the logic that is
   * run *by* the scheduling functionality. Specifically, it computes the state
   * and calls all of the relevant {@link shifty.tweenConfig} functions supplied
   * to each of the tweens for the current point in time (as determined by {@link
   * shifty.Tweenable.now}.
   *
   * This is a low-level API that won't be needed in the majority of situations.
   * It is primarily useful as a hook for higher-level animation systems that are
   * built on top of Shifty. If you need this function, it is likely you need to
   * pass something like `() => {}` to {@link
   * shifty.Tweenable.setScheduleFunction}, override {@link shifty.Tweenable.now}
   * and manage the scheduling logic yourself.
   *
   * @method shifty.processTweens
   * @see https://github.com/jeremyckahn/shifty/issues/109
   */
  (() => {
    currentTime = Tweenable.now()
    currentTween = listHead

    while (currentTween) {
      nextTweenToProcess = currentTween._next
      processTween(currentTween, currentTime)
      currentTween = nextTweenToProcess
    }
  })()

/**
 * Handles the update logic for one tick of a tween.
 * @param {number} [currentTimeOverride] Needed for accurate timestamp in
 * shifty.Tweenable#seek.
 * @private
 */
export const scheduleUpdate = () => {
  if (!listHead) {
    return
  }

  scheduleFunction.call(root, scheduleUpdate, UPDATE_TIME)

  processTweens()
}

/**
 * Creates a usable easing Object from a string, a function or another easing
 * Object.  If `easing` is an Object, then this function clones it and fills
 * in the missing properties with `"linear"`.
 * @param {Object.<string|Function>} fromTweenParams
 * @param {Object|string|Function} [easing]
 * @param {Object} [composedEasing] Reused composedEasing object (used internally)
 * @return {Object.<string|Function>}
 * @private
 */
export const composeEasingObject = (
  fromTweenParams,
  easing = DEFAULT_EASING,
  composedEasing = {}
) => {
  let typeofEasing = typeof easing

  if (typeofEasing === TYPE_STRING || typeofEasing === TYPE_FUNCTION) {
    for (const prop in fromTweenParams) {
      composedEasing[prop] = easing
    }
  } else {
    for (const prop in fromTweenParams) {
      composedEasing[prop] = easing[prop] || DEFAULT_EASING
    }
  }

  return composedEasing
}

// Private declarations used below

const remove = ((previousTween, nextTween) => tween => {
  // Adapted from:
  // https://github.com/trekhleb/javascript-algorithms/blob/7c9601df3e8ca4206d419ce50b88bd13ff39deb6/src/data-structures/doubly-linked-list/DoublyLinkedList.js#L73-L121
  if (tween === listHead) {
    listHead = tween._next

    if (listHead) {
      listHead._previous = null
    } else {
      listTail = null
    }
  } else if (tween === listTail) {
    listTail = tween._previous

    if (listTail) {
      listTail._next = null
    } else {
      listHead = null
    }
  } else {
    previousTween = tween._previous
    nextTween = tween._next

    previousTween._next = nextTween
    nextTween._previous = previousTween
  }

  // Clean up any references in case the tween is restarted later.
  tween._previous = tween._next = null
})()

export class Tweenable {
  _config = {}
  _data = {}
  _filters = []
  _next = null
  _previous = null
  _timestamp = null
  _resolve = null
  _reject = null
  _currentState = {}
  _originalState = {}
  _targetState = {}
  _start = noop
  _render = noop

  /**
   * @param {Object} [initialState={}] The values that the initial tween should
   * start at if a `from` value is not provided to {@link
   * shifty.Tweenable#tween} or {@link shifty.Tweenable#setConfig}.
   * @param {shifty.tweenConfig} [config] Configuration object to be passed to
   * {@link shifty.Tweenable#setConfig}.
   * @constructs shifty.Tweenable
   */
  constructor(initialState = {}, config = undefined) {
    // The || doesn't seem necessary here, but it prevents a (tested) issue
    // where initialState is null.
    this._currentState = initialState || this._currentState

    // To prevent unnecessary calls to setConfig do not set default
    // configuration here.  Only set default configuration immediately before
    // tweening if none has been set.
    if (config) {
      this.setConfig(config)
    }
  }

  /**
   * Applies a filter to Tweenable instance.
   * @param {string} filterName The name of the filter to apply.
   * @private
   */
  _applyFilter(filterName) {
    for (let i = this._filters.length; i > 0; i--) {
      const filterType = this._filters[i - i]
      const filter = filterType[filterName]

      if (filter) {
        filter(this)
      }
    }
  }

  /**
   * Configure and start a tween. If this {@link shifty.Tweenable}'s instance
   * is already running, then it will stop playing the old tween and
   * immediately play the new one.
   * @method shifty.Tweenable#tween
   * @param {shifty.tweenConfig} [config] Gets passed to {@link
   * shifty.Tweenable#setConfig}.
   * @return {external:Promise} This `Promise` resolves with a {@link
   * shifty.promisedData} object.
   */
  tween(config = undefined) {
    if (this._isPlaying) {
      this.stop()
    }

    if (config || !this._config) {
      this.setConfig(config)
    }

    this._pausedAtTime = null
    this._timestamp = Tweenable.now()
    this._start(this.get(), this._data)

    return this._resume(this._timestamp)
  }

  /**
   * Configure a tween that will start at some point in the future. Aside from
   * `delay`, `from`, and `to`, each configuration option will automatically
   * default to the same option used in the preceding tween of this {@link
   * shifty.Tweenable} instance.
   * @method shifty.Tweenable#setConfig
   * @param {shifty.tweenConfig} [config={}]
   * @return {shifty.Tweenable}
   */
  setConfig(config = {}) {
    assign(this._config, config)

    // Configuration options to reuse from previous tweens
    const {
      promise = Promise,
      start = noop,
      render = this._config.step || noop,

      // Legacy option. Superseded by `render`.
      step = noop,
    } = this._config

    // Attach something to this Tweenable instance (e.g.: a DOM element, an
    // object, a string, etc.);
    this._data = this._config.data || this._config.attachment || this._data

    // Init the internal state
    this._isPlaying = false
    this._pausedAtTime = null
    this._scheduleId = null
    this._delay = config.delay || 0
    this._start = start
    this._render = render || step
    this._duration = this._config.duration || DEFAULT_DURATION
    assign(this._currentState, config.from)
    assign(this._originalState, this._currentState)

    // Ensure that there is always something to tween to.
    assign(this._targetState, this._currentState, config.to)

    this._easing = composeEasingObject(
      this._currentState,
      this._config.easing,
      this._easing
    )

    this._filters.length = 0

    for (const name in Tweenable.filters) {
      if (Tweenable.filters[name].doesApply(this)) {
        this._filters.push(Tweenable.filters[name])
      }
    }

    this._applyFilter(TWEEN_CREATED)

    this._promise = new promise((resolve, reject) => {
      this._resolve = resolve
      this._reject = reject
    })

    return this
  }

  /**
   * @method shifty.Tweenable#get
   * @return {Object} The current state.
   */
  get() {
    return assign({}, this._currentState)
  }

  /**
   * Set the current state.
   * @method shifty.Tweenable#set
   * @param {Object} state The state to set.
   */
  set(state) {
    this._currentState = state
  }

  /**
   * Pause a tween. Paused tweens can be resumed from the point at which they
   * were paused. If a tween is not running, this is a no-op.
   * @method shifty.Tweenable#pause
   * @return {shifty.Tweenable}
   */
  pause() {
    if (!this._isPlaying) {
      return
    }

    this._pausedAtTime = Tweenable.now()
    this._isPlaying = false
    remove(this)

    return this
  }

  /**
   * Resume a paused tween.
   * @method shifty.Tweenable#resume
   * @return {external:Promise}
   */
  resume() {
    return this._resume()
  }

  _resume(currentTime = Tweenable.now()) {
    if (this._timestamp === null) {
      return this.tween()
    }

    if (this._isPlaying) {
      return this._promise
    }

    if (this._pausedAtTime) {
      this._timestamp += currentTime - this._pausedAtTime
      this._pausedAtTime = null
    }

    this._isPlaying = true

    if (listHead === null) {
      listHead = this
      listTail = this
      scheduleUpdate()
    } else {
      this._previous = listTail
      listTail._next = this

      listTail = this
    }

    return this._promise
  }

  /**
   * Move the state of the animation to a specific point in the tween's
   * timeline.  If the animation is not running, this will cause {@link
   * shifty.renderFunction} handlers to be called.
   * @method shifty.Tweenable#seek
   * @param {millisecond} millisecond The millisecond of the animation to seek
   * to.  This must not be less than `0`.
   * @return {shifty.Tweenable}
   */
  seek(millisecond) {
    millisecond = Math.max(millisecond, 0)
    const currentTime = Tweenable.now()

    if (this._timestamp + millisecond === 0) {
      return this
    }

    this._timestamp = currentTime - millisecond

    if (!this._isPlaying) {
      // If the animation is not running, call processTween to make sure that
      // any render handlers are run.
      processTween(this, currentTime)
    }

    return this
  }

  /**
   * Stops a tween. If a tween is not running, this is a no-op. This method
   * does not cancel the tween {@link external:Promise}. For that, use {@link
   * shifty.Tweenable#cancel}.
   * @param {boolean} [gotoEnd] If `false`, the tween just stops at its current
   * state.  If `true`, the tweened object's values are instantly set to the
   * target values.
   * @method shifty.Tweenable#stop
   * @return {shifty.Tweenable}
   */
  stop(gotoEnd = false) {
    if (!this._isPlaying) {
      return this
    }

    this._isPlaying = false

    remove(this)

    if (gotoEnd) {
      this._applyFilter(BEFORE_TWEEN)
      tweenProps(
        1,
        this._currentState,
        this._originalState,
        this._targetState,
        1,
        0,
        this._easing
      )
      this._applyFilter(AFTER_TWEEN)
      this._applyFilter(AFTER_TWEEN_END)
    }

    if (this._resolve) {
      this._resolve({
        data: this._data,
        state: this._currentState,
        tweenable: this,
      })
    }

    this._resolve = null
    this._reject = null

    assign(this._targetState, this._currentState)
    assign(this._originalState, this._targetState)

    return this
  }

  /**
   * {@link shifty.Tweenable#stop}s a tween and also `reject`s its {@link
   * external:Promise}. If a tween is not running, this is a no-op.
   * @param {boolean} [gotoEnd] Is propagated to {@link shifty.Tweenable#stop}.
   * @method shifty.Tweenable#cancel
   * @return {shifty.Tweenable}
   * @see https://github.com/jeremyckahn/shifty/issues/122
   */
  cancel(gotoEnd = false) {
    const { _currentState, _data, _isPlaying } = this

    if (!_isPlaying) {
      return this
    }

    this._reject({
      data: _data,
      state: _currentState,
      tweenable: this,
    })

    this._resolve = null
    this._reject = null

    return this.stop(gotoEnd)
  }

  /**
   * Whether or not a tween is running.
   * @method shifty.Tweenable#isPlaying
   * @return {boolean}
   */
  isPlaying() {
    return this._isPlaying
  }

  /**
   * @method shifty.Tweenable#setScheduleFunction
   * @param {shifty.scheduleFunction} scheduleFunction
   * @deprecated Will be removed in favor of {@link shifty.Tweenable.setScheduleFunction} in 3.0.
   */
  setScheduleFunction(scheduleFunction) {
    Tweenable.setScheduleFunction(scheduleFunction)
  }

  /**
   * Get and optionally set the data that gets passed as `data` to {@link
   * shifty.promisedData}, {@link shifty.startFunction} and {@link
   * shifty.renderFunction}.
   * @param {Object} [data]
   * @method shifty.Tweenable#data
   * @return {Object} The internally stored `data`.
   */
  data(data = null) {
    if (data) {
      this._data = assign({}, data)
    }

    return this._data
  }

  /**
   * `delete` all "own" properties.  Call this when the {@link
   * shifty.Tweenable} instance is no longer needed to free memory.
   * @method shifty.Tweenable#dispose
   */
  dispose() {
    for (const prop in this) {
      delete this[prop]
    }
  }
}

/**
 * Set a custom schedule function.
 *
 * By default,
 * [`requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window.requestAnimationFrame)
 * is used if available, otherwise
 * [`setTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/Window.setTimeout)
 * is used.
 * @method shifty.Tweenable.setScheduleFunction
 * @param {shifty.scheduleFunction} fn The function to be
 * used to schedule the next frame to be rendered.
 * @return {shifty.scheduleFunction} The function that was set.
 */
Tweenable.setScheduleFunction = fn => (scheduleFunction = fn)

Tweenable.formulas = formulas

/**
 * The {@link shifty.filter}s available for use.  These filters are
 * automatically applied at tween-time by Shifty. You can define your own
 * {@link shifty.filter}s and attach them to this object.
 * @member shifty.Tweenable.filters
 * @type {Object.<shifty.filter>}
 */
Tweenable.filters = {}

/**
 * @method shifty.Tweenable.now
 * @static
 * @returns {number} The current timestamp.
 */
Tweenable.now = Date.now || (() => +new Date())

/**
 * @method shifty.tween
 * @param {shifty.tweenConfig} [config={}]
 * @description Standalone convenience method that functions identically to
 * {@link shifty.Tweenable#tween}.  You can use this to create tweens without
 * needing to set up a {@link shifty.Tweenable} instance.
 *
 * ```
 * import { tween } from 'shifty';
 *
 * tween({ from: { x: 0 }, to: { x: 10 } }).then(
 *   () => console.log('All done!')
 * );
 * ```
 *
 * @returns {external:Promise} This `Promise` has a property called `tweenable`
 * that is the {@link shifty.Tweenable} instance that is running the tween.
 */
export function tween(config = {}) {
  const tweenable = new Tweenable()
  const promise = tweenable.tween(config)
  promise.tweenable = tweenable

  return promise
}