mirror of
https://github.com/marko-js/marko.git
synced 2025-12-08 19:26:05 +00:00
728 lines
22 KiB
JavaScript
728 lines
22 KiB
JavaScript
/*
|
|
* Copyright 2011 eBay Software Foundation
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
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 = /^\#(\w+)( .*)?/;
|
|
|
|
var lifecycleEventMethods = {
|
|
'beforeDestroy': 'onBeforeDestroy',
|
|
'destroy': 'onDestroy',
|
|
'beforeUpdate': 'onBeforeUpdate',
|
|
'update': 'onUpdate',
|
|
'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.__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_<stateName>" 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<len; i++) {
|
|
var handler = handlers[i];
|
|
var propertyName = handler[0];
|
|
handlerMethod = handler[1];
|
|
|
|
newValue = stateChanges[propertyName];
|
|
oldValue = oldState[propertyName];
|
|
handlerMethod.call(this, newValue, oldValue);
|
|
}
|
|
|
|
emitLifecycleEvent(this, 'update');
|
|
|
|
resetWidget(this);
|
|
|
|
return true;
|
|
},
|
|
|
|
shouldUpdate: function(newState, newProps) {
|
|
return true;
|
|
},
|
|
|
|
doUpdate: function (stateChanges, oldState) {
|
|
this.rerender();
|
|
},
|
|
|
|
_emitLifecycleEvent: function(eventType, eventArg) {
|
|
emitLifecycleEvent(this, eventType, eventArg);
|
|
},
|
|
|
|
rerender: function(props) {
|
|
var self = this;
|
|
|
|
if (!self.renderer) {
|
|
throw new Error('Widget does not have a "renderer" property');
|
|
}
|
|
|
|
var elToReplace = this.__document.getElementById(self.id);
|
|
|
|
var renderer = self.renderer;
|
|
self.__lifecycleState = 'rerender';
|
|
|
|
var templateData = extend({}, props || self.state);
|
|
|
|
var globalData = {};
|
|
|
|
globalData.__rerenderWidget = self;
|
|
globalData.__rerenderEl = self.el;
|
|
globalData.__rerender = true;
|
|
|
|
if (!props) {
|
|
globalData.__rerenderState = props ? null : self.state;
|
|
}
|
|
|
|
updateManager.batchUpdate(function() {
|
|
var createOut = renderer.createOut || marko.createOut;
|
|
var out = createOut(globalData);
|
|
renderer(templateData, out);
|
|
|
|
var targetNode;
|
|
|
|
if (out.isVDOM) {
|
|
targetNode = out.getOutput().firstChild;
|
|
} else {
|
|
targetNode = out.getNode(self.__document);
|
|
}
|
|
|
|
var widgetsContext = out.global.widgets;
|
|
|
|
function onNodeDiscarded(node) {
|
|
var widget = node.__widget;
|
|
if (widget) {
|
|
destroy(widget, false, false);
|
|
}
|
|
}
|
|
|
|
function onBeforeElUpdated(fromEl, toEl) {
|
|
var id = fromEl.id;
|
|
var existingWidget;
|
|
|
|
var preservedAttrs = !out.isVDOM && toEl.getAttribute('data-preserve-attrs');
|
|
if (preservedAttrs) {
|
|
preservedAttrs = preservedAttrs.split(/\s*[,]\s*/);
|
|
for (var i=0; i<preservedAttrs.length; i++) {
|
|
var preservedAttrName = preservedAttrs[i];
|
|
var preservedAttrValue = fromEl.getAttribute(preservedAttrName);
|
|
if (preservedAttrValue == null) {
|
|
toEl.removeAttribute(preservedAttrName);
|
|
} else {
|
|
toEl.setAttribute(preservedAttrName, preservedAttrValue);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (widgetsContext && id) {
|
|
if (widgetsContext.isPreservedEl(id)) {
|
|
|
|
if (widgetsContext.hasUnpreservedBody(id)) {
|
|
existingWidget = fromEl.__widget;
|
|
|
|
morphdom(existingWidget.bodyEl, toEl, {
|
|
childrenOnly: true,
|
|
onNodeDiscarded: onNodeDiscarded,
|
|
onBeforeElUpdated: onBeforeElUpdated,
|
|
onBeforeElChildrenUpdated: onBeforeElChildrenUpdated
|
|
});
|
|
}
|
|
|
|
// Don't morph elements that are associated with widgets that are being
|
|
// reused or elements that are being preserved. For widgets being reused,
|
|
// the morphing will take place when the reused widget updates.
|
|
return MORPHDOM_SKIP;
|
|
} else {
|
|
existingWidget = fromEl.__widget;
|
|
if (existingWidget && !hasCompatibleWidget(widgetsContext, existingWidget)) {
|
|
// We found a widget in an old DOM node that does not have
|
|
// a compatible widget that was rendered so we need to
|
|
// destroy the old widget
|
|
destroy(existingWidget, false, false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function onBeforeElChildrenUpdated(el) {
|
|
if (widgetsContext && el.id) {
|
|
if (widgetsContext.isPreservedBodyEl(el.id)) {
|
|
// Don't morph the children since they are preserved
|
|
return MORPHDOM_SKIP;
|
|
}
|
|
}
|
|
}
|
|
|
|
morphdom(elToReplace, targetNode, {
|
|
onNodeDiscarded: onNodeDiscarded,
|
|
onBeforeElUpdated: onBeforeElUpdated,
|
|
onBeforeElChildrenUpdated: onBeforeElChildrenUpdated
|
|
});
|
|
|
|
// Trigger any 'onUpdate' events for all of the rendered widgets
|
|
out.afterInsert(elToReplace);
|
|
|
|
self.__lifecycleState = null;
|
|
|
|
if (!props) {
|
|
// We have re-rendered with the new state so our state
|
|
// is no longer dirty. Before updating a widget
|
|
// we check if a widget is dirty. If a widget is not
|
|
// dirty then we abort the update. Therefore, if the
|
|
// widget was queued for update and the re-rendered
|
|
// before the update occurred then nothing will happen
|
|
// at the time of the update.
|
|
resetWidget(self);
|
|
}
|
|
});
|
|
},
|
|
|
|
detach: function () {
|
|
dom.detach(this.el);
|
|
|
|
},
|
|
appendTo: function (targetEl) {
|
|
dom.appendTo(this.el, targetEl);
|
|
},
|
|
replace: function (targetEl) {
|
|
dom.replace(this.el, targetEl);
|
|
},
|
|
replaceChildrenOf: function (targetEl) {
|
|
dom.replaceChildrenOf(this.el, targetEl);
|
|
},
|
|
insertBefore: function (targetEl) {
|
|
dom.insertBefore(this.el, targetEl);
|
|
},
|
|
insertAfter: function (targetEl) {
|
|
dom.insertAfter(this.el, targetEl);
|
|
},
|
|
prependTo: function (targetEl) {
|
|
dom.prependTo(this.el, targetEl);
|
|
},
|
|
ready: function (callback) {
|
|
var document = this.el.ownerDocument;
|
|
markoWidgets.ready(callback, this, document);
|
|
},
|
|
$: function (arg) {
|
|
var jquery = markoWidgets.$;
|
|
|
|
var args = arguments;
|
|
if (args.length === 1) {
|
|
//Handle an "ondomready" callback function
|
|
if (typeof arg === 'function') {
|
|
var _this = this;
|
|
return _this.ready(function() {
|
|
arg.call(_this);
|
|
});
|
|
} else if (typeof arg === 'string') {
|
|
var match = idRegExp.exec(arg);
|
|
//Reset the search to 0 so the next call to exec will start from the beginning for the new string
|
|
if (match != null) {
|
|
var widgetElId = match[1];
|
|
if (match[2] == null) {
|
|
return jquery(this.getEl(widgetElId));
|
|
} else {
|
|
return jquery('#' + this.getElId(widgetElId) + match[2]);
|
|
}
|
|
} else {
|
|
var rootEl = this.getEl();
|
|
if (!rootEl) {
|
|
throw new Error('Root element is not defined for widget');
|
|
}
|
|
if (rootEl) {
|
|
return jquery(arg, rootEl);
|
|
}
|
|
}
|
|
}
|
|
} else if (args.length === 2 && typeof args[1] === 'string') {
|
|
return jquery(arg, this.getEl(args[1]));
|
|
} else if (args.length === 0) {
|
|
return jquery(this.el);
|
|
}
|
|
return jquery.apply(window, arguments);
|
|
}
|
|
};
|
|
|
|
widgetProto.elId = widgetProto.getElId;
|
|
|
|
inherit(Widget, EventEmitter);
|
|
|
|
module.exports = Widget;
|