implemented undo 🙌

This commit is contained in:
Kevin Jahns 2017-10-30 11:33:00 +01:00
parent c545118637
commit 0208d83f91
16 changed files with 302 additions and 109 deletions

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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.

View File

@ -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
}

View File

@ -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
}

View File

@ -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)

View File

@ -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()
}
}

View File

@ -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

View File

@ -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 = {}

View File

@ -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
}

View File

@ -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

View File

@ -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)

View File

@ -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') {

View File

@ -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

92
src/Util/UndoManager.js Normal file
View File

@ -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)
}
}
}
})
}
}

View File

@ -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