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
}