statemachine.js 11.3 KB
/*
 * ADOBE CONFIDENTIAL
 * __________________
 *
 *  Copyright 2015 Adobe Systems Incorporated
 *  All Rights Reserved.
 *
 * NOTICE:  All information contained herein is, and remains
 * the property of Adobe Systems Incorporated and its suppliers,
 * if any.  The intellectual and technical concepts contained
 * herein are proprietary to Adobe Systems Incorporated and its
 * suppliers and are protected by trade secret or copyright law.
 * Dissemination of this information or reproduction of this material
 * is strictly forbidden unless prior written permission is obtained
 * from Adobe Systems Incorporated.
 */

/* eslint no-use-before-define: 1*/

/**
 * @module screens/player/shared/statemachine
 */
define('screens/player/shared/statemachine', ['underscore', 'eventemitter'], function(_, EventEmitter) {
    'use strict';

    /**
     * Default options for the component.
     *
     * Example configuration:
     * <pre>
     * {
     *  initialState: 's0',
     *  context: someUserObject,
     *  states: {
     *      // here we define the states. each state configuration is an object that defines a set of handlers.
     *      s0: {
     *          // simple string based handler. when triggered, transitions to the given state
     *          button_0: 's1',
     *
     *          // function based handler. you can basically do whatever you want here
     *          button_1: function() {
     *              if (condition) {
     *                  this.transition('s2');
     *              } else {
     *                  // invoke something on the configured context
     *                  this.context.doSomething();
     *              }
     *          },
     *
     *          // optional default handler if non of the other handlers match
     *          '*': 's3'
     *      },
     *
     *      s1: {
     *          // special handler that is invoked when the state is transitioned into
     *          '@enter': function() {
     *              console.log('entered state: ' + this._name);
     *          },
     *
     *          // special handler that is invoked when the state is transitioned out
     *          '@exit': function() {
     *              console.log('left state: ' + this._name);
     *          }
     *      },
     *
     *      s2: {
     *          button_0: function() {
     *              // you can als trigger an even from within a handler. optional with a timeout.
     *              this.trigger('t1', 500);
     *          },
     *          button_1: function() {
     *              // or you can directly transition to another state with a timeout
     *              this.transition('t0', 500);
     *          },
     *          't1': function() {
     *              console.log('t1 triggered after 500ms');
     *          }
     *      },
     *
     *      s3: {
     *          // you can also use a promise as trigger function. assume that 'p1' resolves to 'ok' and reject 'error'.
     *          '@enter': p1,
     *          'ok': 's4',
     *          'error' 's4-error'
     *      }
     *  }
     * }
     * </pre>
     *
     * @typedef {Object} StateMachineOptions
     *
     * @property {String} [initialState='start'] The name of the initial state
     * @property {*} [context] User defined context.
     * @property {Object} states Configuration of the states.
     */
    var DEFAULTS = {
        initialState: 'start',
        context: null,
        states: {}
    };

    /**
     * State class
     * @class StateMachine.State
     * @private
     *
     * @param {StateMachine} sm The parent statemachine
     * @param {String} name name of the state
     * @param {Object} config config of the state
     */
    var State = function(sm, name, config) {
        this._sm = sm;
        this._name = name;
        this._config = config;
        this._timers = [];

        // define enter and exit handlers
        this._on_exit_handler = _.isFunction(config['@exit']) ? config['@exit'] : null;
    };

    State.prototype = {

        _onEnter: function() {
            // enter handler is like a normal trigger
            this.trigger('@enter');
        },

        _onExit: function() {
            this._clearTimers();

            // exit handler only allows functions
            if (this._on_exit_handler) {
                this._on_exit_handler.call(this);
            }
        },

        _clearTimers: function() {
            _.each(this._timers, clearTimeout);
            this._timers.length = 0;
        },

        /**
         * Similar to {@link StateMachine.transition} but with an optional timeout. Note that the timer is bound to
         * this state and is cleared on exit.
         *
         * @see StateMachine.transition
         *
         * @param {String} name Name of the transition
         * @param {number} timeout optional timeout after which the transition is triggered.
         *
         * @memberof StateMachine.State
         * @instance
         */
        transition: function(name, timeout) {
            var sm = this._sm;
            if (_.isNumber(timeout) && timeout > 0) {
                this._timers.push(setTimeout(function() {
                    sm.transition.call(sm, name);
                }, timeout));
            } else {
                sm.transition.call(sm, name);
            }
        },

        /**
         * Triggers the handler with the given name if it exists. Optional after a timeout
         * @param {string} name Name of the handler
         * @param {number} timeout Timeout in milliseconds.
         *
         * @memberof StateMachine.State
         * @instance
         */
        trigger: function(name, timeout) {
            var cfg = this._config;
            var handler = cfg[name];
            if (_.isUndefined(handler)) {
                if (name.charAt(0) === '@') {
                    return;
                }
                // check default handler
                handler = cfg['*'];
                if (_.isUndefined(handler)) {
                    // don't do anything here ?
                    console.log('handler not defined for trigger ' + name);
                    return;
                }
            }
            if (_.isNumber(timeout) && timeout > 0) {
                var self = this;
                this._timers.push(setTimeout(function() {
                    self._trigger_now(handler);
                }, timeout));
            } else {
                this._trigger_now(handler);
            }
        },

        _trigger_now: function(handler) {
            if (_.isString(handler)) {
                // direct transition
                this.transition(handler);
                return;
            }
            if (_.isFunction(handler)) {
                handler.call(this);
                return;
            }
            if (_.isFunction(handler.then)) {
                // handle promise
                var self = this;
                handler.then(function(name) {
                    if (name) {
                        self.trigger(name);
                    }
                }).catch(function(name) {
                    if (name) {
                        self.trigger(name);
                    }
                });
                return;
            }
            if (_.isObject(handler)) {
                // todo:
                throw Error('not supported');
            }
            throw Error('unsupported handler');
        }
    };

    /**
     * Creates a new StateMachine.
     *
     * @classdesc Simple state machine.
     * @class StateMachine
     * @extends EventEmitter
     *
     * @param {StateMachineOptions} [options] An object of configurable options.
     */
    var StateMachine = function(options) {
        this.options = _.defaults({}, options, DEFAULTS);
        this._initialState = this.options.initialState;
    };

    /* eslint quote-props: [2, "consistent"] */
    StateMachine.prototype = _.create(EventEmitter.prototype, _.assign({
        '_super': EventEmitter.prototype,
        'constructor': StateMachine
    },  /** @lends StateMachine.prototype */ {


        /**
         * Starts this state machine. The machine transitions automatically into the 'initialState'
         */
        start: function() {
            if (this.isStarted()) {
                throw Error('already started');
            }
            // the holder is the object that tracks the state. we don't want to clutter 'this' object too much. this
            // also allows us to later use an external _holder to track states.
            this._holder = {
                // current state
                state: new State(this, '', {}),

                // previous state
                prev: null
            };

            // create the state classes
            var self = this;
            this._states = {};

            _.each(this.options.states, function(s, n) {
                var state = self._states[n] = new State(self, n, s);
                state.context = self.options.context;
            });

            this.emit('started');
            this.transition(this._initialState);
        },

        /**
         * Stops this state machine.
         */
        stop: function() {
            if (!this.isStarted()) {
                throw Error('already stopped');
            }
            for (var stateId in this._states) {
                if (this._states.hasOwnProperty(stateId)) {
                    this._states[stateId]._clearTimers();
                }
            }
            this._holder = null;
            this.emit('stopped');
        },

        /**
         * Checks if this statemachine is started.
         * @returns {boolean} `true` if started.
         */
        isStarted: function() {
            return !!this._holder;
        },

        /**
         * Returns the name of the current state.
         * @returns {String} The state.
         */
        getState: function() {
            return this._holder.state._name;
        },

        /**
         * Transitions from the current to the next state.
         *
         * Emits a 'transition' event along with an object that contains the 'from' and 'to' state names.
         * Before the event is emitted, it calls the '@exit' callback on the previous state.
         * After the event is emitted, it calls the '@enter' callback on the next state.
         *
         * @param {string} name name of the next state
         */
        transition: function(name) {
            if (!this.isStarted()) {
                throw Error('not started');
            }
            var next = this._states[name];
            if (!next) {
                throw Error('no such state: ' + name);
            }
            var h = this._holder;
            h.prev = h.state;
            h.state = next;
            var tx = {
                from: h.prev._name,
                to: h.state._name
            };
            if (h.prev) {
                h.prev._onExit();
            }
            this.emit('transition', tx);
            h.state._onEnter();
        },

        /**
         * Trigger an event inside the state machine. The current state's respective handler is invoked, if it exists.
         * @param {string} name The name of the handler.
         */
        trigger: function(name) {
            if (!this.isStarted()) {
                throw Error('no started');
            }
            this._holder.state.trigger(name);
        }

    }));

    return StateMachine;
});