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.

872 lines (764 loc) 20.7 kB
import { forEach, isString, isNil, isNumber, last, inBrowser } from '../util' import ElementType from 'domelementtype' import cssSelect from '../vendor/css-select' import DomUtils from '../vendor/domutils' import DOMElement from './DOMElement' import parseMarkup from './parseMarkup' export default class MemoryDOMElement extends DOMElement { constructor(type, args = {}) { super() this.type = type if (!type) throw new Error("'type' is mandatory") this.ownerDocument = args.ownerDocument /* istanbul ignore next */ if (type !== 'document' && !this.ownerDocument) { throw new Error("'ownerDocument' is mandatory") } // NOTE: there are some properties which are named so that this // can be used together with htmlparser2 and css-select // but which could have a better naming, e.g., name -> tagName switch(type) { case ElementType.Tag: { if (!args.name) throw new Error("'name' is mandatory.") this.name = this._normalizeName(args.name) this.nameWithoutNS = nameWithoutNS(this.name) this.properties = new Map() this.attributes = new Map() this.classes = new Set() this.styles = new Map() this.eventListeners = [] this.childNodes = args.children || args.childNodes || [] this._assign(args) break } case ElementType.Text: case ElementType.Comment: { this.data = args.data || '' break } case ElementType.CDATA: { this.data = args.data || '' break } case ElementType.Directive: { if (!args.name) throw new Error("'name' is mandatory.") this.name = this._normalizeName(args.name) this.nameWithoutNS = nameWithoutNS(this.name) this.data = args.data break } case 'document': { let format = args.format this.format = format if (!format) throw new Error("'format' is mandatory.") this.childNodes = args.children || args.childNodes || [] switch(format) { case 'xml': this.contentType = 'application/xml' break case 'html': this.contentType = 'text/html' break default: throw new Error('Unsupported format ' + format) } break } default: this.name = null this.properties = new Map() this.attributes = new Map() this.classes = new Set() this.styles = new Map() this.eventListeners = [] this.childNodes = args.children || args.childNodes || [] } } getNativeElement() { return this } getNodeType() { switch(this.type) { case ElementType.Tag: case ElementType.Script: case ElementType.Style: return 'element' default: return this.type } } isTextNode() { return this.type === "text" } isElementNode() { return this.type === "tag" || this.type === "script" } isCommentNode() { return this.type === "comment" } isDocumentNode() { return this.type === "document" } isComponentNode() { return this.type === "component" } clone(deep) { let clone = new MemoryDOMElement(this.type, this) if (this.childNodes) { clone.childNodes.length = 0 if (deep) { this.childNodes.forEach((child) => { clone.appendChild(child.clone(deep)) }) } } return clone } get tagName() { return this.getTagName() } set tagName(tagName) { this.setTagName(tagName) } getTagName() { return this.name } setTagName(tagName) { if (this._isXML()) { this.name = String(tagName) } else { this.name = String(tagName).toLowerCase() } return this } hasAttribute(name) { return this.attributes.has(name) } getAttribute(name) { return this.attributes.get(name) } setAttribute(name, value) { value = String(value) // Note: keeping the Set version of classes and styles in sync switch(name) { case 'class': parseClasses(this.classes, value) break case 'style': parseStyles(this.styles, value) break default: // } this.attributes.set(name, value) if (this._isHTML()) { deriveHTMLPropertyFromAttribute(this, name, value) } return this } removeAttribute(name) { switch(name) { case 'class': this.classes = new Set() break case 'style': this.styles = new Map() break default: // } this.attributes.delete(name) return this } getAttributes() { return this.attributes } getProperty(name) { if (this.properties) { return this.properties.get(name) } } setProperty(name, value) { if (this.properties) { if (this._isXML()) { throw new Error('setProperty() is only be used on HTML elements') } _setHTMLPropertyValue(this, name, value) } return this } hasClass(name) { if (this.classes) { return this.classes.has(name) } } addClass(name) { this.classes.add(name) this.attributes.set('class', stringifyClasses(this.classes)) return this } removeClass(name) { if (this.classes && this.classes.has(name)) { this.classes.delete(name) this.attributes.set('class', stringifyClasses(this.classes)) } return this } getContentType() { return this.getOwnerDocument().contentType } getInnerHTML() { return DomUtils.getInnerHTML(this, { decodeEntities: true }) } // TODO: parse html using settings from el, // clear old childNodes and append new childNodes setInnerHTML(html) { if (this.childNodes) { let _doc = parseMarkup(html, { ownerDocument: this.getOwnerDocument(), decodeEntities: true }) this.empty() // ATTENTION: important to copy the childNodes array first // as appendChild removes from parent _doc.childNodes.slice(0).forEach((child) => { this.appendChild(child) }) } return this } getOuterHTML() { return DomUtils.getOuterHTML(this, { xmlMode: this._isXML(), decodeEntities: true }) } getTextContent() { return DomUtils.getText(this) } setTextContent(text) { switch(this.type) { case ElementType.Text: case ElementType.Comment: case ElementType.CDATA: { this.data = text break } default: { if (this.childNodes) { let child = this.createTextNode(text) this.empty() this.appendChild(child) } } } return this } getStyle(name) { if (this.styles) { return this.styles.get(name) } } setStyle(name, value) { if (this.styles) { if (DOMElement.pxStyles[name] && isNumber(value)) { value = value + "px" } this.styles.set(name, value) this.attributes.set('style', stringifyStyles(this.styles)) } return this } is(cssSelector) { return cssSelect.is(this, cssSelector, { xmlMode: this._isXML() }) } find(cssSelector) { return cssSelect.selectOne(cssSelector, this, { xmlMode: this._isXML() }) } findAll(cssSelector) { return cssSelect.selectAll(cssSelector, this, { xmlMode: this._isXML() }) } getChildCount() { if (this.childNodes) { return this.childNodes.length } else { return 0 } } getChildNodes() { return this.childNodes.slice(0) } getChildren() { return this.childNodes.filter(function(node) { return node.type === "tag" }) } get children() { return this.getChildren() } getChildAt(pos) { if (this.childNodes) { return this.childNodes[pos] } } getChildIndex(child) { if (this.childNodes) { return this.childNodes.indexOf(child) } } getLastChild() { if (this.childNodes) { return last(this.childNodes) } } getFirstChild() { if (this.childNodes) { return this.childNodes[0] } } getNextSibling() { return this.next } getPreviousSibling() { return this.prev } getParent() { return this.parent } getOwnerDocument() { return (this.type === 'document') ? this : this.ownerDocument } getFormat() { return this.getOwnerDocument().format } createDocument(format) { return MemoryDOMElement.createDocument(format) } createElement(tagName) { return new MemoryDOMElement(ElementType.Tag, { name: tagName, ownerDocument: this.getOwnerDocument() }) } createTextNode(text) { return new MemoryDOMElement(ElementType.Text, { data: text, ownerDocument: this.getOwnerDocument() }) } createComment(data) { return new MemoryDOMElement(ElementType.Comment, { data: data, ownerDocument: this.getOwnerDocument() }) } createProcessingInstruction(name, data) { return new MemoryDOMElement(ElementType.Directive, { name: name, data: data, ownerDocument: this.getOwnerDocument() }) } createCDATASection(data) { return new MemoryDOMElement(ElementType.CDATA, { data: data, ownerDocument: this.getOwnerDocument() }) } appendChild(child) { if (this.childNodes && !isNil(child)) { child = this._normalizeChild(child) if (!child) return this DomUtils.appendChild(this, child) child.ownerDocument = this.getOwnerDocument() } return this } removeChild(child) { if (child.parentNode === this) { child.remove() } } insertAt(pos, child) { child = this._normalizeChild(child) if (!child) return this let childNodes = this.childNodes if (childNodes) { // NOTE: manipulating htmlparser's internal children array if (pos >= childNodes.length) { DomUtils.appendChild(this, child) } else { DomUtils.prepend(childNodes[pos], child) } child.ownerDocument = this.getOwnerDocument() } return this } insertBefore(newChild, before) { if (isNil(before)) { return this.appendChild(newChild) } else if (this.childNodes) { var pos = this.childNodes.indexOf(before) if (pos > -1) { DomUtils.prepend(before, newChild) newChild.ownerDocument = this.getOwnerDocument() } else { throw new Error('insertBefore(): reference node is not a child of this element.') } } return this } removeAt(pos) { let childNodes = this.childNodes if (childNodes) { let child = childNodes[pos] child.remove() } return this } empty() { let childNodes = this.childNodes if (childNodes) { childNodes.forEach((child) => { child.next = child.prev = child.parent = null }) childNodes.length = 0 } return this } remove() { DomUtils.removeElement(this) return this } replaceChild(oldChild, newChild) { if (oldChild.parent === this) { oldChild.replaceWith(newChild) } return this } replaceWith(newEl) { newEl = this._normalizeChild(newEl) DomUtils.replaceElement(this, newEl) newEl.ownerDocument = this.getOwnerDocument() return this } getEventListeners() { return this.eventListeners || [] } click() { this.emit('click', { target: this }) return this } emit(name, data) { this._propagateEvent(new MemoryDOMElementEvent(name, this, data)) } _propagateEvent(event) { let listeners = this.eventListeners if (listeners) { let listener = listeners.find((l) => { return l.eventName === event.type }) if (listener) listener.handler(event) if (event.stopped) return let p = this.parentNode if (p) p._propagateEvent(event) } } removeAllEventListeners() { this.eventListeners = [] return this } _assign(other) { if (other.name) this.name = other.name if (this.classes && other.classes) { other.classes.forEach((val) => { this.classes.add(val) }) } if (this.styles && other.styles) { forEach(other.styles, (val, name) => { this.styles.set(name, val) }) } // TODO: while it is 'smart' to deal with 'style' and 'class' // implicitly, it introduces some confusion here let otherAttributes = other.attributes || other.attribs if (this.attributes && otherAttributes) { forEach(otherAttributes, (val, name) => { switch (name) { case 'class': { parseClasses(this.classes, val) break } case 'style': { parseStyles(this.styles, val) break } default: this.attributes.set(name, val) } }) } if (this.eventListeners && other.eventListeners) { this.eventListeners = this.eventListeners.concat(other.eventListeners) } } _normalizeChild(child) { if (isNil(child)) return if (isString(child)) { child = this.createTextNode(child) } /* istanbul ignore next */ if (!child || !child._isMemoryDOMElement) { throw new Error('Illegal argument: only String and MemoryDOMElement instances are valid.') } return child } _normalizeName(name) { if (this._isXML()) { return name } else { return name.toLowerCase() } } _isHTML() { return this.getFormat() === 'html' } _isXML() { return this.getFormat() === 'xml' } } MemoryDOMElement.prototype._isMemoryDOMElement = true MemoryDOMElement.createDocument = function(format) { if (format === 'xml') { return new MemoryDOMElement('document', { format: format }) } else { return MemoryDOMElement.parseMarkup(DOMElement.EMPTY_HTML, 'html') } } MemoryDOMElement.parseMarkup = function(str, format, options={}) { if (!str) { return MemoryDOMElement.createDocument(format) } if (options.snippet) { str = `<__snippet__>${str}</__snippet__>` } let doc if (format === 'html') { doc = parseMarkup(str, { format: format, decodeEntities: true }) _sanitizeHTMLStructure(doc) } else if (format === 'xml') { doc = parseMarkup(str, { format: format, decodeEntities: true }) } if (options.snippet) { let childNodes = doc.find('__snippet__').childNodes if (childNodes.length === 1) { return childNodes[0] } else { return childNodes } } else { return doc } } MemoryDOMElement.wrap = MemoryDOMElement.wrapNativeElement = function(el) { if (inBrowser) { // HACK: at many places we have an `isBrowser` check // to skip code that uses window or window.document // To be able to test such code together with the memory DOM implementation // we stub out window and document if (el === window || el === window.document) { return new DOMElementStub() } // HACK: additionally, if a window.document.Node or a BrowserDOMElement is given // as it happens when trying to mount onto t.sandbox with DefaultDOMElement using MemoryDOMElement as default // we just return a new root element else if (el instanceof window.Node || el._isBrowserDOMElement) { // return MemoryDOMElement.createDocument('html').createElement('div') } } /* istanbul ignore next */ if (!el._isMemoryDOMElement) { throw new Error('Illegal argument: expected MemoryDOMElement instance') } return el } MemoryDOMElement.unwrap = function(el) { /* istanbul ignore next */ if (!el._isMemoryDOMElement) { throw new Error('Illegal argument: expected MemoryDOMElement instance') } return el } // TODO: this is used only in browser to determine if // a selection is reverse. /* istanbul ignore next */ MemoryDOMElement.isReverse = function() { return false } // Stub let _browserWindowStub MemoryDOMElement.getBrowserWindow = function() { // HACK: this is a bit awkward if (!_browserWindowStub) { _browserWindowStub = MemoryDOMElement.createDocument('html') } return _browserWindowStub } function parseClasses(classes, classStr) { classStr.split(/\s+/).forEach((name) => { classes.add(name) }) } function stringifyClasses(classes) { return Array.from(classes).join(' ') } function parseStyles(styles, styleStr) { styleStr = (styleStr || '').trim() if (!styleStr) return styleStr.split(';').forEach((style) => { let n = style.indexOf(':') // skip if there is no :, or if it is the first/last character if (n < 1 || n === style.length-1) return let name = style.slice(0,n).trim() let val = style.slice(n+1).trim() styles.set(name, val) }) } function stringifyStyles(styles) { if (!styles) return '' let str = Object.keys(styles).map((name) => { return name + ':' + styles[name] }).join(';') if (str.length > 0) str += ';' return str } const BUILTIN_EVENTS = [ 'keydown', 'keyup', 'keypress', 'mousedown', 'mouseup', 'mouseover', 'click', 'dblclick' ].reduce((m, k)=>{m[k]=true;return m}, {}) class MemoryDOMElementEvent { constructor(type, target, detail) { this.type = type this.timeStamp = Date.now() this.target = target if (BUILTIN_EVENTS[type]) { // TODO: dunno if this is the best way of doing it if (detail) { Object.assign(this, detail) } } else { this.detail = detail } } stopPropagation() { this.stopped = true } preventDefault() { this.defaultPrevented = true } } class DOMElementStub { on() {} off(){} } function nameWithoutNS(name) { const idx = name.indexOf(':') if (idx > 0) { return name.slice(idx+1) } else { return name } } // Note: some attributes are used to initialize an // element property const ATTR_TO_PROPS = { "input": { "value": true, "checked": (el, name, value) => { const checked = (value !== 'off') el.setProperty('checked', checked) } } } function deriveHTMLPropertyFromAttribute(el, name, value) { const mappings = ATTR_TO_PROPS[el.tagName] if (mappings) { let mapper = mappings[name] if (mapper === true) { el.setProperty(name, value) } else if (mapper) { mapper(el, name, value) } } } const PROPERTY_TRANSFORMATIONS = { "input": { "checked": (el, name, value) => { if (value === true) { el.properties.set(name, true) el.properties.set('value', 'on') } else { el.properties.set(name, false) el.properties.set('value', 'off') } }, "value": (el, name, value) => { let type = el.getAttribute('type') switch(type) { case 'checkbox': if (value === 'on') { el.properties.set(name, true) el.properties.set('value', 'on') } else { el.properties.set(name, false) el.properties.set('value', 'off') } break default: _setProperty(el, name, value) } } } } function _setProperty(el, name, value) { if (value === undefined) { el.properties.delete(name) } else { el.properties.set(name, String(value)) } } function _setHTMLPropertyValue(el, name, value) { const trafos = PROPERTY_TRANSFORMATIONS[el.tagName] if (trafos) { let mapper = trafos[name] if (mapper) { mapper(el, name, value) return } } _setProperty(el, name, value) } function _sanitizeHTMLStructure(doc) { // as opposed to DOMParser in the browser // htmlparser2 does not create <head> and <body> per se // thus we need to make sure that everything is working // similar as in the browser let htmlEl = doc.find('html') if (!htmlEl) { // remove head and nodes which must go into the head // so they do not go into the body let headEl = doc.find('head') let titleEl = doc.find('title') let metaEls = doc.findAll('meta') let bodyEl = doc.find('body') if (headEl) headEl.remove() if (titleEl) titleEl.remove() metaEls.forEach(e => e.remove()) if (bodyEl) bodyEl.remove() // keep the remaining content nodes, // we will add them to the body let contentNodes = doc.childNodes.slice() contentNodes.forEach((c)=>{c.parent = null}) doc.childNodes.length = 0 htmlEl = doc.createElement('html') // if not there create a <head> and // add all the elements that are supposed to // go there if (!headEl) { headEl = doc.createElement('head') headEl.appendChild(titleEl) headEl.append(metaEls) htmlEl.appendChild(headEl) } if (!bodyEl) { bodyEl = doc.createElement('body') bodyEl.append(contentNodes) } htmlEl.appendChild(bodyEl) doc.append(htmlEl) } }