marko/components/Component.js

644 lines
18 KiB
JavaScript

'use strict';
/* jshint newcap:false */
var domInsert = require('../runtime/dom-insert');
var marko = require('../');
var componentsUtil = require('./util');
var componentLookup = componentsUtil.$__componentLookup;
var emitLifecycleEvent = componentsUtil.$__emitLifecycleEvent;
var destroyComponentForEl = componentsUtil.$__destroyComponentForEl;
var destroyElRecursive = componentsUtil.$__destroyElRecursive;
var getElementById = componentsUtil.$__getElementById;
var EventEmitter = require('events-light');
var RenderResult = require('../runtime/RenderResult');
var SubscriptionTracker = require('listener-tracker');
var inherit = require('raptor-util/inherit');
var updateManager = require('./update-manager');
var morphdom = require('../morphdom');
var eventDelegation = require('./event-delegation');
var slice = Array.prototype.slice;
var MORPHDOM_SKIP = true;
var COMPONENT_SUBSCRIBE_TO_OPTIONS;
var NON_COMPONENT_SUBSCRIBE_TO_OPTIONS = {
addDestroyListener: false
};
var emit = EventEmitter.prototype.emit;
function removeListener(removeEventListenerHandle) {
removeEventListenerHandle();
}
function checkCompatibleComponent(globalComponentsContext, el) {
var component = el._w;
while(component) {
var id = component.id;
var newComponentDef = globalComponentsContext.$__componentsById[id];
if (newComponentDef && component.$__type == newComponentDef.$__component.$__type) {
break;
}
var rootFor = component.$__rootFor;
if (rootFor) {
component = rootFor;
} else {
component.$__destroyShallow();
break;
}
}
}
function handleCustomEventWithMethodListener(component, targetMethodName, args, extraArgs) {
// Remove the "eventType" argument
args.push(component);
if (extraArgs) {
args = extraArgs.concat(args);
}
var targetComponent = componentLookup[component.$__scope];
var targetMethod = targetComponent[targetMethodName];
if (!targetMethod) {
throw Error('Method not found: ' + targetMethodName);
}
targetMethod.apply(targetComponent, args);
}
function getElIdHelper(component, componentElId, index) {
var id = component.id;
var elId = componentElId != null ? id + '-' + componentElId : id;
if (index != null) {
elId += '[' + index + ']';
}
return elId;
}
/**
* 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.
*/
function processUpdateHandlers(component, stateChanges, oldState) {
var handlerMethod;
var handlers;
for (var propName in stateChanges) {
if (stateChanges.hasOwnProperty(propName)) {
var handlerMethodName = 'update_' + propName;
handlerMethod = component[handlerMethodName];
if (handlerMethod) {
(handlers || (handlers=[])).push([propName, handlerMethod]);
} else {
// This state change does not have a state handler so return false
// to force a rerender
return;
}
}
}
// 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) {
// Otherwise, there are handlers for all of the changed properties
// so apply the updates using those handlers
handlers.forEach(function(handler, i) {
var propertyName = handler[0];
handlerMethod = handler[1];
var newValue = stateChanges[propertyName];
var oldValue = oldState[propertyName];
handlerMethod.call(component, newValue, oldValue);
});
emitLifecycleEvent(component, 'update');
component.$__reset();
}
return true;
}
function checkInputChanged(existingComponent, oldInput, newInput) {
if (oldInput != newInput) {
if (oldInput == null || newInput == null) {
return true;
}
var oldKeys = Object.keys(oldInput);
var newKeys = Object.keys(newInput);
var len = oldKeys.length;
if (len !== newKeys.length) {
return true;
}
for (var i=0; i<len; i++) {
var key = oldKeys[i];
if (oldInput[key] !== newInput[key]) {
return true;
}
}
}
return false;
}
function onNodeDiscarded(node) {
if (node.nodeType === 1) {
destroyComponentForEl(node);
}
}
function onBeforeNodeDiscarded(node) {
return eventDelegation.$__handleNodeDetach(node);
}
function onBeforeElUpdated(fromEl, key, globalComponentsContext) {
if (key) {
var preserved = globalComponentsContext.$__preserved[key];
if (preserved === true) {
// Don't morph elements that are associated with components that are being
// reused or elements that are being preserved. For components being reused,
// the morphing will take place when the reused component updates.
return MORPHDOM_SKIP;
} else {
// We may need to destroy a Component associated with the current element
// if a new UI component was rendered to the same element and the types
// do not match
checkCompatibleComponent(globalComponentsContext, fromEl);
}
}
}
function onBeforeElChildrenUpdated(el, key, globalComponentsContext) {
if (key) {
var preserved = globalComponentsContext.$__preservedBodies[key];
if (preserved === true) {
// Don't morph the children since they are preserved
return MORPHDOM_SKIP;
}
}
}
function onNodeAdded(node, globalComponentsContext) {
eventDelegation.$__handleNodeAttach(node, globalComponentsContext.$__out);
}
var componentProto;
/**
* Base component type.
*
* NOTE: Any methods that are prefixed with an underscore should be considered private!
*/
function Component(id) {
EventEmitter.call(this);
this.id = id;
this.el = null;
this.$__state = null;
this.$__roots = null;
this.$__subscriptions = null;
this.$__domEventListenerHandles = null;
this.$__bubblingDomEvents = null;
this.$__customEvents = null;
this.$__scope = null;
this.$__renderInput = null;
this.$__input = undefined;
this.$__destroyed = false;
this.$__updateQueued = false;
this.$__dirty = false;
this.$__settingInput = false;
this.$__document = undefined;
}
Component.prototype = componentProto = {
$__isComponent: true,
subscribeTo: function(target) {
if (!target) {
throw TypeError();
}
var subscriptions = this.$__subscriptions || (this.$__subscriptions = new SubscriptionTracker());
var subscribeToOptions = target.$__isComponent ?
COMPONENT_SUBSCRIBE_TO_OPTIONS :
NON_COMPONENT_SUBSCRIBE_TO_OPTIONS;
return subscriptions.subscribeTo(target, subscribeToOptions);
},
emit: function(eventType) {
var customEvents = this.$__customEvents;
var target;
if (customEvents && (target = customEvents[eventType])) {
var targetMethodName = target[0];
var extraArgs = target[1];
var args = slice.call(arguments, 1);
handleCustomEventWithMethodListener(this, targetMethodName, args, extraArgs);
}
if (this.listenerCount(eventType)) {
return emit.apply(this, arguments);
}
},
getElId: function (componentElId, index) {
return getElIdHelper(this, componentElId, index);
},
getEl: function (componentElId, index) {
var doc = this.$__document;
if (componentElId != null) {
return getElementById(doc, getElIdHelper(this, componentElId, index));
} else {
return this.el || getElementById(doc, getElIdHelper(this));
}
},
getEls: function(id) {
var els = [];
var i = 0;
var el;
while((el = this.getEl(id, i))) {
els.push(el);
i++;
}
return els;
},
getComponent: function(id, index) {
return componentLookup[getElIdHelper(this, id, index)];
},
getComponents: function(id) {
var components = [];
var i = 0;
var component;
while((component = componentLookup[getElIdHelper(this, id, i)])) {
components.push(component);
i++;
}
return components;
},
destroy: function() {
if (this.$__destroyed) {
return;
}
var els = this.els;
this.$__destroyShallow();
var rootComponents = this.$__rootComponents;
if (rootComponents) {
rootComponents.forEach(function(rootComponent) {
rootComponent.$__destroy();
});
}
els.forEach(function(el) {
destroyElRecursive(el);
var parentNode = el.parentNode;
if (parentNode) {
parentNode.removeChild(el);
}
});
},
$__destroyShallow: function() {
if (this.$__destroyed) {
return;
}
emitLifecycleEvent(this, 'destroy');
this.$__destroyed = true;
this.el = null;
// Unsubscribe from all DOM events
this.$__removeDOMEventListeners();
var subscriptions = this.$__subscriptions;
if (subscriptions) {
subscriptions.removeAllListeners();
this.$__subscriptions = null;
}
delete componentLookup[this.id];
},
isDestroyed: function() {
return this.$__destroyed;
},
get state() {
return this.$__state;
},
set state(newState) {
var state = this.$__state;
if (!state && !newState) {
return;
}
if (!state) {
state = this.$__state = new this.$__State(this);
}
state.$__replace(newState || {});
if (state.$__dirty) {
this.$__queueUpdate();
}
if (!newState) {
this.$__state = null;
}
},
setState: function(name, value) {
var state = this.$__state;
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)) {
state.$__set(k, newState[k], true /* ensure:true */);
}
}
} else {
state.$__set(name, value, true /* ensure:true */);
}
},
setStateDirty: function(name, value) {
var state = this.$__state;
if (arguments.length == 1) {
value = state[name];
}
state.$__set(name, value, true /* ensure:true */, true /* forceDirty:true */);
},
replaceState: function(newState) {
this.$__state.$__replace(newState);
},
get input() {
return this.$__input;
},
set input(newInput) {
if (this.$__settingInput) {
this.$__input = newInput;
} else {
this.$__setInput(newInput);
}
},
$__setInput: function(newInput, onInput, out) {
onInput = onInput || this.onInput;
var updatedInput;
var oldInput = this.$__input;
this.$__input = undefined;
if (onInput) {
// We need to set a flag to preview `this.input = foo` inside
// onInput causing infinite recursion
this.$__settingInput = true;
updatedInput = onInput.call(this, newInput || {}, out);
this.$__settingInput = false;
}
newInput = this.$__renderInput = updatedInput || newInput;
if ((this.$__dirty = checkInputChanged(this, oldInput, newInput))) {
this.$__queueUpdate();
}
if (this.$__input === undefined) {
this.$__input = newInput;
}
return newInput;
},
forceUpdate: function() {
this.$__dirty = true;
this.$__queueUpdate();
},
$__queueUpdate: function() {
if (!this.$__updateQueued) {
updateManager.$__queueComponentUpdate(this);
}
},
update: function() {
if (this.$__destroyed === true || this.$__isDirty === false) {
return;
}
var input = this.$__input;
var state = this.$__state;
if (this.$__dirty === false && state !== null && state.$__dirty === true) {
if (processUpdateHandlers(this, state.$__changes, state.$__old, state)) {
state.$__dirty = false;
}
}
if (this.$__isDirty === true) {
// The UI component is still dirty after process state handlers
// then we should rerender
if (this.shouldUpdate(input, state) !== false) {
this.$__rerender();
}
}
this.$__reset();
},
get $__isDirty() {
return this.$__dirty === true || (this.$__state !== null && this.$__state.$__dirty === true);
},
$__reset: function() {
this.$__dirty = false;
this.$__updateQueued = false;
this.$__renderInput = null;
var state = this.$__state;
if (state) {
state.$__reset();
}
},
shouldUpdate: function(newState, newProps) {
return true;
},
$__emitLifecycleEvent: function(eventType, eventArg1, eventArg2) {
emitLifecycleEvent(this, eventType, eventArg1, eventArg2);
},
$__rerender: function(input) {
if (input) {
this.input = input;
}
var self = this;
var renderer = self.$__renderer;
if (!renderer) {
throw TypeError();
}
var globalData = {
$w: self
};
var fromEls = self.$__getRootEls({});
var doc = self.$__document;
input = this.$__renderInput || this.$__input;
updateManager.$__batchUpdate(function() {
var createOut = renderer.createOut || marko.createOut;
var out = createOut(globalData);
out.sync();
out.$__document = self.$__document;
renderer(input, out);
var result = new RenderResult(out);
var targetNode = out.$__getOutput();
var globalComponentsContext = out.global.components;
var fromEl;
var targetEl = targetNode.firstChild;
while(targetEl) {
var id = targetEl.id;
if (id) {
fromEl = fromEls[id];
if (fromEl) {
morphdom(
fromEl,
targetEl,
globalComponentsContext,
onNodeAdded,
onBeforeElUpdated,
onBeforeNodeDiscarded,
onNodeDiscarded,
onBeforeElChildrenUpdated);
}
}
targetEl = targetEl.nextSibling;
}
result.afterInsert(doc);
out.emit('$__componentsInitialized');
});
this.$__reset();
},
$__getRootEls: function(rootEls) {
var i, len;
var componentEls = this.els;
for (i=0, len=componentEls.length; i<len; i++) {
var componentEl = componentEls[i];
rootEls[componentEl.id] = componentEl;
}
var rootComponents = this.$__rootComponents;
if (rootComponents) {
for (i=0, len=rootComponents.length; i<len; i++) {
var rootComponent = rootComponents[i];
rootComponent.$__getRootEls(rootEls);
}
}
return rootEls;
},
$__removeDOMEventListeners: function() {
var eventListenerHandles = this.$__domEventListenerHandles;
if (eventListenerHandles) {
eventListenerHandles.forEach(removeListener);
this.$__domEventListenerHandles = null;
}
},
get $__rawState() {
var state = this.$__state;
return state && state.$__raw;
},
$__setCustomEvents: function(customEvents, scope) {
var finalCustomEvents = this.$__customEvents = {};
this.$__scope = scope;
customEvents.forEach(function(customEvent) {
var eventType = customEvent[0];
var targetMethodName = customEvent[1];
var extraArgs = customEvent[2];
finalCustomEvents[eventType] = [targetMethodName, extraArgs];
});
}
};
componentProto.elId = componentProto.getElId;
componentProto.$__update = componentProto.update;
componentProto.$__destroy = componentProto.destroy;
// Add all of the following DOM methods to Component.prototype:
// - appendTo(referenceEl)
// - replace(referenceEl)
// - replaceChildrenOf(referenceEl)
// - insertBefore(referenceEl)
// - insertAfter(referenceEl)
// - prependTo(referenceEl)
domInsert(
componentProto,
function getEl(component) {
var els = this.els;
var elCount = els.length;
if (elCount > 1) {
var fragment = component.$__document.createDocumentFragment();
els.forEach(function(el) {
fragment.appendChild(el);
});
return fragment;
} else {
return els[0];
}
},
function afterInsert(component) {
return component;
});
inherit(Component, EventEmitter);
module.exports = Component;