Compare commits

..

3 Commits

Author SHA1 Message Date
Kevin Jahns
00135dcfab v13.0.0-62 -- distribution files 2018-06-13 00:08:16 +02:00
Kevin Jahns
5cf6f45f19 13.0.0-62 2018-06-13 00:08:01 +02:00
Kevin Jahns
967903673b fixed undo/redo issues and implemented ability to manually flush the UndoManager 2018-06-13 00:06:38 +02:00
18 changed files with 487 additions and 19551 deletions

View File

@@ -20,7 +20,7 @@ let quill = new Quill('#quill-container', {
[{ header: [1, 2, false] }],
['bold', 'italic', 'underline'],
['image', 'code-block'],
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ script: 'sub' }, { script: 'super' }],
['link', 'image'],
['link', 'code-block'],
@@ -31,7 +31,7 @@ let quill = new Quill('#quill-container', {
}
},
placeholder: 'Compose an epic...',
theme: 'snow' // or 'bubble'
theme: 'snow' // or 'bubble'
})
let cursors = quill.getModule('cursors')

View File

@@ -13,7 +13,7 @@ let quill = new Quill('#quill-container', {
[{ header: [1, 2, false] }],
['bold', 'italic', 'underline'],
['image', 'code-block'],
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ script: 'sub' }, { script: 'super' }],
['link', 'image'],
['link', 'code-block'],
@@ -21,7 +21,7 @@ let quill = new Quill('#quill-container', {
]
},
placeholder: 'Compose an epic...',
theme: 'snow' // or 'bubble'
theme: 'snow' // or 'bubble'
})
let yText = y.define('quill', Y.Text)

View File

@@ -35,7 +35,7 @@ Y({
toolbar: [
[{ size: ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline'],
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ script: 'sub' }, { script: 'super' }],
['link', 'image'],
['link', 'code-block'],

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "yjs",
"version": "13.0.0-61",
"version": "13.0.0-62",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "yjs",
"version": "13.0.0-61",
"version": "13.0.0-62",
"description": "A framework for real-time p2p shared editing on any data",
"main": "./y.node.js",
"browser": "./y.js",

View File

@@ -1,8 +1,9 @@
/* global MutationObserver */
/* global MutationObserver, getSelection */
import { fromRelativePosition } from '../../Util/relativePosition.js'
import Binding from '../Binding.js'
import { createAssociation, removeAssociation } from './util.js'
import { beforeTransactionSelectionFixer, afterTransactionSelectionFixer } from './selection.js'
import { beforeTransactionSelectionFixer, afterTransactionSelectionFixer, getCurrentRelativeSelection } from './selection.js'
import { defaultFilter, applyFilterOnType } from './filter.js'
import typeObserver from './typeObserver.js'
import domObserver from './domObserver.js'
@@ -67,16 +68,25 @@ export default class DomBinding extends Binding {
characterData: true,
subtree: true
})
this._currentSel = null
document.addEventListener('selectionchange', () => {
this._currentSel = getCurrentRelativeSelection(this)
})
const y = type._y
this.y = y
// Force flush dom changes before Type changes are applied (they might
// modify the dom)
this._beforeTransactionHandler = (y, transaction, remote) => {
this._domObserver(this._mutationObserver.takeRecords())
beforeTransactionSelectionFixer(y, this, transaction, remote)
this._mutualExclude(() => {
beforeTransactionSelectionFixer(this, remote)
})
}
y.on('beforeTransaction', this._beforeTransactionHandler)
this._afterTransactionHandler = (y, transaction, remote) => {
afterTransactionSelectionFixer(y, this, transaction, remote)
this._mutualExclude(() => {
afterTransactionSelectionFixer(this, remote)
})
// remove associations
// TODO: this could be done more efficiently
// e.g. Always delete using the following approach, or removeAssociation
@@ -115,6 +125,67 @@ export default class DomBinding extends Binding {
// TODO: apply filter to all elements
}
_getUndoStackInfo () {
return this.getSelection()
}
_restoreUndoStackInfo (info) {
this.restoreSelection(info)
}
getSelection () {
return this._currentSel
}
restoreSelection (selection) {
if (selection !== null) {
const { to, from } = selection
let shouldUpdate = false
/**
* There is little information on the difference between anchor/focus and base/extent.
* MDN doesn't even mention base/extent anymore.. though you still have to call
* setBaseAndExtent to change the selection..
* I can observe that base/extend refer to notes higher up in the xml hierachy.
* Espesially for undo/redo this is preferred. If this becomes a problem in the future,
* we should probably go back to anchor/focus.
*/
const browserSelection = getSelection()
let { baseNode, baseOffset, extentNode, extentOffset } = browserSelection
if (from !== null) {
let sel = fromRelativePosition(this.y, from)
if (sel !== null) {
let node = this.typeToDom.get(sel.type)
let offset = sel.offset
if (node !== baseNode || offset !== baseOffset) {
baseNode = node
baseOffset = offset
shouldUpdate = true
}
}
}
if (to !== null) {
let sel = fromRelativePosition(this.y, to)
if (sel !== null) {
let node = this.typeToDom.get(sel.type)
let offset = sel.offset
if (node !== extentNode || offset !== extentOffset) {
extentNode = node
extentOffset = offset
shouldUpdate = true
}
}
}
if (shouldUpdate) {
browserSelection.setBaseAndExtent(
baseNode,
baseOffset,
extentNode,
extentOffset
)
}
}
}
/**
* Remove all properties that are handled by this class.
*/
@@ -130,11 +201,10 @@ export default class DomBinding extends Binding {
super.destroy()
}
}
/**
* A filter defines which elements and attributes to share.
* Return null if the node should be filtered. Otherwise return the Map of
* accepted attributes.
*
* @typedef {function(nodeName: String, attrs: Map): Map|null} FilterFunction
*/
/**
* A filter defines which elements and attributes to share.
* Return null if the node should be filtered. Otherwise return the Map of
* accepted attributes.
*
* @typedef {function(nodeName: String, attrs: Map): Map|null} FilterFunction
*/

View File

@@ -1,84 +1,35 @@
/* globals getSelection */
import { getRelativePosition, fromRelativePosition } from '../../Util/relativePosition.js'
import { getRelativePosition } from '../../Util/relativePosition.js'
let browserSelection = null
let relativeSelection = null
/**
* @private
*/
export let beforeTransactionSelectionFixer
if (typeof getSelection !== 'undefined') {
beforeTransactionSelectionFixer = function _beforeTransactionSelectionFixer (y, domBinding, transaction, remote) {
if (!remote) {
return
}
relativeSelection = { from: null, to: null, fromY: null, toY: null }
browserSelection = getSelection()
const anchorNode = browserSelection.anchorNode
const anchorNodeType = domBinding.domToType.get(anchorNode)
if (anchorNode !== null && anchorNodeType !== undefined) {
relativeSelection.from = getRelativePosition(anchorNodeType, browserSelection.anchorOffset)
relativeSelection.fromY = anchorNodeType._y
}
const focusNode = browserSelection.focusNode
const focusNodeType = domBinding.domToType.get(focusNode)
if (focusNode !== null && focusNodeType !== undefined) {
relativeSelection.to = getRelativePosition(focusNodeType, browserSelection.focusOffset)
relativeSelection.toY = focusNodeType._y
function _getCurrentRelativeSelection (domBinding) {
const { baseNode, baseOffset, extentNode, extentOffset } = getSelection()
const baseNodeType = domBinding.domToType.get(baseNode)
const extentNodeType = domBinding.domToType.get(extentNode)
if (baseNodeType !== undefined && extentNodeType !== undefined) {
return {
from: getRelativePosition(baseNodeType, baseOffset),
to: getRelativePosition(extentNodeType, extentOffset)
}
}
} else {
beforeTransactionSelectionFixer = function _fakeBeforeTransactionSelectionFixer () {}
return null
}
export const getCurrentRelativeSelection = typeof getSelection !== 'undefined' ? _getCurrentRelativeSelection : () => null
export function beforeTransactionSelectionFixer (domBinding, remote) {
if (remote) {
relativeSelection = getCurrentRelativeSelection(domBinding)
}
}
/**
* @private
*/
export function afterTransactionSelectionFixer (y, domBinding, transaction, remote) {
if (relativeSelection === null || !remote) {
return
}
const to = relativeSelection.to
const from = relativeSelection.from
const fromY = relativeSelection.fromY
const toY = relativeSelection.toY
let shouldUpdate = false
let anchorNode = browserSelection.anchorNode
let anchorOffset = browserSelection.anchorOffset
let focusNode = browserSelection.focusNode
let focusOffset = browserSelection.focusOffset
if (from !== null) {
let sel = fromRelativePosition(fromY, from)
if (sel !== null) {
let node = domBinding.typeToDom.get(sel.type)
let offset = sel.offset
if (node !== anchorNode || offset !== anchorOffset) {
anchorNode = node
anchorOffset = offset
shouldUpdate = true
}
}
}
if (to !== null) {
let sel = fromRelativePosition(toY, to)
if (sel !== null) {
let node = domBinding.typeToDom.get(sel.type)
let offset = sel.offset
if (node !== focusNode || offset !== focusOffset) {
focusNode = node
focusOffset = offset
shouldUpdate = true
}
}
}
if (shouldUpdate) {
browserSelection.setBaseAndExtent(
anchorNode,
anchorOffset,
focusNode,
focusOffset
)
export function afterTransactionSelectionFixer (domBinding, remote) {
if (relativeSelection !== null && remote) {
domBinding.restoreSelection(relativeSelection)
}
}

View File

@@ -120,7 +120,7 @@ export default class Item {
*
* @private
*/
_redo (y) {
_redo (y, redoitems) {
if (this._redone !== null) {
return this._redone
}
@@ -130,7 +130,10 @@ export default class Item {
let parent = this._parent
// make sure that parent is redone
if (parent._deleted === true && parent._redone === null) {
parent._redo(y)
// try to undo parent if it will be undone anyway
if (!redoitems.has(parent) || !parent._redo(y, redoitems)) {
return false
}
}
if (parent._redone !== null) {
parent = parent._redone
@@ -157,7 +160,7 @@ export default class Item {
struct._parentSub = this._parentSub
struct._integrate(y)
this._redone = struct
return struct
return true
}
/**

View File

@@ -254,7 +254,7 @@ function deleteText (y, length, parent, left, right, currentAttributes) {
* @typedef {Array<Object>} Delta
*/
/**
/**
* Attributes that can be assigned to a selection of text.
*
* @example

View File

@@ -2,7 +2,7 @@ import ID from './ID/ID.js'
import isParentOf from './isParentOf.js'
class ReverseOperation {
constructor (y, transaction) {
constructor (y, transaction, bindingInfos) {
this.created = new Date()
const beforeState = transaction.beforeState
if (beforeState.has(y.userID)) {
@@ -12,15 +12,26 @@ class ReverseOperation {
this.toState = null
this.fromState = null
}
this.deletedStructs = transaction.deletedStructs
this.deletedStructs = new Set()
transaction.deletedStructs.forEach(struct => {
this.deletedStructs.add({
from: struct._id,
len: struct._length
})
})
/**
* Maps from binding to binding information (e.g. cursor information)
*/
this.bindingInfos = bindingInfos
}
}
function applyReverseOperation (y, scope, reverseBuffer) {
let performedUndo = false
let undoOp
y.transact(() => {
while (!performedUndo && reverseBuffer.length > 0) {
let undoOp = reverseBuffer.pop()
undoOp = reverseBuffer.pop()
// make sure that it is possible to iterate {from}-{to}
if (undoOp.fromState !== null) {
y.os.getItemCleanStart(undoOp.fromState)
@@ -35,23 +46,39 @@ function applyReverseOperation (y, scope, reverseBuffer) {
}
})
}
for (let op of undoOp.deletedStructs) {
if (
isParentOf(scope, op) &&
op._parent !== y &&
(
op._id.user !== y.userID ||
undoOp.fromState === null ||
op._id.clock < undoOp.fromState.clock ||
op._id.clock > undoOp.toState.clock
)
) {
performedUndo = true
op._redo(y)
}
const redoitems = new Set()
for (let del of undoOp.deletedStructs) {
const fromState = del.from
const toState = new ID(fromState.user, fromState.clock + del.len - 1)
y.os.getItemCleanStart(fromState)
y.os.getItemCleanEnd(toState)
y.os.iterate(fromState, toState, op => {
if (
isParentOf(scope, op) &&
op._parent !== y &&
(
op._id.user !== y.userID ||
undoOp.fromState === null ||
op._id.clock < undoOp.fromState.clock ||
op._id.clock > undoOp.toState.clock
)
) {
redoitems.add(op)
}
})
}
redoitems.forEach(op => {
const opUndone = op._redo(y, redoitems)
performedUndo = performedUndo || opUndone
})
}
})
if (performedUndo) {
// should be performed after the undo transaction
undoOp.bindingInfos.forEach((info, binding) => {
binding._restoreUndoStackInfo(info)
})
}
return performedUndo
}
@@ -66,6 +93,7 @@ export default class UndoManager {
*/
constructor (scope, options = {}) {
this.options = options
this._bindings = new Set(options.bindings)
options.captureTimeout = options.captureTimeout == null ? 500 : options.captureTimeout
this._undoBuffer = []
this._redoBuffer = []
@@ -76,16 +104,28 @@ export default class UndoManager {
const y = scope._y
this.y = y
y._hasUndoManager = true
let bindingInfos
y.on('beforeTransaction', (y, transaction, remote) => {
if (!remote) {
// Store binding information before transaction is executed
// By restoring the binding information, we can make sure that the state
// before the transaction can be recovered
bindingInfos = new Map()
this._bindings.forEach(binding => {
bindingInfos.set(binding, binding._getUndoStackInfo())
})
}
})
y.on('afterTransaction', (y, transaction, remote) => {
if (!remote && transaction.changedParentTypes.has(scope)) {
let reverseOperation = new ReverseOperation(y, transaction)
let reverseOperation = new ReverseOperation(y, transaction, bindingInfos)
if (!this._undoing) {
let lastUndoOp = this._undoBuffer.length > 0 ? this._undoBuffer[this._undoBuffer.length - 1] : null
if (
this._redoing === false &&
this._lastTransactionWasUndo === false &&
lastUndoOp !== null &&
reverseOperation.created - lastUndoOp.created <= options.captureTimeout
(options.captureTimeout < 0 || reverseOperation.created - lastUndoOp.created <= options.captureTimeout)
) {
lastUndoOp.created = reverseOperation.created
if (reverseOperation.toState !== null) {
@@ -110,6 +150,13 @@ export default class UndoManager {
})
}
/**
* Enforce that the next change is created as a separate item in the undo stack
*/
flushChanges () {
this._lastTransactionWasUndo = true
}
/**
* Undo the last locally created change.
*/

View File

@@ -76,7 +76,10 @@ export function fromRelativePosition (y, rpos) {
} else {
id = new RootID(rpos[3], rpos[4])
}
const type = y.os.get(id)
let type = y.os.get(id)
while (type._redone !== null) {
type = type._redone
}
if (type === null || type.constructor === GC) {
return null
}
@@ -87,12 +90,16 @@ export function fromRelativePosition (y, rpos) {
} else {
let offset = 0
let struct = y.os.findNodeWithUpperBound(new ID(rpos[0], rpos[1])).val
const diff = rpos[1] - struct._id.clock
while (struct._redone !== null) {
struct = struct._redone
}
const parent = struct._parent
if (struct.constructor === GC || parent._deleted) {
return null
}
if (!struct._deleted) {
offset = rpos[1] - struct._id.clock
offset = diff
}
struct = struct._left
while (struct !== null) {

View File

@@ -111,7 +111,7 @@ function compareEvent (t, is, should) {
t.assert(
should[key] === is[key] ||
JSON.stringify(should[key]) === JSON.stringify(is[key])
, 'event works as expected'
, 'event works as expected'
)
}
}

8
y.js

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

499
y.node.js
View File

@@ -1,7 +1,7 @@
/**
* yjs - A framework for real-time p2p shared editing on any data
* @version v13.0.0-61
* @version v13.0.0-62
* @license MIT
*/
@@ -1677,7 +1677,7 @@ class Item {
*
* @private
*/
_redo (y) {
_redo (y, redoitems) {
if (this._redone !== null) {
return this._redone
}
@@ -1687,7 +1687,10 @@ class Item {
let parent = this._parent;
// make sure that parent is redone
if (parent._deleted === true && parent._redone === null) {
parent._redo(y);
// try to undo parent if it will be undone anyway
if (!redoitems.has(parent) || !parent._redo(y, redoitems)) {
return false
}
}
if (parent._redone !== null) {
parent = parent._redone;
@@ -1714,7 +1717,7 @@ class Item {
struct._parentSub = this._parentSub;
struct._integrate(y);
this._redone = struct;
return struct
return true
}
/**
@@ -3386,7 +3389,7 @@ function deleteText (y, length, parent, left, right, currentAttributes) {
* @typedef {Array<Object>} Delta
*/
/**
/**
* Attributes that can be assigned to a selection of text.
*
* @example
@@ -4995,6 +4998,119 @@ class NamedEventHandler {
}
}
// TODO: Implement function to describe ranges
/**
* A relative position that is based on the Yjs model. In contrast to an
* absolute position (position by index), the relative position can be
* recomputed when remote changes are received. For example:
*
* ```Insert(0, 'x')('a|bc') = 'xa|bc'``` Where | is the cursor position.
*
* A relative cursor position can be obtained with the function
* {@link getRelativePosition} and it can be transformed to an absolute position
* with {@link fromRelativePosition}.
*
* Pro tip: Use this to implement shared cursor locations in YText or YXml!
* The relative position is {@link encodable}, so you can send it to other
* clients.
*
* @example
* // Current cursor position is at position 10
* let relativePosition = getRelativePosition(yText, 10)
* // modify yText
* yText.insert(0, 'abc')
* yText.delete(3, 10)
* // Compute the cursor position
* let absolutePosition = fromRelativePosition(y, relativePosition)
* absolutePosition.type // => yText
* console.log('cursor location is ' + absolutePosition.offset) // => cursor location is 3
*
* @typedef {encodable} RelativePosition
*/
/**
* Create a relativePosition based on a absolute position.
*
* @param {YType} type The base type (e.g. YText or YArray).
* @param {Integer} offset The absolute position.
*/
function getRelativePosition (type, offset) {
// TODO: rename to createRelativePosition
let t = type._start;
while (t !== null) {
if (t._deleted === false) {
if (t._length > offset) {
return [t._id.user, t._id.clock + offset]
}
offset -= t._length;
}
t = t._right;
}
return ['endof', type._id.user, type._id.clock || null, type._id.name || null, type._id.type || null]
}
/**
* @typedef {Object} AbsolutePosition The result of {@link fromRelativePosition}
* @property {YType} type The type on which to apply the absolute position.
* @property {Integer} offset The absolute offset.r
*/
/**
* Transforms a relative position back to a relative position.
*
* @param {Y} y The Yjs instance in which to query for the absolute position.
* @param {RelativePosition} rpos The relative position.
* @return {AbsolutePosition} The absolute position in the Yjs model
* (type + offset).
*/
function fromRelativePosition (y, rpos) {
if (rpos[0] === 'endof') {
let id;
if (rpos[3] === null) {
id = new ID(rpos[1], rpos[2]);
} else {
id = new RootID(rpos[3], rpos[4]);
}
let type = y.os.get(id);
while (type._redone !== null) {
type = type._redone;
}
if (type === null || type.constructor === GC) {
return null
}
return {
type,
offset: type.length
}
} else {
let offset = 0;
let struct = y.os.findNodeWithUpperBound(new ID(rpos[0], rpos[1])).val;
const diff = rpos[1] - struct._id.clock;
while (struct._redone !== null) {
struct = struct._redone;
}
const parent = struct._parent;
if (struct.constructor === GC || parent._deleted) {
return null
}
if (!struct._deleted) {
offset = diff;
}
struct = struct._left;
while (struct !== null) {
if (!struct._deleted) {
offset += struct._length;
}
struct = struct._left;
}
return {
type: parent,
offset: offset
}
}
}
// TODO: rename mutex
/**
@@ -5073,192 +5189,37 @@ class Binding {
}
}
// TODO: Implement function to describe ranges
/**
* A relative position that is based on the Yjs model. In contrast to an
* absolute position (position by index), the relative position can be
* recomputed when remote changes are received. For example:
*
* ```Insert(0, 'x')('a|bc') = 'xa|bc'``` Where | is the cursor position.
*
* A relative cursor position can be obtained with the function
* {@link getRelativePosition} and it can be transformed to an absolute position
* with {@link fromRelativePosition}.
*
* Pro tip: Use this to implement shared cursor locations in YText or YXml!
* The relative position is {@link encodable}, so you can send it to other
* clients.
*
* @example
* // Current cursor position is at position 10
* let relativePosition = getRelativePosition(yText, 10)
* // modify yText
* yText.insert(0, 'abc')
* yText.delete(3, 10)
* // Compute the cursor position
* let absolutePosition = fromRelativePosition(y, relativePosition)
* absolutePosition.type // => yText
* console.log('cursor location is ' + absolutePosition.offset) // => cursor location is 3
*
* @typedef {encodable} RelativePosition
*/
/**
* Create a relativePosition based on a absolute position.
*
* @param {YType} type The base type (e.g. YText or YArray).
* @param {Integer} offset The absolute position.
*/
function getRelativePosition (type, offset) {
// TODO: rename to createRelativePosition
let t = type._start;
while (t !== null) {
if (t._deleted === false) {
if (t._length > offset) {
return [t._id.user, t._id.clock + offset]
}
offset -= t._length;
}
t = t._right;
}
return ['endof', type._id.user, type._id.clock || null, type._id.name || null, type._id.type || null]
}
/**
* @typedef {Object} AbsolutePosition The result of {@link fromRelativePosition}
* @property {YType} type The type on which to apply the absolute position.
* @property {Integer} offset The absolute offset.r
*/
/**
* Transforms a relative position back to a relative position.
*
* @param {Y} y The Yjs instance in which to query for the absolute position.
* @param {RelativePosition} rpos The relative position.
* @return {AbsolutePosition} The absolute position in the Yjs model
* (type + offset).
*/
function fromRelativePosition (y, rpos) {
if (rpos[0] === 'endof') {
let id;
if (rpos[3] === null) {
id = new ID(rpos[1], rpos[2]);
} else {
id = new RootID(rpos[3], rpos[4]);
}
const type = y.os.get(id);
if (type === null || type.constructor === GC) {
return null
}
return {
type,
offset: type.length
}
} else {
let offset = 0;
let struct = y.os.findNodeWithUpperBound(new ID(rpos[0], rpos[1])).val;
const parent = struct._parent;
if (struct.constructor === GC || parent._deleted) {
return null
}
if (!struct._deleted) {
offset = rpos[1] - struct._id.clock;
}
struct = struct._left;
while (struct !== null) {
if (!struct._deleted) {
offset += struct._length;
}
struct = struct._left;
}
return {
type: parent,
offset: offset
}
}
}
/* globals getSelection */
let browserSelection = null;
let relativeSelection = null;
/**
* @private
*/
let beforeTransactionSelectionFixer;
if (typeof getSelection !== 'undefined') {
beforeTransactionSelectionFixer = function _beforeTransactionSelectionFixer (y, domBinding, transaction, remote) {
if (!remote) {
return
function _getCurrentRelativeSelection (domBinding) {
const { baseNode, baseOffset, extentNode, extentOffset } = getSelection();
const baseNodeType = domBinding.domToType.get(baseNode);
const extentNodeType = domBinding.domToType.get(extentNode);
if (baseNodeType !== undefined && extentNodeType !== undefined) {
return {
from: getRelativePosition(baseNodeType, baseOffset),
to: getRelativePosition(extentNodeType, extentOffset)
}
relativeSelection = { from: null, to: null, fromY: null, toY: null };
browserSelection = getSelection();
const anchorNode = browserSelection.anchorNode;
const anchorNodeType = domBinding.domToType.get(anchorNode);
if (anchorNode !== null && anchorNodeType !== undefined) {
relativeSelection.from = getRelativePosition(anchorNodeType, browserSelection.anchorOffset);
relativeSelection.fromY = anchorNodeType._y;
}
const focusNode = browserSelection.focusNode;
const focusNodeType = domBinding.domToType.get(focusNode);
if (focusNode !== null && focusNodeType !== undefined) {
relativeSelection.to = getRelativePosition(focusNodeType, browserSelection.focusOffset);
relativeSelection.toY = focusNodeType._y;
}
};
} else {
beforeTransactionSelectionFixer = function _fakeBeforeTransactionSelectionFixer () {};
}
return null
}
const getCurrentRelativeSelection = typeof getSelection !== 'undefined' ? _getCurrentRelativeSelection : () => null;
function beforeTransactionSelectionFixer (domBinding, remote) {
if (remote) {
relativeSelection = getCurrentRelativeSelection(domBinding);
}
}
/**
* @private
*/
function afterTransactionSelectionFixer (y, domBinding, transaction, remote) {
if (relativeSelection === null || !remote) {
return
}
const to = relativeSelection.to;
const from = relativeSelection.from;
const fromY = relativeSelection.fromY;
const toY = relativeSelection.toY;
let shouldUpdate = false;
let anchorNode = browserSelection.anchorNode;
let anchorOffset = browserSelection.anchorOffset;
let focusNode = browserSelection.focusNode;
let focusOffset = browserSelection.focusOffset;
if (from !== null) {
let sel = fromRelativePosition(fromY, from);
if (sel !== null) {
let node = domBinding.typeToDom.get(sel.type);
let offset = sel.offset;
if (node !== anchorNode || offset !== anchorOffset) {
anchorNode = node;
anchorOffset = offset;
shouldUpdate = true;
}
}
}
if (to !== null) {
let sel = fromRelativePosition(toY, to);
if (sel !== null) {
let node = domBinding.typeToDom.get(sel.type);
let offset = sel.offset;
if (node !== focusNode || offset !== focusOffset) {
focusNode = node;
focusOffset = offset;
shouldUpdate = true;
}
}
}
if (shouldUpdate) {
browserSelection.setBaseAndExtent(
anchorNode,
anchorOffset,
focusNode,
focusOffset
);
function afterTransactionSelectionFixer (domBinding, remote) {
if (relativeSelection !== null && remote) {
domBinding.restoreSelection(relativeSelection);
}
}
@@ -5541,7 +5502,7 @@ function domObserver (mutations, _document) {
});
}
/* global MutationObserver */
/* global MutationObserver, getSelection */
/**
* A binding that binds the children of a YXmlFragment to a DOM element.
@@ -5603,16 +5564,25 @@ class DomBinding extends Binding {
characterData: true,
subtree: true
});
this._currentSel = null;
document.addEventListener('selectionchange', () => {
this._currentSel = getCurrentRelativeSelection(this);
});
const y = type._y;
this.y = y;
// Force flush dom changes before Type changes are applied (they might
// modify the dom)
this._beforeTransactionHandler = (y, transaction, remote) => {
this._domObserver(this._mutationObserver.takeRecords());
beforeTransactionSelectionFixer(y, this, transaction, remote);
this._mutualExclude(() => {
beforeTransactionSelectionFixer(this, remote);
});
};
y.on('beforeTransaction', this._beforeTransactionHandler);
this._afterTransactionHandler = (y, transaction, remote) => {
afterTransactionSelectionFixer(y, this, transaction, remote);
this._mutualExclude(() => {
afterTransactionSelectionFixer(this, remote);
});
// remove associations
// TODO: this could be done more efficiently
// e.g. Always delete using the following approach, or removeAssociation
@@ -5651,6 +5621,67 @@ class DomBinding extends Binding {
// TODO: apply filter to all elements
}
_getUndoStackInfo () {
return this.getSelection()
}
_restoreUndoStackInfo (info) {
this.restoreSelection(info);
}
getSelection () {
return this._currentSel
}
restoreSelection (selection) {
if (selection !== null) {
const { to, from } = selection;
let shouldUpdate = false;
/**
* There is little information on the difference between anchor/focus and base/extent.
* MDN doesn't even mention base/extent anymore.. though you still have to call
* setBaseAndExtent to change the selection..
* I can observe that base/extend refer to notes higher up in the xml hierachy.
* Espesially for undo/redo this is preferred. If this becomes a problem in the future,
* we should probably go back to anchor/focus.
*/
const browserSelection = getSelection();
let { baseNode, baseOffset, extentNode, extentOffset } = browserSelection;
if (from !== null) {
let sel = fromRelativePosition(this.y, from);
if (sel !== null) {
let node = this.typeToDom.get(sel.type);
let offset = sel.offset;
if (node !== baseNode || offset !== baseOffset) {
baseNode = node;
baseOffset = offset;
shouldUpdate = true;
}
}
}
if (to !== null) {
let sel = fromRelativePosition(this.y, to);
if (sel !== null) {
let node = this.typeToDom.get(sel.type);
let offset = sel.offset;
if (node !== extentNode || offset !== extentOffset) {
extentNode = node;
extentOffset = offset;
shouldUpdate = true;
}
}
}
if (shouldUpdate) {
browserSelection.setBaseAndExtent(
baseNode,
baseOffset,
extentNode,
extentOffset
);
}
}
}
/**
* Remove all properties that are handled by this class.
*/
@@ -5666,14 +5697,13 @@ class DomBinding extends Binding {
super.destroy();
}
}
/**
* A filter defines which elements and attributes to share.
* Return null if the node should be filtered. Otherwise return the Map of
* accepted attributes.
*
* @typedef {function(nodeName: String, attrs: Map): Map|null} FilterFunction
*/
/**
* A filter defines which elements and attributes to share.
* Return null if the node should be filtered. Otherwise return the Map of
* accepted attributes.
*
* @typedef {function(nodeName: String, attrs: Map): Map|null} FilterFunction
*/
/**
* Anything that can be encoded with `JSON.stringify` and can be decoded with
@@ -5949,7 +5979,7 @@ Y.extend = function extendYjs () {
};
class ReverseOperation {
constructor (y, transaction) {
constructor (y, transaction, bindingInfos) {
this.created = new Date();
const beforeState = transaction.beforeState;
if (beforeState.has(y.userID)) {
@@ -5959,15 +5989,26 @@ class ReverseOperation {
this.toState = null;
this.fromState = null;
}
this.deletedStructs = transaction.deletedStructs;
this.deletedStructs = new Set();
transaction.deletedStructs.forEach(struct => {
this.deletedStructs.add({
from: struct._id,
len: struct._length
});
});
/**
* Maps from binding to binding information (e.g. cursor information)
*/
this.bindingInfos = bindingInfos;
}
}
function applyReverseOperation (y, scope, reverseBuffer) {
let performedUndo = false;
let undoOp;
y.transact(() => {
while (!performedUndo && reverseBuffer.length > 0) {
let undoOp = reverseBuffer.pop();
undoOp = reverseBuffer.pop();
// make sure that it is possible to iterate {from}-{to}
if (undoOp.fromState !== null) {
y.os.getItemCleanStart(undoOp.fromState);
@@ -5982,23 +6023,39 @@ function applyReverseOperation (y, scope, reverseBuffer) {
}
});
}
for (let op of undoOp.deletedStructs) {
if (
isParentOf(scope, op) &&
op._parent !== y &&
(
op._id.user !== y.userID ||
undoOp.fromState === null ||
op._id.clock < undoOp.fromState.clock ||
op._id.clock > undoOp.toState.clock
)
) {
performedUndo = true;
op._redo(y);
}
const redoitems = new Set();
for (let del of undoOp.deletedStructs) {
const fromState = del.from;
const toState = new ID(fromState.user, fromState.clock + del.len - 1);
y.os.getItemCleanStart(fromState);
y.os.getItemCleanEnd(toState);
y.os.iterate(fromState, toState, op => {
if (
isParentOf(scope, op) &&
op._parent !== y &&
(
op._id.user !== y.userID ||
undoOp.fromState === null ||
op._id.clock < undoOp.fromState.clock ||
op._id.clock > undoOp.toState.clock
)
) {
redoitems.add(op);
}
});
}
redoitems.forEach(op => {
const opUndone = op._redo(y, redoitems);
performedUndo = performedUndo || opUndone;
});
}
});
if (performedUndo) {
// should be performed after the undo transaction
undoOp.bindingInfos.forEach((info, binding) => {
binding._restoreUndoStackInfo(info);
});
}
return performedUndo
}
@@ -6013,6 +6070,7 @@ class UndoManager {
*/
constructor (scope, options = {}) {
this.options = options;
this._bindings = new Set(options.bindings);
options.captureTimeout = options.captureTimeout == null ? 500 : options.captureTimeout;
this._undoBuffer = [];
this._redoBuffer = [];
@@ -6023,16 +6081,28 @@ class UndoManager {
const y = scope._y;
this.y = y;
y._hasUndoManager = true;
let bindingInfos;
y.on('beforeTransaction', (y, transaction, remote) => {
if (!remote) {
// Store binding information before transaction is executed
// By restoring the binding information, we can make sure that the state
// before the transaction can be recovered
bindingInfos = new Map();
this._bindings.forEach(binding => {
bindingInfos.set(binding, binding._getUndoStackInfo());
});
}
});
y.on('afterTransaction', (y, transaction, remote) => {
if (!remote && transaction.changedParentTypes.has(scope)) {
let reverseOperation = new ReverseOperation(y, transaction);
let reverseOperation = new ReverseOperation(y, transaction, bindingInfos);
if (!this._undoing) {
let lastUndoOp = this._undoBuffer.length > 0 ? this._undoBuffer[this._undoBuffer.length - 1] : null;
if (
this._redoing === false &&
this._lastTransactionWasUndo === false &&
lastUndoOp !== null &&
reverseOperation.created - lastUndoOp.created <= options.captureTimeout
(options.captureTimeout < 0 || reverseOperation.created - lastUndoOp.created <= options.captureTimeout)
) {
lastUndoOp.created = reverseOperation.created;
if (reverseOperation.toState !== null) {
@@ -6057,6 +6127,13 @@ class UndoManager {
});
}
/**
* Enforce that the next change is created as a separate item in the undo stack
*/
flushChanges () {
this._lastTransactionWasUndo = true;
}
/**
* Undo the last locally created change.
*/

File diff suppressed because one or more lines are too long

19218
y.test.js

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long