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.
847 lines (728 loc) • 21 kB
JavaScript
import { isString, isNumber, isNil, uuid } from '../util'
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) {
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) {
let ownerDocument = this._getNativeOwnerDocument()
let oldDocType = ownerDocument.doctype
let newDocType = ownerDocument.implementation.createDocumentType(
qualifiedNameStr, publicId, systemId
)
if (oldDocType) {
oldDocType.parentNode.replaceChild(newDocType, oldDocType)
} else {
ownerDocument.appendChild(newDocType)
}
}
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
}
getAttribute(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.')
if (!this._changedProperties) this._changedProperties = new Set()
// NOTE: there is no removeProperty()
// thus we are clearing our property when value=undefined
if (value === undefined) {
this._changedProperties.delete(name)
} else {
this._changedProperties.add(name)
}
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) {
let newEl = this.createElement(tagName)
let attributes = this.el.attributes
let l = attributes.length
let i
for(i = 0; i < l; i++) {
let attr = attributes.item(i)
newEl.setAttribute(attr.name, attr.value)
}
// NOTE: it does not make sense to set properties as they have a dynamic nature
// which depends strongly on the type they are defined on
// if (!this._changedProperties) this._changedProperties = new Set()
// this._changedProperties.forEach((name)=>{
// newEl[name] = this.el[name]
// })
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) {
// NOTE: important to provide computed style, otherwise we don't get inherited styles
let style = this.getComputedStyle()
return style[name] || this.el.style[name]
}
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()) {
let xs = new window.XMLSerializer()
let 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()) {
let 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() {
let childNodes = []
for (let node = this.el.firstChild; node; node = node.nextSibling) {
childNodes.push(BrowserDOMElement.wrap(node))
}
return childNodes
}
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
let 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() {
let 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() {
let next = this.el.nextSibling
/* istanbul ignore else */
if (next) {
return BrowserDOMElement.wrap(next)
} else {
return null
}
}
getPreviousSibling() {
let previous = this.el.previousSibling
/* istanbul ignore else */
if (previous) {
return BrowserDOMElement.wrap(previous)
} else {
return null
}
}
clone(deep) {
let clone = this.el.cloneNode(deep)
return BrowserDOMElement.wrap(clone)
}
createDocument(format) {
return BrowserDOMElement.createDocument(format)
}
createElement(tagName) {
let doc = this._getNativeOwnerDocument()
let el = doc.createElement(tagName)
return BrowserDOMElement.wrap(el)
}
createTextNode(text) {
let doc = this._getNativeOwnerDocument()
let el = doc.createTextNode(text)
return BrowserDOMElement.wrap(el)
}
createComment(data) {
let doc = this._getNativeOwnerDocument()
let el = doc.createComment(data)
return BrowserDOMElement.wrap(el)
}
createProcessingInstruction(name, data) {
let doc = this._getNativeOwnerDocument()
let el = doc.createProcessingInstruction(name, data)
return BrowserDOMElement.wrap(el)
}
createCDATASection(data) {
let doc = this._getNativeOwnerDocument()
let 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
let el = this.el
/* istanbul ignore else */
if (this.isElementNode()) {
return matches(el, cssSelector)
} else {
return false
}
}
getParent() {
let 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)
}
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) {
let nativeChild = this._normalizeChild(child)
if (nativeChild) {
this.el.appendChild(nativeChild)
}
return this
}
insertAt(pos, child) {
let nativeChild = this._normalizeChild(child)
let 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() {
let 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() {
let outerHTML = this.el.outerHTML
if (isString(outerHTML)) {
return outerHTML
} else {
let 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.")
let oldEl = this.el
let parentNode = oldEl.parentNode
if (parentNode) {
parentNode.replaceChild(newEl, oldEl)
}
this.el = newEl
_detach(oldEl)
_attach(newEl, this)
}
_getChildNodeCount() {
return this.el.childNodes.length
}
focus() {
this.el.focus()
return this
}
select() {
this.el.select()
return this
}
blur() {
this.el.focus()
return this
}
click() {
this.el.click()
return this
}
getWidth() {
let rect = this.el.getClientRects()[0]
if (rect) {
return rect.width
} else {
return 0
}
}
getHeight() {
let rect = this.el.getClientRects()[0]
if (rect) {
return rect.height
} else {
return 0
}
}
getOffset() {
let 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) {
let 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 })
} else {
event = new window.Event(name)
}
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) {
let doc
if (format === 'xml') {
// HACK: didn't find a way to create an empty XML doc without a root element
doc = window.document.implementation.createDocument(null, 'dummy')
// 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
let 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) {
let childNodes = doc.find('#__snippet__').childNodes
if (childNodes.length === 1) {
return childNodes[0]
} else {
return childNodes
}
} else {
return doc
}
function _check(doc) {
if (doc) {
let parserError = doc.querySelector('parsererror')
if (parserError) {
throw new Error("ParserError: " + parserError)
}
}
return doc
}
}
BrowserDOMElement.wrap =
BrowserDOMElement.wrapNativeElement = function(el) {
if (el) {
let _el = _unwrap(el)
if (_el) {
return _el
} else if (el instanceof window.Node) {
return new BrowserDOMElement(el)
} else if (el._isBrowserDOMElement) {
return new BrowserDOMElement(el.getNativeElement())
} 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
window.__BrowserDOMElementWrapper__ = this
}
}
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)
let cmp = _r1.compareBoundaryPoints(window.Range.START_TO_START, _r2)
if (cmp === 1) {
return true
}
}
return false
}
BrowserDOMElement.getWindowSelection = function() {
let nativeSel = window.getSelection()
let result = {
anchorNode: BrowserDOMElement.wrap(nativeSel.anchorNode),
anchorOffset: nativeSel.anchorOffset,
focusNode: BrowserDOMElement.wrap(nativeSel.focusNode),
focusOffset: nativeSel.focusOffset
}
return result
}
function matches(el, selector) {
let elProto = window.Element.prototype
let _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) {
let 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) {
let 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] })
}
}