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