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 system. It is developed to power our online editing platform [Substance](http://substance.io).

737 lines (641 loc) 17.6 kB
import deleteFromArray from '../util/deleteFromArray' import flattenOften from '../util/flattenOften' import isArray from '../util/isArray' import isFunction from '../util/isFunction' import isNumber from '../util/isNumber' import isBoolean from '../util/isBoolean' import isNil from '../util/isNil' import isPlainObject from '../util/isPlainObject' import isString from '../util/isString' import _isDefined from '../util/_isDefined' import hasOwnProperty from '../util/hasOwnProperty' import without from '../util/without' import map from '../util/map' import DOMElement from './DOMElement' /** 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} */ export default 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() } getChildCount () { return this.children.length } getChildAt (idx) { return this.children[idx] } /* 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 should create a ref() // unfortunately, with the current implementation this can not be ensured 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.has(ref)) { throw new Error('An item with reference "' + ref + '" already exists.') } refs.set(ref, this) } return this } isInDocument () { return false } get _isVirtualElement () { return true } } /* A virtual HTML element. */ 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) { this.attributes.delete(name) } return this } getAttribute (name) { if (this.attributes) { return this.attributes.get(name) } } setAttribute (name, value) { if (!this.attributes) { this.attributes = new Map() } this.attributes.set(name, String(value)) return this } getAttributes () { // we are having separated storages for different // kind of attributes which we now pull together // in the same way as a native DOM element has it // TODO: is this really a good idea? // maybe we should also treat the others as attributes let entries = [] if (this.attributes) { entries = Array.from(this.attributes) } if (this.classNames) { entries.push(['class', this.classNames.join(' ')]) } if (this.style) { entries.push(['style', map(this.style, function (val, key) { return key + ':' + val }).join(';')]) } return new Map(entries) } 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 (!_isDefined(this._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.get(name) } } setProperty (name, value) { if (!this.htmlProps) { this.htmlProps = new Map() } this.htmlProps.set(name, value) return this } removeProperty (name) { if (this.htmlProps) { this.htmlProps.delete(name) } return this } getStyle (name) { if (this.style) { return this.style.get(name) } } setStyle (name, value) { if (!this.style) { this.style = new Map() } if (DOMElement.pxStyles[name] && isNumber(value)) value = value + 'px' this.style.set(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)) { } else if (child._isVirtualElement) { return child } else if (isString(child) || isBoolean(child) || isNumber(child)) { return new VirtualTextNode(String(child)) } else { console.error('Unsupported child type', child) 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) { if (child._owner !== this._owner && child._isVirtualComponent) { this._context.injectedComponents.push(child) } if (child._owner !== this._owner && child._ref) { this._context.foreignRefs[child._ref] = child } } } _detach (child) { child.parent = null if (this._context) { if (child._isVirtualComponent) { deleteFromArray(this._context.injectedComponents, child) } if (child._owner !== this._owner && child._ref) { this._context.foreignRefs.delete(child._ref) } } } _copy () { if (this.classNames || this.attributes || this.eventListeners || this.htmlProps || this.style) { const copy = {} if (this.classNames) { copy.classNames = this.classNames.slice() } if (this.attributes) { copy.attributes = new Map(this.attributes) } if (this.eventListeners) { copy.eventListeners = this.eventListeners.slice() } if (this.htmlProps) { copy.htmlProps = new Map(this.htmlProps) } if (this.style) { copy.style = new Map(this.style) } return copy } } _clear () { this.classNames = null this.attributes = null this.htmlProps = null this.style = null this.eventListeners = null } _merge (other) { if (!other) return const ARRAY_TYPE_VALS = ['classNames', 'eventListeners'] for (const name of ARRAY_TYPE_VALS) { const otherVal = other[name] if (otherVal) { const thisVal = this[name] if (!thisVal) { this[name] = otherVal.slice() } else { this[name] = thisVal.concat(otherVal) } } } const MAP_TYPE_VALS = ['attributes', 'htmlProps', 'style'] for (const name of MAP_TYPE_VALS) { const otherVal = other[name] if (otherVal) { const thisVal = this[name] if (!thisVal) { this[name] = new Map(otherVal) } else { this[name] = new Map([...thisVal, ...otherVal]) } } } } get _isVirtualHTMLElement () { return true } } /* A virtual element which gets rendered by a custom component. */ 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 } 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' } // TODO: this seems to be not so useful // as this is also possible by just using props outlet (name) { return new Outlet(this, name) } setInnerHTML () { throw new Error('Can not set innerHTML of a Component') } _attach (child) { child._preliminaryParent = this } _detach (child) { child._preliminaryParent = null } get _isVirtualHTMLElement () { return false } get _isVirtualComponent () { return true } } 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)) { 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 (!hasOwnProperty(_second, 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 } VirtualElement.Context = class VirtualElementContext { constructor (owner) { this.owner = owner // used to track refs created via `el.ref()` this.refs = new Map() // used to keep refs that are set by a different owner, when a component is // passed via props this.foreignRefs = new Map() // all VirtualElements created such as `$$('div')` this.elements = [] // all VirtualComponents created such as `$$(Foo)` this.components = [] // all VirtualComponents that are appended but not owned, i.e. injected from parent this.injectedComponents = [] this.$$ = this._createElement.bind(this) this.$$.capturing = true } _createElement () { const vel = VirtualElement.createElement.apply(this, arguments) vel._context = this vel._owner = this.owner if (vel._isVirtualComponent) { // virtual components need to be captured recursively this.components.push(vel) } this.elements.push(vel) return vel } }