UNPKG

substance

Version:

Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing systems.

683 lines (583 loc) 15.4 kB
import { clone, flattenOften, isArray, isFunction, isNumber, isBoolean, isNil, isPlainObject, isString, without, map } from '../util' import { DOMElement } from '../dom' /** A virtual {@link DOMElement} which is used by the {@link Component} API. A VirtualElement is just a description of a DOM structure. It represents a virtual DOM mixed with Components. This virtual structure needs to be compiled to a {@link Component} to actually create a real DOM element, which is done by {@link RenderingEngine} */ class VirtualElement extends DOMElement { constructor(owner) { super() // set when this gets inserted into another virtual element this.parent = null // set when created by RenderingContext this._owner = owner // set when ref'd this._ref = null } getParent() { return this.parent } get childNodes() { return this.getChildNodes() } /* Provides the component after this VirtualElement has been rendered. */ getComponent() { return this._comp } /** Associates a reference identifier with this element. When rendered the corresponding component is stored in the owner using the given key. In addition to that, components with a reference are preserved when its parent is rerendered. > Attention: only the owner should use this method, as it only affects the owner's references @param {String} ref id for the compiled Component */ ref(ref) { if (!ref) throw new Error('Illegal argument') /* Attention: only the owner can create a ref() If you run into this situation, e.g. when you pass down a virtual element to a component which wants to have a ref itself, then you have other options: 1. via props: ```js this.props.content.getComponent() ``` 2. via Component.getChildAt or Component.find() ``` this.getChildAt(0) this.find('.child') ``` */ if (this._ref) throw new Error('A VirtualElement can only be referenced once.') this._ref = ref if (this._context) { const refs = this._context.refs if(refs[ref]) { throw new Error('An item with reference "'+ref+'" already exists.') } refs[ref] = this } return this } isInDocument() { return false } } VirtualElement.prototype._isVirtualElement = true /* A virtual HTML element. @private @class VirtualElement.VirtualHTMLElement @extends ui/VirtualElement */ class VirtualHTMLElement extends VirtualElement { constructor(tagName) { super() this._tagName = tagName this.classNames = null this.attributes = null this.htmlProps = null this.style = null this.eventListeners = null // TODO: this is semantically incorrect. It should be named childNodes this.children = [] } getTagName() { return this._tagName } setTagName(tagName) { this._tagName = tagName return this } hasClass(className) { if (this.classNames) { return this.classNames.indexOf(className) > -1 } return false } addClass(className) { if (!this.classNames) { this.classNames = [] } this.classNames.push(className) return this } removeClass(className) { if (this.classNames) { this.classNames = without(this.classNames, className) } return this } removeAttribute(name) { if (this.attributes) { delete this.attributes[name] } return this } getAttribute(name) { if (this.attributes) { return this.attributes[name] } } setAttribute(name, value) { if (!this.attributes) { this.attributes = {} } this.attributes[name] = value return this } getAttributes() { // we are having separated storages for differet // kind of attributes which we now pull together // in the same way as a native DOM element has it var attributes = {} if (this.attributes) { Object.assign(attributes, this.attributes) } if (this.classNames) { attributes.class = this.classNames.join(' ') } if (this.style) { attributes.style = map(this.style, function(val, key) { return key + ":" + val }).join(';') } return attributes } getId() { return this.getAttribute('id') } setId(id) { this.setAttribute('id', id) return this } setTextContent(text) { text = String(text || '') this.empty() this.appendChild(text) return this } setInnerHTML(html) { html = html || '' this.empty() this._innerHTMLString = html return this } getInnerHTML() { if (!this.hasOwnProperty('_innerHTMLString')) { throw new Error('Not supported.') } else { return this._innerHTMLString } } getValue() { return this.htmlProp('value') } setValue(value) { this.htmlProp('value', value) return this } getChildNodes() { return this.children } getChildren() { return this.children.filter(function(child) { return child.getNodeType() !== "text" }) } isTextNode() { return false } isElementNode() { return true } isCommentNode() { return false } isDocumentNode() { return false } append() { if (this._innerHTMLString) { throw Error('It is not possible to mix $$.html() with $$.append(). You can call $$.empty() to reset this virtual element.') } this._append(this.children, arguments) return this } appendChild(child) { if (this._innerHTMLString) { throw Error('It is not possible to mix $$.html() with $$.append(). You can call $$.empty() to reset this virtual element.') } this._appendChild(this.children, child) return this } insertAt(pos, child) { child = this._normalizeChild(child) if (!child) { throw new Error('Illegal child: ' + child) } if (!child._isVirtualElement) { throw new Error('Illegal argument for $$.insertAt():' + child) } if (pos < 0 || pos > this.children.length) { throw new Error('insertAt(): index out of bounds.') } this._insertAt(this.children, pos, child) return this } insertBefore(child, before) { var pos = this.children.indexOf(before) if (pos > -1) { this.insertAt(pos, child) } else { throw new Error('insertBefore(): reference node is not a child of this element.') } return this } removeAt(pos) { if (pos < 0 || pos >= this.children.length) { throw new Error('removeAt(): Index out of bounds.') } this._removeAt(pos) return this } removeChild(child) { if (!child || !child._isVirtualElement) { throw new Error('removeChild(): Illegal arguments. Expecting a CheerioDOMElement instance.') } var idx = this.children.indexOf(child) if (idx < 0) { throw new Error('removeChild(): element is not a child.') } this.removeAt(idx) return this } replaceChild(oldChild, newChild) { if (!newChild || !oldChild || !newChild._isVirtualElement || !oldChild._isVirtualElement) { throw new Error('replaceChild(): Illegal arguments. Expecting BrowserDOMElement instances.') } var idx = this.children.indexOf(oldChild) if (idx < 0) { throw new Error('replaceChild(): element is not a child.') } this.removeAt(idx) this.insertAt(idx, newChild) return this } empty() { var children = this.children while (children.length) { var child = children.pop() child.parent = null } delete this._innerHTMLString return this } getProperty(name) { if (this.htmlProps) { return this.htmlProps[name] } } setProperty(name, value) { if (!this.htmlProps) { this.htmlProps = {} } this.htmlProps[name] = value return this } removeProperty(name) { if (this.htmlProps) { delete this.htmlProps[name] } return this } getStyle(name) { if (this.style) { return this.style[name] } } setStyle(name, value) { if (!this.style) { this.style = {} } if (DOMElement.pxStyles[name] && isNumber(value)) value = value + 'px' this.style[name] = value return this } _createEventListener(eventName, handler, options) { options.context = options.context || this._owner._comp return super._createEventListener(eventName, handler, options) } getNodeType() { return "element" } hasInnerHTML() { return Boolean(this._innerHTMLString) } _normalizeChild(child) { if (isNil(child)) { return } else if (child._isVirtualElement) { return child } else if (isString(child) || isBoolean(child) || isNumber(child)) { return new VirtualTextNode(String(child)) } else { throw new Error('Unsupported child type') } } _append(outlet, args) { if (args.length === 1 && !isArray(args[0])) { this._appendChild(outlet, args[0]) return } var children if (isArray(args[0])) { children = args[0] } else if (arguments.length > 1) { children = Array.prototype.slice.call(args,0) } else { return } children.forEach(this._appendChild.bind(this, outlet)) } _appendChild(outlet, child) { child = this._normalizeChild(child) // TODO: discuss. Having a bad feeling about this, // because it could obscure an implementation error if (!child) return outlet.push(child) this._attach(child) return child } _insertAt(outlet, pos, child) { if (!child) return outlet.splice(pos, 0, child) this._attach(child) } _removeAt(outlet, pos) { var child = outlet[pos] outlet.splice(pos, 1) this._detach(child) } _attach(child) { child.parent = this if (this._context && child._owner !== this._owner && child._ref) { this._context.foreignRefs[child._ref] = child } } _detach(child) { child.parent = null if (this._context && child._owner !== this._owner && child._ref) { delete this.context.foreignRefs[child._ref] } } _mergeHTMLConfig(other) { if (other.classNames) { if (!this.classNames) { this.classNames = [] } this.classNames = this.classNames.concat(other.classNames) } if (other.attributes) { if (!this.attributes) { this.attributes = {} } Object.assign(this.attributes, other.attributes) } if (other.htmlProps) { if (!this.htmlProps) { this.htmlProps = {} } Object.assign(this.htmlProps, other.htmlProps) } if (other.style) { if (!this.style) { this.style = {} } Object.assign(this.style, other.style) } if (other.eventListeners) { if (!this.eventListeners) { this.eventListeners = [] } this.eventListeners = this.eventListeners.concat(other.eventListeners) } } } VirtualHTMLElement.prototype._isVirtualHTMLElement = true /* A virtual element which gets rendered by a custom component. @private @class VirtualElement.VirtualComponent @extends ui/VirtualElement */ class VirtualComponent extends VirtualHTMLElement { constructor(ComponentClass, props) { super() props = props || {} this.ComponentClass = ComponentClass this.props = props if (!props.children) { props.children = [] } this.children = props.children } get _isVirtualComponent() { return true; } getComponent() { return this._comp } // Note: for VirtualComponentElement we put children into props // so that the render method of ComponentClass can place it. getChildren() { return this.props.children } getNodeType() { return 'component' } outlet(name) { return new Outlet(this, name) } _attach(child) { child._preliminaryParent = this } _detach(child) { child._preliminaryParent = null } _copyHTMLConfig() { return { classNames: clone(this.classNames), attributes: clone(this.attributes), htmlProps: clone(this.htmlProps), style: clone(this.style), eventListeners: clone(this.eventListeners) } } } class Outlet { constructor(virtualEl, name) { this.virtualEl = virtualEl this.name = name Object.freeze(this) } _getOutlet() { var outlet = this.virtualEl.props[this.name] if (!outlet) { outlet = [] this.virtualEl.props[this.name] = outlet } return outlet } append() { var outlet = this._getOutlet() this.virtualEl._append(outlet, arguments) return this } empty() { var arr = this.virtualEl.props[this.name] arr.forEach(function(el) { this._detach(el) }.bind(this)) arr.splice(0, arr.length) return this } } class VirtualTextNode extends VirtualElement { constructor(text) { super() this.text = text } get _isVirtualTextNode() { return true; } } VirtualElement.Component = VirtualComponent VirtualElement.TextNode = VirtualTextNode /** Create a virtual DOM representation which is used by Component for differential/reactive rendering. @param elementType HTML tag name or Component class @param [props] a properties object for Component classes @return {VirtualElement} a virtual DOM node @example Create a virtual DOM Element ``` $$('a').attr({href: './foo'}).addClass('se-nav-item') ``` Create a virtual Component ``` $$(HelloMessage, {name: 'John'}) ``` */ VirtualElement.createElement = function() { var content var _first = arguments[0] var _second = arguments[1] var type if (isString(_first)) { type = "element" } else if (isFunction(_first) && _first.prototype._isComponent) { type = "component" } else if (isNil(_first)) { throw new Error('$$(null): provided argument was null or undefined.') } else { throw new Error('Illegal usage of $$()') } // some props are mapped to built-ins var props = {} var classNames, ref var eventHandlers = [] for(var key in _second) { if (!_second.hasOwnProperty(key)) continue var val = _second[key] switch(key) { case 'class': classNames = val break case 'ref': ref = val break default: props[key] = val } } if (type === 'element') { content = new VirtualHTMLElement(_first) // remaining props are attributes // TODO: should we make sure that these are only string values? content.attr(props) } else { content = new VirtualComponent(_first, props) } // HACK: this is set to the current context by RenderingEngine // otherwise this will provide rubbish content._owner = this.owner if (classNames) { content.addClass(classNames) } if (ref) { content.ref(ref) } eventHandlers.forEach(function(h) { if (isFunction(h.handler)) { content.on(h.name, h.handler) } else if (isPlainObject(h.handler)) { var params = h.handler content.on(h.name, params.handler, params.context, params) } else { throw new Error('Illegal arguments for $$(_,{ on'+h.name+'})') } }) // allow a notation similar to React.createElement // $$(MyComponent, {}, ...children) if (arguments.length > 2) { content.append(flattenOften(Array.prototype.slice.call(arguments, 2), 3)) } return content } export default VirtualElement