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).
911 lines (790 loc) • 23 kB
JavaScript
import isString from '../util/isString'
import isNumber from '../util/isNumber'
import isNil from '../util/isNil'
import uuid from '../util/uuid'
import DOMElement from './DOMElement'
// using a dynamic signature to store the wrapper on the native element
// this way, we avoid to inadvertently use a wrong API created by another
// DOMElement instance on the element
const SIGNATURE = uuid('_BrowserDOMElement')
function _attach (nativeEl, browserDOMElement) {
if (!browserDOMElement._isBrowserDOMElement) throw new Error('Invalid argument')
if (Object.prototype.hasOwnProperty.call(nativeEl, SIGNATURE)) throw new Error('Already attached')
nativeEl[SIGNATURE] = browserDOMElement
}
function _detach (nativeEl) {
delete nativeEl[SIGNATURE]
}
function _unwrap (nativeEl) {
return nativeEl[SIGNATURE]
}
export default
class BrowserDOMElement extends DOMElement {
constructor (el) {
super()
console.assert(el instanceof window.Node, 'Expecting native DOM node.')
this.el = el
// creating a backlink so we can move between the native DOM API and
// the DOMElement API
_attach(el, this)
}
getNativeElement () {
return this.el
}
getNodeType () {
switch (this.el.nodeType) {
case window.Node.TEXT_NODE:
return 'text'
case window.Node.ELEMENT_NODE:
return 'element'
case window.Node.DOCUMENT_NODE:
return 'document'
case window.Node.COMMENT_NODE:
return 'comment'
case window.Node.PROCESSING_INSTRUCTION_NODE:
return 'directive'
case window.Node.CDATA_SECTION_NODE:
return 'cdata'
default:
//
}
}
getDoctype () {
if (this.isDocumentNode()) {
return this.el.doctype
} else {
return this.getOwnerDocument().getDoctype()
}
}
setDoctype (qualifiedNameStr, publicId, systemId) {
const ownerDocument = this._getNativeOwnerDocument()
const oldDocType = ownerDocument.doctype
const newDocType = ownerDocument.implementation.createDocumentType(
qualifiedNameStr, publicId, systemId
)
if (oldDocType) {
oldDocType.parentNode.replaceChild(newDocType, oldDocType)
} else {
ownerDocument.insertBefore(newDocType, ownerDocument.firstChild)
}
}
isTextNode () {
return (this.el.nodeType === window.Node.TEXT_NODE)
}
isElementNode () {
return (this.el.nodeType === window.Node.ELEMENT_NODE)
}
isCommentNode () {
return (this.el.nodeType === window.Node.COMMENT_NODE)
}
isDocumentNode () {
return (this.el.nodeType === window.Node.DOCUMENT_NODE)
}
hasClass (className) {
return this.el.classList.contains(className)
}
addClass (className) {
this.el.classList.add(className)
return this
}
removeClass (className) {
this.el.classList.remove(className)
return this
}
hasAttribute (name) {
return this.el.hasAttribute(name)
}
getAttribute (name) {
// NOTE: returning undefined if the attribute is not present
// The native implementation returns null
if (this.el.hasAttribute(name)) {
return this.el.getAttribute(name)
}
}
setAttribute (name, value) {
this.el.setAttribute(name, String(value))
return this
}
removeAttribute (name) {
this.el.removeAttribute(name)
return this
}
getAttributes () {
if (!this.el.attributes._mapAdapter) {
this.el.attributes._mapAdapter = new AttributesMapAdapter(this.el.attributes)
}
return this.el.attributes._mapAdapter
}
getProperty (name) {
return this.el[name]
}
setProperty (name, value) {
// ATTENTION: element properties are only used on HTML elements, such as the 'value' of an <input> element
// In XML there are only attributes
if (this._isXML()) throw new Error('setProperty() is only supported for HTML elements.')
this.el[name] = value
return this
}
getTagName () {
// it is convenient in HTML mode to always use tagName in lower case
// however, in XML this is not allowed, as tag names are case sensitive there
if (this._isXML()) {
return this.el.tagName
} else if (this.el.tagName) {
return this.el.tagName.toLowerCase()
}
}
setTagName (tagName) {
const newEl = this.createElement(tagName)
const attributes = this.el.attributes
const l = attributes.length
let i
for (i = 0; i < l; i++) {
const attr = attributes.item(i)
newEl.setAttribute(attr.name, attr.value)
}
if (this.eventListeners) {
this.eventListeners.forEach(function (listener) {
newEl.addEventListener(listener.eventName, listener.handler, listener.capture)
})
}
newEl.append(this.getChildNodes())
this._replaceNativeEl(newEl.getNativeElement())
return this
}
getId () {
return this.el.id
}
setId (id) {
this.el.id = id
return this
}
getStyle (name) {
let val = this.el.style[name]
if (!val) {
const computedStyle = this.getComputedStyle()
val = computedStyle[name]
}
return val
}
getComputedStyle () {
return window.getComputedStyle(this.el)
}
setStyle (name, value) {
if (DOMElement.pxStyles[name] && isNumber(value)) value = value + 'px'
this.el.style[name] = value
return this
}
getTextContent () {
return this.el.textContent
}
setTextContent (text) {
this.el.textContent = text
return this
}
getInnerHTML () {
if (this._isXML()) {
const xs = new window.XMLSerializer()
const result = Array.prototype.map.call(this.el.childNodes, c => xs.serializeToString(c))
return result.join('')
} else {
return this.el.innerHTML
}
}
setInnerHTML (html) {
// TODO: if in some cases we need to use XMLSerializer to get the innerHTML
// then we probably need to use DOMParser here accordingly
this.el.innerHTML = html
return this
}
getOuterHTML () {
// NOTE: this was necessary in some browsers, which did not provide
// el.outerHTML for XML elements
if (this._isXML()) {
const xs = new window.XMLSerializer()
return xs.serializeToString(this.el)
} else {
return this.el.outerHTML
}
}
_addEventListenerNative (listener) {
this.el.addEventListener(listener.eventName, listener.handler, listener.capture)
}
_removeEventListenerNative (listener) {
this.el.removeEventListener(listener.eventName, listener.handler)
}
getEventListeners () {
return this.eventListeners || []
}
getChildCount () {
return this.el.childNodes.length
}
getChildNodes () {
const childNodes = []
for (let node = this.el.firstChild; node; node = node.nextSibling) {
childNodes.push(BrowserDOMElement.wrap(node))
}
return childNodes
}
getChildNodeIterator () {
return new BrowserChildNodeIterator(this.el)
}
get childNodes () {
return this.getChildNodes()
}
getChildren () {
// Some browsers don't filter elements here and also include text nodes,
// that why we can't use el.children
const children = []
for (let node = this.el.firstChild; node; node = node.nextSibling) {
if (node.nodeType === window.Node.ELEMENT_NODE) {
children.push(BrowserDOMElement.wrap(node))
}
}
return children
}
get children () {
return this.getChildren()
}
getChildAt (pos) {
return BrowserDOMElement.wrap(this.el.childNodes[pos])
}
getChildIndex (child) {
/* istanbul ignore next */
if (!child._isBrowserDOMElement) {
throw new Error('Expecting a BrowserDOMElement instance.')
}
return Array.prototype.indexOf.call(this.el.childNodes, child.el)
}
getFirstChild () {
const firstChild = this.el.firstChild
/* istanbul ignore else */
if (firstChild) {
return BrowserDOMElement.wrap(firstChild)
} else {
return null
}
}
getLastChild () {
var lastChild = this.el.lastChild
/* istanbul ignore else */
if (lastChild) {
return BrowserDOMElement.wrap(lastChild)
} else {
return null
}
}
getNextSibling () {
const next = this.el.nextSibling
/* istanbul ignore else */
if (next) {
return BrowserDOMElement.wrap(next)
} else {
return null
}
}
getPreviousSibling () {
const previous = this.el.previousSibling
/* istanbul ignore else */
if (previous) {
return BrowserDOMElement.wrap(previous)
} else {
return null
}
}
clone (deep) {
const clone = this.el.cloneNode(deep)
return BrowserDOMElement.wrap(clone)
}
createDocument (format, opts) {
return BrowserDOMElement.createDocument(format, opts)
}
createElement (tagName) {
const doc = this._getNativeOwnerDocument()
const el = doc.createElement(tagName)
return BrowserDOMElement.wrap(el)
}
createTextNode (text) {
const doc = this._getNativeOwnerDocument()
const el = doc.createTextNode(text)
return BrowserDOMElement.wrap(el)
}
createComment (data) {
const doc = this._getNativeOwnerDocument()
const el = doc.createComment(data)
return BrowserDOMElement.wrap(el)
}
createProcessingInstruction (name, data) {
const doc = this._getNativeOwnerDocument()
const el = doc.createProcessingInstruction(name, data)
return BrowserDOMElement.wrap(el)
}
createCDATASection (data) {
const doc = this._getNativeOwnerDocument()
const el = doc.createCDATASection(data)
return BrowserDOMElement.wrap(el)
}
is (cssSelector) {
// ATTENTION: looking at https://developer.mozilla.org/en/docs/Web/API/Element/matches
// Element.matches might not be supported by some mobile browsers
const el = this.el
/* istanbul ignore else */
if (this.isElementNode()) {
return matches(el, cssSelector)
} else {
return false
}
}
getParent () {
const parent = this.el.parentNode
/* istanbul ignore else */
if (parent) {
return BrowserDOMElement.wrap(parent)
} else {
return null
}
}
getOwnerDocument () {
return BrowserDOMElement.wrap(this._getNativeOwnerDocument())
}
get ownerDocument () {
return this.getOwnerDocument()
}
_getNativeOwnerDocument () {
return (this.isDocumentNode() ? this.el : this.el.ownerDocument)
}
getElementById (id) {
const result = this._getNativeOwnerDocument().getElementById(id)
if (result) {
return BrowserDOMElement.wrap(result)
} else {
return null
}
}
find (cssSelector) {
let result = null
if (this.el.querySelector) {
result = this.el.querySelector(cssSelector)
}
if (result) {
return BrowserDOMElement.wrap(result)
} else {
return null
}
}
findAll (cssSelector) {
let result = []
if (this.el.querySelectorAll) {
result = this.el.querySelectorAll(cssSelector)
}
return Array.prototype.map.call(result, function (el) {
return BrowserDOMElement.wrap(el)
})
}
_normalizeChild (child) {
if (isNil(child)) return child
if (child instanceof window.Node) {
child = BrowserDOMElement.wrap(child)
// Note: element is owned by a different implementation.
// Probably you are using two different versions of Substance on the same element.
// Can't tell if this is bad. For now we continue by wrapping it again
} else if (child._isBrowserDOMElement && !(child instanceof BrowserDOMElement)) {
child = BrowserDOMElement.wrap(child)
} else if (isString(child) || isNumber(child)) {
child = this.createTextNode(child)
}
/* istanbul ignore next */
if (!child || !child._isBrowserDOMElement) {
throw new Error('Illegal child type.')
}
console.assert(_unwrap(child.el) === child, 'The backlink to the wrapper should be consistent')
return child.getNativeElement()
}
appendChild (child) {
const nativeChild = this._normalizeChild(child)
if (nativeChild) {
this.el.appendChild(nativeChild)
}
return this
}
insertAt (pos, child) {
const nativeChild = this._normalizeChild(child)
const childNodes = this.el.childNodes
if (pos >= childNodes.length) {
this.el.appendChild(nativeChild)
} else {
this.el.insertBefore(nativeChild, childNodes[pos])
}
return this
}
insertBefore (child, before) {
/* istanbul ignore next */
if (isNil(before)) {
return this.appendChild(child)
}
if (!before._isBrowserDOMElement) {
throw new Error('insertBefore(): Illegal arguments. "before" must be a BrowserDOMElement instance.')
}
var nativeChild = this._normalizeChild(child)
if (nativeChild) {
this.el.insertBefore(nativeChild, before.el)
}
return this
}
removeAt (pos) {
this.el.removeChild(this.el.childNodes[pos])
return this
}
removeChild (child) {
/* istanbul ignore next */
if (!child || !child._isBrowserDOMElement) {
throw new Error('removeChild(): Illegal arguments. Expecting a BrowserDOMElement instance.')
}
this.el.removeChild(child.el)
return this
}
replaceChild (oldChild, newChild) {
/* istanbul ignore next */
if (!newChild || !oldChild ||
!newChild._isBrowserDOMElement || !oldChild._isBrowserDOMElement) {
throw new Error('replaceChild(): Illegal arguments. Expecting BrowserDOMElement instances.')
}
// Attention: Node.replaceChild has weird semantics
this.el.replaceChild(newChild.el, oldChild.el)
return this
}
empty () {
const el = this.el
while (el.lastChild) {
el.removeChild(el.lastChild)
}
return this
}
remove () {
if (this.el.parentNode) {
this.el.parentNode.removeChild(this.el)
}
return this
}
serialize () {
const outerHTML = this.el.outerHTML
if (isString(outerHTML)) {
return outerHTML
} else {
const xs = new window.XMLSerializer()
return xs.serializeToString(this.el)
}
}
isInDocument () {
let el = this.el
while (el) {
if (el.nodeType === window.Node.DOCUMENT_NODE) {
return true
}
el = el.parentNode
}
}
_replaceNativeEl (newEl) {
console.assert(newEl instanceof window.Node, 'Expecting a native element.')
const oldEl = this.el
const parentNode = oldEl.parentNode
if (parentNode) {
parentNode.replaceChild(newEl, oldEl)
}
this.el = newEl
_detach(oldEl)
_detach(newEl)
_attach(newEl, this)
}
_getChildNodeCount () {
return this.el.childNodes.length
}
focus (opts) {
this.el.focus(opts)
return this
}
select () {
this.el.select()
return this
}
blur () {
this.el.blur()
return this
}
click () {
// ATTENTION: unfortunately there is no way to detect an exception during the native click
// the Browser swallows an error displaying it on console without throwing on the caller side
// I have tried to register a hook once, but this does not work properly, because an exception could happen while bubbling up
// binding to document does not work neither, because the event might be stopped
this.el.click()
return true
}
getWidth () {
const rect = this.el.getClientRects()[0]
if (rect) {
return rect.width
} else {
return 0
}
}
getHeight () {
const rect = this.el.getClientRects()[0]
if (rect) {
return rect.height
} else {
return 0
}
}
getOffset () {
const rect = this.el.getBoundingClientRect()
return {
top: rect.top + document.body.scrollTop,
left: rect.left + document.body.scrollLeft
}
}
getPosition () {
return { left: this.el.offsetLeft, top: this.el.offsetTop }
}
getOuterHeight (withMargin) {
let outerHeight = this.el.offsetHeight
if (withMargin) {
const style = this.getComputedStyle()
outerHeight += parseInt(style.marginTop, 10) + parseInt(style.marginBottom, 10)
}
return outerHeight
}
getContentType () {
return this._getNativeOwnerDocument().contentType
}
_isXML () {
return this.getContentType() === 'application/xml'
}
emit (name, data) {
let event
if (data) {
event = new window.CustomEvent(name, {
detail: data,
bubbles: true,
cancelable: true
})
} else {
event = new window.Event(name, {
bubbles: true,
cancelable: true
})
}
this.el.dispatchEvent(event)
}
}
BrowserDOMElement.prototype._isBrowserDOMElement = true
// TODO: flesh out how options should look like (e.g. XML namespaceURI etc.)
BrowserDOMElement.createDocument = function (format, opts = {}) {
let doc
if (format === 'xml') {
const xmlInstruction = []
if (opts.version) {
xmlInstruction.push(`version="${opts.version}"`)
}
if (opts.encoding) {
xmlInstruction.push(`encoding="${opts.encoding}"`)
}
let xmlStr
if (xmlInstruction.length > 0) {
xmlStr = `<?xml ${xmlInstruction.join(' ')}?><dummy/>`
} else {
xmlStr = '<dummy/>'
}
// HACK: didn't find a way to create an empty XML doc without a root element
doc = (new window.DOMParser()).parseFromString(xmlStr, 'application/xml')
// remove the
doc.removeChild(doc.firstChild)
} else {
doc = (new window.DOMParser()).parseFromString(DOMElement.EMPTY_HTML, 'text/html')
}
return BrowserDOMElement.wrap(doc)
}
BrowserDOMElement.parseMarkup = function (str, format, options = {}) {
if (!str) {
return BrowserDOMElement.createDocument(format)
}
if (options.snippet) {
str = `<div id='__snippet__'>${str}</div>`
}
let doc
const parser = new window.DOMParser()
if (format === 'html') {
doc = BrowserDOMElement.wrap(
_check(
parser.parseFromString(str, 'text/html')
)
)
} else if (format === 'xml') {
doc = BrowserDOMElement.wrap(
_check(
parser.parseFromString(str, 'application/xml')
)
)
}
if (options.snippet) {
const childNodes = doc.find('#__snippet__').childNodes
if (childNodes.length === 1) {
return childNodes[0]
} else {
return childNodes
}
} else {
return doc
}
function _check (doc) {
if (doc) {
const parserError = doc.querySelector('parsererror')
if (parserError) {
// extracting a more readable message from parserError
// which is a native DOM element
throw new Error('ParserError: ' + BrowserDOMElement.wrap(parserError).outerHTML)
}
}
return doc
}
}
BrowserDOMElement.wrap =
BrowserDOMElement.wrapNativeElement = function (el) {
if (el) {
const _el = _unwrap(el)
if (_el) {
return _el
} else if (el instanceof window.Node) {
return new BrowserDOMElement(el)
} else if (el._isBrowserDOMElement) {
return el
} else if (el === window) {
return BrowserDOMElement.getBrowserWindow()
}
} else {
return null
}
}
BrowserDOMElement.unwrap = function (nativeEl) {
return _unwrap(nativeEl)
}
/*
Wrapper for the window element exposing DOMElement's EventListener API.
*/
class BrowserWindow {
constructor () {
// Note: not
this.el = window
_attach(window, this)
}
get _isBrowserDOMElement () { return true }
}
BrowserWindow.prototype.getNativeElement = BrowserDOMElement.prototype.getNativeElement
BrowserWindow.prototype.on = BrowserDOMElement.prototype.on
BrowserWindow.prototype.off = BrowserDOMElement.prototype.off
BrowserWindow.prototype.addEventListener = BrowserDOMElement.prototype.addEventListener
BrowserWindow.prototype.removeEventListener = BrowserDOMElement.prototype.removeEventListener
BrowserWindow.prototype._createEventListener = BrowserDOMElement.prototype._createEventListener
BrowserWindow.prototype._addEventListenerNative = BrowserDOMElement.prototype._addEventListenerNative
BrowserWindow.prototype._removeEventListenerNative = BrowserDOMElement.prototype._removeEventListenerNative
BrowserWindow.prototype.getEventListeners = BrowserDOMElement.prototype.getEventListeners
BrowserDOMElement.getBrowserWindow = function () {
if (window[SIGNATURE]) return window[SIGNATURE]
return new BrowserWindow(window)
}
BrowserDOMElement.isReverse = function (anchorNode, anchorOffset, focusNode, focusOffset) {
// the selection is reversed when the focus propertyEl is before
// the anchor el or the computed charPos is in reverse order
if (focusNode && anchorNode) {
if (!BrowserDOMElement.isReverse._r1) {
BrowserDOMElement.isReverse._r1 = window.document.createRange()
BrowserDOMElement.isReverse._r2 = window.document.createRange()
}
const _r1 = BrowserDOMElement.isReverse._r1
const _r2 = BrowserDOMElement.isReverse._r2
_r1.setStart(anchorNode.getNativeElement(), anchorOffset)
_r2.setStart(focusNode.getNativeElement(), focusOffset)
const cmp = _r1.compareBoundaryPoints(window.Range.START_TO_START, _r2)
if (cmp === 1) {
return true
}
}
return false
}
BrowserDOMElement.getWindowSelection = function () {
const nativeSel = window.getSelection()
const result = {
anchorNode: BrowserDOMElement.wrap(nativeSel.anchorNode),
anchorOffset: nativeSel.anchorOffset,
focusNode: BrowserDOMElement.wrap(nativeSel.focusNode),
focusOffset: nativeSel.focusOffset
}
return result
}
function matches (el, selector) {
const elProto = window.Element.prototype
const _matches = (
elProto.matches || elProto.matchesSelector ||
elProto.msMatchesSelector || elProto.webkitMatchesSelector
)
return _matches.call(el, selector)
}
class AttributesMapAdapter {
constructor (attributes) {
this.attributes = attributes
}
get size () {
return this.attributes.length
}
get (name) {
const item = this.attributes.getNamedItem(name)
if (item) {
return item.value
}
}
set (name, value) {
this.attributes.setNamedItem(name, value)
}
forEach (fn) {
const S = this.size
for (let i = 0; i < S; i++) {
const item = this.attributes.item(i)
fn(item.value, item.name)
}
}
map (fn) {
const result = []
this.forEach((val, key) => { result.push(fn(val, key)) })
return result
}
keys () {
return this.map((val, key) => { return key })
}
values () {
return this.map((val) => { return val })
}
entries () {
return this.map((val, key) => { return [key, val] })
}
}
class BrowserChildNodeIterator {
constructor (el) {
this._next = el.firstChild
this._curr = null
}
hasNext () {
return Boolean(this._next)
}
next () {
const next = this._next
this._curr = next
this._next = next.nextSibling
return BrowserDOMElement.wrap(next)
}
back () {
this._next = this._curr
this._curr = this._curr.previousSibling
}
peek () {
return BrowserDOMElement.wrap(this._curr)
}
}