kekule
Version:
Open source JavaScript toolkit for chemoinformatics
717 lines (656 loc) • 21.7 kB
JavaScript
/**
* @fileoverview
* Build web components from Kekule widgets.
* Note since web component is only supported in modern browsers, here we use the class keyword to create JS class.
* @author Partridge Jiang
*/
/*
* requires /lan/classes.js
* requires /utils/kekule.utils.js
* requires /utils/kekule.domUtils.js
* requires /xbrowsers/kekule.x.js
* requires /widget/kekule.widget.base.js
*/
(function(){
"use strict";
if (typeof(HTMLElement) === 'undefined') // avoid exceptions in Node environment
return;
/**
* Base namespace of web component functions and classes.
* @namespace
*/
Kekule.WebComponent = {};
Kekule.globalOptions.add('webComponent.init', {
shadowInitOptions: {mode: 'open'}
});
Kekule.globalOptions.add('webComponent.widgetWrapper', {
exposeWidgetProperties: true,
exposeWidgetMethods: false,
// widget properties may conflict with element, should not be exposed in wrapping
ignoredProperties: ['id', 'draggable', 'droppable', 'innerHTML', 'style', 'offsetParent', 'offsetLeft', 'offsetTop', 'offsetWidth', 'offsetHeight'],
ignoredMethods: [
'constructor', 'initialize', 'finalize', 'doFinalize', 'afterInitialization', 'initProperties', 'initPropValues', 'saved', 'loaded',
'invokeEvent',
]
});
/** @ignore */
var WC = Kekule.WebComponent;
var DU = Kekule.DomUtils;
/**
* A helper class to transfer HTML events from web component shadow root to widget event global manager.
* User should not use this class directly.
* @augments {Kekule.Widget.BaseEventsReceiver}
* @class
*/
Kekule.WebComponent.WebComponentContextEventRelayer = Class.create(Kekule.Widget.BaseEventsReceiver,
/** @lends Kekule.WebComponent.WebComponentContextEventRelayer# */
{
/** @private */
CLASS_NAME: 'Kekule.WebComponent.WebComponentContextEventRelayer',
/** @constructs */
initialize: function(/*$super, */doc, eventRoot, globalManager)
{
this._globalManager = globalManager;
this.tryApplySuper('initialize', [doc, eventRoot]) /* $super(doc, eventRoot) */;
this._isInShadow = eventRoot && DU.isInShadowRoot(eventRoot);
this._globalManager.setHasWebComponentContext(true); // notify GM that web component context has been built
},
/**
* Returns whether the event root object is inside the shadow root of web component.
* @returns {Bool}
*/
isInShadow: function()
{
return this._isInShadow;
},
// here we only relay UI and DOM insert/remove events.
// Window event are handled directly by global manager, and hammer touch event are handled directly by widget itself.
/** @ignore */
reactUiEvent: function(/*$super, */e)
{
var result = this.tryApplySuper('reactUiEvent', [e]) /* $super(e) */;
if (this.isInShadow())
{
result = this._globalManager.reactUiEvent(e);
e.stopPropagation();
}
return result;
},
/** @ignore */
reactDomNodeInsertEvent: function(/*$super, */e)
{
if (this.isInShadow())
{
e.stopPropagation();
return this._globalManager.reactDomNodeInsertEvent(e);
}
else
return this.tryApplySuper('reactDomNodeInsertEvent', [e]) /* $super(e) */;
},
/** @ignore */
reactDomNodeRemoveEvent: function(/*$super, */e)
{
if (this.isInShadow())
{
e.stopPropagation();
return this._globalManager.reactDomNodeRemoveEvent(e);
}
else
return this.tryApplySuper('reactDomNodeRemoveEvent', [e]) /* $super(e) */;
},
/** @ignore */
reactDomMutation: function(/*$super, */mutations)
{
if (this.isInShadow())
return this._globalManager.reactDomMutation(mutations);
else
return this.tryApplySuper('reactDomMutation') /* $super() */;
}
});
/**
* A helper class to hold the popups of Kekule widget system.
* User should not use this class directly.
* @augments {HTMLElement}
* @class
* @ignore
*/
class WebComponentContextPopupHost extends HTMLElement
{
constructor()
{
super();
var shadow = this.attachShadow(Kekule.globalOptions.webComponent.init.shadowInitOptions);
this._initStyles(shadow);
this._importStyleSheet(shadow);
this._createSubElements(shadow);
var eventRelayer = new Kekule.WebComponent.WebComponentContextEventRelayer(this.ownerDocument, this.hostElem, Kekule.Widget.globalManager);
}
_createSubElements(host)
{
var containerElem = this.ownerDocument.createElement('div');
host.appendChild(containerElem);
this._hostElem = containerElem;
}
_importStyleSheet(shadow)
{
Kekule.Widget.globalManager.loadTheme(shadow);
}
_initStyles(shadow)
{
var styleElem = shadow.ownerDocument.createElement('style');
styleElem.innerHTML = ':host { position: static; width: 0px; height: 0px; left: 0px; top: 0px; }';
shadow.appendChild(styleElem);
}
ensureToBeLast()
{
var body = this.ownerDocument.body;
if (body.lastChild !== this)
{
body.appendChild(this);
}
}
get hostElem()
{
return this._hostElem;
}
}
Kekule.WebComponent.WebComponentContextPopupHost = WebComponentContextPopupHost;
// important, must register custom element, otherwise the caller of WebComponentContextPopupHost constructor causes error
if (typeof(customElements) !== 'undefined')
customElements.define('kekule-webcomponent-popup-host', WebComponentContextPopupHost);
////////////// Extend Container /////////////////
/*
ClassEx.extendMethod(Kekule.Widget.Container, 'getChildHolderSlot', function($origin){
if (Kekule.BrowserFeature.htmlSlot)
{
var elem = this.getChildrenHolderElement();
if (elem && elem.tagName.toLowerCase() === 'slot')
return elem;
}
return null;
});
ClassEx.defineProp(Kekule.Widget.Container, 'childHolderSlotName', {
'dataType': DataType.STRING, 'serializable': false,
'getter': function() { var elem = this.getChildHolderSlot(); return elem && elem.name; },
'setter': function(value) { var elem = this.getChildHolderSlot(); if (elem) elem.name = value; }
});
*/
////////////// Extend GlobalManager /////////////////
/** @ignore */
Kekule.WebComponent.widgetWrapperPopupHost = null;
ClassEx.extend(Kekule.Widget.GlobalManager, {
/** @private */
getWebComponentContextRootElem: function()
{
if (!Kekule.WebComponent.widgetWrapperPopupHost)
{
Kekule.WebComponent.widgetWrapperPopupHost = new WebComponentContextPopupHost();
document.body.appendChild(Kekule.WebComponent.widgetWrapperPopupHost);
}
Kekule.WebComponent.widgetWrapperPopupHost.ensureToBeLast(); // ensure it is always the last element in body
return Kekule.WebComponent.widgetWrapperPopupHost.hostElem;
},
isWidgetEnvironmentSetupIn: function(contextName)
{
if (contextName === 'webComponent')
return !!this.getHasWebComponentContext();
else // default, document context
{
if (!this._docContextEnvironmentSetup)
{
var defThemeLoaded = this._detectIfThemeLoaded(this.getDocContextRootElem());
if (defThemeLoaded) // document context already setup, store it
{
this._docContextEnvironmentSetup = true;
}
}
return this._docContextEnvironmentSetup;
}
}
});
ClassEx.extendMethod(Kekule.Widget.GlobalManager, 'getWidgetContextRootElement', function($origin, widget){
if (widget && widget.isWrappedInWebComponent())
{
/*
if (!Kekule.WebComponent.widgetWrapperPopupHost)
{
Kekule.WebComponent.widgetWrapperPopupHost = new WebComponentContextPopupHost();
document.body.appendChild(Kekule.WebComponent.widgetWrapperPopupHost);
}
Kekule.WebComponent.widgetWrapperPopupHost.ensureToBeLast(); // ensure it is always the last element in body
return Kekule.WebComponent.widgetWrapperPopupHost.hostElem;
*/
return this.getWebComponentContextRootElem();
}
else
return $origin(widget);
});
ClassEx.extendMethod(Kekule.Widget.GlobalManager, 'getDefaultContextRootElem', function($origin){
if (this.isWidgetEnvironmentSetupIn('document'))
return this.getDocContextRootElem();
else if (this.isWidgetEnvironmentSetupIn('webComponent'))
return this.getWebComponentContextRootElem();
else
return $origin();
});
/*
ClassEx.extendMethod(Kekule.Widget.GlobalManager, '_retrieveTouchEventActualTarget', function($origin, event){
var result = $origin(event);
if (result.shadowRoot) // has shadow DOM, event may be evoked by child in the shadow
{
if (result.widget && result.widget instanceof Kekule.Widget.BaseWidget) // has wrapped widget
{
var shadow = result.shadowRoot;
var actualTarget = shadow.elementFromPoint(event.clientX, event.clientY) || result;
return actualTarget;
}
}
return result;
});
*/
// A special property indicating whether the web component context has been built
ClassEx.defineProp(Kekule.Widget.GlobalManager, 'hasWebComponentContext', {'dataType': DataType.BOOL, 'serializable': false});
ClassEx.extend(Kekule.Widget.BaseWidget, {
isWrappedInWebComponent: function()
{
if (typeof(ShadowRoot) === 'undefined')
return false;
var elem = this.getElement();
var root = elem.getRootNode && elem.getRootNode();
return !!(root && root instanceof ShadowRoot)
}
});
/**
* A base class for component wrapped from Kekule widget.
* User should not use this class directly.
* @augments {HTMLElement}
* @class
*/
Kekule.WebComponent.BaseWidgetWrapper = class extends HTMLElement {
constructor()
{
super();
var shadow = this.attachShadow(Kekule.globalOptions.webComponent.init.shadowInitOptions);
this._shadow = shadow;
var widget = this._createWidget(this.constructor.widgetClass, shadow);
this._prepareStyles(shadow);
var eventRelayer = new Kekule.WebComponent.WebComponentContextEventRelayer(this.ownerDocument, widget.getElement()/*shadow*/, Kekule.Widget.globalManager);
this._reflectingChangedAttributes = [];
}
connectedCallback()
{
// set up prop values from attribute
var attribNames = this.getAttributeNames();
var observedAttribNames = this.constructor.observedAttributes;
attribNames = Kekule.ArrayUtils.intersect(attribNames, observedAttribNames);
for (var i = 0, l = attribNames.length; i < l; ++i)
{
var value = this.getAttribute(attribNames[i]);
//Kekule.Widget.Utils.setWidgetPropFromElemAttrib(this.widget, attribNames[i], value);
this._setWidgetPropValueFromAttribute(attribNames[i], value);
}
}
static get observedAttributes()
{
var attributes = [];
if (this._exposedWidgetProperties)
{
for (var i = 0, l = this._exposedWidgetProperties.length; i < l; ++i)
{
var propInfo = this._exposedWidgetProperties[i];
var pname = propInfo.name;
attributes.push(pname.dasherize());
attributes.push('data-' + pname.dasherize());
}
}
//Kekule.ArrayUtils.pushUnique(attributes, 'slot-name'); // always observe this
return attributes;
}
attributeChangedCallback(name, oldVal, newVal)
{
if (!this._isChangingPropFromAttribute(name)) // avoid recurse
{
try
{
this._reflectingChangedAttributes.push(name);
this._setWidgetPropValueFromAttribute(name, newVal);
}
finally
{
Kekule.ArrayUtils.remove(this._reflectingChangedAttributes, name);
}
}
}
_setWidgetPropValueFromAttribute(attribName, value)
{
/*
if (attribName === 'slot-name')
this.slotName = value;
else
*/
Kekule.Widget.Utils.setWidgetPropFromElemAttrib(this.widget, attribName, value);
}
_isChangingPropFromAttribute(attribName)
{
return this._reflectingChangedAttributes.indexOf(attribName) >= 0;
}
static get widgetClass()
{
return this._widgetClass;
}
static set widgetClass(value)
{
this._widgetClass = value;
}
static _exposeWidgetProperties(exposedProps, ignoredProps, options)
{
var op = options || {};
var wClass = this.widgetClass;
var props = ClassEx.getAllPropList(wClass);
var exposedPropInfos = [];
var getterSetterMethods = [];
for (var i = 0, l = props.getLength(); i < l; ++i)
{
var propInfo = props.getPropInfoAt(i);
if (propInfo.scope < Class.PropertyScope.PUBLIC)
continue;
if (exposedProps && exposedProps.indexOf(propInfo.name) < 0)
continue;
if (ignoredProps && ignoredProps.indexOf(propInfo.name) >= 0)
continue;
if (Kekule.ObjUtils.getPropertyDescriptor(this.prototype, propInfo.name, true)) // already has this property
{
if (!op.overwriteExisitedProperties)
{
continue;
}
}
//if (__definePropertyAvailable__)
{
exposedPropInfos.push(propInfo);
var descs = {
'enumerable': propInfo.enumerable,
'configurable': false
};
if (descs.enumerable === undefined)
descs.enumerable = true;
//console.log('define prop', propInfo);
if (propInfo.getter)
{
var getterName = propInfo.getter;
var selfGetterName = 'get' + propInfo.name.upperFirst();
//var getFunc = this.prototype[selfGetterName] = new Function('return this.widget.getPropValue("' + propInfo.name + '");');
(function(propName, descs){
var getFunc = function(){
return this.widget.getPropValue(propName);
};
descs.get = getFunc;
})(propInfo.name, descs);
getterSetterMethods.push(selfGetterName);
}
if (propInfo.setter)
{
var setterName = propInfo.setter;
var selfSetterName = 'set' + propInfo.name.upperFirst();
/*
var setFunc = this.prototype[selfSetterName]
= new Function(
'value',
'var result = this.widget.setPropValue("' + propInfo.name + '", value);' +
'var newVal = this.widget.getPropValue("' + propInfo.name + '");' +
'this._exposedPropValueChanged("' + propInfo.name + '", newVal);' +
'return result;'
);
*/
(function(propName, descs){
var setFunc = function(value){
var result = this.widget.setPropValue(propName, value);
var newVal = this.widget.getPropValue(propName);
this._exposedPropValueChanged(propName, newVal);
return result;
};
descs.set = setFunc;
})(propInfo.name, descs);
getterSetterMethods.push(selfSetterName);
}
try
{
Object.defineProperty(this.prototype, propInfo.name, descs);
}
catch(e)
{
throw e;
}
}
}
this._exposedWidgetProperties = exposedPropInfos;
return {
'exposedPropInfos': exposedPropInfos,
'getterSetterMethodNames': getterSetterMethods
};
}
static _exposeWidgetMethods(exposedMethods, ignoredMethods, options)
{
var op = options || {};
function _getOwnedMethodNames(aClass)
{
var proto = ClassEx.getPrototype(aClass);
var superClass = ClassEx.getSuperClass(aClass);
var superProto = superClass && ClassEx.getPrototype(superClass);
var fields = Object.getOwnPropertyNames(proto);
var result = [];
for (var i = 0, l = fields.length; i < l; ++i)
{
var name = fields[i];
var descriptor = Object.getOwnPropertyDescriptor(proto, name);
if (descriptor && descriptor.value && typeof(descriptor.value) === 'function') // ensure is function
{
result.push(name);
}
}
return result;
}
function _getAllMethodNamesOfClass(aClass, rootClass) // aClass must be descendant of ObjectEx
{
var result = _getOwnedMethodNames(aClass);
if (aClass !== rootClass)
{
var superClass = ClassEx.getSuperClass(aClass);
if (superClass)
{
var superNames = _getAllMethodNamesOfClass(superClass, rootClass);
result = superNames.concat(result);
}
}
return result;
}
var wClass = this.widgetClass;
var proto = ClassEx.getPrototype(wClass);
var methodNames = _getAllMethodNamesOfClass(wClass, Kekule.Widget.BaseWidget);
//var ignoredSuperClassMethodNames = _getAllMethodNamesOfClass(ClassEx.getSuperClass(Kekule.Widget.BaseWidget), ObjectEx); // ignore all methods of super classes
var excludeSuperClassProto = ClassEx.getPrototype(ClassEx.getSuperClass(Kekule.Widget.BaseWidget));
var exposedMethodNames = [];
for (var i = 0, l = methodNames.length; i < l; ++i)
{
var name = methodNames[i];
var explicitExpose = exposedMethods && exposedMethods.indexOf(name) >= 0;
if (ignoredMethods && ignoredMethods.indexOf(name) >= 0)
continue;
if (exposedMethods && exposedMethods.indexOf(name) < 0)
continue;
if (!explicitExpose && (name in excludeSuperClassProto)) // exclude all methods before Kekule.Widget.BaseWidget
continue;
if (!explicitExpose && name.startsWith('_')) // bypass private methods
continue;
if (Object.getOwnPropertyDescriptor(name))
continue;
try
{
if (name in this) // can not overwrite existed
{
continue;
}
}
catch(e)
{
}
/*
this.prototype[name] = new Function('',
'var func = this.widget["' + name +'"];' +
'return func.apply(this.widget, arguments);'
);
*/
(function(self, name, exposedMethodNames){
self.prototype[name] = function(){
var func = this.widget[name];
return func.apply(this.widget, arguments);
};
exposedMethodNames.push(name);
})(this, name, exposedMethodNames);
}
//console.log('expose method names', exposedMethodNames);
return exposedMethodNames;
}
_createWidget(widgetClass, shadow)
{
this._widget = new widgetClass(this.ownerDocument);
this._widget.beginUpdate();
try
{
var style = this._widget.getElement().style;
style.width = '100%';
style.height = '100%'; // widget fulfill the component element
//this._installSlotEventHandler(this._widget);
this._widget.appendToElem(shadow);
}
finally
{
this._widget.endUpdate();
}
return this._widget;
}
/*
_installSlotEventHandler(widget)
{
//var EU = Kekule.
if (widget.getChildHolderSlot)
{
widget.getElement().addEventListener('slotchange', function(e){
var slot = widget.getChildHolderSlot()
if (slot && slot === e.target)
{
if (Kekule.DomUtils.isElement(e.currentTarget))
{
var currTargetWidget = Kekule.Widget.getWidgetOnElem(e.currentTarget);
if (currTargetWidget)
{
var assignedNodes = slot.assignedNodes();
if (assignedNodes.indexOf(e.currentTarget) < 0) // a new node added
{
//widget.childWidgetAdded(currTargetWidget);
//console.log('widget inserted', currTargetWidget.getClassName(), e.currentTarget);
}
else // old node removed
{
widget.childWidgetRemoved(currTargetWidget);
}
//console.log(e.target, slot.assignedNodes(), e.target in slot.assignedNodes());
}
}
}
});
}
}
*/
_prepareStyles(shadow)
{
Kekule.Widget.globalManager.loadTheme(shadow);
var styleElem = shadow.ownerDocument.createElement('style');
styleElem.innerHTML = ':host { display: inline-block; position: relative; vertical-align: bottom; }\n' +
':host([hidden]) { display: none }';
shadow.appendChild(styleElem);
}
_exposedPropValueChanged(propName, value)
{
var propInfo = this.widget.getPropInfo(propName);
if (propInfo && DataType.isSimpleValue(value)) // only feedback the simple value attributes
{
var attribName = propName.dasherize();
this.setAttribute(attribName, value);
}
}
get widgetHost()
{
return this._shadow;
}
get widget()
{
return this._widget;
}
/*
get slotName()
{
return this.widget.getChildHolderSlotName();
}
set slotName(value)
{
this.widget.setChildHolderSlotName(value);
}
*/
};
/**
* Some util functions about web component
* @class
*/
Kekule.WebComponent.Utils = {
/**
* Wrap a Kekule widget into web component.
* @param {Class} widgetClass
* @param {String} customElemTag Custom element tag name binding with this component.
* @param {Hash} options May include fields:
* {
* exposeWidgetProperties: bool,
* exposedProperties: array,
* ignoredProperties: array,
* overwriteExisitedProperties: bool,
* exposeWidgetMethods: bool,
* exposedMethods: array,
* ignoredMethods: array,
* //overwriteExisitedProperties: bool
* }
* @returns {Class} Web component class.
*/
wrapWidget: function(widgetClass, customElemTag, options)
{
var globalOptions = Kekule.globalOptions.webComponent.widgetWrapper;
var op = Object.extend({
exposeWidgetProperties: globalOptions.exposeWidgetProperties,
ignoredProperties: globalOptions.ignoredProperties,
exposeWidgetMethods: globalOptions.exposeWidgetMethods,
ignoredMethods: globalOptions.ignoredMethods
}, options || {});
var result = class extends Kekule.WebComponent.BaseWidgetWrapper {
constructor()
{
super();
}
};
result.widgetClass = widgetClass;
var exposedPropResult;
if (op.exposeWidgetProperties)
{
exposedPropResult = result._exposeWidgetProperties(op.exposedProperties, op.ignoredProperties, {overwriteExisitedProperties: op.overwriteExisitedProperties});
}
if (op.exposeWidgetMethods)
{
var ignoredMethods = op.ignoredMethods || [];
if (exposedPropResult)
ignoredMethods = ignoredMethods.concat(exposedPropResult.getterSetterMethodNames);
result._exposeWidgetMethods(op.exposedMethods, ignoredMethods, {});
}
if (customElemTag && typeof(customElements) !== 'undefined')
customElements.define(customElemTag, result);
return result;
}
}
})();