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).
1,057 lines (934 loc) • 26.3 kB
JavaScript
import forEach from '../util/forEach'
import isString from '../util/isString'
import isNil from '../util/isNil'
import isNumber from '../util/isNumber'
import last from '../util/last'
import inBrowser from '../util/inBrowser'
import ElementType from '../vendor/domelementtype'
import cssSelect from '../vendor/css-select'
import DomUtils from './domutils'
import DOMElement from './DOMElement'
import parseMarkup from './parseMarkup'
// Singleton for browser window stub
let _browserWindowStub
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 ElementType.Doctype: {
this.data = args.data
break
}
case 'document': {
const format = args.format
this.format = format
if (!format) throw new Error("'format' is mandatory.")
this.childNodes = args.children || args.childNodes || []
this._index = null
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) {
const 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()
}
this.nameWithoutNS = nameWithoutNS(this.name)
return this
}
hasAttribute (name) {
if (this.attributes) {
return this.attributes.has(name)
}
}
getAttribute (name) {
if (this.attributes) {
return this.attributes.get(name)
}
}
setAttribute (name, value) {
if (this.attributes) {
value = String(value)
// Note: keeping the Set version of classes and styles in sync
switch (name) {
case 'id': {
this._invalidateIndex()
break
}
case 'class':
this.classes = new Set()
parseClasses(this.classes, value)
break
case 'style':
this.styles = new Map()
parseStyles(this.styles, value)
break
default:
//
}
this.attributes.set(name, value)
if (this._isHTML()) {
deriveHTMLPropertyFromAttribute(this, name, value)
}
}
return this
}
removeAttribute (name) {
if (this.attributes) {
switch (name) {
case 'id':
this._invalidateIndex()
break
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) {
if (this.classes) {
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
}
getDoctype () {
if (this.isDocumentNode()) {
return _findDocTypeElement(this)
} else {
return this.getOwnerDocument().getDoctype()
}
}
setDoctype (qualifiedNameStr, publicId, systemId) {
// NOTE: there must be only one <!DOCTYPE> before the first content element
const doc = this.getOwnerDocument()
const oldDocType = _findDocTypeElement(doc)
const newDocType = this.createDocumentType(qualifiedNameStr, publicId, systemId)
if (oldDocType) {
doc.replaceChild(oldDocType, newDocType)
} else {
// insert it before the first ELEMENT
doc.insertBefore(newDocType, doc.getChildren()[0])
}
doc.doctype = newDocType
}
getInnerHTML () {
const isXML = this._isXML()
return DomUtils.getInnerHTML(this, { xmlMode: isXML, decodeEntities: !isXML })
}
// TODO: parse html using settings from el,
// clear old childNodes and append new childNodes
setInnerHTML (html) {
if (this.childNodes) {
const isXML = this._isXML()
const _doc = parseMarkup(html, {
ownerDocument: this.getOwnerDocument(),
format: isXML ? 'xml' : 'html',
decodeEntities: !isXML,
elementFactory: MemoryDOMElementFactory
})
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 () {
const isXML = this._isXML()
return DomUtils.getOuterHTML(this, { xmlMode: isXML, decodeEntities: !isXML })
}
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) {
const 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() })
}
// TODO: it would be nice if we could use an index here
// however,
getElementById (id) {
const doc = this.getOwnerDocument()
if (!doc._index) {
doc._createIndex()
}
return doc._index.get(id)
}
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 () {
// TODO: to be consistent with the Browser implementation
// root elements should return null as parent element.
// However, this breaks other code ATM.
// let parent = this.parent
// if (parent && parent.type !== 'document') {
// return this.parent
// } else {
// return null
// }
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() })
}
createDocumentType (qualifiedNameStr, publicId, systemId) {
return new MemoryDOMDoctype(ElementType.Doctype, { data: { name: qualifiedNameStr, publicId, systemId }, 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
if (child.id) this._invalidateIndex()
DomUtils.appendChild(this, child)
child.ownerDocument = this.getOwnerDocument()
}
return this
}
removeChild (child) {
if (child.parentNode === this) {
if (child.id) this._invalidateIndex()
child.remove()
}
}
insertAt (pos, child) {
child = this._normalizeChild(child)
if (!child) return this
if (child.id) this._invalidateIndex()
const 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 (newChild.id) this._invalidateIndex()
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) {
const childNodes = this.childNodes
if (childNodes) {
const child = childNodes[pos]
if (child.id) this._invalidateIndex()
child.remove()
}
return this
}
empty () {
this._invalidateIndex()
const childNodes = this.childNodes
if (childNodes) {
childNodes.forEach((child) => {
child.next = child.prev = child.parent = null
})
childNodes.length = 0
}
this._invalidateIndex()
return this
}
remove () {
if (this.id) this._invalidateIndex()
DomUtils.removeElement(this)
return this
}
replaceChild (oldChild, newChild) {
if (oldChild.id || newChild.id) this._invalidateIndex()
if (oldChild.parent === this) {
oldChild.replaceWith(newChild)
}
return this
}
replaceWith (newEl) {
if (this.id || newEl.id) this._invalidateIndex()
newEl = this._normalizeChild(newEl)
DomUtils.replaceElement(this, newEl)
newEl.ownerDocument = this.getOwnerDocument()
return this
}
getEventListeners () {
return this.eventListeners || []
}
click () {
this.emit('click', { target: this, currentTarget: this })
return true
}
emit (name, data) {
this._propagateEvent(new MemoryDOMElementEvent(name, this, data))
}
getBoundingClientRect () {
return { top: 0, left: 0, height: 0, width: 0 }
}
getClientRects () {
return [{ top: 0, left: 0, height: 0, width: 0 }]
}
_propagateEvent (event) {
const listeners = this.eventListeners
if (listeners) {
listeners.forEach(l => {
if (l.eventName === event.type) {
l.handler(event)
}
})
if (event.stopped) return
const 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
const 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'
}
_invalidateIndex () {
this.getOwnerDocument()._index = null
}
_createIndex () {
const elementsWithId = this.getOwnerDocument().findAll('[id]')
this._index = new Map(elementsWithId.map(el => [el.id, el]))
}
// TODO: do we really need this?
get _isMemoryDOMElement () { return true }
static createDocument (format, opts = {}) {
if (format === 'xml') {
const doc = new MemoryDOMElement('document', { format: format })
const xmlInstruction = []
if (opts.version) {
xmlInstruction.push(`version="${opts.version}"`)
}
if (opts.encoding) {
xmlInstruction.push(`encoding="${opts.encoding}"`)
}
if (xmlInstruction.length > 0) {
doc._xmlInstruction = doc.createProcessingInstruction('xml', xmlInstruction.join(' '))
}
return doc
} else {
return MemoryDOMElement.parseMarkup(DOMElement.EMPTY_HTML, 'html')
}
}
static parseMarkup (str, format, options = {}) {
if (!str) {
return MemoryDOMElement.createDocument(format)
}
// decodeEntities by default only in HTML mode
const decodeEntities = format === 'html'
const parserOpts = Object.assign({
format,
decodeEntities,
elementFactory: MemoryDOMElementFactory
}, options)
// opt-out from HTML structure sanitization
if (options.raw) {
return parseMarkup(str, parserOpts)
}
if (options.snippet) {
str = `<__snippet__>${str}</__snippet__>`
}
let doc
if (format === 'html') {
doc = parseMarkup(str, parserOpts)
_sanitizeHTMLStructure(doc)
} else if (format === 'xml') {
doc = parseMarkup(str, parserOpts)
}
if (options.snippet) {
const childNodes = doc.find('__snippet__').childNodes
if (childNodes.length === 1) {
return childNodes[0]
} else {
return childNodes
}
} else {
return doc
}
}
static wrapNativeElement (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
}
static wrap (el) { return MemoryDOMElement.wrapNativeElement(el) }
static unwrap (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 */
static isReverse () {
return false
}
static getBrowserWindow () {
// HACK: this is a bit awkward
if (!_browserWindowStub) {
_browserWindowStub = new MemoryWindowStub()
}
return _browserWindowStub
}
}
function MemoryDOMElementFactory (type, data) {
return new MemoryDOMElement(type, data)
}
class MemoryDOMDoctype extends MemoryDOMElement {
get name () { return this.data.name }
get publicId () { return this.data.publicId }
get systemId () { return this.data.systemId }
}
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) => {
const n = style.indexOf(':')
// skip if there is no :, or if it is the first/last character
if (n < 1 || n === style.length - 1) return
const name = style.slice(0, n).trim()
const 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 () {}
}
// EXPERIMENTAL: we want to be able to use the Router in
// tests using MemoryDOM
class MemoryWindowStub extends MemoryDOMElement {
constructor () {
super('window', { ownerDocument: MemoryDOMElement.createDocument('html') })
const location = {
href: '',
hash: ''
}
function _updateLocation (url) {
const hashIdx = url.indexOf('#')
location.href = url
if (hashIdx >= 0) {
location.hash = url.slice(hashIdx)
}
}
const history = {
replaceState (stateObj, title, url) {
_updateLocation(url)
},
pushState (stateObj, title, url) {
_updateLocation(url)
}
}
this.location = location
this.history = history
}
}
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) {
const 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) => {
const 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) {
const 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')
const titleEl = doc.find('title')
const 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
const 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)
}
}
function _findDocTypeElement (doc) {
// Note: the looked up doctype element will be cached on the document element
if (doc.doctype) return doc.doctype
const childNodes = doc.childNodes
for (let i = 0; i < childNodes.length; i++) {
const child = childNodes[i]
if (child.type === ElementType.Doctype) {
doc.doctype = child
return child
}
}
}