Sequencer/Sequencer.js

/**
* Менеджер очередей анимаций.  
* Позволяет выполнять действия с задержкой и управлять очередью изнутри выполняемых действий.  
* Добавление действия (`queueUp`) возвращает объект с функцией `then`, 
* которая позволяет добавлять следующее действие и т.д.  
* Добавленные таким образом действия считаются одной очередью.
* Вызов queueUp снова создаст новую очередь.  
* Из добавляемых действий можно добавлять дополнительные действия (вложенность неограничена)
* при помощи функции `append`, передаваемой в действия вместе с еще несколькими.
* `append` также возвращает `then`.  
* Вызов `append` создает новую очередь, которая будет выполнена сразу после очереди,
* из действия которой был вызван `append`.  
* Помимо `append` передаются следующие действия:  
* `abort()` - прерывает текущую очередь и все вложенные в нее очереди  
* `skip(num)` - будет пропущено `num` следующих действий в текущей очереди  
* @class
* @param {function} onComplete
* @param {boolean} inDebugMode
* @example
* // Функции выполнятся в порядке нумерации с интервалом в 500мс
*
* var sequence = new Sequencer();
*
* function action0(){};
* function action1(seq){
* 	seq.append(action3)
* };
* function action2(){};
* function action3(){};
* function action4(){};
* 
* sequence.queueUp(action0, 500)
* 	.then(action1, 500)
* 	.then(action2, 500);
* 	
* sequence.queueUp(action4, 500);
*
* @example
* // Способы указания длительности действия
*
* // Аргумент
* sequence.queueUp(action0, 500);
*
* // Возвращается из действия
* function action1(){
* 	return 500;
* } 
* sequence.queueUp(action1);
*
* // Вместо действия
* sequence.queueUp(500);
*
* // Если указать аргументом и возвратить из функции
* // будет использован аргумент
* function action2(){
* 	return 1000;
* }
* sequence.queueUp(action2, 500); // длительность будет 500
* 
*/
var Sequencer = function(onComplete, inDebugMode){

	/**
	* Метод, выполняемый по завершению всех очередей.
	* @type {function}
	*/
	this.onComplete = onComplete;

	if(typeof this.onComplete != 'function'){
		this.onComplete = function(){};
	}

	/**
	* Будут ли выводиться сообщения о выполняемых действиях в консоль.
	* @type {Boolean}
	*/
	this.inDebugMode = inDebugMode || false;	

	/**
	* Все очереди.
	* @type {Array}
	*/
	this._queue = [];

	/**
	* Еще не добавленные в массив очередей вложенные очереди. 
	* @type {Array}
	*/
	this._nestedQueue = [];

	/**
	* Если `true` очередь выполнится линейно (без `setTimeout`).
	* @type {Boolean}
	*/
	this._isSync = false;

	/**
	* Кол-во шагов, которые будут пропущены в текущей очереди.
	* @type {Number}
	*/
	this._skips = 0;

	/**
	* Была ли прервана очередь.
	* @type {Boolean}
	*/
	this._wasAborted = false;
};

Sequencer.prototype = {

	/**
	* Создает новую очередь с действием, добавляет ее после всех очередей.
	* Запускает очередь, если она была пустой.
	* @param {function} action   Действие. При выполнении в него передадутся два параметра:  
	*                            `seq` - набор методов для управлением очередью (`append, abort, skip`)  
	*                            `sync` - выполняется ли очередь в реальном времени (на очереди был вызван метод finish)
	* @param {number}   duration Длительность действия. Может быть передана вместо действия или быть возвращена действием.
	* @param {object}   context  контекст действия
	*
	* @return {object} Объект для добавления действий `{then}`.
	*/
	queueUp: function(action, duration, context){
		if(!this.timeout){
			this.timeout = setTimeout(this._go.bind(this), 0);
		}
		return this._addQueue(this._queue, action, duration, context);
	},

	/** 
	* Завершает все очереди синхронно.
	* @param  {boolean} [disableOnComplete] `onComplete` метод не будет выполнен
	*/
	finish: function(disableOnComplete){
		this._disableOnComplete = disableOnComplete || false;
		this._resetTimeout();
		this._isSync = true;
		this._go();
	},

	/** 
	* Прерывает все очереди.
	* @param  {boolean} [disableOnComplete] `onComplete` метод не будет выполнен
	*/
	abort: function(disableOnComplete){
		this._log('aborted');
		this._disableOnComplete = disableOnComplete || false;
		this._nestedQueue.length = null;
		this._queue.length = 0;
		this._resetFull();
	},

	/** 
	* Прерывает указанную очередь изнутри действия.
	* @param  {object} queue очередь, которую нужно прервать
	*/
	_abort: function(queue){
		if(this.inDebugMode){
			console.log(
				'aborted', 
				queue.map(function(s){return s.name;}), 
				this._nestedQueue.map(function(s){return s.name;})
			);
		}
		this._nestedQueue.length = null;
		queue.length = 0;
		this._wasAborted = true;
		this._reset();
	},

	/** Ресетит таймаут. */
	_resetTimeout: function(){
		clearTimeout(this.timeout);
		this.timeout = null;
	},

	/** Ресетит очередь. */
	_reset: function(){
		this._resetTimeout();
		this._skips = 0;
	},

	/** 
	* Ресетит очередь, включая статус завершения.
	* Выполняет `onComplete` менеджера, если не был установлен флаг пропуска `onComplete`.
	* Убирает флаг пропуска `onComplete`.
	*/
	_resetFull: function(){
		this._reset();
		this._isSync = false;
		if(!this._disableOnComplete){
			this.onComplete();
		}
		this._disableOnComplete = false;
	},

	/**
	* Пропускает указанное кол-во шагов текущей очереди.
	* @param  {number} num кол-во пропускаемых шагов
	*/
	_skip: function(num){
		if(typeof num != 'number' || isNaN(num)){
			num = 1;
		}
		this._skips += num;
	},

	/**
	* Добавляет новую очередь с новым действием в очередь.
	* @param {object}   queueHolder очередь, в которую будет добавлена новая очередь
	* @param {function} action      действие
	* @param {number}   duration    длительность действия
	* @param {object}   context     контекст действия
	*
	* @return {object} Объект для добавления действий `{then}`.
	*/
	_addQueue: function(queueHolder, action, duration, context){
		var queue = [];
		var next = this._addStep(queue, action, duration, context);
		queueHolder.push(queue);
		return next;
	},

	/**
	* Добавляет новое действие в очередь.
	* @param {object}   queue    очеред, в которую будет добавлено действие
	* @param {function} action   действие
	* @param {number}   duration длительность действия
	* @param {object}   context  контекст действия
	*
	* @return {object} Объект для добавления действий `{then}`.
	*/
	_addStep: function(queue, action, duration, context){
		var step = {
			action: action,
			name: action && (action.name || action._name),
			duration: duration,
			context: context,
			next: {
				then: this._addStep.bind(this, queue)
			}	
		};
		queue.push(step);
		return step.next;
	},

	/** Запускает выполнение действий. */
	_go: function(){
		// jshint curly:false
		while(this._next());
	},

	/** 
	* Выполняет текущее действие в очереди и запускает таймаут следующего.
	* @return {boolean} Возвращает нужно ли запустить функцию снова.
	*/
	_next: function(){

		// console.log(JSON.stringify(this._queue, null, '    '))
		
		// Больше нет очередей, ресетим менеджер и завершаем
		var queue = this._queue[0];
		if(!queue){
			this._log('ended');
			this._resetFull();
			return false;
		}

		// В текущей очереди нет действий, удаляем ее,
		// добавляем вложенные очереди в основную очередь и переходим к след. очереди
		var step = queue[0];
		if(!step){
			this._skips = 0;
			this._queue.shift();
			this._appendNestedQueue();
			return true;
		}

		// Убираем текущий шаг из очереди
		queue.shift();

		// Пропускаем действие, если указаны пропуски
		if(this._skips !== 0){
			this._log(step.name, 'skipped');
			this._skips--;
			return true;
		}

		// Вызываем действие текущего шага
		var duration = this._executeAction(step, queue);

		// Если очередь была прервана из действия, переходим к след. очереди
		if(this._wasAborted){
			this._log(step.name);
			this._wasAborted = false;
			if(!this._isSync){
				this._resetTimeout();
				this.timeout = setTimeout(this._go.bind(this), 0);
				return false;
			}
			return true;
		}

		// Переходим к след. шагу с указанной задержкой
		if(!this._isSync){
			this._log(step.name, duration);
			this._resetTimeout();
			this.timeout = setTimeout(this._go.bind(this), duration);
			return false;
		}

		// Переходим к след. шагу без задержки
		this._log(step.name);
		return true;
	},

	/** Добавляет вложенную очередь в начало последовательности. */
	_appendNestedQueue: function(){
		for(var i = this._nestedQueue.length - 1; i >= 0; i--){
			this._queue.unshift(this._nestedQueue[i]);
		}
		this._nestedQueue.length = 0;
	},

	/**
	* Выполняет действие, если оно есть.
	* @param {object} step  шаг, к которому пренадледит действие
	* @param {array}  queue очередь, к которой пренадлежит шаг
	*
	* @return {number} длительность действия
	*/
	_executeAction: function(step, queue){
		var duration;
		if(typeof step.action == 'function'){
			duration = step.action.call(step.context || null, this._getMethods(queue), this._isSync);
		}
		else if(typeof step.action == 'number' && !isNaN(step.action)){
			duration = step.action;
		}

		if(typeof step.duration == 'number' && !isNaN(step.duration)){
			duration = step.duration;
		}

		if(typeof duration != 'number' || isNaN(duration)){
			duration = 0;
		}
		return duration;
	},

	/**
	* Возвращает методы, которые можно вызывать из действий для управления очередью.
	* @param {array} queue очередь, которую можно завершить
	*/
	_getMethods: function(queue){
		return {
			append: this._addQueue.bind(this, this._nestedQueue),
			abort: this._abort.bind(this, queue),
			skip: this._skip.bind(this)
		};
	},

	/** Выводит аргументы в лог, если менеджер находится в режиме дебага. */
	_log: function(){
		if(this.inDebugMode){
			console.log.apply(console, arguments);
		}
	}
};

// jshint unused:false
function testSequence(){
	var time = 500;
	var seq = new Sequencer(function(){console.log('done');}, true);
	function action0(seq){
		seq.append(func('14', action1), time).then(func('14.5'), time);
	}
	function action1(seq){
		seq.abort();
		seq.append(action3, time).then(action4, time);

	}
	function action2(seq){
		seq.abort();
		seq.append(action6, 2000);
	}
	function action3(seq){
		seq.skip(1);
		seq.append(action5, time);
	}
	function action4(seq){
		return 1000;
	}
	function action5(seq){
		
	}
	function action6(seq){
		
	}

	function func(name, action){
		var func = action || function(){};
		func._name = name;
		return func;
	}
	seq.queueUp(func('1', action4));
	seq.queueUp(func('2', action2), time).then(func('2.5'), time).then(func('2.75'), time);
/*	seq.queueUp(time).then(func('3'), time);
	seq.queueUp(func('11'), time)
		.then(func('12', action0), time)
		.then(func('13'), time);
	seq.queueUp(func('15'), time);
	seq.queueUp(func('16'), time);
	seq.queueUp(func('17'), time);
	seq.queueUp(func('18'), time);
	seq.queueUp(func('19'), time);
	seq.queueUp(func('20'), time);
	seq.queueUp(func('21'), time);
	seq.queueUp(func('22'), time);
	seq.queueUp(func('23', action0), time);*/
	//seq.finish(true);
	return seq;
}