diff --git a/src/Bindings/DomBinding.js b/src/Bindings/DomBinding.js deleted file mode 100644 index e69de29b..00000000 diff --git a/src/Bindings/DomBinding/DomBinding.js b/src/Bindings/DomBinding/DomBinding.js new file mode 100644 index 00000000..06b46c1c --- /dev/null +++ b/src/Bindings/DomBinding/DomBinding.js @@ -0,0 +1,164 @@ +/* global MutationObserver */ + +import Binding from './Binding.js' +import diff from '../Util/simpleDiff.js' +import YXmlFragment from '../../Type/YXml/YXmlFragment.js' +import YXmlHook from '../../Type/YXml/YXmlHook.js' + + +function defaultFilter (nodeName, attrs) { + return attrs +} + +function applyFilter (target, filter, type) { + if (type._deleted) { + return + } + // check if type is a child of this + let isChild = false + let p = type + while (p !== undefined) { + if (p === target) { + isChild = true + break + } + p = p._parent + } + if (!isChild) { + return + } + // filter attributes + const attributes = new Map() + if (type.getAttributes !== undefined) { + let attrs = type.getAttributes() + for (let key in attrs) { + attributes.set(key, attrs[key]) + } + } + let result = filter(type.nodeName, new Map(attributes)) + if (result === null) { + type._delete(this._y) + } else { + attributes.forEach((value, key) => { + if (!result.has(key)) { + type.removeAttribute(key) + } + }) + } +} + +function typeObserver (events) { + this._mutualExclude(() => { + reflectChangesOnDom.call(this, events) + }) +} + +function domObserver (mutations) { + this._mutualExclude(() => { + this._y.transact(() => { + let diffChildren = new Set() + mutations.forEach(mutation => { + const dom = mutation.target + const yxml = this.domToYXml.get(dom._yxml) + if (yxml == null || yxml.constructor === YXmlHook) { + // dom element is filtered + return + } + switch (mutation.type) { + case 'characterData': + var change = diff(yxml.toString(), dom.nodeValue) + yxml.delete(change.pos, change.remove) + yxml.insert(change.pos, change.insert) + break + case 'attributes': + if (yxml.constructor === YXmlFragment) { + break + } + let name = mutation.attributeName + let val = dom.getAttribute(name) + // check if filter accepts attribute + let attributes = new Map() + attributes.set(name, val) + if (this.filter(dom.nodeName, attributes).size > 0 && yxml.constructor !== YXmlFragment) { + if (yxml.getAttribute(name) !== val) { + if (val == null) { + yxml.removeAttribute(name) + } else { + yxml.setAttribute(name, val) + } + } + } + break + case 'childList': + diffChildren.add(mutation.target) + break + } + }) + for (let dom of diffChildren) { + if (dom.yOnChildrenChanged !== undefined) { + dom.yOnChildrenChanged() + } + const yxml = this.domToType.get(dom) + applyChangesFromDom(dom, yxml) + } + }) + }) +} + +/** + * A binding that binds the children of a YXmlFragment to a DOM element. + * + * This binding is automatically destroyed when its parent is deleted. + * + * @example + * const div = document.createElement('div') + * const type = y.define('xml', Y.XmlFragment) + * const binding = new Y.QuillBinding(type, div) + * + */ +export default class DomBinding extends Binding { + /** + * @param {YXmlFragment} type The bind source. This is the ultimate source of + * truth. + * @param {Element} target The bind target. Mirrors the target. + */ + constructor (type, target, opts) { + // Binding handles textType as this.type and domTextarea as this.target + super(type, target) + this.domToType = new Map() + this.typeToDom = new Map() + this.filter = opts.filter || defaultFilter + // set initial value + target.innerHTML = '' + for (let child of type) { + target.insertBefore(child.toDom(this.domToType, this.typeToDom), null) + } + this._typeObserver = typeObserver.bind(this) + this._domObserver = domObserver.bind(this) + type.observe(this._typeObserver) + this._domObserver = domObserver.bind(this) + this._mutationObserver = new MutationObserver(this._domObserver()) + this._mutationObserver.observe(target, { + childList: true, + attributes: true, + characterData: true, + subtree: true + }) + this._beforeTransactionHandler = () => { + this._domObserverListener(this._domObserver.takeRecords()) + } + this._y.on('beforeTransaction', this._beforeTransactionHandler) + } + + /** + * Remove all properties that are handled by this class + */ + destroy () { + this.domToType = null + this.typeToDom = null + this.type.unobserve(this._typeObserver) + this._mutationObserver.disconnect() + this.type._y.off('beforeTransaction', this._beforeTransactionHandler) + super.destroy() + } +} diff --git a/src/Bindings/DomBinding/applyChangesFromDom.js b/src/Bindings/DomBinding/applyChangesFromDom.js new file mode 100644 index 00000000..94578ef7 --- /dev/null +++ b/src/Bindings/DomBinding/applyChangesFromDom.js @@ -0,0 +1,75 @@ +import YXmlHook from '../../YXml/YXmlHook.js' +import { + iterateUntilUndeleted, + removeAssociation, + insertNodeHelper } from './util.js' + +/* + * 1. Check if any of the nodes was deleted + * 2. Iterate over the children. + * 2.1 If a node exists without _yxml property, insert a new node + * 2.2 If _contents.length < dom.childNodes.length, fill the + * rest of _content with childNodes + * 2.3 If a node was moved, delete it and + * recreate a new yxml element that is bound to that node. + * You can detect that a node was moved because expectedId + * !== actualId in the list + */ +export default function applyChangesFromDom (dom, yxml) { + if (yxml == null || yxml === false || yxml.constructor === YXmlHook) { + return + } + const y = yxml._y + const knownChildren = new Set() + for (let child in dom.childNodes) { + const type = knownChildren.get(child) + if (type !== undefined && type !== false) { + knownChildren.add(type) + } + } + // 1. Check if any of the nodes was deleted + yxml.forEach(function (childType) { + if (knownChildren.has(childType) === false) { + childType._delete(y) + } + }) + // 2. iterate + const childNodes = dom.childNodes + const len = childNodes.length + let prevExpectedType = null + let expectedType = iterateUntilUndeleted(yxml._start) + for (let domCnt = 0; domCnt < len; domCnt++) { + const childNode = childNodes[domCnt] + const childType = this.domToYXml.get(childNode) + if (childType != null) { + if (childType === false) { + // should be ignored or is going to be deleted + continue + } + if (expectedType !== null) { + if (expectedType !== childType) { + // 2.3 Not expected node + if (childType._parent !== yxml) { + // child was moved from another parent + // childType is going to be deleted by its previous parent + removeAssociation(this, childNode, this.domToYXml(childNode)) + } else { + // child was moved to a different position. + childType._delete(y) + } + prevExpectedType = insertNodeHelper(yxml, prevExpectedType, childNode) + } else { + // Found expected node + prevExpectedType = expectedType + expectedType = iterateUntilUndeleted(expectedType._right) + } + } else { + // 2.2 Fill _content with child nodes + prevExpectedType = insertNodeHelper(yxml, prevExpectedType, childNode) + } + } else { + // 2.1 A new node was found + prevExpectedType = insertNodeHelper(yxml, prevExpectedType, childNode) + } + } +} diff --git a/src/Bindings/DomBinding/util.js b/src/Bindings/DomBinding/util.js new file mode 100644 index 00000000..cc2e3e64 --- /dev/null +++ b/src/Bindings/DomBinding/util.js @@ -0,0 +1,26 @@ + +export function iterateUntilUndeleted (item) { + while (item !== null && item._deleted) { + item = item._right + } + return item +} + +export function removeAssociation (domBinding, dom, type) { + domBinding.domToType.delete(dom) + domBinding.typeToDom.delete(type) +} + +export function createAssociation (domBinding, dom, type) { + domBinding.domToType.set(dom, type) + domBinding.typeToDom.set(type, dom) +} + +function insertNodeHelper (yxml, prevExpectedNode, child) { + let insertedNodes = yxml.insertDomElementsAfter(prevExpectedNode, [child]) + if (insertedNodes.length > 0) { + return insertedNodes[0] + } else { + return prevExpectedNode + } +} diff --git a/src/Bindings/QuillBinding.js b/src/Bindings/QuillBinding/QuillBinding.js similarity index 100% rename from src/Bindings/QuillBinding.js rename to src/Bindings/QuillBinding/QuillBinding.js diff --git a/src/Bindings/TextareaBinding.js b/src/Bindings/TextareaBinding/TextareaBinding.js similarity index 93% rename from src/Bindings/TextareaBinding.js rename to src/Bindings/TextareaBinding/TextareaBinding.js index 829bfe9d..b09fb0a7 100644 --- a/src/Bindings/TextareaBinding.js +++ b/src/Bindings/TextareaBinding/TextareaBinding.js @@ -27,12 +27,12 @@ function domObserver () { /** * A binding that binds a YText to a dom textarea. * - * This binding will automatically be destroyed when it's parent is deleted + * This binding is automatically destroyed when its parent is deleted. * * @example * const textare = document.createElement('textarea') * const type = y.define('textarea', Y.Text) - * const binding = new Y.QuillBinding(textarea, type) + * const binding = new Y.QuillBinding(type, textarea) * */ export default class TextareaBinding extends Binding { diff --git a/src/Types/YXml/YXmlFragment.js b/src/Types/YXml/YXmlFragment.js index 8faa3a13..5f98042c 100644 --- a/src/Types/YXml/YXmlFragment.js +++ b/src/Types/YXml/YXmlFragment.js @@ -371,42 +371,7 @@ export default class YXmlFragment extends YArray { } this._y.on('beforeTransaction', beforeTransactionSelectionFixer) this._y.on('afterTransaction', afterTransactionSelectionFixer) - const applyFilter = (type) => { - if (type._deleted) { - return - } - // check if type is a child of this - let isChild = false - let p = type - while (p !== this._y) { - if (p === this) { - isChild = true - break - } - p = p._parent - } - if (!isChild) { - return - } - // filter attributes - let attributes = new Map() - if (type.getAttributes !== undefined) { - let attrs = type.getAttributes() - for (let key in attrs) { - attributes.set(key, attrs[key]) - } - } - let result = this._domFilter(type.nodeName, new Map(attributes)) - if (result === null) { - type._delete(this._y) - } else { - attributes.forEach((value, key) => { - if (!result.has(key)) { - type.removeAttribute(key) - } - }) - } - } + this._y.on('beforeObserverCalls', function (y, transaction) { // apply dom filter to new and changed types transaction.changedTypes.forEach(function (subs, type) { @@ -417,76 +382,6 @@ export default class YXmlFragment extends YArray { }) transaction.newTypes.forEach(applyFilter) }) - // Apply Y.Xml events to dom - this.observeDeep(events => { - reflectChangesOnDom.call(this, events, _document) - }) - // Apply Dom changes on Y.Xml - if (typeof MutationObserver !== 'undefined') { - this._beforeTransactionHandler = () => { - this._domObserverListener(this._domObserver.takeRecords()) - } - this._y.on('beforeTransaction', this._beforeTransactionHandler) - this._domObserverListener = mutations => { - this._mutualExclude(() => { - this._y.transact(() => { - let diffChildren = new Set() - mutations.forEach(mutation => { - const dom = mutation.target - const yxml = dom._yxml - if (yxml == null || yxml.constructor === YXmlHook) { - // dom element is filtered - return - } - switch (mutation.type) { - case 'characterData': - var change = diff(yxml.toString(), dom.nodeValue) - yxml.delete(change.pos, change.remove) - yxml.insert(change.pos, change.insert) - break - case 'attributes': - if (yxml.constructor === YXmlFragment) { - break - } - let name = mutation.attributeName - let val = dom.getAttribute(name) - // check if filter accepts attribute - let attributes = new Map() - attributes.set(name, val) - if (this._domFilter(dom.nodeName, attributes).size > 0 && yxml.constructor !== YXmlFragment) { - if (yxml.getAttribute(name) !== val) { - if (val == null) { - yxml.removeAttribute(name) - } else { - yxml.setAttribute(name, val) - } - } - } - break - case 'childList': - diffChildren.add(mutation.target) - break - } - }) - for (let dom of diffChildren) { - if (dom.yOnChildrenChanged !== undefined) { - dom.yOnChildrenChanged() - } - if (dom._yxml != null && dom._yxml !== false) { - applyChangesFromDom(dom) - } - } - }) - }) - } - this._domObserver = new MutationObserver(this._domObserverListener) - this._domObserver.observe(dom, { - childList: true, - attributes: true, - characterData: true, - subtree: true - }) - } return dom } diff --git a/src/Types/YXml/YXmlTreeWalker.js b/src/Types/YXml/YXmlTreeWalker.js new file mode 100644 index 00000000..84d43f02 --- /dev/null +++ b/src/Types/YXml/YXmlTreeWalker.js @@ -0,0 +1,72 @@ +import YXmlFragment from './YXmlFragment.js' + +/** + * Define the elements to which a set of CSS queries apply. + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors|CSS_Selectors} + * + * @example + * query = '.classSelector' + * query = 'nodeSelector' + * query = '#idSelector' + * + * @typedef {string} CSS_Selector + */ + +/** + * Represents a subset of the nodes of a YXmlElement / YXmlFragment and a + * position within them. + * + * Can be created with {@link YXmlFragment#createTreeWalker} + */ +export default class YXmlTreeWalker { + constructor (root, f) { + this._filter = f || (() => true) + this._root = root + this._currentNode = root + this._firstCall = true + } + [Symbol.iterator] () { + return this + } + /** + * Get the next node. + * + * @return {YXmlElement} The next node. + */ + next () { + let n = this._currentNode + if (this._firstCall) { + this._firstCall = false + if (!n._deleted && this._filter(n)) { + return { value: n, done: false } + } + } + do { + if (!n._deleted && (n.constructor === YXmlFragment._YXmlElement || n.constructor === YXmlFragment) && n._start !== null) { + // walk down in the tree + n = n._start + } else { + // walk right or up in the tree + while (n !== this._root) { + if (n._right !== null) { + n = n._right + break + } + n = n._parent + } + if (n === this._root) { + n = null + } + } + if (n === this._root) { + break + } + } while (n !== null && (n._deleted || !this._filter(n))) + this._currentNode = n + if (n === null) { + return { done: true } + } else { + return { value: n, done: false } + } + } +} diff --git a/src/Types/YXml/utils.js b/src/Types/YXml/utils.js index 3db5b61b..f405d8a3 100644 --- a/src/Types/YXml/utils.js +++ b/src/Types/YXml/utils.js @@ -55,88 +55,6 @@ export function fixScrollPosition (scrollElement, fix) { } } -function iterateUntilUndeleted (item) { - while (item !== null && item._deleted) { - item = item._right - } - return item -} - -function _insertNodeHelper (yxml, prevExpectedNode, child) { - let insertedNodes = yxml.insertDomElementsAfter(prevExpectedNode, [child]) - if (insertedNodes.length > 0) { - return insertedNodes[0] - } else { - return prevExpectedNode - } -} - -/* - * 1. Check if any of the nodes was deleted - * 2. Iterate over the children. - * 2.1 If a node exists without _yxml property, insert a new node - * 2.2 If _contents.length < dom.childNodes.length, fill the - * rest of _content with childNodes - * 2.3 If a node was moved, delete it and - * recreate a new yxml element that is bound to that node. - * You can detect that a node was moved because expectedId - * !== actualId in the list - */ -export function applyChangesFromDom (dom) { - const yxml = dom._yxml - if (yxml.constructor === YXmlHook) { - return - } - const y = yxml._y - let knownChildren = - new Set( - Array.prototype.map.call(dom.childNodes, child => child._yxml) - .filter(id => id !== undefined) - ) - // 1. Check if any of the nodes was deleted - yxml.forEach(function (childType, i) { - if (!knownChildren.has(childType)) { - childType._delete(y) - } - }) - // 2. iterate - let childNodes = dom.childNodes - let len = childNodes.length - let prevExpectedNode = null - let expectedNode = iterateUntilUndeleted(yxml._start) - for (let domCnt = 0; domCnt < len; domCnt++) { - const child = childNodes[domCnt] - const childYXml = child._yxml - if (childYXml != null) { - if (childYXml === false) { - // should be ignored or is going to be deleted - continue - } - if (expectedNode !== null) { - if (expectedNode !== childYXml) { - // 2.3 Not expected node - if (childYXml._parent !== this) { - // element is going to be deleted by its previous parent - child._yxml = null - } else { - childYXml._delete(y) - } - prevExpectedNode = _insertNodeHelper(yxml, prevExpectedNode, child) - } else { - prevExpectedNode = expectedNode - expectedNode = iterateUntilUndeleted(expectedNode._right) - } - // if this is the expected node id, just continue - } else { - // 2.2 fill _conten with child nodes - prevExpectedNode = _insertNodeHelper(yxml, prevExpectedNode, child) - } - } else { - // 2.1 A new node was found - prevExpectedNode = _insertNodeHelper(yxml, prevExpectedNode, child) - } - } -} export function reflectChangesOnDom (events, _document) { // Make sure that no filtered attributes are applied to the structure