diff --git a/examples/html-editor/index.js b/examples/html-editor/index.js index 4d8fe7ab..307cb34a 100644 --- a/examples/html-editor/index.js +++ b/examples/html-editor/index.js @@ -10,8 +10,23 @@ let y = new Y({ } }) window.yXml = y +window.yXmlType = y.get('xml', Y.XmlFragment) window.onload = function () { console.log('start!') // Bind children of XmlFragment to the document.body - y.get('xml', Y.XmlFragment).bindToDom(document.body) + window.yXmlType.bindToDom(document.body) +} +window.undoManager = new Y.utils.UndoManager(window.yXmlType) + +document.onkeydown = function interceptUndoRedo (e) { + if (e.keyCode === 90 && e.ctrlKey) { + if (!e.shiftKey) { + console.info('Undo!') + window.undoManager.undo() + } else { + console.info('Redo!') + window.undoManager.redo() + } + e.preventDefault() + } } diff --git a/src/Store/StateStore.js b/src/Store/StateStore.js index a4492f2c..0d547e87 100644 --- a/src/Store/StateStore.js +++ b/src/Store/StateStore.js @@ -38,6 +38,10 @@ export default class StateStore { } setState (user, state) { // TODO: modify missingi structs here + const beforeState = this.y._transaction.beforeState + if (!beforeState.has(user)) { + beforeState.set(user, this.getState(user)) + } this.state.set(user, state) } } diff --git a/src/Struct/Item.js b/src/Struct/Item.js index ef6e41b4..62a598f3 100644 --- a/src/Struct/Item.js +++ b/src/Struct/Item.js @@ -52,6 +52,19 @@ export default class Item { this._parentSub = null this._deleted = false } + /** + * Copy the effect of struct + */ + _copy () { + let struct = new this.constructor() + struct._origin = this._left + struct._left = this._left + struct._right = this + struct._right_origin = this + struct._parent = this._parent + struct._parentSub = this._parentSub + return struct + } get _lastId () { return new ID(this._id.user, this._id.clock + this._length - 1) } @@ -83,6 +96,7 @@ export default class Item { del._integrate(y, true) } transactionTypeChanged(y, this._parent, this._parentSub) + y._transaction.deletedStructs.add(this) } /** * This is called right before this struct receives any children. diff --git a/src/Struct/ItemJSON.js b/src/Struct/ItemJSON.js index 3c5fba14..8c6610bc 100644 --- a/src/Struct/ItemJSON.js +++ b/src/Struct/ItemJSON.js @@ -6,6 +6,11 @@ export default class ItemJSON extends Item { super() this._content = null } + _copy () { + let struct = super._copy() + struct._content = this._content + return struct + } get _length () { return this._content.length } diff --git a/src/Struct/ItemString.js b/src/Struct/ItemString.js index 3409ae42..50ea4839 100644 --- a/src/Struct/ItemString.js +++ b/src/Struct/ItemString.js @@ -6,6 +6,11 @@ export default class ItemString extends Item { super() this._content = null } + _copy () { + let struct = super._copy() + struct._content = this._content + return struct + } get _length () { return this._content.length } diff --git a/src/Struct/Type.js b/src/Struct/Type.js index a865d4b3..bf1952cd 100644 --- a/src/Struct/Type.js +++ b/src/Struct/Type.js @@ -37,6 +37,48 @@ export default class Type extends Item { this._start = null this._y = null this._eventHandler = new EventHandler() + this._deepEventHandler = new EventHandler() + } + _callEventHandler (event) { + this._eventHandler.callEventListeners(event) + let type = this + while (type !== this._y) { + type._deepEventHandler.callEventListeners(event) + type = type._parent + } + } + _copy (undeleteChildren) { + let copy = super._copy() + let map = new Map() + copy._map = map + for (let [key, value] of this._map) { + if (undeleteChildren.has(value) || !value.deleted) { + let _item = value._copy(undeleteChildren) + _item._parent = copy + map.set(key, value._copy(undeleteChildren)) + } + } + let prevUndeleted = null + copy._start = null + let item = this._start + while (item !== null) { + if (undeleteChildren.has(item) || !item.deleted) { + let _item = item._copy(undeleteChildren) + _item._left = prevUndeleted + _item._origin = prevUndeleted + _item._right = null + _item._right_origin = null + _item._parent = copy + if (prevUndeleted === null) { + copy._start = _item + } else { + prevUndeleted._right = _item + } + prevUndeleted = _item + } + item = item._right + } + return copy } _transact (f) { const y = this._y @@ -49,9 +91,15 @@ export default class Type extends Item { observe (f) { this._eventHandler.addEventListener(f) } + observeDeep (f) { + this._deepEventHandler.addEventListener(f) + } unobserve (f) { this._eventHandler.removeEventListener(f) } + unobserveDeep (f) { + this._deepEventHandler.removeEventListener(f) + } _integrate (y) { y._transaction.newTypes.add(this) super._integrate(y) diff --git a/src/Transaction.js b/src/Transaction.js index 95cd3a5c..3376620a 100644 --- a/src/Transaction.js +++ b/src/Transaction.js @@ -5,8 +5,10 @@ export default class Transaction { // types added during transaction this.newTypes = new Set() // changed types (does not include new types) - // maps from type to parentSubs (item.parentSub = null for array elements) + // maps from type to parentSubs (item._parentSub = null for array elements) this.changedTypes = new Map() + this.deletedStructs = new Set() + this.beforeState = new Map() } } diff --git a/src/Type/YArray.js b/src/Type/YArray.js index 58b60f15..85a9bb88 100644 --- a/src/Type/YArray.js +++ b/src/Type/YArray.js @@ -11,7 +11,7 @@ class YArrayEvent { export default class YArray extends Type { _callObserver (parentSubs, remote) { - this._eventHandler.callEventListeners(new YArrayEvent(this, remote)) + this._callEventHandler(new YArrayEvent(this, remote)) } get (pos) { let n = this._start diff --git a/src/Type/YMap.js b/src/Type/YMap.js index 8c76686b..ccab1010 100644 --- a/src/Type/YMap.js +++ b/src/Type/YMap.js @@ -13,7 +13,7 @@ class YMapEvent { export default class YMap extends Type { _callObserver (parentSubs, remote) { - this._eventHandler.callEventListeners(new YMapEvent(this, parentSubs, remote)) + this._callEventHandler(new YMapEvent(this, parentSubs, remote)) } toJSON () { const map = {} diff --git a/src/Type/y-xml/YXmlElement.js b/src/Type/y-xml/YXmlElement.js index 9cc664d7..64eb7a6f 100644 --- a/src/Type/y-xml/YXmlElement.js +++ b/src/Type/y-xml/YXmlElement.js @@ -1,5 +1,3 @@ -/* global MutationObserver */ - // import diff from 'fast-diff' import { defaultDomFilter } from './utils.js' @@ -23,12 +21,18 @@ export default class YXmlElement extends YXmlFragment { this._domFilter = arg2 } } + _copy (undeleteChildren) { + let struct = super._copy(undeleteChildren) + struct.nodeName = this.nodeName + return struct + } _setDom (dom) { if (this._dom != null) { throw new Error('Only call this method if you know what you are doing ;)') } else if (dom._yxml != null) { // TODO do i need to check this? - no.. but for dev purps.. throw new Error('Already bound to an YXml type') } else { + this._dom = dom dom._yxml = this // tag is already set in constructor // set attributes @@ -43,9 +47,7 @@ export default class YXmlElement extends YXmlFragment { this.setAttribute(attrName, attrValue) } this.insertDomElements(0, Array.prototype.slice.call(dom.childNodes)) - if (MutationObserver != null) { - this._dom = this._bindToDom(dom) - } + this._bindToDom(dom) return dom } } @@ -112,6 +114,7 @@ export default class YXmlElement extends YXmlFragment { let dom = this._dom if (dom == null) { dom = document.createElement(this.nodeName) + this._dom = dom dom._yxml = this let attrs = this.getAttributes() for (let key in attrs) { @@ -120,9 +123,7 @@ export default class YXmlElement extends YXmlFragment { this.forEach(yxml => { dom.appendChild(yxml.getDom()) }) - if (MutationObserver !== null) { - this._dom = this._bindToDom(dom) - } + this._bindToDom(dom) } return dom } diff --git a/src/Type/y-xml/YXmlFragment.js b/src/Type/y-xml/YXmlFragment.js index 7f6519fc..ffa3f558 100644 --- a/src/Type/y-xml/YXmlFragment.js +++ b/src/Type/y-xml/YXmlFragment.js @@ -1,11 +1,13 @@ /* global MutationObserver */ import { defaultDomFilter, applyChangesFromDom, reflectChangesOnDom } from './utils.js' +import { beforeTransactionSelectionFixer, afterTransactionSelectionFixer } from './selection.js' import YArray from '../YArray.js' import YXmlText from './YXmlText.js' import YXmlEvent from './YXmlEvent.js' import { logID } from '../../MessageHandler/messageToString.js' +import diff from 'fast-diff' function domToYXml (parent, doms) { const types = [] @@ -52,8 +54,6 @@ export default class YXmlFragment extends YArray { token = true } } - // Apply Y.Xml events to dom - this.observe(reflectChangesOnDom) } enableSmartScrolling (scrollElement) { this._scrollElement = scrollElement @@ -68,7 +68,7 @@ export default class YXmlFragment extends YArray { }) } _callObserver (parentSubs, remote) { - this._eventHandler.callEventListeners(new YXmlEvent(this, parentSubs, remote)) + this._callEventHandler(new YXmlEvent(this, parentSubs, remote)) } toString () { return this.map(xml => xml.toString()).join('') @@ -111,57 +111,91 @@ export default class YXmlFragment extends YArray { throw new Error('Not able to bind to a DOM element, because MutationObserver is not available!') } dom.innerHTML = '' + this._dom = dom + dom._yxml = this this.forEach(t => { dom.insertBefore(t.getDom(), null) }) - this._dom = dom - dom._yxml = this this._bindToDom(dom) } // binds to a dom element // Only call if dom and YXml are isomorph _bindToDom (dom) { + if (this._parent === null || this._parent._dom != null || typeof MutationObserver === 'undefined') { + // only bind if parent did not already bind + return + } + this._y.on('beforeTransaction', () => { + this._domObserverListener(this._domObserver.takeRecords()) + }) + this._y.on('beforeTransaction', beforeTransactionSelectionFixer) + this._y.on('afterTransaction', afterTransactionSelectionFixer) + // Apply Y.Xml events to dom + this.observeDeep(reflectChangesOnDom.bind(this)) + // Apply Dom changes on Y.Xml this._domObserverListener = mutations => { this._mutualExclude(() => { this._y.transact(() => { - let diffChildren = false + let diffChildren = new Set() mutations.forEach(mutation => { - if (mutation.type === 'attributes') { - let name = mutation.attributeName - // check if filter accepts attribute - if (this._domFilter(this._dom, [name]).length > 0) { - var val = mutation.target.getAttribute(name) - if (this.getAttribute(name) !== val) { - if (val == null) { - this.removeAttribute(name) - } else { - this.setAttribute(name, val) + const dom = mutation.target + const yxml = dom._yxml + if (yxml == null) { + // dom element is filtered + return + } + switch (mutation.type) { + case 'characterData': + var diffs = diff(yxml.toString(), dom.nodeValue) + var pos = 0 + for (var i = 0; i < diffs.length; i++) { + var d = diffs[i] + if (d[0] === 0) { // EQUAL + pos += d[1].length + } else if (d[0] === -1) { // DELETE + yxml.delete(pos, d[1].length) + } else { // INSERT + yxml.insert(pos, d[1]) + pos += d[1].length } } - } - } else if (mutation.type === 'childList') { - diffChildren = true + break + case 'attributes': + let name = mutation.attributeName + // check if filter accepts attribute + if (this._domFilter(dom, [name]).length > 0) { + var val = dom.getAttribute(name) + if (yxml.getAttribute(name) !== val) { + if (val == null) { + yxml.removeAttribute(name) + } else { + yxml.setAttribute(name, val) + } + } + } + break + case 'childList': + diffChildren.add(mutation.target) + break } }) - if (diffChildren) { - applyChangesFromDom(this) + for (let dom of diffChildren) { + if (dom._yxml != null) { + applyChangesFromDom(dom) + } } }) }) } this._domObserver = new MutationObserver(this._domObserverListener) - const observeOptions = { childList: true } - if (this instanceof YXmlFragment._YXmlElement) { - observeOptions.attributes = true - } - this._domObserver.observe(dom, observeOptions) + this._domObserver.observe(dom, { + childList: true, + attributes: true, + characterData: true, + subtree: true + }) return dom } - _beforeChange () { - if (this._domObserver != null) { - this._domObserverListener(this._domObserver.takeRecords()) - } - } _logString () { const left = this._left !== null ? this._left._lastId : null const origin = this._origin !== null ? this._origin._lastId : null diff --git a/src/Type/y-xml/YXmlText.js b/src/Type/y-xml/YXmlText.js index cff8f487..e8c48c18 100644 --- a/src/Type/y-xml/YXmlText.js +++ b/src/Type/y-xml/YXmlText.js @@ -1,9 +1,4 @@ -/* global MutationObserver */ - -import diff from 'fast-diff' import YText from '../YText.js' -import { getAnchorViewPosition, fixScrollPosition, getBoundingClientRect } from './utils.js' -import { beforeTransactionSelectionFixer, afterTransactionSelectionFixer } from './selection.js' export default class YXmlText extends YText { constructor (arg1) { @@ -25,6 +20,7 @@ export default class YXmlText extends YText { if (dom !== null) { this._setDom(arg1) } + /* var token = true this._mutualExclude = f => { if (token) { @@ -54,11 +50,7 @@ export default class YXmlText extends YText { }) } }) - } - _integrate (y) { - super._integrate(y) - y.on('beforeTransaction', beforeTransactionSelectionFixer) - y.on('afterTransaction', afterTransactionSelectionFixer) + */ } setDomFilter () {} enableSmartScrolling (scrollElement) { @@ -74,42 +66,15 @@ export default class YXmlText extends YText { // set marker this._dom = dom dom._yxml = this - if (typeof MutationObserver === 'undefined') { - return - } - this._domObserverListener = () => { - this._mutualExclude(() => { - var diffs = diff(this.toString(), dom.nodeValue) - var pos = 0 - for (var i = 0; i < diffs.length; i++) { - var d = diffs[i] - if (d[0] === 0) { // EQUAL - pos += d[1].length - } else if (d[0] === -1) { // DELETE - this.delete(pos, d[1].length) - } else { // INSERT - this.insert(pos, d[1]) - pos += d[1].length - } - } - }) - } - this._domObserver = new MutationObserver(this._domObserverListener) - this._domObserver.observe(this._dom, { characterData: true }) } getDom () { - if (this._dom == null) { + if (this._dom === null) { const dom = document.createTextNode(this.toString()) this._setDom(dom) return dom } return this._dom } - _beforeChange () { - if (this._domObserver != null && this._y !== null) { // TODO: do I need th y condition - this._domObserverListener(this._domObserver.takeRecords()) - } - } _delete (y, createDelete) { this._unbindFromDom() super._delete(y, createDelete) diff --git a/src/Type/y-xml/utils.js b/src/Type/y-xml/utils.js index 0f035323..6c351129 100644 --- a/src/Type/y-xml/utils.js +++ b/src/Type/y-xml/utils.js @@ -1,3 +1,4 @@ +import YXmlText from './YXmlText.js' export function defaultDomFilter (node, attributes) { return attributes @@ -81,11 +82,12 @@ function _insertNodeHelper (yxml, prevExpectedNode, child) { * You can detect that a node was moved because expectedId * !== actualId in the list */ -export function applyChangesFromDom (yxml) { +export function applyChangesFromDom (dom) { + const yxml = dom._yxml const y = yxml._y let knownChildren = new Set( - Array.prototype.map.call(yxml._dom.childNodes, child => child._yxml) + Array.prototype.map.call(dom.childNodes, child => child._yxml) .filter(id => id !== undefined) ) // 1. Check if any of the nodes was deleted @@ -95,7 +97,7 @@ export function applyChangesFromDom (yxml) { } }) // 2. iterate - let childNodes = yxml._dom.childNodes + let childNodes = dom.childNodes let len = childNodes.length let prevExpectedNode = null let expectedNode = iterateUntilUndeleted(yxml._start) @@ -137,33 +139,36 @@ export function reflectChangesOnDom (event) { const yxml = event.target const dom = yxml._dom if (dom != null) { - yxml._mutualExclude(() => { + this._mutualExclude(() => { // TODO: do this once before applying stuff // let anchorViewPosition = getAnchorViewPosition(yxml._scrollElement) - - // update attributes - event.attributesChanged.forEach(attributeName => { - const value = yxml.getAttribute(attributeName) - if (value === undefined) { - dom.removeAttribute(attributeName) - } else { - dom.setAttribute(attributeName, value) - } - }) - if (event.childListChanged) { - // create fragment of undeleted nodes - const fragment = document.createDocumentFragment() - yxml.forEach(function (t) { - fragment.append(t.getDom()) + if (yxml.constructor === YXmlText) { + yxml._dom.nodeValue = yxml.toString() + } else { + // update attributes + event.attributesChanged.forEach(attributeName => { + const value = yxml.getAttribute(attributeName) + if (value === undefined) { + dom.removeAttribute(attributeName) + } else { + dom.setAttribute(attributeName, value) + } }) - // remove remainding nodes - let lastChild = dom.lastChild - while (lastChild !== null) { - dom.removeChild(lastChild) - lastChild = dom.lastChild + if (event.childListChanged) { + // create fragment of undeleted nodes + const fragment = document.createDocumentFragment() + yxml.forEach(function (t) { + fragment.append(t.getDom()) + }) + // remove remainding nodes + let lastChild = dom.lastChild + while (lastChild !== null) { + dom.removeChild(lastChild) + lastChild = dom.lastChild + } + // insert fragment of undeleted nodes + dom.append(fragment) } - // insert fragment of undeleted nodes - dom.append(fragment) } /* TODO: smartscrolling .. else if (event.type === 'childInserted' || event.type === 'insert') { diff --git a/src/Util/EventHandler.js b/src/Util/EventHandler.js index d92d3184..1928adac 100644 --- a/src/Util/EventHandler.js +++ b/src/Util/EventHandler.js @@ -20,7 +20,8 @@ export default class EventHandler { callEventListeners (event) { for (var i = 0; i < this.eventListeners.length; i++) { try { - this.eventListeners[i](event) + const f = this.eventListeners[i] + f(event) } catch (e) { /* Your observer threw an error. This error was caught so that Yjs diff --git a/src/Util/UndoManager.js b/src/Util/UndoManager.js new file mode 100644 index 00000000..aed06735 --- /dev/null +++ b/src/Util/UndoManager.js @@ -0,0 +1,92 @@ +import ID from './ID.js' + +class ReverseOperation { + constructor (y) { + const beforeState = y._transaction.beforeState + this.toState = new ID(y.userID, y.ss.getState(y.userID) - 1) + if (beforeState.has(y.userID)) { + this.fromState = new ID(y.userID, beforeState.get(y.userID)) + } else { + this.fromState = this.toState + } + this.deletedStructs = y._transaction.deletedStructs + } +} + +function isStructInScope (y, struct, scope) { + while (struct !== y) { + if (struct === scope) { + return true + } + struct = struct._parent + } + return false +} + +export default class UndoManager { + constructor (scope) { + this._undoBuffer = [] + this._redoBuffer = [] + this._scope = scope + this._undoing = false + this._redoing = false + const y = scope._y + this.y = y + y.on('afterTransaction', (y, remote) => { + if (!remote && (y._transaction.beforeState.has(y.userID) || y._transaction.deletedStructs.size > 0)) { + let reverseOperation = new ReverseOperation(y) + if (!this._undoing) { + this._undoBuffer.push(reverseOperation) + if (!this._redoing) { + this._redoBuffer = [] + } + } else { + this._redoBuffer.push(reverseOperation) + } + } + }) + } + undo () { + this._undoing = true + this._applyReverseOperation(this._undoBuffer) + this._undoing = false + } + redo () { + this._redoing = true + this._applyReverseOperation(this._redoBuffer) + this._redoing = false + } + _applyReverseOperation (reverseBuffer) { + this.y.transact(() => { + let performedUndo = false + while (!performedUndo && reverseBuffer.length > 0) { + let undoOp = reverseBuffer.pop() + // make sure that it is possible to iterate {from}-{to} + this.y.os.getItemCleanStart(undoOp.fromState) + this.y.os.getItemCleanEnd(undoOp.toState) + this.y.os.iterate(undoOp.fromState, undoOp.toState, op => { + if (!op._deleted && isStructInScope(this.y, op, this._scope)) { + performedUndo = true + op._delete(this.y) + } + }) + for (let op of undoOp.deletedStructs) { + if ( + isStructInScope(this.y, op, this._scope) && + op._parent !== this.y && + !op._parent._deleted && + ( + op._parent._id.user !== this.y.userID || + op._parent._id.clock < undoOp.fromState.clock || + op._parent._id.clock > undoOp.fromState.clock + ) + ) { + performedUndo = true + op = op._copy(undoOp.deletedStructs) + op._integrate(this.y) + } + } + } + }) + } +} diff --git a/src/Y.js b/src/Y.js index 87b4866f..746aeda9 100644 --- a/src/Y.js +++ b/src/Y.js @@ -4,6 +4,7 @@ import StateStore from './Store/StateStore.js' import { generateUserID } from './Util/generateUserID.js' import RootID from './Util/RootID.js' import NamedEventHandler from './Util/NamedEventHandler.js' +import UndoManager from './Util/UndoManager.js' import { messageToString, messageToRoomname } from './MessageHandler/messageToString.js' @@ -132,7 +133,8 @@ Y.XmlFragment = YXmlFragment Y.XmlText = YXmlText Y.utils = { - BinaryDecoder + BinaryDecoder, + UndoManager } Y.debug = debug