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).
540 lines (475 loc) • 12.9 kB
JavaScript
import ElementType from '../vendor/domelementtype'
/*
ATTENTION:
We found out that using the encoder from `entities` module
is doing too much
Instead we do a simpler version now, encoding just a small
set:
- in attributes we only need to escape '"'
- in textContent we need to escape '<' and '&'
Note: this is copy and pasted from the bundled entities vendor file.
*/
const _encodeXMLContent = ((obj) => {
const invObj = getInverseObj(obj)
const replacer = getInverseReplacer(invObj)
return getInverse(invObj, replacer)
})({
amp: '&',
gt: '>',
lt: '<'
})
const _encodeXMLAttr = ((obj) => {
const invObj = getInverseObj(obj)
const replacer = getInverseReplacer(invObj)
return getInverse(invObj, replacer)
})({
quot: '"'
})
function getInverseObj (obj) {
return Object.keys(obj).sort().reduce(function (inverse, name) {
inverse[obj[name]] = '&' + name + ';'
return inverse
}, {})
}
function getInverseReplacer (inverse) {
const single = []
const multiple = []
Object.keys(inverse).forEach(function (k) {
if (k.length === 1) {
single.push('\\' + k)
} else {
multiple.push(k)
}
})
multiple.unshift('[' + single.join('') + ']')
return new RegExp(multiple.join('|'), 'g')
}
const RE_NON_ASCII = /[^\0-\x7F]/g
const RE_ASTRAL_SYMBOLS = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g
function singleCharReplacer (c) {
return '&#x' + c.charCodeAt(0).toString(16).toUpperCase() + ';'
}
function astralReplacer (c) {
var high = c.charCodeAt(0)
var low = c.charCodeAt(1)
var codePoint = (high - 0xD800) * 0x400 + low - 0xDC00 + 0x10000
return '&#x' + codePoint.toString(16).toUpperCase() + ';'
}
function getInverse (inverse, re) {
function func (name) {
return inverse[name]
}
return function (data) {
return data
.replace(re, func)
.replace(RE_ASTRAL_SYMBOLS, astralReplacer)
.replace(RE_NON_ASCII, singleCharReplacer)
}
}
const booleanAttributes = {
__proto__: null,
allowfullscreen: true,
async: true,
autofocus: true,
autoplay: true,
checked: true,
controls: true,
default: true,
defer: true,
disabled: true,
hidden: true,
ismap: true,
loop: true,
multiple: true,
muted: true,
open: true,
readonly: true,
required: true,
reversed: true,
scoped: true,
seamless: true,
selected: true,
typemustmatch: true
}
const unencodedElements = {
__proto__: null,
style: true,
script: true,
xmp: true,
iframe: true,
noembed: true,
noframes: true,
plaintext: true,
noscript: true
}
const singleTag = {
__proto__: null,
area: true,
base: true,
basefont: true,
br: true,
col: true,
command: true,
embed: true,
frame: true,
hr: true,
img: true,
input: true,
isindex: true,
keygen: true,
link: true,
meta: true,
param: true,
source: true,
track: true,
wbr: true
}
export default class DomUtils {
isTag (elem) {
return ElementType.isTag(elem)
}
removeElement (elem) {
if (elem.prev) elem.prev.next = elem.next
if (elem.next) elem.next.prev = elem.prev
if (elem.parent) {
var childs = elem.parent.childNodes
const pos = childs.lastIndexOf(elem)
if (pos < 0) throw new Error('Invalid state')
childs.splice(pos, 1)
elem.parent = null
}
}
replaceElement (elem, replacement) {
if (replacement.parent) this.removeElement(replacement)
var prev = replacement.prev = elem.prev
if (prev) {
prev.next = replacement
}
var next = replacement.next = elem.next
if (next) {
next.prev = replacement
}
var parent = replacement.parent = elem.parent
if (parent) {
var childs = parent.childNodes
const pos = childs.lastIndexOf(elem)
if (pos < 0) throw new Error('Invalid state')
childs[pos] = replacement
}
}
appendChild (elem, child) {
if (child.parent) this.removeElement(child)
child.parent = elem
if (elem.childNodes.push(child) !== 1) {
var sibling = elem.childNodes[elem.childNodes.length - 2]
sibling.next = child
child.prev = sibling
child.next = null
}
}
append (elem, next) {
if (next.parent) this.removeElement(next)
const parent = elem.parent
const currNext = elem.next
next.next = currNext
next.prev = elem
elem.next = next
next.parent = parent
if (currNext) {
currNext.prev = next
if (parent) {
var childs = parent.childNodes
const pos = childs.lastIndexOf(currNext)
if (pos < 0) throw new Error('Invalid state')
childs.splice(pos, 0, next)
}
} else if (parent) {
parent.childNodes.push(next)
}
}
prepend (elem, prev) {
if (prev.parent) this.removeElement(prev)
var parent = elem.parent
if (parent) {
var childs = parent.childNodes
const pos = childs.lastIndexOf(elem)
if (pos < 0) throw new Error('Invalid state')
childs.splice(pos, 0, prev)
}
if (elem.prev) {
elem.prev.next = prev
}
prev.parent = parent
prev.prev = elem.prev
prev.next = elem
elem.prev = prev
}
filter (test, element, recurse, limit) {
if (!Array.isArray(element)) element = [element]
if (typeof limit !== 'number' || !isFinite(limit)) {
limit = Infinity
}
return this.find(test, element, recurse !== false, limit)
}
find (test, elems, recurse, limit) {
let result = []
let childs
for (var i = 0, j = elems.length; i < j; i++) {
if (test(elems[i])) {
result.push(elems[i])
if (--limit <= 0) break
}
childs = this.getChildren(elems[i])
if (recurse && childs && childs.length > 0) {
childs = this.find(test, childs, recurse, limit)
result = result.concat(childs)
limit -= childs.length
if (limit <= 0) break
}
}
return result
}
findOneChild (test, elems) {
for (var i = 0, l = elems.length; i < l; i++) {
if (test(elems[i])) return elems[i]
}
return null
}
findOne (test, elems) {
var elem = null
for (var i = 0, l = elems.length; i < l && !elem; i++) {
const child = elems[i]
if (!this.isTag(child)) {
continue
} else if (test(child)) {
elem = child
} else {
const childNodes = this.getChildren(child)
if (childNodes.length > 0) {
elem = this.findOne(test, childNodes)
}
}
}
return elem
}
existsOne (test, elems) {
for (var i = 0, l = elems.length; i < l; i++) {
const elem = elems[i]
// test only elements
if (!this.isTag(elem)) continue
// found if the element itself matches
if (test(elem)) return true
// otherwise, if one of its children matches
const childNodes = this.getChildren(elem)
if (childNodes.length > 0 && this.existsOne(test, childNodes)) return true
}
return false
}
findAll (test, elems) {
var result = []
for (var i = 0, j = elems.length; i < j; i++) {
const elem = elems[i]
if (!this.isTag(elem)) continue
if (test(elem)) result.push(elem)
const childNodes = this.getChildren(elem)
if (childNodes.length > 0) {
result = result.concat(this.findAll(test, childNodes))
}
}
return result
}
getAttributes (el) {
const attribs = el.getAttributes()
// HACK: this is a bit confusing, because MemoryDOMElement and BrowserDOMElement are
// not 100% compatible yet regarding getAttributes()
if (attribs instanceof Map) {
return Array.from(attribs)
} else if (attribs && attribs.forEach) {
const res = []
attribs.forEach((val, key) => {
res.push([key, val])
})
return res
} else {
return []
}
}
formatAttribs (el, opts = {}) {
const output = []
const attributes = this.getAttributes(el)
attributes.forEach(([key, value]) => {
if (opts.disallowHandlers && /^\s*on/.exec(key)) return
if (opts.disallowHandlers && /^javascript[:]/.exec(value)) return
if (opts.disallowedAttributes && opts.disallowedAttributes.has(key)) return
if (!value && booleanAttributes[key]) {
output.push(key)
} else {
output.push(key + '="' + (opts.decodeEntities ? _encodeXMLAttr(value) : value) + '"')
}
})
return output.join(' ')
}
render (dom, opts) {
if (!Array.isArray(dom)) dom = [dom]
opts = opts || {}
const output = []
for (var i = 0; i < dom.length; i++) {
const elem = dom[i]
switch (elem.type) {
case 'root':
case 'document': {
if (elem._xmlInstruction) {
output.push(this.render(elem._xmlInstruction, opts))
}
output.push(this.render(this.getChildren(elem), opts))
break
}
case ElementType.Tag:
case ElementType.Script:
case ElementType.Style: {
output.push(this.renderTag(elem, opts))
break
}
case ElementType.CDATA: {
if (!opts.stripCDATA) {
output.push(this.renderCdata(elem))
}
break
}
case ElementType.Comment: {
if (!opts.stripComments) {
output.push(this.renderComment(elem))
}
break
}
case ElementType.Directive: {
output.push(this.renderDirective(elem))
break
}
case ElementType.Doctype: {
output.push(this.renderDoctype(elem))
break
}
case ElementType.Text: {
output.push(this.renderText(elem, opts))
break
}
default:
throw new Error('Not implement yet: render of element type ' + elem.type)
}
}
return output.join('')
}
renderTag (elem, opts) {
const name = this.getName(elem)
if (opts.allowedTags) {
if (!opts.allowedTags.has(this.getNameWithoutNS(elem))) return
}
if (opts.disallowedTags) {
if (opts.disallowedTags.has(this.getNameWithoutNS(elem))) return
}
if (name === 'svg') opts = Object.assign({}, opts, { decodeEntities: opts.decodeEntities, xmlMode: true })
let tag = '<' + name
const attribs = this.formatAttribs(elem, opts)
if (attribs) {
tag += ' ' + attribs
}
const childNodes = this.getChildren(elem)
if (opts.xmlMode && childNodes.length === 0) {
tag += '/>'
} else {
tag += '>'
if (childNodes.length > 0) {
tag += this.render(childNodes, opts)
}
if (!singleTag[name] || opts.xmlMode) {
tag += '</' + name + '>'
}
}
return tag
}
renderDirective (elem) {
return '<?' + this.getName(elem) + ' ' + this.getData(elem) + '?>'
}
renderDoctype (elem) {
const { name, publicId, systemId } = this.getData(elem)
const frags = ['DOCTYPE', name]
if (publicId) {
frags.push('PUBLIC')
frags.push('"' + publicId + '"')
if (systemId) frags.push('"' + systemId + '"')
}
return '<!' + frags.join(' ') + '>'
}
renderText (elem, opts) {
let text = this.getText(elem)
if (opts.decodeEntities) {
const parent = this.getParent(elem)
if (!(parent && this.getName(parent) in unencodedElements)) {
text = _encodeXMLContent(text)
}
}
return text
}
renderCdata (elem) {
return '<![CDATA[' + this.getData(elem) + ']]>'
}
renderComment (elem) {
return '<!--' + this.getData(elem) + '-->'
}
getInnerHTML (elem, opts) {
const childNodes = this.getChildren(elem)
return childNodes.map((child) => {
return this.render(child, opts)
}).join('')
}
getOuterHTML (elem, opts) {
return this.render(elem, opts)
}
getData (elem) {
return elem.data
}
getText (elem, sub) {
if (Array.isArray(elem)) return elem.map(e => this.getText(e, sub)).join('')
switch (elem.type) {
case ElementType.Tag:
case ElementType.Script:
case ElementType.Style:
return this.getText(this.getChildren(elem), true)
case ElementType.Text:
case ElementType.CDATA:
return elem.data
case ElementType.Comment:
// comments are not rendered
// into the textContent of parentNodes
if (sub) {
return ''
}
return elem.data
default:
return ''
}
}
getChildren (elem) {
return elem.childNodes
}
getParent (elem) {
return elem.parent
}
getSiblings (elem) {
var parent = this.getParent(elem)
return parent ? this.getChildren(parent) : [elem]
}
getAttributeValue (elem, name) {
return elem.getAttribute(name)
}
hasAttrib (elem, name) {
return elem.hasAttribute(name)
}
getName (elem) {
return elem.name
}
getNameWithoutNS (elem) {
return elem.nameWithoutNS
}
}