'use strict'; var inherit = require('raptor-util/inherit'); var dom = require('./dom'); var markoWidgets = require('./'); var EventEmitter = require('events').EventEmitter; var listenerTracker = require('listener-tracker'); var arrayFromArguments = require('raptor-util/arrayFromArguments'); var extend = require('raptor-util/extend'); var updateManager = require('./update-manager'); var morphdom = require('morphdom'); var marko = require('marko'); var MORPHDOM_SKIP = false; var WIDGET_SUBSCRIBE_TO_OPTIONS = null; var NON_WIDGET_SUBSCRIBE_TO_OPTIONS = { addDestroyListener: false }; var emit = EventEmitter.prototype.emit; var idRegExp = /^\#(\S+)( .*)?/; var lifecycleEventMethods = { 'beforeDestroy': 'onBeforeDestroy', 'destroy': 'onDestroy', 'beforeUpdate': 'onBeforeUpdate', 'update': 'onUpdate', 'mount': 'onMount', 'render': 'onRender', 'beforeInit': 'onBeforeInit', 'afterInit': 'onAfterInit' }; function removeListener(eventListenerHandle) { eventListenerHandle.remove(); } function destroyRecursive(el) { dom.forEachChildEl(el, function (childEl) { var descendentWidget = childEl.__widget; if (descendentWidget) { destroy(descendentWidget, false, false); } destroyRecursive(childEl); }); } /** * This method handles invoking a widget's event handler method * (if present) while also emitting the event through * the standard EventEmitter.prototype.emit method. * * Special events and their corresponding handler methods * include the following: * * beforeDestroy --> onBeforeDestroy * destroy --> onDestroy * beforeUpdate --> onBeforeUpdate * update --> onUpdate * render --> onRender */ function emitLifecycleEvent(widget, eventType, eventArg) { var listenerMethod = widget[lifecycleEventMethods[eventType]]; if (listenerMethod) { listenerMethod.call(widget, eventArg); } widget.emit(eventType, eventArg); } function removeDOMEventListeners(widget) { var eventListenerHandles = widget.__evHandles; if (eventListenerHandles) { eventListenerHandles.forEach(removeListener); widget.__evHandles = null; } } function destroy(widget, removeNode, recursive) { if (widget.isDestroyed()) { return; } var rootEl = widget.getEl(); emitLifecycleEvent(widget, 'beforeDestroy'); widget.__lifecycleState = 'destroyed'; if (rootEl) { if (recursive) { destroyRecursive(rootEl); } if (removeNode && rootEl.parentNode) { //Remove the widget's DOM nodes from the DOM tree if the root element is known rootEl.parentNode.removeChild(rootEl); } rootEl.__widget = null; } // Unsubscribe from all DOM events removeDOMEventListeners(widget); if (widget.__subscriptions) { widget.__subscriptions.removeAllListeners(); widget.__subscriptions = null; } emitLifecycleEvent(widget, 'destroy'); } function setState(widget, name, value, forceDirty, noQueue) { if (typeof value === 'function') { return; } if (value === null) { // Treat null as undefined to simplify our comparison logic value = undefined; } if (forceDirty) { var dirtyState = widget.__dirtyState || (widget.__dirtyState = {}); dirtyState[name] = true; } else if (widget.state[name] === value) { return; } var clean = !widget.__dirty; if (clean) { // This is the first time we are modifying the widget state // so introduce some properties to do some tracking of // changes to the state var currentState = widget.state; widget.__dirty = true; // Mark the widget state as dirty (i.e. modified) widget.__oldState = currentState; widget.state = extend({}, currentState); widget.__stateChanges = {}; } widget.__stateChanges[name] = value; if (value == null) { // Don't store state properties with an undefined or null value delete widget.state[name]; } else { // Otherwise, store the new value in the widget state widget.state[name] = value; } if (clean && noQueue !== true) { // If we were clean before then we are now dirty so queue // up the widget for update updateManager.queueWidgetUpdate(widget); } } function replaceState(widget, newState, noQueue) { var k; for (k in widget.state) { if (widget.state.hasOwnProperty(k) && !newState.hasOwnProperty(k)) { setState(widget, k, undefined, false, noQueue); } } for (k in newState) { if (newState.hasOwnProperty(k)) { setState(widget, k, newState[k], false, noQueue); } } } function resetWidget(widget) { widget.__oldState = null; widget.__dirty = false; widget.__stateChanges = null; widget.__newProps = null; widget.__dirtyState = null; } function hasCompatibleWidget(widgetsContext, existingWidget) { var id = existingWidget.id; var newWidgetDef = widgetsContext.getWidget(id); if (!newWidgetDef) { return false; } return existingWidget.__type === newWidgetDef.type; } function handleCustomEventWithMethodListener(widget, targetMethodName, args) { // Remove the "eventType" argument args = arrayFromArguments(args, 1); args.push(widget); var targetWidget = markoWidgets.getWidgetForEl(widget.__scope); var targetMethod = targetWidget[targetMethodName]; if (!targetMethod) { throw new Error('Method not found for widget ' + targetWidget.id + ': ' + targetMethodName); } targetMethod.apply(targetWidget, args); } var widgetProto; /** * Base widget type. * * NOTE: Any methods that are prefixed with an underscore should be considered private! */ function Widget(id, document) { EventEmitter.call(this); this.id = id; this.el = null; this.bodyEl = null; this.state = null; this.__subscriptions = null; this.__evHandles = null; this.__lifecycleState = null; this.__customEvents = null; this.__scope = null; this.__dirty = false; this.__oldState = null; this.__stateChanges = null; this.__updateQueued = false; this.__dirtyState = null; this.__document = document; } Widget.prototype = widgetProto = { _isWidget: true, subscribeTo: function(target) { if (!target) { throw new Error('target is required'); } var tracker = this.__subscriptions; if (!tracker) { this.__subscriptions = tracker = listenerTracker.createTracker(); } var subscribeToOptions = target._isWidget ? WIDGET_SUBSCRIBE_TO_OPTIONS : NON_WIDGET_SUBSCRIBE_TO_OPTIONS; return tracker.subscribeTo(target, subscribeToOptions); }, emit: function(eventType) { var customEvents = this.__customEvents; var targetMethodName; if (customEvents && (targetMethodName = customEvents[eventType])) { var len = arguments.length; var args = new Array(len-1); for (var i = 1; i < len; i++) { args[i] = arguments[i]; } handleCustomEventWithMethodListener(this, targetMethodName, args); } return emit.apply(this, arguments); }, getElId: function (widgetElId, index) { var elId = widgetElId != null ? this.id + '-' + widgetElId : this.id; if (index != null) { elId += '[' + index + ']'; } return elId; }, getEl: function (widgetElId, index) { if (widgetElId != null) { return this.__document.getElementById(this.getElId(widgetElId, index)); } else { return this.el || this.__document.getElementById(this.getElId()); } }, getEls: function(id) { var els = []; var i=0; while(true) { var el = this.getEl(id, i); if (!el) { break; } els.push(el); i++; } return els; }, getWidget: function(id, index) { var targetWidgetId = this.getElId(id, index); return markoWidgets.getWidgetForEl(targetWidgetId, this.__document); }, getWidgets: function(id) { var widgets = []; var i=0; while(true) { var widget = this.getWidget(id, i); if (!widget) { break; } widgets.push(widget); i++; } return widgets; }, destroy: function (options) { options = options || {}; destroy(this, options.removeNode !== false, options.recursive !== false); }, isDestroyed: function () { return this.__lifecycleState === 'destroyed'; }, getBodyEl: function() { return this.bodyEl; }, setState: function(name, value) { if (typeof name === 'object') { // Merge in the new state with the old state var newState = name; for (var k in newState) { if (newState.hasOwnProperty(k)) { setState(this, k, newState[k]); } } return; } setState(this, name, value); }, setStateDirty: function(name, value) { if (arguments.length === 1) { value = this.state[name]; } setState(this, name, value, true /* forceDirty */); }, _replaceState: function(newState) { replaceState(this, newState, true /* do not queue an update */ ); }, _removeDOMEventListeners: function() { removeDOMEventListeners(this); }, replaceState: function(newState) { replaceState(this, newState); }, /** * Recalculate the new state from the given props using the widget's * getInitialState(props) method. If the widget does not have a * getInitialState(props) then it is re-rendered with the new props * as input. * * @param {Object} props The widget's new props */ setProps: function(newProps) { if (this.getInitialState) { if (this.getInitialProps) { newProps = this.getInitialProps(newProps) || {}; } var newState = this.getInitialState(newProps); this.replaceState(newState); return; } if (this.onInput) { var prevState = this.state; this.onInput(newProps || {}); if(this.state !== prevState) { this.replaceState(this.state); } return; } if (!this.__newProps) { updateManager.queueWidgetUpdate(this); } this.__newProps = newProps; }, update: function() { if (this.isDestroyed()) { return; } var newProps = this.__newProps; if (this.shouldUpdate(newProps, this.state) === false) { resetWidget(this); return; } if (newProps) { resetWidget(this); this.rerender(newProps); return; } if (!this.__dirty) { // Don't even bother trying to update this widget since it is // not marked as dirty. return; } if (!this._processUpdateHandlers()) { this.doUpdate(this.__stateChanges, this.__oldState); } // Reset all internal properties for tracking state changes, etc. resetWidget(this); }, isDirty: function() { return this.__dirty; }, _reset: function() { resetWidget(this); }, /** * This method is used to process "update_" handler functions. * If all of the modified state properties have a user provided update handler * then a rerender will be bypassed and, instead, the DOM will be updated * looping over and invoking the custom update handlers. * @return {boolean} Returns true if if the DOM was updated. False, otherwise. */ _processUpdateHandlers: function() { var stateChanges = this.__stateChanges; var oldState = this.__oldState; var handlerMethod; var handlers = []; var newValue; var oldValue; for (var propName in stateChanges) { if (stateChanges.hasOwnProperty(propName)) { newValue = stateChanges[propName]; oldValue = oldState[propName]; if (oldValue === newValue) { // Only do an update for this state property if it is actually // different from the old state or if it was forced to be dirty // using setStateDirty(propName) var dirtyState = this.__dirtyState; if (dirtyState == null || !dirtyState.hasOwnProperty(propName)) { continue; } } var handlerMethodName = 'update_' + propName; handlerMethod = this[handlerMethodName]; if (handlerMethod) { handlers.push([propName, handlerMethod]); } else { // This state change does not have a state handler so return false // to force a rerender return false; } } } // If we got here then all of the changed state properties have // an update handler or there are no state properties that actually // changed. if (!handlers.length) { return true; } // Otherwise, there are handlers for all of the changed properties // so apply the updates using those handlers emitLifecycleEvent(this, 'beforeUpdate'); for (var i=0, len=handlers.length; i