UNPKG

@naikus/stage

Version:

Check out the live [demo](https://codepen.io/naikus/project/full/AzkkER) (POC uses plain javascript and HTML)

1,478 lines (1,361 loc) 47.7 kB
/* global window, define, module */ (function(global, factory) { var stg = factory(global); if(typeof define === "function" && define.amd) { // AMD support define(function() {return stg;}); }else if(typeof module === "object" && module.exports) { // CommonJS support module.exports = stg; }else { // We are probably running in the browser global.Stage = stg; } })(typeof window === "undefined" ? this : window, function(global, undefined) { // All global variables that are used in stagejs var document = global.document, setTimeout = global.setTimeout, // setInterval = global.setInterval, XMLHttpRequest = global.XMLHttpRequest, getComputedStyle = global.getComputedStyle, requestAnimationFrame = global.requestAnimationFrame; /* ------------------------------------- Utility Functions ------------------------------------ */ var Util = (function() { // Some utility functions var noop = function() {}, createObject = (Object.create || function create(from) { function T() {} T.prototype = from; return new T(); }), AProto = Array.prototype, OProto = Object.prototype, slice = AProto.slice, nSlice = slice, objToString = OProto.toString; return { /* @Deadcode create: createObject, */ extend: function(From, implementation) { // we provide for initialization after constructor call var initialize = implementation._constructor || noop; // once initialized, we don't really need it in the actual object delete implementation._constructor; function F() { (From.apply && From.apply(this, arguments)); // jshint ignore:line // call subclass initialization initialize.apply(this, arguments); } var Proto = F.prototype = createObject(From.prototype); for(var k in implementation) { if(implementation.hasOwnProperty(k)) { Proto[k] = implementation[k]; } } Proto.constructor = F; return F; }, shallowCopy: function(/*target, source0, souce1, souce2, ... */) { var target = arguments[0], sources = Array.prototype.slice.call(arguments, 1), src; for(var i = 0, len = sources.length; i < len; i++) { src = sources[i]; for(var k in src) { target[k] = src[k]; } } return target; }, /** * Gets the type of object specified. The type returned is the [[Class]] internal property * of the specified object. For build in types the values are: * ----------------------------------------------------------- * String * Number * Boolean * Date * Error * Array * Function * RegExp * Object * * @param {Object} that The object/function/any of which the type is to be determined */ /* @Deadcode getTypeOf: function(that) { // why 8? cause the result is always of pattern '[object <type>]' return objToString.call(that).slice(8, -1); }, isTypeOf: function(that, type) { return objToString.call(that).slice(8, -1) === type; }, */ /** * Whether the specified object has its own property * @param {type} obj The target object * @param {type} prop The property to check * @returns {Boolean} true if the property belongs to the target object */ ownsProperty: function(obj, prop) { if(obj.hasOwnProperty) { return obj.hasOwnProperty(prop); }else { var val = obj[prop]; return typeof val !== "undefined" && obj.constructor.prototype[prop] !== val; } }, /* @Deadcode isFunction: function(that) { return objToString.call(that) === "[object Function]"; }, isArray: function(that) { return objToString.call(that) === "[object Array]"; }, */ slice: function(arrayLike, start, end) { var arr, i, /* jshint validthis:true */ len = arrayLike.length, s = start || 0, e = end || len; /* jshint validthis:true */ if(objToString.call(arrayLike) === "[object Array]") { /* jshint validthis:true */ arr = nSlice.call(arrayLike, s, e); }else { // so that we can have things like sliceList(1, -1); if(e < 0) { e = len - e; } arr = []; for(i = s; i < e; i++) { arr[arr.length] = arrayLike[i]; } } return arr; }, trim: function(str) { return str.replace(/^\s+|\s+$/g, ""); } }; })(); /* --------------------------------------- DOM Functions -------------------------------------- */ var DOM = (function() { var clsRegExps = {}, isIe = !!global.ActiveXObject, htmlRe = /^\s*<(!--\s*.*)?(\w+)[^>]*>/, div = document.createElement("div"), table = document.createElement("table"), tr = document.createElement("tr"), containers = { "*": div, tbody: table, tfoot: table, tr: document.createElement("tbody"), td: tr, th: tr }, supportsEvent = typeof(global.Event === "function"); function asNodes(nodeName, html, isTable) { var frags, c = div.cloneNode(); html += ""; c.innerHTML = ["<", nodeName, ">", html, "</", nodeName, ">"].join(""); frags = isTable ? c.firstChild.firstChild.childNodes : c.firstChild.childNodes; return frags; } function classRe(clazz) { // new RegExp("\\b" + clazz + "[^\w-]") return clsRegExps[clazz] || (clsRegExps[clazz] = new RegExp("(^|\\s+)" + clazz + "(?:\\s+|$)")); // thank you xui.js :) } function _addClass(elem, clName) { var cList = elem.classList; if(!cList || !clName) { return false; } cList.add(clName); return true; } function _removeClass(elem, clName) { var cList = elem.classList; if(!cList || !clName) { return false; } cList.remove(clName); return true; } function _replaceClass(elem, clName, newClName) { var cList = elem.classList; if(!cList || !clName) { return false; } cList.replace(clName, newClName); return true; } function hasClass(element, clName) { return classRe(clName).test(element.className); } function createEvent(type, node, options) { var event = document.createEvent('Event'); event.initEvent(type, options.bubbles, options.cancelable); event.srcElement = node; return event; } return { selectOne: function(selector, context) { if(typeof selector === "string") { return (context || document).querySelector(selector); } return selector; }, select: function(selector, context) { if(typeof selector === "string") { return (context || document).querySelectorAll(selector); } return selector; }, asFragment: function(html, tgName) { var c, ret, children, tag, fragment; if(!tgName) { ret = htmlRe.exec(html); tgName = ret ? ret[2] : null; } c = (containers[tgName] || div).cloneNode(); if(isIe) { tag = c.tagName.toLowerCase(); if(tag === "tbody" || tag === "table" || tag === "thead" || tag === "tfoot") { children = asNodes("table", html, true); }else { c.innerHTML = "" + html; children = c.childNodes; } }else { c.innerHTML = "" + html; children = c.childNodes; } children = Util.slice(children); fragment = document.createDocumentFragment(); for(var i = 0, len = children.length; i < len; i += 1) { fragment.appendChild(children[i]); } return fragment; }, dispatchEvent: function(type, options) { var event, node = options.element || document, data = options.data; if(supportsEvent) { try { event = new Event(type, { bubbles: options.bubbles || false, cancelable: options.cancelable || false }); }catch(e) { event = createEvent(type, node, options); } }else { event = createEvent(type, node, options); } // event.data = options.data; for(var k in data) { event[k] = data[k]; } return node.dispatchEvent(event); }, hasClass: hasClass, addClass: function(element, clName) { if(!hasClass(element, clName) && !_addClass(element, clName)) { element.className += " " + clName; } return this; }, removeClass: function(element, clName) { if(hasClass(element, clName) && !_removeClass(element, clName)) { element.className = Util.trim(element.className.replace(classRe(clName), "$1")); } return this; }, replaceClass: function(elements, clName, newClName) { var el; if (elements.length) { for (var i = 0, len = elements.length; i < len; i += 1) { el = elements[i]; if (!_replaceClass(el, clName, newClName)) { el.className = Util.trim(el.className.replace(classRe(clName), newClName)); } } } else { el = elements; if (!_replaceClass(el, clName, newClName)) { el.className = Util.trim(el.className.replace(classRe(clName), newClName)); } } return this; }, getComputedStyle: function(elem) { return getComputedStyle ? getComputedStyle(elem) : elem.currentStyle; }, data: function(element) { var name = arguments[1], value = arguments[2], data = element.__stagedata__; if(!data) { data = element.__stagedata__ = {}; } if(arguments.length === 2) { return data[name]; }else if(arguments.length === 3) { data[name] = value; } return this; } }; })(); /* ----------------------------------- Stage Implementation ----------------------------------- */ var Stage = (function() { var VIEW_DEFS = {}, noop = function() {}, raf = (requestAnimationFrame || global.mozRequestAnimationFrame || global.webkitRequestAnimationFrame || global.msRequestAnimationFrame || function(cb) { return setTimeout(cb, 1000 / 60); }), Env = (function() { var prefixes = ["", "Webkit", "Moz", "O", "ms", "MS"], transitionend = [ "transitionend", "webkitTransitionEnd", "transitionend", "oTransitionEnd", "MSTransitionEnd" ], animationend = [ "animationend", "webkitAnimationEnd", "animationend", "oAnimationEnd", "animationend" ], div = document.createElement("div"), style = div.style; return { transition: (function() { var prefix, prop; for(var i = 0, len = prefixes.length; i < len; i += 1) { prefix = prefixes[i]; prop = prefix ? prefix + "Transition" : "transition"; if(typeof style[prop] !== "undefined") { return { property: prop, end: transitionend[i] }; } } return {}; })(), animation:(function() { var prefix, prop; for(var i = 0, len = prefixes.length; i < len; i += 1) { prefix = prefixes[i]; prop = prefix ? prefix + "Animation" : "animation"; if(typeof style[prop] !== "undefined") { return { property: prop, end: animationend[i] }; } } return {}; })() // , hashchange: ("onhashchange" in global) ? "onhashchange" : null }; })(), STAGE_DEFAULT_OPTIONS = { transitionDelay: 100, transition: "slide", debug: false }, DEFAULT_VIEW_TEMPLATE = '<div class="stage-view"></div>', NO_TRANSITION = "no-transition", JS_EXPR = /\.js$/, ACTION_PUSH = "push", ACTION_POP = "pop"; // console.log(Env); /** * Makes an XmlHttpRequest request * @param {Object} options The options can be * { * path: "the path of the resource", * method: http method (defaults to "GET") * success: The success handler * fail: Failure handler * timeout: The timeout for the request * } * @returns {undefined} */ function ajax(options) { var xhr = new XMLHttpRequest(), wasConnected = false, path = options.path, method = options.method || "GET", success = options.success, fail = options.fail, timeout = typeof options.timeout === "undefined" ? 30000 : options.timeout; xhr.open(method, path, true); xhr.timeout = timeout; // Some headers // Add listeners xhr.addEventListener("readystatechange", function() { var state = xhr.readyState, code; // This is for safari/chrome where ready state is 4 but status is 0 in case of local // files i.e. file:// if(state === 2 || state === 3) { wasConnected = true; } if(state === 4) { code = xhr.status; if((code >= 200 && code < 400) || (code === 0 && wasConnected)) { (success && success(xhr)); // jshint ignore:line }else { (fail && fail(code, xhr)); // jshint ignore:line } } }); xhr.addEventListener("timeout", function() { (fail && fail("timeout", xhr)); // jshint ignore:line }); // Send! xhr.send(); } /** * Appends an inline script element to the specified element * @param {String} scriptContent The content of the script * @param {Element} toElement The HTML element * @param {boolean} wrapInAnonFunc Whether to wrap script in an annonymous function (false) * @returns {undefined} */ function addInlineScript(scriptContent, toElement, wrapInAnonFunc) { var script = document.createElement("script"); script.textContent = wrapInAnonFunc ? "(function() {\n" + scriptContent + "\n})();" : scriptContent; toElement.appendChild(script); } /** * Adds a script element with a 'src' attribute and appends the script element to specified * element * @param {String} src The source of the script * @param {Element} toElement The element to append to * @param {function} callback The function to call after the script has loaded * @returns {undefined} */ function addRemoteScript(src, toElement, callback) { var script = document.createElement("script"); script.onerror = function() { callback({ error: true, src: src }); }; if("onreadystatechange" in script) { script.onreadystatechange = function() { if(this.readyState === "loaded" || this.readyState === "complete") { callback({ src: src }); } }; }else { script.onload = function() { callback({ src: src }); }; } script.src = src; script.async = 1; toElement.appendChild(script); } // Adds remote script as inline EXPERIMENTAL!! /* function addRemoteScriptAsInline(src, toElement, callback) { var script = document.createElement("script"); ajax({ path: src, method: "GET", success: function(xhr) { script.setAttribute("data-src", src); addInlineScript(xhr.responseText, toElement, true); callback({src: src}); }, fail: function(err, xhr) { console.log("Error loading script", src, err); callback({error: err, src: src}); } }); } */ /** * Return an already created view holder for whom the view load may have failed. This is because each laod * action should not create view holder elements * @param {String} id The view id * @returns */ function getViewDiv(id) { var div = DOM.selectOne("[view-data-id=" + id + "]") || document.createElement("div"); // div.innerHTML = ""; return div; } /** * Loads the view template along with the scripts the view has defined into the viewPort * @param {String} viewDef The view definition containing 'path' i.e. path to html template * @param {Element} viewPort The viewport element * @param {function} callback The function to call after the view has been loaded * @returns {undefined} */ function loadView(viewDef, viewPort, callback) { var path = viewDef.path, id = viewDef.id; ajax({ path: path, method: "GET", success: function(xhr) { var div = getViewDiv(id), // document.createElement("div"), viewFragment = DOM.asFragment(xhr.responseText), scriptElements = Util.slice(DOM.select("script", viewFragment)).filter(function(se) { var type = se.getAttribute("type") || "text/javascript"; return type.indexOf("/javascript") !== -1; }), processScripts = function(result) { if(result && result.error) { console.error("Error loading script", result.src, result.error); } var script, src; if(scriptElements.length) { script = scriptElements.shift(); if((src = script.getAttribute("src"))) { addRemoteScript(src, div, processScripts); }else { addInlineScript(script.textContent, div); processScripts(); } }else { callback({ path: path, error: false, element: div }); } }; scriptElements.forEach(function(script) { script.parentNode.removeChild(script); }); // set some div attrs div.className = "view-holder"; div.setAttribute("data-view-id", id); div.setAttribute("data-view-template", path); div.appendChild(viewFragment); // put it in the viewport so that the scripts load correctly viewPort.appendChild(div); // start processing scripts processScripts(); }, fail: function(error, xhr) { callback({ path: path, error: error || true, xhr: xhr }); } }); } function loadJsView(viewDef, viewPort, callback) { var viewId = viewDef.id, path = viewDef.path, // template = viewDef.template || DEFAULT_VIEW_TEMPLATE, div = getViewDiv(viewId); // document.createElement("div"); div.className = "view-holder"; div.setAttribute("data-view-id", viewId); div.setAttribute("data-view-template", path); div = viewPort.appendChild(div); addRemoteScript(path, div, function(result) { callback({ path: result.src, element: div, error: result.error }); }); } function createViewUiFromTemplate(template, viewId) { var viewFragment = DOM.asFragment(template), viewUi = viewFragment.firstChild; DOM.addClass(viewUi, "stage-view"); viewUi.setAttribute("data-view", viewId); return viewUi; } function findViewUi(viewDef, viewPort) { var viewId = viewDef.id, selector = '[data-view="' + viewId + '"]', holderSelector = '[data-view-template="' + viewDef.path + '"]', viewHolder, viewUi; // See if view already in viewport viewUi = DOM.selectOne(selector, viewPort); if(!viewUi) { viewUi = createViewUiFromTemplate(viewDef.template || DEFAULT_VIEW_TEMPLATE, viewId); viewHolder = DOM.selectOne(holderSelector, viewPort); if(viewHolder) { viewUi = viewHolder.insertBefore(viewUi, viewHolder.firstChild); }else { viewUi = viewPort.appendChild(viewUi); } return viewUi; }else { return viewUi; } } /* * * View definition * { * id: "view-id", * path: "/path/to/template", * factory: factory function that creates view controller * } * */ function getOrCreateViewDef(viewId, config) { var def = VIEW_DEFS[viewId]; if(!def) { def = VIEW_DEFS[viewId] = { id: viewId, config: config || {} }; } return def; } /** * The View UI object * @param {String} id The id of the view * @param {Element} elem The view DOM element * @param {ViewController} controller view controller for the view * @param {Object} config Any configuration passed to Stage.defineView call */ function View(id, elem, controller, config) { this.id = id; this.element = elem; this.controller = controller; this.config = config; // DOM.data(this.element, "viewId", id); } View.prototype = { constructor: View, show: function(bShowing) { if(bShowing === false) { DOM.removeClass(this.element, "showing"); }else { DOM.addClass(this.element, "showing"); } return this; }, bringIn: function() { DOM.addClass(this.element, "in"); }, isIn: function() { return DOM.hasClass(this.element, "in"); }, stack: function() { if(!DOM.hasClass(this.element, "stack")) { /* DOM.removeClass(this.element, "in") .addClass(this.element, "stack"); */ DOM.replaceClass(this.element, "in", "stack"); } return this; }, isStacked: function() { return DOM.hasClass(this.element, "stack"); }, unStack: function(unstackClass) { if(unstackClass) { DOM.replaceClass(this.element, "stack", "unstack"); }else { DOM.removeClass(this.element, "stack"); } return this; }, wasUnStacked: function() { return DOM.hasClass(this.element, "unstack"); }, pop: function() { // DOM.removeClass(this.element, "in").addClass(this.element, "pop"); DOM.replaceClass(this.element, "in", "pop"); return this; }, wasPopped: function() { return DOM.hasClass(this.element, "pop"); }, reset: function(states) { if(typeof states === "string") { DOM.removeClass(this.element, states); }else { var self = this; states.forEach(function(s) { DOM.removeClass(self.element, s); }); } } }; /** * Create a new view controller */ function ViewController() {} ViewController.prototype = { constructor: ViewController, initialize: function() {}, activate: function() {}, update: function() {}, deactivate: function() {}, destroy: function() {} }; function TransitionTracker() { var name, fromView, toView, progressing = false, eventCount = {}; function getTransitionPropertyCount(viewElem) { var style = DOM.getComputedStyle(viewElem), property = style["transition-property"] || style["-webkit-transition-property"] || style["-moz-transitionProperty"]; // console.log(property); return property ? property.split(",").length : 0; } /* function getTransitionPropertyCount(viewElem) { // console.log(TransitionTracker.PropertyCount); var viewId = viewElem.getAttribute("data-view"), key = name + "_" + viewId, count = TransitionTracker.PropertyCount[key], property, style; if(typeof count === "undefined") { style = DOM.getComputedStyle(viewElem); property = style["transition-property"]; count = TransitionTracker.PropertyCount[key] = property ? property.split(",").length : 0; } return count; } */ return { name: function() { if(arguments.length) { name = arguments[0]; return this; }else { return name; } }, from: function(view) { fromView = view.id; eventCount[view.id] = getTransitionPropertyCount(view.element); return this; }, to: function(view) { toView = view.id; eventCount[view.id] = getTransitionPropertyCount(view.element); return this; }, transitionEnded: function(view) { var ended; eventCount[view.id] -= 1; ended = !eventCount[view.id]; // progressing = !(!eventCount[fromView] && !eventCount[toView]); progressing = eventCount[fromView] || eventCount[toView]; return ended; }, inProgress: function() { if(arguments.length) { progressing = arguments[0]; return this; }else { return progressing; } }, clear: function() { delete eventCount[fromView]; delete eventCount[toView]; progressing = false; fromView = toView = null; } }; } // TransitionTracker.PropertyCount = {}; /** * A limited feature stage context to be used in views * @param {type} stage * @returns {Object} The context object for use in views */ function createDefaultViewContext(stage) { return { getViewPort: function() { return stage.getViewPort(); }, pushView: function(viewId, options) { return stage.pushView(viewId, options); }, popView: function(options) { return stage.popView(options); }, currentView: function() { return stage.currentView(); }, previousView: function() { return stage.previousView(); } }; } /** * Creates a Stage instance * @param {Object} opts The options object as below: * { * viewport: The viewPort element selector * transitionDelay: The delay between transitions (default 50) * transition: The transition name (default "slide") * } * @returns {Object} A stage instance */ function Stage(opts) { var options = Util.shallowCopy({}, STAGE_DEFAULT_OPTIONS, opts), viewPort = DOM.selectOne(options.viewport), views = {}, defaultTransition = options.transition, transitionTracker = TransitionTracker(), viewStack = [], context, instance; if(opts.debug) { console.debug("Stage options ", options); } if(!viewPort || viewPort.nodeType !== 1) { throw new Error("Use a valid element as view port"); } global.addEventListener("pagehide", function(e) { if(!e.persisted) { destroyViews(); } }); function destroyViews() { for(var key in views) { var view = views[key]; if(view && view.controller) { view.controller.destroy(); } } } /** * Prepares the view from view definition (as defined by Stage.defineView()). This method * calls the factory and creates the view controller specific to this Stage instance * @param {String} viewId The vid id specified by data-view attribute * @returns {Object} The view info object */ function prepareView(viewId) { var selector = '[data-view="' + viewId + '"]', // viewUi = DOM.selectOne(selector, viewPort), viewDef = VIEW_DEFS[viewId], viewConfig = viewDef.config, viewUi = findViewUi(viewDef, viewPort), VController, viewController, view; if(!viewUi) { // console.log(viewDef); throw new Error("UI for view " + viewId + " not found."); } viewUi.addEventListener(Env.transition.end || "transitionend", handleViewTransitionEnd, false); viewUi.addEventListener(Env.animation.end || "animationend", handleViewTransitionEnd, false); // console.debug("Creating view factory for ", viewId); VController = Util.extend(ViewController, viewDef.factory(context, viewUi, viewConfig)); viewController = new VController(); view = views[viewId] = new View(viewId, viewUi, viewController, viewConfig); return view; } /** * Pushes a view onto the view stack and transitions the old and new views * @param {String} viewId The id of the defined view to push * @param {Object} viewOptions Optional options and data for the view * @returns {undefined} */ function pushViewInternal(viewId, viewOptions) { var view = views[viewId], currentView, replace = viewOptions.replace, // /* viewCfgTranstion = VIEW_DEFS[viewId].config.transition, transition = "transition" in viewOptions ? viewOptions.transition : viewCfgTranstion || defaultTransition, // */ // transition = "transition" in viewOptions ? viewOptions.transition : defaultTransition, transitionUI = function() { if(currentView) dispatchBeforeViewTransitionEvent("out", currentView); dispatchBeforeViewTransitionEvent("in", view); raf(function() { if(currentView) { stackViewUI(currentView, transition); } pushViewUI(view, transition); }); }; viewOptions.viewAction = ACTION_PUSH; // Transitions are set on the view port // console.debug("pushView(): Using transition ", transition); var currTransition = transitionTracker.name(); if(currTransition !== transition) { transitionTracker.name(transition); if(currTransition) { DOM.removeClass(viewPort, currTransition); } DOM.addClass(viewPort, transition); // console.debug("pushView(): Replacing transition", currTransition, " -> ", transition); } // Check if this is an update to current view currentView = viewStack.length ? viewStack[viewStack.length - 1] : null; if(currentView) { if(currentView.id === viewId) { // Its just a view update with different options currentView.controller.update(viewOptions); transitionTracker.clear(); return; } transitionTracker.from(currentView); viewOptions.fromView = currentView.id; } // We are actually transitioning DOM.addClass(viewPort, "view-transitioning"); // Initialize the view if its a new view if(!view) { view = prepareView(viewId, viewOptions); // Make the dom visible for controller to initialize. view.show(true); // Initialize the view view.controller.initialize(viewOptions); }else { // If this view was earlier stacked, remove the 'stack' class view.unStack().show(true); } // set the current transition (should we use a stack?) view.transition = transition; transitionTracker.to(view); if(currentView) { currentView.controller.deactivate(); } viewStack.push(view); var viewActivate = view.controller.activate; if(viewActivate.length === 2) { // expects acync activation view.controller.activate(viewOptions, function() { setTimeout(transitionUI, options.transitionDelay); }); }else { // @TODO Add Promise API support? view.controller.activate(viewOptions); setTimeout(transitionUI, options.transitionDelay); } // viewStack.push(view); if(replace && currentView) { viewStack.splice(viewStack.length - 2, 1); } } function popViewInternal(viewOptions, toView) { var currentView, view, idx, beginSlice, transition = transitionTracker.name(), transitionUI = function() { dispatchBeforeViewTransitionEvent("out", currentView); dispatchBeforeViewTransitionEvent("in", view); raf(function() { popViewUI(currentView, transition); unstackViewUI(view, transition); }); }; // currentView = viewStack.pop(); currentView = viewStack[viewStack.length - 1]; if(toView) { idx = indexOfView(toView); if(idx === -1) { // viewStack.push(currentView); transitionTracker.clear(); throw new Error("View " + toView + " is not on stack"); }else if(idx === viewStack.length - 1) { transitionTracker.clear(); throw new Error("Cannot pop to the current view: " + toView); }else { view = viewStack[idx]; beginSlice = idx + 1; // Remove upto 'view' views from the stack // viewStack.splice(beginSlice, viewStack.length - beginSlice); } }else { // view = viewStack[viewStack.length - 1]; view = viewStack[viewStack.length - 2]; } // Check if this view has a 'stack' class view.stack(); // We are actually transitioning DOM.addClass(viewPort, "view-transitioning"); view.show(true); transitionTracker.from(currentView).to(view); viewOptions.fromView = currentView.id; viewOptions.viewAction = ACTION_POP; currentView.controller.deactivate(); if(toView) { // Remove upto 'view' views from the stack viewStack.splice(beginSlice, viewStack.length - beginSlice); }else { viewStack.pop(); } var viewActivate = view.controller.activate; if(viewActivate.length === 2) { // expects acync activation view.controller.activate(viewOptions, function() { setTimeout(transitionUI, options.transitionDelay); }); }else { // @TODO Add Promise API support? view.controller.activate(viewOptions); setTimeout(transitionUI, options.transitionDelay); } } function indexOfView(viewId) { var i, len; for(i = 0, len = viewStack.lenth; (i < len && viewStack[i].id !== viewId); i += 1); return i === len ? -1 : i; } function pushViewUI(view, transition) { view.bringIn(); if(!Env.transition.end || !transition) { handleViewTransitionEnd({ target: view.element, propertyName: NO_TRANSITION }); } } function stackViewUI(view, transition) { view.stack(); if(!Env.transition.end || !transition) { handleViewTransitionEnd({ target: view.element, propertyName: NO_TRANSITION }); } } function popViewUI(view, transition) { view.pop(); if(!Env.transition.end || !transition) { handleViewTransitionEnd({ target: view.element, propertyName: NO_TRANSITION }); } } function unstackViewUI(view, transition) { view.unStack("unstack").bringIn(); // console.log(view.element); if(!Env.transition.end || !transition) { handleViewTransitionEnd({ target: view.element, propertyName: NO_TRANSITION }); } } /* function dispatchViewLoad(type, viewId, error) { DOM.dispatchEvent("viewload" + type, { element: viewPort, data: {viewId: viewId, error: error} }); } */ function dispatchViewTransitionEvents(type, view) { DOM.dispatchEvent("viewtransition" + type, { element: viewPort, data: { viewId: view.id } }); DOM.dispatchEvent("transition" + type, { element: view.element }); } function dispatchBeforeViewTransitionEvent(tType, view) { DOM.dispatchEvent("beforeviewtransition" + tType, { element: viewPort, // cancelable: true, data: { viewId: view.id } }); DOM.dispatchEvent("beforetransition" + tType, { element: view.element }); } function handleViewTransitionEnd(e) { var viewElement = e.target, viewId = viewElement.getAttribute("data-view"), // DOM.data(viewElement, "viewId"), view, tType, currTransition, currView; if(!viewId) { // Not our transition end event (Bubbled from children) return; } view = views[viewId]; if(!transitionTracker.transitionEnded(view)) { // console.log("Transition pending for", view.id, e.propertyName); return; } if(view.isIn()) { if(view.wasUnStacked()) { // use the transition of view that was unstacked so that it pops or stacks appropriately view.reset("unstack"); } tType = "in"; }else if(view.wasPopped()) { view.reset(["showing", "pop"]); tType = "out"; }else if(view.isStacked()) { view.show(false); tType = "out"; } dispatchViewTransitionEvents(tType, view); if(!transitionTracker.inProgress()) { // console.log("Transition complete!"); transitionTracker.clear(); DOM.removeClass(viewPort, "view-transitioning"); currTransition = transitionTracker.name(); currView = viewStack[viewStack.length - 1]; if(currTransition !== currView.transition) { /* DOM.removeClass(viewPort, currTransition) .addClass(viewPort, currView.transition); */ DOM.replaceClass(viewPort, currTransition, currView.transition); // console.debug("handleViewTransitionEnd() Replacing transition", currTransition, // " -> ", view.transition); transitionTracker.name(currView.transition); } // runQueuedTransitions(); } } // Queuing push/pop view calls if transitions are already in progress /* var transitionQueue = [], viewActions = {PUSH: "pushView", POP: "popView"}; function runQueuedTransitions() { var viewData = transitionQueue.shift(); if(viewData) { var action = viewData.action, viewId = viewData.viewId, opts = viewData.opts; if(action === viewActions.PUSH) { instance.pushView(viewId, opts); }else if(action === viewActions.POP) { instance.popView(opts); }else { console.log("Update View?") } } } */ instance = { getViewPort: function() { return viewPort; }, pushView: function(viewId, opts) { var self = this, view = views[viewId], viewDef, viewOptions = Util.shallowCopy({}, opts); // If we are already transitioning, ignore this call if(transitionTracker.inProgress()) { console.log("pushView() View transitioin in progress. Ignoring this call"); console.debug("Did you forget to define the transition in css?"); /* transitionQueue.push({ viewId: viewId, opts: opts, action: viewActions.PUSH }); */ return; } transitionTracker.inProgress(true); if(!view) { self.loadView(viewId, function(viewData) { if(viewData.error) { // clear transition states when there are errors transitionTracker.clear(); // throw new Error("Error loading view: " + viewData.error); console.error("Error loading view", viewData); }else { pushViewInternal(viewId, viewOptions); } }); }else { pushViewInternal(viewId, viewOptions); } }, popView: function(opts) { var viewOptions = Util.shallowCopy({}, opts), toViewId = viewOptions.toView; // If we are already transitioning, ignore this call if(transitionTracker.inProgress()) { console.debug("popView() View transitioin in progress. Ignoring this call"); /* transitionQueue.push({ opts: opts, action: viewActions.POP }); */ return; } if(viewStack.length < 2) { throw new Error("Can't pop. One or less view(s)"); } // Indicate that we are transitionin from current view transitionTracker.inProgress(true); popViewInternal(viewOptions, toViewId); }, currentView: function() { var currView = viewStack[viewStack.length - 1]; return currView ? currView.id : null; }, previousView: function() { var preView; if(viewStack.length >= 2) { preView = viewStack[viewStack.length - 2]; return preView ? preView.id : null; } return null; }, indexOfView: function(viewId) { var index = -1; viewStack.some(function(v, i) { if(v.id === viewId) { index = i; return true; } return false; }); return index; }, isTransitionInProgress: function() { return transitionTracker.inProgress(); }, loadView: function(viewId, callback) { var viewDef = VIEW_DEFS[viewId], path, handleViewLoaded = function(viewData) { callback({viewId: viewId, error: viewData.error, path: viewData.path}); // dispatchViewLoad("end", viewId, viewData.error); DOM.dispatchEvent("viewloadend", { element: viewPort, data: {viewId: viewId, error: viewData.error} }); }; if(!viewDef) { var err = new Error("View not defined: " + viewId); callback({viewId: viewId, error: err}); return; } if(!viewDef.factory) { // We have a possibly remote view // dispatchViewLoad("start", viewId); DOM.dispatchEvent("viewloadstart", { element: viewPort, data: {viewId: viewId} }); path = viewDef.path; if(JS_EXPR.test(path)) { loadJsView(viewDef, viewPort, handleViewLoaded); }else { loadView(viewDef, viewPort, handleViewLoaded); } }else { callback({viewId: viewId}); } }, getViewController: function(viewId) { return views[viewId].controller; }, getViewContext: function() { return context; }, getViewConfig: function(viewId) { return views[viewId].config; }, getViewDefinition: function(viewId) { return VIEW_DEFS[viewId]; }, destroy: function() { destroyViews(); views = {}; viewStack = []; viewPort.innerHTML = ""; } }; // This is used as context in view factory var defaultContext = createDefaultViewContext(instance), contextFactory = options.contextFactory, externalContext; if(typeof contextFactory === "function") { externalContext = contextFactory(instance, opts); } context = externalContext ? Util.shallowCopy({}, defaultContext, externalContext) : defaultContext; return instance; } /* ------------------------------------ Some static functions ------------------------------- */ Stage.defineView = function(viewDefn, config) { var viewId = viewDefn.id, viewFactory = viewDefn.factory, template = viewDefn.template || '<div class="stage-view" data-view="' + viewId + '"></div>', viewCfg = config || {}; // console.log("Defining view", viewId, viewFactory, viewCfg); var def = getOrCreateViewDef(viewId, viewCfg); def.factory = viewFactory; def.template = template; }; /** * Register multiple views with stage. The object contains view id as key and template path as * value. * { * "main": { * path: "views/main.html", * config: {} * } * "about": { * path: "views/about.html", * config: { * any: "value" * } * } * "other": { * path: "views/other.js" * } * } * @param {Object} views The views object with keys being view id */ Stage.views = function(views) { var def, conf; for(var viewId in views) { if(!Util.ownsProperty(views, viewId)) { continue; } conf = views[viewId]; def = getOrCreateViewDef(viewId, conf.config); def.path = conf.path; } }; /** * Register a singel view with stage * @param {String} viewId The id of the view. e.g. "main" * @param {String} path The path of the view template (html) e.g. "views/main.html" or js "views/main.js" * @param {String} config Optional configuration object that can be used via stage.getViewConfig() API */ Stage.view = function(viewId, path, config) { var def = getOrCreateViewDef(viewId, config); def.path = path; }; return Stage; })(); return Stage; });