5020 lines
135 KiB
JavaScript
5020 lines
135 KiB
JavaScript
|
|
/**
|
|
* yjs - A framework for real-time p2p shared editing on any data
|
|
* @version v13.0.0-51
|
|
* @license MIT
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
class N {
|
|
// A created node is always red!
|
|
constructor (val) {
|
|
this.val = val;
|
|
this.color = true;
|
|
this._left = null;
|
|
this._right = null;
|
|
this._parent = null;
|
|
}
|
|
isRed () { return this.color }
|
|
isBlack () { return !this.color }
|
|
redden () { this.color = true; return this }
|
|
blacken () { this.color = false; return this }
|
|
get grandparent () {
|
|
return this.parent.parent
|
|
}
|
|
get parent () {
|
|
return this._parent
|
|
}
|
|
get sibling () {
|
|
return (this === this.parent.left)
|
|
? this.parent.right : this.parent.left
|
|
}
|
|
get left () {
|
|
return this._left
|
|
}
|
|
get right () {
|
|
return this._right
|
|
}
|
|
set left (n) {
|
|
if (n !== null) {
|
|
n._parent = this;
|
|
}
|
|
this._left = n;
|
|
}
|
|
set right (n) {
|
|
if (n !== null) {
|
|
n._parent = this;
|
|
}
|
|
this._right = n;
|
|
}
|
|
rotateLeft (tree) {
|
|
var parent = this.parent;
|
|
var newParent = this.right;
|
|
var newRight = this.right.left;
|
|
newParent.left = this;
|
|
this.right = newRight;
|
|
if (parent === null) {
|
|
tree.root = newParent;
|
|
newParent._parent = null;
|
|
} else if (parent.left === this) {
|
|
parent.left = newParent;
|
|
} else if (parent.right === this) {
|
|
parent.right = newParent;
|
|
} else {
|
|
throw new Error('The elements are wrongly connected!')
|
|
}
|
|
}
|
|
next () {
|
|
if (this.right !== null) {
|
|
// search the most left node in the right tree
|
|
var o = this.right;
|
|
while (o.left !== null) {
|
|
o = o.left;
|
|
}
|
|
return o
|
|
} else {
|
|
var p = this;
|
|
while (p.parent !== null && p !== p.parent.left) {
|
|
p = p.parent;
|
|
}
|
|
return p.parent
|
|
}
|
|
}
|
|
prev () {
|
|
if (this.left !== null) {
|
|
// search the most right node in the left tree
|
|
var o = this.left;
|
|
while (o.right !== null) {
|
|
o = o.right;
|
|
}
|
|
return o
|
|
} else {
|
|
var p = this;
|
|
while (p.parent !== null && p !== p.parent.right) {
|
|
p = p.parent;
|
|
}
|
|
return p.parent
|
|
}
|
|
}
|
|
rotateRight (tree) {
|
|
var parent = this.parent;
|
|
var newParent = this.left;
|
|
var newLeft = this.left.right;
|
|
newParent.right = this;
|
|
this.left = newLeft;
|
|
if (parent === null) {
|
|
tree.root = newParent;
|
|
newParent._parent = null;
|
|
} else if (parent.left === this) {
|
|
parent.left = newParent;
|
|
} else if (parent.right === this) {
|
|
parent.right = newParent;
|
|
} else {
|
|
throw new Error('The elements are wrongly connected!')
|
|
}
|
|
}
|
|
getUncle () {
|
|
// we can assume that grandparent exists when this is called!
|
|
if (this.parent === this.parent.parent.left) {
|
|
return this.parent.parent.right
|
|
} else {
|
|
return this.parent.parent.left
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
* This is a Red Black Tree implementation
|
|
*/
|
|
class Tree {
|
|
constructor () {
|
|
this.root = null;
|
|
this.length = 0;
|
|
}
|
|
findNext (id) {
|
|
var nextID = id.clone();
|
|
nextID.clock += 1;
|
|
return this.findWithLowerBound(nextID)
|
|
}
|
|
findPrev (id) {
|
|
let prevID = id.clone();
|
|
prevID.clock -= 1;
|
|
return this.findWithUpperBound(prevID)
|
|
}
|
|
findNodeWithLowerBound (from) {
|
|
var o = this.root;
|
|
if (o === null) {
|
|
return null
|
|
} else {
|
|
while (true) {
|
|
if (from === null || (from.lessThan(o.val._id) && o.left !== null)) {
|
|
// o is included in the bound
|
|
// try to find an element that is closer to the bound
|
|
o = o.left;
|
|
} else if (from !== null && o.val._id.lessThan(from)) {
|
|
// o is not within the bound, maybe one of the right elements is..
|
|
if (o.right !== null) {
|
|
o = o.right;
|
|
} else {
|
|
// there is no right element. Search for the next bigger element,
|
|
// this should be within the bounds
|
|
return o.next()
|
|
}
|
|
} else {
|
|
return o
|
|
}
|
|
}
|
|
}
|
|
}
|
|
findNodeWithUpperBound (to) {
|
|
if (to === void 0) {
|
|
throw new Error('You must define from!')
|
|
}
|
|
var o = this.root;
|
|
if (o === null) {
|
|
return null
|
|
} else {
|
|
while (true) {
|
|
if ((to === null || o.val._id.lessThan(to)) && o.right !== null) {
|
|
// o is included in the bound
|
|
// try to find an element that is closer to the bound
|
|
o = o.right;
|
|
} else if (to !== null && to.lessThan(o.val._id)) {
|
|
// o is not within the bound, maybe one of the left elements is..
|
|
if (o.left !== null) {
|
|
o = o.left;
|
|
} else {
|
|
// there is no left element. Search for the prev smaller element,
|
|
// this should be within the bounds
|
|
return o.prev()
|
|
}
|
|
} else {
|
|
return o
|
|
}
|
|
}
|
|
}
|
|
}
|
|
findSmallestNode () {
|
|
var o = this.root;
|
|
while (o != null && o.left != null) {
|
|
o = o.left;
|
|
}
|
|
return o
|
|
}
|
|
findWithLowerBound (from) {
|
|
var n = this.findNodeWithLowerBound(from);
|
|
return n == null ? null : n.val
|
|
}
|
|
findWithUpperBound (to) {
|
|
var n = this.findNodeWithUpperBound(to);
|
|
return n == null ? null : n.val
|
|
}
|
|
iterate (from, to, f) {
|
|
var o;
|
|
if (from === null) {
|
|
o = this.findSmallestNode();
|
|
} else {
|
|
o = this.findNodeWithLowerBound(from);
|
|
}
|
|
while (
|
|
o !== null &&
|
|
(
|
|
to === null || // eslint-disable-line no-unmodified-loop-condition
|
|
o.val._id.lessThan(to) ||
|
|
o.val._id.equals(to)
|
|
)
|
|
) {
|
|
f(o.val);
|
|
o = o.next();
|
|
}
|
|
}
|
|
find (id) {
|
|
let n = this.findNode(id);
|
|
if (n !== null) {
|
|
return n.val
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
findNode (id) {
|
|
var o = this.root;
|
|
if (o === null) {
|
|
return null
|
|
} else {
|
|
while (true) {
|
|
if (o === null) {
|
|
return null
|
|
}
|
|
if (id.lessThan(o.val._id)) {
|
|
o = o.left;
|
|
} else if (o.val._id.lessThan(id)) {
|
|
o = o.right;
|
|
} else {
|
|
return o
|
|
}
|
|
}
|
|
}
|
|
}
|
|
delete (id) {
|
|
var d = this.findNode(id);
|
|
if (d == null) {
|
|
// throw new Error('Element does not exist!')
|
|
return
|
|
}
|
|
this.length--;
|
|
if (d.left !== null && d.right !== null) {
|
|
// switch d with the greates element in the left subtree.
|
|
// o should have at most one child.
|
|
var o = d.left;
|
|
// find
|
|
while (o.right !== null) {
|
|
o = o.right;
|
|
}
|
|
// switch
|
|
d.val = o.val;
|
|
d = o;
|
|
}
|
|
// d has at most one child
|
|
// let n be the node that replaces d
|
|
var isFakeChild;
|
|
var child = d.left || d.right;
|
|
if (child === null) {
|
|
isFakeChild = true;
|
|
child = new N(null);
|
|
child.blacken();
|
|
d.right = child;
|
|
} else {
|
|
isFakeChild = false;
|
|
}
|
|
|
|
if (d.parent === null) {
|
|
if (!isFakeChild) {
|
|
this.root = child;
|
|
child.blacken();
|
|
child._parent = null;
|
|
} else {
|
|
this.root = null;
|
|
}
|
|
return
|
|
} else if (d.parent.left === d) {
|
|
d.parent.left = child;
|
|
} else if (d.parent.right === d) {
|
|
d.parent.right = child;
|
|
} else {
|
|
throw new Error('Impossible!')
|
|
}
|
|
if (d.isBlack()) {
|
|
if (child.isRed()) {
|
|
child.blacken();
|
|
} else {
|
|
this._fixDelete(child);
|
|
}
|
|
}
|
|
this.root.blacken();
|
|
if (isFakeChild) {
|
|
if (child.parent.left === child) {
|
|
child.parent.left = null;
|
|
} else if (child.parent.right === child) {
|
|
child.parent.right = null;
|
|
} else {
|
|
throw new Error('Impossible #3')
|
|
}
|
|
}
|
|
}
|
|
_fixDelete (n) {
|
|
function isBlack (node) {
|
|
return node !== null ? node.isBlack() : true
|
|
}
|
|
function isRed (node) {
|
|
return node !== null ? node.isRed() : false
|
|
}
|
|
if (n.parent === null) {
|
|
// this can only be called after the first iteration of fixDelete.
|
|
return
|
|
}
|
|
// d was already replaced by the child
|
|
// d is not the root
|
|
// d and child are black
|
|
var sibling = n.sibling;
|
|
if (isRed(sibling)) {
|
|
// make sibling the grandfather
|
|
n.parent.redden();
|
|
sibling.blacken();
|
|
if (n === n.parent.left) {
|
|
n.parent.rotateLeft(this);
|
|
} else if (n === n.parent.right) {
|
|
n.parent.rotateRight(this);
|
|
} else {
|
|
throw new Error('Impossible #2')
|
|
}
|
|
sibling = n.sibling;
|
|
}
|
|
// parent, sibling, and children of n are black
|
|
if (n.parent.isBlack() &&
|
|
sibling.isBlack() &&
|
|
isBlack(sibling.left) &&
|
|
isBlack(sibling.right)
|
|
) {
|
|
sibling.redden();
|
|
this._fixDelete(n.parent);
|
|
} else if (n.parent.isRed() &&
|
|
sibling.isBlack() &&
|
|
isBlack(sibling.left) &&
|
|
isBlack(sibling.right)
|
|
) {
|
|
sibling.redden();
|
|
n.parent.blacken();
|
|
} else {
|
|
if (n === n.parent.left &&
|
|
sibling.isBlack() &&
|
|
isRed(sibling.left) &&
|
|
isBlack(sibling.right)
|
|
) {
|
|
sibling.redden();
|
|
sibling.left.blacken();
|
|
sibling.rotateRight(this);
|
|
sibling = n.sibling;
|
|
} else if (n === n.parent.right &&
|
|
sibling.isBlack() &&
|
|
isRed(sibling.right) &&
|
|
isBlack(sibling.left)
|
|
) {
|
|
sibling.redden();
|
|
sibling.right.blacken();
|
|
sibling.rotateLeft(this);
|
|
sibling = n.sibling;
|
|
}
|
|
sibling.color = n.parent.color;
|
|
n.parent.blacken();
|
|
if (n === n.parent.left) {
|
|
sibling.right.blacken();
|
|
n.parent.rotateLeft(this);
|
|
} else {
|
|
sibling.left.blacken();
|
|
n.parent.rotateRight(this);
|
|
}
|
|
}
|
|
}
|
|
put (v) {
|
|
var node = new N(v);
|
|
if (this.root !== null) {
|
|
var p = this.root; // p abbrev. parent
|
|
while (true) {
|
|
if (node.val._id.lessThan(p.val._id)) {
|
|
if (p.left === null) {
|
|
p.left = node;
|
|
break
|
|
} else {
|
|
p = p.left;
|
|
}
|
|
} else if (p.val._id.lessThan(node.val._id)) {
|
|
if (p.right === null) {
|
|
p.right = node;
|
|
break
|
|
} else {
|
|
p = p.right;
|
|
}
|
|
} else {
|
|
p.val = node.val;
|
|
return p
|
|
}
|
|
}
|
|
this._fixInsert(node);
|
|
} else {
|
|
this.root = node;
|
|
}
|
|
this.length++;
|
|
this.root.blacken();
|
|
return node
|
|
}
|
|
_fixInsert (n) {
|
|
if (n.parent === null) {
|
|
n.blacken();
|
|
return
|
|
} else if (n.parent.isBlack()) {
|
|
return
|
|
}
|
|
var uncle = n.getUncle();
|
|
if (uncle !== null && uncle.isRed()) {
|
|
// Note: parent: red, uncle: red
|
|
n.parent.blacken();
|
|
uncle.blacken();
|
|
n.grandparent.redden();
|
|
this._fixInsert(n.grandparent);
|
|
} else {
|
|
// Note: parent: red, uncle: black or null
|
|
// Now we transform the tree in such a way that
|
|
// either of these holds:
|
|
// 1) grandparent.left.isRed
|
|
// and grandparent.left.left.isRed
|
|
// 2) grandparent.right.isRed
|
|
// and grandparent.right.right.isRed
|
|
if (n === n.parent.right && n.parent === n.grandparent.left) {
|
|
n.parent.rotateLeft(this);
|
|
// Since we rotated and want to use the previous
|
|
// cases, we need to set n in such a way that
|
|
// n.parent.isRed again
|
|
n = n.left;
|
|
} else if (n === n.parent.left && n.parent === n.grandparent.right) {
|
|
n.parent.rotateRight(this);
|
|
// see above
|
|
n = n.right;
|
|
}
|
|
// Case 1) or 2) hold from here on.
|
|
// Now traverse grandparent, make parent a black node
|
|
// on the highest level which holds two red nodes.
|
|
n.parent.blacken();
|
|
n.grandparent.redden();
|
|
if (n === n.parent.left) {
|
|
// Case 1
|
|
n.grandparent.rotateRight(this);
|
|
} else {
|
|
// Case 2
|
|
n.grandparent.rotateLeft(this);
|
|
}
|
|
}
|
|
}
|
|
flush () {}
|
|
}
|
|
|
|
class ID {
|
|
constructor (user, clock) {
|
|
this.user = user;
|
|
this.clock = clock;
|
|
}
|
|
clone () {
|
|
return new ID(this.user, this.clock)
|
|
}
|
|
equals (id) {
|
|
return id !== null && id.user === this.user && id.clock === this.clock
|
|
}
|
|
lessThan (id) {
|
|
if (id.constructor === ID) {
|
|
return this.user < id.user || (this.user === id.user && this.clock < id.clock)
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
class DSNode {
|
|
constructor (id, len, gc) {
|
|
this._id = id;
|
|
this.len = len;
|
|
this.gc = gc;
|
|
}
|
|
clone () {
|
|
return new DSNode(this._id, this.len, this.gc)
|
|
}
|
|
}
|
|
|
|
class DeleteStore extends Tree {
|
|
logTable () {
|
|
const deletes = [];
|
|
this.iterate(null, null, function (n) {
|
|
deletes.push({
|
|
user: n._id.user,
|
|
clock: n._id.clock,
|
|
len: n.len,
|
|
gc: n.gc
|
|
});
|
|
});
|
|
console.table(deletes);
|
|
}
|
|
isDeleted (id) {
|
|
var n = this.findWithUpperBound(id);
|
|
return n !== null && n._id.user === id.user && id.clock < n._id.clock + n.len
|
|
}
|
|
/*
|
|
* Mark an operation as deleted. returns the deleted node
|
|
*/
|
|
markDeleted (id, length) {
|
|
if (length == null) {
|
|
throw new Error('length must be defined')
|
|
}
|
|
var n = this.findWithUpperBound(id);
|
|
if (n != null && n._id.user === id.user) {
|
|
if (n._id.clock <= id.clock && id.clock <= n._id.clock + n.len) {
|
|
// id is in n's range
|
|
var diff = id.clock + length - (n._id.clock + n.len); // overlapping right
|
|
if (diff > 0) {
|
|
// id+length overlaps n
|
|
if (!n.gc) {
|
|
n.len += diff;
|
|
} else {
|
|
diff = n._id.clock + n.len - id.clock; // overlapping left (id till n.end)
|
|
if (diff < length) {
|
|
// a partial deletion
|
|
let nId = id.clone();
|
|
nId.clock += diff;
|
|
n = new DSNode(nId, length - diff, false);
|
|
this.put(n);
|
|
} else {
|
|
// already gc'd
|
|
throw new Error(
|
|
'DS reached an inconsistent state. Please report this issue!'
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
// no overlapping, already deleted
|
|
return n
|
|
}
|
|
} else {
|
|
// cannot extend left (there is no left!)
|
|
n = new DSNode(id, length, false);
|
|
this.put(n); // TODO: you double-put !!
|
|
}
|
|
} else {
|
|
// cannot extend left
|
|
n = new DSNode(id, length, false);
|
|
this.put(n);
|
|
}
|
|
// can extend right?
|
|
var next = this.findNext(n._id);
|
|
if (
|
|
next != null &&
|
|
n._id.user === next._id.user &&
|
|
n._id.clock + n.len >= next._id.clock
|
|
) {
|
|
diff = n._id.clock + n.len - next._id.clock; // from next.start to n.end
|
|
while (diff >= 0) {
|
|
// n overlaps with next
|
|
if (next.gc) {
|
|
// gc is stronger, so reduce length of n
|
|
n.len -= diff;
|
|
if (diff >= next.len) {
|
|
// delete the missing range after next
|
|
diff = diff - next.len; // missing range after next
|
|
if (diff > 0) {
|
|
this.put(n); // unneccessary? TODO!
|
|
this.markDeleted(new ID(next._id.user, next._id.clock + next.len), diff);
|
|
}
|
|
}
|
|
break
|
|
} else {
|
|
// we can extend n with next
|
|
if (diff > next.len) {
|
|
// n is even longer than next
|
|
// get next.next, and try to extend it
|
|
var _next = this.findNext(next._id);
|
|
this.delete(next._id);
|
|
if (_next == null || n._id.user !== _next._id.user) {
|
|
break
|
|
} else {
|
|
next = _next;
|
|
diff = n._id.clock + n.len - next._id.clock; // from next.start to n.end
|
|
// continue!
|
|
}
|
|
} else {
|
|
// n just partially overlaps with next. extend n, delete next, and break this loop
|
|
n.len += next.len - diff;
|
|
this.delete(next._id);
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this.put(n);
|
|
return n
|
|
}
|
|
}
|
|
|
|
class BinaryDecoder {
|
|
constructor (buffer) {
|
|
if (buffer instanceof ArrayBuffer) {
|
|
this.uint8arr = new Uint8Array(buffer);
|
|
} else if (buffer instanceof Uint8Array || (typeof Buffer !== 'undefined' && buffer instanceof Buffer)) {
|
|
this.uint8arr = buffer;
|
|
} else {
|
|
throw new Error('Expected an ArrayBuffer or Uint8Array!')
|
|
}
|
|
this.pos = 0;
|
|
}
|
|
/**
|
|
* Clone this decoder instance
|
|
* Optionally set a new position parameter
|
|
*/
|
|
clone (newPos = this.pos) {
|
|
let decoder = new BinaryDecoder(this.uint8arr);
|
|
decoder.pos = newPos;
|
|
return decoder
|
|
}
|
|
/**
|
|
* Number of bytes
|
|
*/
|
|
get length () {
|
|
return this.uint8arr.length
|
|
}
|
|
/**
|
|
* Skip one byte, jump to the next position
|
|
*/
|
|
skip8 () {
|
|
this.pos++;
|
|
}
|
|
/**
|
|
* Read one byte as unsigned integer
|
|
*/
|
|
readUint8 () {
|
|
return this.uint8arr[this.pos++]
|
|
}
|
|
/**
|
|
* Read 4 bytes as unsigned integer
|
|
*/
|
|
readUint32 () {
|
|
let uint =
|
|
this.uint8arr[this.pos] +
|
|
(this.uint8arr[this.pos + 1] << 8) +
|
|
(this.uint8arr[this.pos + 2] << 16) +
|
|
(this.uint8arr[this.pos + 3] << 24);
|
|
this.pos += 4;
|
|
return uint
|
|
}
|
|
/**
|
|
* Look ahead without incrementing position
|
|
* to the next byte and read it as unsigned integer
|
|
*/
|
|
peekUint8 () {
|
|
return this.uint8arr[this.pos]
|
|
}
|
|
/**
|
|
* Read unsigned integer (32bit) with variable length
|
|
* 1/8th of the storage is used as encoding overhead
|
|
* - numbers < 2^7 is stored in one byte
|
|
* - numbers < 2^14 is stored in two bytes
|
|
* ..
|
|
*/
|
|
readVarUint () {
|
|
let num = 0;
|
|
let len = 0;
|
|
while (true) {
|
|
let r = this.uint8arr[this.pos++];
|
|
num = num | ((r & 0b1111111) << len);
|
|
len += 7;
|
|
if (r < 1 << 7) {
|
|
return num >>> 0 // return unsigned number!
|
|
}
|
|
if (len > 35) {
|
|
throw new Error('Integer out of range!')
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Read string of variable length
|
|
* - varUint is used to store the length of the string
|
|
*/
|
|
readVarString () {
|
|
let len = this.readVarUint();
|
|
let bytes = new Array(len);
|
|
for (let i = 0; i < len; i++) {
|
|
bytes[i] = this.uint8arr[this.pos++];
|
|
}
|
|
let encodedString = String.fromCodePoint(...bytes);
|
|
return decodeURIComponent(escape(encodedString))
|
|
}
|
|
/**
|
|
* Look ahead and read varString without incrementing position
|
|
*/
|
|
peekVarString () {
|
|
let pos = this.pos;
|
|
let s = this.readVarString();
|
|
this.pos = pos;
|
|
return s
|
|
}
|
|
/**
|
|
* Read ID
|
|
* - If first varUint read is 0xFFFFFF a RootID is returned
|
|
* - Otherwise an ID is returned
|
|
*/
|
|
readID () {
|
|
let user = this.readVarUint();
|
|
if (user === RootFakeUserID) {
|
|
// read property name and type id
|
|
const rid = new RootID(this.readVarString(), null);
|
|
rid.type = this.readVarUint();
|
|
return rid
|
|
}
|
|
return new ID(user, this.readVarUint())
|
|
}
|
|
}
|
|
|
|
class MissingEntry {
|
|
constructor (decoder, missing, struct) {
|
|
this.decoder = decoder;
|
|
this.missing = missing.length;
|
|
this.struct = struct;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Integrate remote struct
|
|
* When a remote struct is integrated, other structs might be ready to ready to
|
|
* integrate.
|
|
*/
|
|
function _integrateRemoteStructHelper (y, struct) {
|
|
const id = struct._id;
|
|
if (id === undefined) {
|
|
struct._integrate(y);
|
|
} else {
|
|
if (y.ss.getState(id.user) > id.clock) {
|
|
return
|
|
}
|
|
struct._integrate(y);
|
|
let msu = y._missingStructs.get(id.user);
|
|
if (msu != null) {
|
|
let clock = id.clock;
|
|
const finalClock = clock + struct._length;
|
|
for (;clock < finalClock; clock++) {
|
|
const missingStructs = msu.get(clock);
|
|
if (missingStructs !== undefined) {
|
|
missingStructs.forEach(missingDef => {
|
|
missingDef.missing--;
|
|
if (missingDef.missing === 0) {
|
|
const decoder = missingDef.decoder;
|
|
let oldPos = decoder.pos;
|
|
let missing = missingDef.struct._fromBinary(y, decoder);
|
|
decoder.pos = oldPos;
|
|
if (missing.length === 0) {
|
|
y._readyToIntegrate.push(missingDef.struct);
|
|
}
|
|
}
|
|
});
|
|
msu.delete(clock);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function stringifyStructs (y, decoder, strBuilder) {
|
|
const len = decoder.readUint32();
|
|
for (let i = 0; i < len; i++) {
|
|
let reference = decoder.readVarUint();
|
|
let Constr = getStruct(reference);
|
|
let struct = new Constr();
|
|
let missing = struct._fromBinary(y, decoder);
|
|
let logMessage = ' ' + struct._logString();
|
|
if (missing.length > 0) {
|
|
logMessage += ' .. missing: ' + missing.map(logID).join(', ');
|
|
}
|
|
strBuilder.push(logMessage);
|
|
}
|
|
}
|
|
|
|
function integrateRemoteStructs (y, decoder) {
|
|
const len = decoder.readUint32();
|
|
for (let i = 0; i < len; i++) {
|
|
let reference = decoder.readVarUint();
|
|
let Constr = getStruct(reference);
|
|
let struct = new Constr();
|
|
let decoderPos = decoder.pos;
|
|
let missing = struct._fromBinary(y, decoder);
|
|
if (missing.length === 0) {
|
|
while (struct != null) {
|
|
_integrateRemoteStructHelper(y, struct);
|
|
struct = y._readyToIntegrate.shift();
|
|
}
|
|
} else {
|
|
let _decoder = new BinaryDecoder(decoder.uint8arr);
|
|
_decoder.pos = decoderPos;
|
|
let missingEntry = new MissingEntry(_decoder, missing, struct);
|
|
let missingStructs = y._missingStructs;
|
|
for (let i = missing.length - 1; i >= 0; i--) {
|
|
let m = missing[i];
|
|
if (!missingStructs.has(m.user)) {
|
|
missingStructs.set(m.user, new Map());
|
|
}
|
|
let msu = missingStructs.get(m.user);
|
|
if (!msu.has(m.clock)) {
|
|
msu.set(m.clock, []);
|
|
}
|
|
let mArray = msu = msu.get(m.clock);
|
|
mArray.push(missingEntry);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const bits7 = 0b1111111;
|
|
const bits8 = 0b11111111;
|
|
|
|
class BinaryEncoder {
|
|
constructor () {
|
|
// TODO: implement chained Uint8Array buffers instead of Array buffer
|
|
this.data = [];
|
|
}
|
|
|
|
get length () {
|
|
return this.data.length
|
|
}
|
|
|
|
get pos () {
|
|
return this.data.length
|
|
}
|
|
|
|
createBuffer () {
|
|
return Uint8Array.from(this.data).buffer
|
|
}
|
|
|
|
writeUint8 (num) {
|
|
this.data.push(num & bits8);
|
|
}
|
|
|
|
setUint8 (pos, num) {
|
|
this.data[pos] = num & bits8;
|
|
}
|
|
|
|
writeUint16 (num) {
|
|
this.data.push(num & bits8, (num >>> 8) & bits8);
|
|
}
|
|
|
|
setUint16 (pos, num) {
|
|
this.data[pos] = num & bits8;
|
|
this.data[pos + 1] = (num >>> 8) & bits8;
|
|
}
|
|
|
|
writeUint32 (num) {
|
|
for (let i = 0; i < 4; i++) {
|
|
this.data.push(num & bits8);
|
|
num >>>= 8;
|
|
}
|
|
}
|
|
|
|
setUint32 (pos, num) {
|
|
for (let i = 0; i < 4; i++) {
|
|
this.data[pos + i] = num & bits8;
|
|
num >>>= 8;
|
|
}
|
|
}
|
|
|
|
writeVarUint (num) {
|
|
while (num >= 0b10000000) {
|
|
this.data.push(0b10000000 | (bits7 & num));
|
|
num >>>= 7;
|
|
}
|
|
this.data.push(bits7 & num);
|
|
}
|
|
|
|
writeVarString (str) {
|
|
let encodedString = unescape(encodeURIComponent(str));
|
|
let bytes = encodedString.split('').map(c => c.codePointAt());
|
|
let len = bytes.length;
|
|
this.writeVarUint(len);
|
|
for (let i = 0; i < len; i++) {
|
|
this.data.push(bytes[i]);
|
|
}
|
|
}
|
|
|
|
writeID (id) {
|
|
const user = id.user;
|
|
this.writeVarUint(user);
|
|
if (user !== RootFakeUserID) {
|
|
this.writeVarUint(id.clock);
|
|
} else {
|
|
this.writeVarString(id.name);
|
|
this.writeVarUint(id.type);
|
|
}
|
|
}
|
|
}
|
|
|
|
function readStateSet (decoder) {
|
|
let ss = new Map();
|
|
let ssLength = decoder.readUint32();
|
|
for (let i = 0; i < ssLength; i++) {
|
|
let user = decoder.readVarUint();
|
|
let clock = decoder.readVarUint();
|
|
ss.set(user, clock);
|
|
}
|
|
return ss
|
|
}
|
|
|
|
function writeStateSet (y, encoder) {
|
|
let lenPosition = encoder.pos;
|
|
let len = 0;
|
|
encoder.writeUint32(0);
|
|
for (let [user, clock] of y.ss.state) {
|
|
encoder.writeVarUint(user);
|
|
encoder.writeVarUint(clock);
|
|
len++;
|
|
}
|
|
encoder.setUint32(lenPosition, len);
|
|
}
|
|
|
|
function writeDeleteSet (y, encoder) {
|
|
let currentUser = null;
|
|
let currentLength;
|
|
let lastLenPos;
|
|
|
|
let numberOfUsers = 0;
|
|
let laterDSLenPus = encoder.pos;
|
|
encoder.writeUint32(0);
|
|
|
|
y.ds.iterate(null, null, function (n) {
|
|
var user = n._id.user;
|
|
var clock = n._id.clock;
|
|
var len = n.len;
|
|
var gc = n.gc;
|
|
if (currentUser !== user) {
|
|
numberOfUsers++;
|
|
// a new user was found
|
|
if (currentUser !== null) { // happens on first iteration
|
|
encoder.setUint32(lastLenPos, currentLength);
|
|
}
|
|
currentUser = user;
|
|
encoder.writeVarUint(user);
|
|
// pseudo-fill pos
|
|
lastLenPos = encoder.pos;
|
|
encoder.writeUint32(0);
|
|
currentLength = 0;
|
|
}
|
|
encoder.writeVarUint(clock);
|
|
encoder.writeVarUint(len);
|
|
encoder.writeUint8(gc ? 1 : 0);
|
|
currentLength++;
|
|
});
|
|
if (currentUser !== null) { // happens on first iteration
|
|
encoder.setUint32(lastLenPos, currentLength);
|
|
}
|
|
encoder.setUint32(laterDSLenPus, numberOfUsers);
|
|
}
|
|
|
|
function readDeleteSet (y, decoder) {
|
|
let dsLength = decoder.readUint32();
|
|
for (let i = 0; i < dsLength; i++) {
|
|
let user = decoder.readVarUint();
|
|
let dv = [];
|
|
let dvLength = decoder.readUint32();
|
|
for (let j = 0; j < dvLength; j++) {
|
|
let from = decoder.readVarUint();
|
|
let len = decoder.readVarUint();
|
|
let gc = decoder.readUint8() === 1;
|
|
dv.push([from, len, gc]);
|
|
}
|
|
if (dvLength > 0) {
|
|
let pos = 0;
|
|
let d = dv[pos];
|
|
let deletions = [];
|
|
y.ds.iterate(new ID(user, 0), new ID(user, Number.MAX_VALUE), function (n) {
|
|
// cases:
|
|
// 1. d deletes something to the right of n
|
|
// => go to next n (break)
|
|
// 2. d deletes something to the left of n
|
|
// => create deletions
|
|
// => reset d accordingly
|
|
// *)=> if d doesn't delete anything anymore, go to next d (continue)
|
|
// 3. not 2) and d deletes something that also n deletes
|
|
// => reset d so that it doesn't contain n's deletion
|
|
// *)=> if d does not delete anything anymore, go to next d (continue)
|
|
while (d != null) {
|
|
var diff = 0; // describe the diff of length in 1) and 2)
|
|
if (n._id.clock + n.len <= d[0]) {
|
|
// 1)
|
|
break
|
|
} else if (d[0] < n._id.clock) {
|
|
// 2)
|
|
// delete maximum the len of d
|
|
// else delete as much as possible
|
|
diff = Math.min(n._id.clock - d[0], d[1]);
|
|
// deleteItemRange(y, user, d[0], diff)
|
|
deletions.push([user, d[0], diff]);
|
|
} else {
|
|
// 3)
|
|
diff = n._id.clock + n.len - d[0]; // never null (see 1)
|
|
if (d[2] && !n.gc) {
|
|
// d marks as gc'd but n does not
|
|
// then delete either way
|
|
// deleteItemRange(y, user, d[0], Math.min(diff, d[1]))
|
|
deletions.push([user, d[0], Math.min(diff, d[1])]);
|
|
}
|
|
}
|
|
if (d[1] <= diff) {
|
|
// d doesn't delete anything anymore
|
|
d = dv[++pos];
|
|
} else {
|
|
d[0] = d[0] + diff; // reset pos
|
|
d[1] = d[1] - diff; // reset length
|
|
}
|
|
}
|
|
});
|
|
// TODO: It would be more performant to apply the deletes in the above loop
|
|
// Adapt the Tree implementation to support delete while iterating
|
|
for (let i = deletions.length - 1; i >= 0; i--) {
|
|
const del = deletions[i];
|
|
deleteItemRange(y, del[0], del[1], del[2]);
|
|
}
|
|
// for the rest.. just apply it
|
|
for (; pos < dv.length; pos++) {
|
|
d = dv[pos];
|
|
deleteItemRange(y, user, d[0], d[1]);
|
|
// deletions.push([user, d[0], d[1], d[2]])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function stringifySyncStep1 (y, decoder, strBuilder) {
|
|
let auth = decoder.readVarString();
|
|
let protocolVersion = decoder.readVarUint();
|
|
strBuilder.push(` - auth: "${auth}"`);
|
|
strBuilder.push(` - protocolVersion: ${protocolVersion}`);
|
|
// write SS
|
|
let ssBuilder = [];
|
|
let len = decoder.readUint32();
|
|
for (let i = 0; i < len; i++) {
|
|
let user = decoder.readVarUint();
|
|
let clock = decoder.readVarUint();
|
|
ssBuilder.push(`(${user}:${clock})`);
|
|
}
|
|
strBuilder.push(' == SS: ' + ssBuilder.join(','));
|
|
}
|
|
|
|
function sendSyncStep1 (connector, syncUser) {
|
|
let encoder = new BinaryEncoder();
|
|
encoder.writeVarString(connector.y.room);
|
|
encoder.writeVarString('sync step 1');
|
|
encoder.writeVarString(connector.authInfo || '');
|
|
encoder.writeVarUint(connector.protocolVersion);
|
|
writeStateSet(connector.y, encoder);
|
|
connector.send(syncUser, encoder.createBuffer());
|
|
}
|
|
|
|
function writeStructs (y, encoder, ss) {
|
|
const lenPos = encoder.pos;
|
|
encoder.writeUint32(0);
|
|
let len = 0;
|
|
for (let user of y.ss.state.keys()) {
|
|
let clock = ss.get(user) || 0;
|
|
if (user !== RootFakeUserID) {
|
|
y.os.iterate(new ID(user, clock), new ID(user, Number.MAX_VALUE), function (struct) {
|
|
struct._toBinary(encoder);
|
|
len++;
|
|
});
|
|
}
|
|
}
|
|
encoder.setUint32(lenPos, len);
|
|
}
|
|
|
|
function readSyncStep1 (decoder, encoder, y, senderConn, sender) {
|
|
let protocolVersion = decoder.readVarUint();
|
|
// check protocol version
|
|
if (protocolVersion !== y.connector.protocolVersion) {
|
|
console.warn(
|
|
`You tried to sync with a Yjs instance that has a different protocol version
|
|
(You: ${protocolVersion}, Client: ${protocolVersion}).
|
|
`);
|
|
y.destroy();
|
|
}
|
|
// write sync step 2
|
|
encoder.writeVarString('sync step 2');
|
|
encoder.writeVarString(y.connector.authInfo || '');
|
|
const ss = readStateSet(decoder);
|
|
writeStructs(y, encoder, ss);
|
|
writeDeleteSet(y, encoder);
|
|
y.connector.send(senderConn.uid, encoder.createBuffer());
|
|
senderConn.receivedSyncStep2 = true;
|
|
if (y.connector.role === 'slave') {
|
|
sendSyncStep1(y.connector, sender);
|
|
}
|
|
}
|
|
|
|
function stringifySyncStep2 (y, decoder, strBuilder) {
|
|
strBuilder.push(' - auth: ' + decoder.readVarString());
|
|
strBuilder.push(' == OS:');
|
|
stringifyStructs(y, decoder, strBuilder);
|
|
// write DS to string
|
|
strBuilder.push(' == DS:');
|
|
let len = decoder.readUint32();
|
|
for (let i = 0; i < len; i++) {
|
|
let user = decoder.readVarUint();
|
|
strBuilder.push(` User: ${user}: `);
|
|
let len2 = decoder.readUint32();
|
|
for (let j = 0; j < len2; j++) {
|
|
let from = decoder.readVarUint();
|
|
let to = decoder.readVarUint();
|
|
let gc = decoder.readUint8() === 1;
|
|
strBuilder.push(`[${from}, ${to}, ${gc}]`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function readSyncStep2 (decoder, encoder, y, senderConn, sender) {
|
|
integrateRemoteStructs(y, decoder);
|
|
readDeleteSet(y, decoder);
|
|
y.connector._setSyncedWith(sender);
|
|
}
|
|
|
|
function messageToString ([y, buffer]) {
|
|
let decoder = new BinaryDecoder(buffer);
|
|
decoder.readVarString(); // read roomname
|
|
let type = decoder.readVarString();
|
|
let strBuilder = [];
|
|
strBuilder.push('\n === ' + type + ' ===');
|
|
if (type === 'update') {
|
|
stringifyStructs(y, decoder, strBuilder);
|
|
} else if (type === 'sync step 1') {
|
|
stringifySyncStep1(y, decoder, strBuilder);
|
|
} else if (type === 'sync step 2') {
|
|
stringifySyncStep2(y, decoder, strBuilder);
|
|
} else {
|
|
strBuilder.push('-- Unknown message type - probably an encoding issue!!!');
|
|
}
|
|
return strBuilder.join('\n')
|
|
}
|
|
|
|
function messageToRoomname (buffer) {
|
|
let decoder = new BinaryDecoder(buffer);
|
|
decoder.readVarString(); // roomname
|
|
return decoder.readVarString() // messageType
|
|
}
|
|
|
|
function logID (id) {
|
|
if (id !== null && id._id != null) {
|
|
id = id._id;
|
|
}
|
|
if (id === null) {
|
|
return '()'
|
|
} else if (id instanceof ID) {
|
|
return `(${id.user},${id.clock})`
|
|
} else if (id instanceof RootID) {
|
|
return `(${id.name},${id.type})`
|
|
} else if (id.constructor === Y$1) {
|
|
return `y`
|
|
} else {
|
|
throw new Error('This is not a valid ID!')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete all items in an ID-range
|
|
* TODO: implement getItemCleanStartNode for better performance (only one lookup)
|
|
*/
|
|
function deleteItemRange (y, user, clock, range) {
|
|
const createDelete = y.connector !== null && y.connector._forwardAppliedStructs;
|
|
let item = y.os.getItemCleanStart(new ID(user, clock));
|
|
if (item !== null) {
|
|
if (!item._deleted) {
|
|
item._splitAt(y, range);
|
|
item._delete(y, createDelete);
|
|
}
|
|
let itemLen = item._length;
|
|
range -= itemLen;
|
|
clock += itemLen;
|
|
if (range > 0) {
|
|
let node = y.os.findNode(new ID(user, clock));
|
|
while (node !== null && range > 0 && node.val._id.equals(new ID(user, clock))) {
|
|
const nodeVal = node.val;
|
|
if (!nodeVal._deleted) {
|
|
nodeVal._splitAt(y, range);
|
|
nodeVal._delete(y, createDelete);
|
|
}
|
|
const nodeLen = nodeVal._length;
|
|
range -= nodeLen;
|
|
clock += nodeLen;
|
|
node = node.next();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete is not a real struct. It will not be saved in OS
|
|
*/
|
|
class Delete {
|
|
constructor () {
|
|
this._target = null;
|
|
this._length = null;
|
|
}
|
|
_fromBinary (y, decoder) {
|
|
// TODO: set target, and add it to missing if not found
|
|
// There is an edge case in p2p networks!
|
|
const targetID = decoder.readID();
|
|
this._targetID = targetID;
|
|
this._length = decoder.readVarUint();
|
|
if (y.os.getItem(targetID) === null) {
|
|
return [targetID]
|
|
} else {
|
|
return []
|
|
}
|
|
}
|
|
_toBinary (encoder) {
|
|
encoder.writeUint8(getReference(this.constructor));
|
|
encoder.writeID(this._targetID);
|
|
encoder.writeVarUint(this._length);
|
|
}
|
|
/**
|
|
* - If created remotely (a remote user deleted something),
|
|
* this Delete is applied to all structs in id-range.
|
|
* - If created lokally (e.g. when y-array deletes a range of elements),
|
|
* this struct is broadcasted only (it is already executed)
|
|
*/
|
|
_integrate (y, locallyCreated = false) {
|
|
if (!locallyCreated) {
|
|
// from remote
|
|
const id = this._targetID;
|
|
deleteItemRange(y, id.user, id.clock, this._length);
|
|
} else if (y.connector !== null) {
|
|
// from local
|
|
y.connector.broadcastStruct(this);
|
|
}
|
|
if (y.persistence !== null) {
|
|
y.persistence.saveStruct(y, this);
|
|
}
|
|
}
|
|
_logString () {
|
|
return `Delete - target: ${logID(this._targetID)}, len: ${this._length}`
|
|
}
|
|
}
|
|
|
|
class Transaction {
|
|
constructor (y) {
|
|
this.y = y;
|
|
// 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)
|
|
this.changedTypes = new Map();
|
|
this.deletedStructs = new Set();
|
|
this.beforeState = new Map();
|
|
this.changedParentTypes = new Map();
|
|
}
|
|
}
|
|
|
|
function transactionTypeChanged (y, type, sub) {
|
|
if (type !== y && !type._deleted && !y._transaction.newTypes.has(type)) {
|
|
const changedTypes = y._transaction.changedTypes;
|
|
let subs = changedTypes.get(type);
|
|
if (subs === undefined) {
|
|
// create if it doesn't exist yet
|
|
subs = new Set();
|
|
changedTypes.set(type, subs);
|
|
}
|
|
subs.add(sub);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper utility to split an Item (see _splitAt)
|
|
* - copy all properties from a to b
|
|
* - connect a to b
|
|
* - assigns the correct _id
|
|
* - save b to os
|
|
*/
|
|
function splitHelper (y, a, b, diff) {
|
|
const aID = a._id;
|
|
b._id = new ID(aID.user, aID.clock + diff);
|
|
b._origin = a;
|
|
b._left = a;
|
|
b._right = a._right;
|
|
if (b._right !== null) {
|
|
b._right._left = b;
|
|
}
|
|
b._right_origin = a._right_origin;
|
|
// do not set a._right_origin, as this will lead to problems when syncing
|
|
a._right = b;
|
|
b._parent = a._parent;
|
|
b._parentSub = a._parentSub;
|
|
b._deleted = a._deleted;
|
|
// now search all relevant items to the right and update origin
|
|
// if origin is not it foundOrigins, we don't have to search any longer
|
|
let foundOrigins = new Set();
|
|
foundOrigins.add(a);
|
|
let o = b._right;
|
|
while (o !== null && foundOrigins.has(o._origin)) {
|
|
if (o._origin === a) {
|
|
o._origin = b;
|
|
}
|
|
foundOrigins.add(o);
|
|
o = o._right;
|
|
}
|
|
y.os.put(b);
|
|
}
|
|
|
|
class Item {
|
|
constructor () {
|
|
this._id = null;
|
|
this._origin = null;
|
|
this._left = null;
|
|
this._right = null;
|
|
this._right_origin = null;
|
|
this._parent = null;
|
|
this._parentSub = null;
|
|
this._deleted = false;
|
|
}
|
|
/**
|
|
* Copy the effect of struct
|
|
*/
|
|
_copy (undeleteChildren, copyPosition) {
|
|
let struct = new this.constructor();
|
|
if (copyPosition) {
|
|
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)
|
|
}
|
|
get _length () {
|
|
return 1
|
|
}
|
|
/**
|
|
* Splits this struct so that another struct can be inserted in-between.
|
|
* This must be overwritten if _length > 1
|
|
* Returns right part after split
|
|
* - diff === 0 => this
|
|
* - diff === length => this._right
|
|
* - otherwise => split _content and return right part of split
|
|
* (see ItemJSON/ItemString for implementation)
|
|
*/
|
|
_splitAt (y, diff) {
|
|
if (diff === 0) {
|
|
return this
|
|
}
|
|
return this._right
|
|
}
|
|
_delete (y, createDelete = true) {
|
|
if (!this._deleted) {
|
|
this._deleted = true;
|
|
y.ds.markDeleted(this._id, this._length);
|
|
let del = new Delete();
|
|
del._targetID = this._id;
|
|
del._length = this._length;
|
|
if (createDelete) {
|
|
// broadcast and persists Delete
|
|
del._integrate(y, true);
|
|
} else if (y.persistence !== null) {
|
|
// only persist Delete
|
|
y.persistence.saveStruct(y, del);
|
|
}
|
|
transactionTypeChanged(y, this._parent, this._parentSub);
|
|
y._transaction.deletedStructs.add(this);
|
|
}
|
|
}
|
|
/**
|
|
* This is called right before this struct receives any children.
|
|
* It can be overwritten to apply pending changes before applying remote changes
|
|
*/
|
|
_beforeChange () {
|
|
// nop
|
|
}
|
|
/*
|
|
* - Integrate the struct so that other types/structs can see it
|
|
* - Add this struct to y.os
|
|
* - Check if this is struct deleted
|
|
*/
|
|
_integrate (y) {
|
|
y._transaction.newTypes.add(this);
|
|
const parent = this._parent;
|
|
const selfID = this._id;
|
|
const user = selfID === null ? y.userID : selfID.user;
|
|
const userState = y.ss.getState(user);
|
|
if (selfID === null) {
|
|
this._id = y.ss.getNextID(this._length);
|
|
} else if (selfID.user === RootFakeUserID) {
|
|
// nop
|
|
} else if (selfID.clock < userState) {
|
|
// already applied..
|
|
return []
|
|
} else if (selfID.clock === userState) {
|
|
y.ss.setState(selfID.user, userState + this._length);
|
|
} else {
|
|
// missing content from user
|
|
throw new Error('Can not apply yet!')
|
|
}
|
|
if (!parent._deleted && !y._transaction.changedTypes.has(parent) && !y._transaction.newTypes.has(parent)) {
|
|
// this is the first time parent is updated
|
|
// or this types is new
|
|
this._parent._beforeChange();
|
|
}
|
|
/*
|
|
# $this has to find a unique position between origin and the next known character
|
|
# case 1: $origin equals $o.origin: the $creator parameter decides if left or right
|
|
# let $OL= [o1,o2,o3,o4], whereby $this is to be inserted between o1 and o4
|
|
# o2,o3 and o4 origin is 1 (the position of o2)
|
|
# there is the case that $this.creator < o2.creator, but o3.creator < $this.creator
|
|
# then o2 knows o3. Since on another client $OL could be [o1,o3,o4] the problem is complex
|
|
# therefore $this would be always to the right of o3
|
|
# case 2: $origin < $o.origin
|
|
# if current $this insert_position > $o origin: $this ins
|
|
# else $insert_position will not change
|
|
# (maybe we encounter case 1 later, then this will be to the right of $o)
|
|
# case 3: $origin > $o.origin
|
|
# $this insert_position is to the left of $o (forever!)
|
|
*/
|
|
// handle conflicts
|
|
let o;
|
|
// set o to the first conflicting item
|
|
if (this._left !== null) {
|
|
o = this._left._right;
|
|
} else if (this._parentSub !== null) {
|
|
o = this._parent._map.get(this._parentSub) || null;
|
|
} else {
|
|
o = this._parent._start;
|
|
}
|
|
let conflictingItems = new Set();
|
|
let itemsBeforeOrigin = new Set();
|
|
// Let c in conflictingItems, b in itemsBeforeOrigin
|
|
// ***{origin}bbbb{this}{c,b}{c,b}{o}***
|
|
// Note that conflictingItems is a subset of itemsBeforeOrigin
|
|
while (o !== null && o !== this._right) {
|
|
itemsBeforeOrigin.add(o);
|
|
conflictingItems.add(o);
|
|
if (this._origin === o._origin) {
|
|
// case 1
|
|
if (o._id.user < this._id.user) {
|
|
this._left = o;
|
|
conflictingItems.clear();
|
|
}
|
|
} else if (itemsBeforeOrigin.has(o._origin)) {
|
|
// case 2
|
|
if (!conflictingItems.has(o._origin)) {
|
|
this._left = o;
|
|
conflictingItems.clear();
|
|
}
|
|
} else {
|
|
break
|
|
}
|
|
// TODO: try to use right_origin instead.
|
|
// Then you could basically omit conflictingItems!
|
|
// Note: you probably can't use right_origin in every case.. only when setting _left
|
|
o = o._right;
|
|
}
|
|
// reconnect left/right + update parent map/start if necessary
|
|
const parentSub = this._parentSub;
|
|
if (this._left === null) {
|
|
let right;
|
|
if (parentSub !== null) {
|
|
const pmap = parent._map;
|
|
right = pmap.get(parentSub) || null;
|
|
pmap.set(parentSub, this);
|
|
} else {
|
|
right = parent._start;
|
|
parent._start = this;
|
|
}
|
|
this._right = right;
|
|
if (right !== null) {
|
|
right._left = this;
|
|
}
|
|
} else {
|
|
const left = this._left;
|
|
const right = left._right;
|
|
this._right = right;
|
|
left._right = this;
|
|
if (right !== null) {
|
|
right._left = this;
|
|
}
|
|
}
|
|
if (parent._deleted) {
|
|
this._delete(y, false);
|
|
}
|
|
y.os.put(this);
|
|
transactionTypeChanged(y, parent, parentSub);
|
|
if (this._id.user !== RootFakeUserID) {
|
|
if (y.connector !== null && (y.connector._forwardAppliedStructs || this._id.user === y.userID)) {
|
|
y.connector.broadcastStruct(this);
|
|
}
|
|
if (y.persistence !== null) {
|
|
y.persistence.saveStruct(y, this);
|
|
}
|
|
}
|
|
}
|
|
_toBinary (encoder) {
|
|
encoder.writeUint8(getReference(this.constructor));
|
|
let info = 0;
|
|
if (this._origin !== null) {
|
|
info += 0b1; // origin is defined
|
|
}
|
|
// TODO: remove
|
|
/* no longer send _left
|
|
if (this._left !== this._origin) {
|
|
info += 0b10 // do not copy origin to left
|
|
}
|
|
*/
|
|
if (this._right_origin !== null) {
|
|
info += 0b100;
|
|
}
|
|
if (this._parentSub !== null) {
|
|
info += 0b1000;
|
|
}
|
|
encoder.writeUint8(info);
|
|
encoder.writeID(this._id);
|
|
if (info & 0b1) {
|
|
encoder.writeID(this._origin._lastId);
|
|
}
|
|
// TODO: remove
|
|
/* see above
|
|
if (info & 0b10) {
|
|
encoder.writeID(this._left._lastId)
|
|
}
|
|
*/
|
|
if (info & 0b100) {
|
|
encoder.writeID(this._right_origin._id);
|
|
}
|
|
if ((info & 0b101) === 0) {
|
|
// neither origin nor right is defined
|
|
encoder.writeID(this._parent._id);
|
|
}
|
|
if (info & 0b1000) {
|
|
encoder.writeVarString(JSON.stringify(this._parentSub));
|
|
}
|
|
}
|
|
_fromBinary (y, decoder) {
|
|
let missing = [];
|
|
const info = decoder.readUint8();
|
|
const id = decoder.readID();
|
|
this._id = id;
|
|
// read origin
|
|
if (info & 0b1) {
|
|
// origin != null
|
|
const originID = decoder.readID();
|
|
// we have to query for left again because it might have been split/merged..
|
|
const origin = y.os.getItemCleanEnd(originID);
|
|
if (origin === null) {
|
|
missing.push(originID);
|
|
} else {
|
|
this._origin = origin;
|
|
this._left = this._origin;
|
|
}
|
|
}
|
|
// read right
|
|
if (info & 0b100) {
|
|
// right != null
|
|
const rightID = decoder.readID();
|
|
// we have to query for right again because it might have been split/merged..
|
|
const right = y.os.getItemCleanStart(rightID);
|
|
if (right === null) {
|
|
missing.push(rightID);
|
|
} else {
|
|
this._right = right;
|
|
this._right_origin = right;
|
|
}
|
|
}
|
|
// read parent
|
|
if ((info & 0b101) === 0) {
|
|
// neither origin nor right is defined
|
|
const parentID = decoder.readID();
|
|
// parent does not change, so we don't have to search for it again
|
|
if (this._parent === null) {
|
|
const parent = y.os.get(parentID);
|
|
if (parent === null) {
|
|
missing.push(parentID);
|
|
} else {
|
|
this._parent = parent;
|
|
}
|
|
}
|
|
} else if (this._parent === null) {
|
|
if (this._origin !== null) {
|
|
this._parent = this._origin._parent;
|
|
} else if (this._right_origin !== null) {
|
|
this._parent = this._right_origin._parent;
|
|
}
|
|
}
|
|
if (info & 0b1000) {
|
|
// TODO: maybe put this in read parent condition (you can also read parentsub from left/right)
|
|
this._parentSub = JSON.parse(decoder.readVarString());
|
|
}
|
|
if (y.ss.getState(id.user) < id.clock) {
|
|
missing.push(new ID(id.user, id.clock - 1));
|
|
}
|
|
return missing
|
|
}
|
|
}
|
|
|
|
class EventHandler {
|
|
constructor () {
|
|
this.eventListeners = [];
|
|
}
|
|
destroy () {
|
|
this.eventListeners = null;
|
|
}
|
|
addEventListener (f) {
|
|
this.eventListeners.push(f);
|
|
}
|
|
removeEventListener (f) {
|
|
this.eventListeners = this.eventListeners.filter(function (g) {
|
|
return f !== g
|
|
});
|
|
}
|
|
removeAllEventListeners () {
|
|
this.eventListeners = [];
|
|
}
|
|
callEventListeners (transaction, event) {
|
|
for (var i = 0; i < this.eventListeners.length; i++) {
|
|
try {
|
|
const f = this.eventListeners[i];
|
|
f(event);
|
|
} catch (e) {
|
|
/*
|
|
Your observer threw an error. This error was caught so that Yjs
|
|
can ensure data consistency! In order to debug this error you
|
|
have to check "Pause On Caught Exceptions" in developer tools.
|
|
*/
|
|
console.error(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// restructure children as if they were inserted one after another
|
|
function integrateChildren (y, start) {
|
|
let right;
|
|
do {
|
|
right = start._right;
|
|
start._right = null;
|
|
start._right_origin = null;
|
|
start._origin = start._left;
|
|
start._integrate(y);
|
|
start = right;
|
|
} while (right !== null)
|
|
}
|
|
|
|
|
|
|
|
class Type extends Item {
|
|
constructor () {
|
|
super();
|
|
this._map = new Map();
|
|
this._start = null;
|
|
this._y = null;
|
|
this._eventHandler = new EventHandler();
|
|
this._deepEventHandler = new EventHandler();
|
|
}
|
|
getPathTo (type) {
|
|
if (type === this) {
|
|
return []
|
|
}
|
|
const path = [];
|
|
const y = this._y;
|
|
while (type._parent !== this && this._parent !== y) {
|
|
let parent = type._parent;
|
|
if (type._parentSub !== null) {
|
|
path.push(type._parentSub);
|
|
} else {
|
|
// parent is array-ish
|
|
for (let [i, child] of parent) {
|
|
if (child === type) {
|
|
path.push(i);
|
|
break
|
|
}
|
|
}
|
|
}
|
|
type = parent;
|
|
}
|
|
if (this._parent !== this) {
|
|
throw new Error('The type is not a child of this node')
|
|
}
|
|
return path
|
|
}
|
|
_callEventHandler (transaction, event) {
|
|
const changedParentTypes = transaction.changedParentTypes;
|
|
this._eventHandler.callEventListeners(transaction, event);
|
|
let type = this;
|
|
while (type !== this._y) {
|
|
let events = changedParentTypes.get(type);
|
|
if (events === undefined) {
|
|
events = [];
|
|
changedParentTypes.set(type, events);
|
|
}
|
|
events.push(event);
|
|
type = type._parent;
|
|
}
|
|
}
|
|
_copy (undeleteChildren, copyPosition) {
|
|
let copy = super._copy(undeleteChildren, copyPosition);
|
|
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, false);
|
|
_item._parent = copy;
|
|
_item._parentSub = key;
|
|
map.set(key, _item);
|
|
}
|
|
}
|
|
let prevUndeleted = null;
|
|
copy._start = null;
|
|
let item = this._start;
|
|
while (item !== null) {
|
|
if (undeleteChildren.has(item) || !item.deleted) {
|
|
let _item = item._copy(undeleteChildren, false);
|
|
_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;
|
|
if (y !== null) {
|
|
y.transact(f);
|
|
} else {
|
|
f(y);
|
|
}
|
|
}
|
|
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) {
|
|
super._integrate(y);
|
|
this._y = y;
|
|
// when integrating children we must make sure to
|
|
// integrate start
|
|
const start = this._start;
|
|
if (start !== null) {
|
|
this._start = null;
|
|
integrateChildren(y, start);
|
|
}
|
|
// integrate map children
|
|
const map = this._map;
|
|
this._map = new Map();
|
|
for (let t of map.values()) {
|
|
// TODO make sure that right elements are deleted!
|
|
integrateChildren(y, t);
|
|
}
|
|
}
|
|
_delete (y, createDelete) {
|
|
super._delete(y, createDelete);
|
|
y._transaction.changedTypes.delete(this);
|
|
// delete map types
|
|
for (let value of this._map.values()) {
|
|
if (value instanceof Item && !value._deleted) {
|
|
value._delete(y, false);
|
|
}
|
|
}
|
|
// delete array types
|
|
let t = this._start;
|
|
while (t !== null) {
|
|
if (!t._deleted) {
|
|
t._delete(y, false);
|
|
}
|
|
t = t._right;
|
|
}
|
|
}
|
|
}
|
|
|
|
class ItemJSON extends Item {
|
|
constructor () {
|
|
super();
|
|
this._content = null;
|
|
}
|
|
_copy (undeleteChildren, copyPosition) {
|
|
let struct = super._copy(undeleteChildren, copyPosition);
|
|
struct._content = this._content;
|
|
return struct
|
|
}
|
|
get _length () {
|
|
return this._content.length
|
|
}
|
|
_fromBinary (y, decoder) {
|
|
let missing = super._fromBinary(y, decoder);
|
|
let len = decoder.readVarUint();
|
|
this._content = new Array(len);
|
|
for (let i = 0; i < len; i++) {
|
|
const ctnt = decoder.readVarString();
|
|
let parsed;
|
|
if (ctnt === 'undefined') {
|
|
parsed = undefined;
|
|
} else {
|
|
parsed = JSON.parse(ctnt);
|
|
}
|
|
this._content[i] = parsed;
|
|
}
|
|
return missing
|
|
}
|
|
_toBinary (encoder) {
|
|
super._toBinary(encoder);
|
|
let len = this._content.length;
|
|
encoder.writeVarUint(len);
|
|
for (let i = 0; i < len; i++) {
|
|
let encoded;
|
|
let content = this._content[i];
|
|
if (content === undefined) {
|
|
encoded = 'undefined';
|
|
} else {
|
|
encoded = JSON.stringify(content);
|
|
}
|
|
encoder.writeVarString(encoded);
|
|
}
|
|
}
|
|
_logString () {
|
|
const left = this._left !== null ? this._left._lastId : null;
|
|
const origin = this._origin !== null ? this._origin._lastId : null;
|
|
return `ItemJSON(id:${logID(this._id)},content:${JSON.stringify(this._content)},left:${logID(left)},origin:${logID(origin)},right:${logID(this._right)},parent:${logID(this._parent)},parentSub:${this._parentSub})`
|
|
}
|
|
_splitAt (y, diff) {
|
|
if (diff === 0) {
|
|
return this
|
|
} else if (diff >= this._length) {
|
|
return this._right
|
|
}
|
|
let item = new ItemJSON();
|
|
item._content = this._content.splice(diff);
|
|
splitHelper(y, this, item, diff);
|
|
return item
|
|
}
|
|
}
|
|
|
|
class ItemString extends Item {
|
|
constructor () {
|
|
super();
|
|
this._content = null;
|
|
}
|
|
_copy (undeleteChildren, copyPosition) {
|
|
let struct = super._copy(undeleteChildren, copyPosition);
|
|
struct._content = this._content;
|
|
return struct
|
|
}
|
|
get _length () {
|
|
return this._content.length
|
|
}
|
|
_fromBinary (y, decoder) {
|
|
let missing = super._fromBinary(y, decoder);
|
|
this._content = decoder.readVarString();
|
|
return missing
|
|
}
|
|
_toBinary (encoder) {
|
|
super._toBinary(encoder);
|
|
encoder.writeVarString(this._content);
|
|
}
|
|
_logString () {
|
|
const left = this._left !== null ? this._left._lastId : null;
|
|
const origin = this._origin !== null ? this._origin._lastId : null;
|
|
return `ItemJSON(id:${logID(this._id)},content:${JSON.stringify(this._content)},left:${logID(left)},origin:${logID(origin)},right:${logID(this._right)},parent:${logID(this._parent)},parentSub:${this._parentSub})`
|
|
}
|
|
_splitAt (y, diff) {
|
|
if (diff === 0) {
|
|
return this
|
|
} else if (diff >= this._length) {
|
|
return this._right
|
|
}
|
|
let item = new ItemString();
|
|
item._content = this._content.slice(diff);
|
|
this._content = this._content.slice(0, diff);
|
|
splitHelper(y, this, item, diff);
|
|
return item
|
|
}
|
|
}
|
|
|
|
class YEvent {
|
|
constructor (target) {
|
|
this.target = target;
|
|
this.currentTarget = target;
|
|
}
|
|
get path () {
|
|
const path = [];
|
|
let type = this.target;
|
|
const y = type._y;
|
|
while (type !== this.currentTarget && type !== y) {
|
|
let parent = type._parent;
|
|
if (type._parentSub !== null) {
|
|
path.unshift(type._parentSub);
|
|
} else {
|
|
// parent is array-ish
|
|
for (let [i, child] of parent) {
|
|
if (child === type) {
|
|
path.unshift(i);
|
|
break
|
|
}
|
|
}
|
|
}
|
|
type = parent;
|
|
}
|
|
return path
|
|
}
|
|
}
|
|
|
|
class YArrayEvent extends YEvent {
|
|
constructor (yarray, remote, transaction) {
|
|
super(yarray);
|
|
this.remote = remote;
|
|
this._transaction = transaction;
|
|
this._addedElements = null;
|
|
}
|
|
get addedElements () {
|
|
if (this._addedElements === null) {
|
|
const target = this.target;
|
|
const transaction = this._transaction;
|
|
const addedElements = new Set();
|
|
transaction.newTypes.forEach(function (type) {
|
|
if (type._parent === target && !transaction.deletedStructs.has(type)) {
|
|
addedElements.add(type);
|
|
}
|
|
});
|
|
this._addedElements = addedElements;
|
|
}
|
|
return this._addedElements
|
|
}
|
|
get removedElements () {
|
|
const target = this.target;
|
|
const transaction = this._transaction;
|
|
const removedElements = new Set();
|
|
transaction.deletedStructs.forEach(function (struct) {
|
|
if (struct._parent === target && !transaction.newTypes.has(struct)) {
|
|
removedElements.add(struct);
|
|
}
|
|
});
|
|
return removedElements
|
|
}
|
|
}
|
|
|
|
class YArray extends Type {
|
|
_callObserver (transaction, parentSubs, remote) {
|
|
this._callEventHandler(transaction, new YArrayEvent(this, remote, transaction));
|
|
}
|
|
get (pos) {
|
|
let n = this._start;
|
|
while (n !== null) {
|
|
if (!n._deleted) {
|
|
if (pos < n._length) {
|
|
if (n.constructor === ItemJSON || n.constructor === ItemString) {
|
|
return n._content[pos]
|
|
} else {
|
|
return n
|
|
}
|
|
}
|
|
pos -= n._length;
|
|
}
|
|
n = n._right;
|
|
}
|
|
}
|
|
toArray () {
|
|
return this.map(c => c)
|
|
}
|
|
toJSON () {
|
|
return this.map(c => {
|
|
if (c instanceof Type) {
|
|
if (c.toJSON !== null) {
|
|
return c.toJSON()
|
|
} else {
|
|
return c.toString()
|
|
}
|
|
}
|
|
return c
|
|
})
|
|
}
|
|
map (f) {
|
|
const res = [];
|
|
this.forEach((c, i) => {
|
|
res.push(f(c, i, this));
|
|
});
|
|
return res
|
|
}
|
|
forEach (f) {
|
|
let pos = 0;
|
|
let n = this._start;
|
|
while (n !== null) {
|
|
if (!n._deleted) {
|
|
if (n instanceof Type) {
|
|
f(n, pos++, this);
|
|
} else {
|
|
const content = n._content;
|
|
const contentLen = content.length;
|
|
for (let i = 0; i < contentLen; i++) {
|
|
pos++;
|
|
f(content[i], pos, this);
|
|
}
|
|
}
|
|
}
|
|
n = n._right;
|
|
}
|
|
}
|
|
get length () {
|
|
let length = 0;
|
|
let n = this._start;
|
|
while (n !== null) {
|
|
if (!n._deleted) {
|
|
length += n._length;
|
|
}
|
|
n = n._right;
|
|
}
|
|
return length
|
|
}
|
|
[Symbol.iterator] () {
|
|
return {
|
|
next: function () {
|
|
while (this._item !== null && (this._item._deleted || this._item._length <= this._itemElement)) {
|
|
// item is deleted or itemElement does not exist (is deleted)
|
|
this._item = this._item._right;
|
|
this._itemElement = 0;
|
|
}
|
|
if (this._item === null) {
|
|
return {
|
|
done: true
|
|
}
|
|
}
|
|
let content;
|
|
if (this._item instanceof Type) {
|
|
content = this._item;
|
|
} else {
|
|
content = this._item._content[this._itemElement++];
|
|
}
|
|
return {
|
|
value: [this._count, content],
|
|
done: false
|
|
}
|
|
},
|
|
_item: this._start,
|
|
_itemElement: 0,
|
|
_count: 0
|
|
}
|
|
}
|
|
delete (pos, length = 1) {
|
|
this._y.transact(() => {
|
|
let item = this._start;
|
|
let count = 0;
|
|
while (item !== null && length > 0) {
|
|
if (!item._deleted) {
|
|
if (count <= pos && pos < count + item._length) {
|
|
const diffDel = pos - count;
|
|
item = item._splitAt(this._y, diffDel);
|
|
item._splitAt(this._y, length);
|
|
length -= item._length;
|
|
item._delete(this._y);
|
|
count += diffDel;
|
|
} else {
|
|
count += item._length;
|
|
}
|
|
}
|
|
item = item._right;
|
|
}
|
|
});
|
|
if (length > 0) {
|
|
throw new Error('Delete exceeds the range of the YArray')
|
|
}
|
|
}
|
|
insertAfter (left, content) {
|
|
this._transact(y => {
|
|
let right;
|
|
if (left === null) {
|
|
right = this._start;
|
|
} else {
|
|
right = left._right;
|
|
}
|
|
let prevJsonIns = null;
|
|
for (let i = 0; i < content.length; i++) {
|
|
let c = content[i];
|
|
if (typeof c === 'function') {
|
|
c = new c(); // eslint-disable-line new-cap
|
|
}
|
|
if (c instanceof Type) {
|
|
if (prevJsonIns !== null) {
|
|
if (y !== null) {
|
|
prevJsonIns._integrate(y);
|
|
}
|
|
left = prevJsonIns;
|
|
prevJsonIns = null;
|
|
}
|
|
c._origin = left;
|
|
c._left = left;
|
|
c._right = right;
|
|
c._right_origin = right;
|
|
c._parent = this;
|
|
if (y !== null) {
|
|
c._integrate(y);
|
|
} else if (left === null) {
|
|
this._start = c;
|
|
} else {
|
|
left._right = c;
|
|
}
|
|
left = c;
|
|
} else {
|
|
if (prevJsonIns === null) {
|
|
prevJsonIns = new ItemJSON();
|
|
prevJsonIns._origin = left;
|
|
prevJsonIns._left = left;
|
|
prevJsonIns._right = right;
|
|
prevJsonIns._right_origin = right;
|
|
prevJsonIns._parent = this;
|
|
prevJsonIns._content = [];
|
|
}
|
|
prevJsonIns._content.push(c);
|
|
}
|
|
}
|
|
if (prevJsonIns !== null) {
|
|
if (y !== null) {
|
|
prevJsonIns._integrate(y);
|
|
} else if (prevJsonIns._left === null) {
|
|
this._start = prevJsonIns;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
insert (pos, content) {
|
|
let left = null;
|
|
let right = this._start;
|
|
let count = 0;
|
|
const y = this._y;
|
|
while (right !== null) {
|
|
const rightLen = right._deleted ? 0 : (right._length - 1);
|
|
if (count <= pos && pos <= count + rightLen) {
|
|
const splitDiff = pos - count;
|
|
right = right._splitAt(y, splitDiff);
|
|
left = right._left;
|
|
count += splitDiff;
|
|
break
|
|
}
|
|
if (!right._deleted) {
|
|
count += right._length;
|
|
}
|
|
left = right;
|
|
right = right._right;
|
|
}
|
|
if (pos > count) {
|
|
throw new Error('Position exceeds array range!')
|
|
}
|
|
this.insertAfter(left, content);
|
|
}
|
|
push (content) {
|
|
let n = this._start;
|
|
let lastUndeleted = null;
|
|
while (n !== null) {
|
|
if (!n._deleted) {
|
|
lastUndeleted = n;
|
|
}
|
|
n = n._right;
|
|
}
|
|
this.insertAfter(lastUndeleted, content);
|
|
}
|
|
_logString () {
|
|
const left = this._left !== null ? this._left._lastId : null;
|
|
const origin = this._origin !== null ? this._origin._lastId : null;
|
|
return `YArray(id:${logID(this._id)},start:${logID(this._start)},left:${logID(left)},origin:${logID(origin)},right:${logID(this._right)},parent:${logID(this._parent)},parentSub:${this._parentSub})`
|
|
}
|
|
}
|
|
|
|
class YMapEvent extends YEvent {
|
|
constructor (ymap, subs, remote) {
|
|
super(ymap);
|
|
this.keysChanged = subs;
|
|
this.remote = remote;
|
|
}
|
|
}
|
|
|
|
class YMap extends Type {
|
|
_callObserver (transaction, parentSubs, remote) {
|
|
this._callEventHandler(transaction, new YMapEvent(this, parentSubs, remote));
|
|
}
|
|
toJSON () {
|
|
const map = {};
|
|
for (let [key, item] of this._map) {
|
|
if (!item._deleted) {
|
|
let res;
|
|
if (item instanceof Type) {
|
|
if (item.toJSON !== undefined) {
|
|
res = item.toJSON();
|
|
} else {
|
|
res = item.toString();
|
|
}
|
|
} else {
|
|
res = item._content[0];
|
|
}
|
|
map[key] = res;
|
|
}
|
|
}
|
|
return map
|
|
}
|
|
keys () {
|
|
let keys = [];
|
|
for (let [key, value] of this._map) {
|
|
if (!value._deleted) {
|
|
keys.push(key);
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
delete (key) {
|
|
this._transact((y) => {
|
|
let c = this._map.get(key);
|
|
if (y !== null && c !== undefined) {
|
|
c._delete(y);
|
|
}
|
|
});
|
|
}
|
|
set (key, value) {
|
|
this._transact(y => {
|
|
const old = this._map.get(key) || null;
|
|
if (old !== null) {
|
|
if (old.constructor === ItemJSON && !old._deleted && old._content[0] === value) {
|
|
// Trying to overwrite with same value
|
|
// break here
|
|
return value
|
|
}
|
|
if (y !== null) {
|
|
old._delete(y);
|
|
}
|
|
}
|
|
let v;
|
|
if (typeof value === 'function') {
|
|
v = new value(); // eslint-disable-line new-cap
|
|
value = v;
|
|
} else if (value instanceof Item) {
|
|
v = value;
|
|
} else {
|
|
v = new ItemJSON();
|
|
v._content = [value];
|
|
}
|
|
v._right = old;
|
|
v._right_origin = old;
|
|
v._parent = this;
|
|
v._parentSub = key;
|
|
if (y !== null) {
|
|
v._integrate(y);
|
|
} else {
|
|
this._map.set(key, v);
|
|
}
|
|
});
|
|
return value
|
|
}
|
|
get (key) {
|
|
let v = this._map.get(key);
|
|
if (v === undefined || v._deleted) {
|
|
return undefined
|
|
}
|
|
if (v instanceof Type) {
|
|
return v
|
|
} else {
|
|
return v._content[v._content.length - 1]
|
|
}
|
|
}
|
|
has (key) {
|
|
let v = this._map.get(key);
|
|
if (v === undefined || v._deleted) {
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
_logString () {
|
|
const left = this._left !== null ? this._left._lastId : null;
|
|
const origin = this._origin !== null ? this._origin._lastId : null;
|
|
return `YMap(id:${logID(this._id)},mapSize:${this._map.size},left:${logID(left)},origin:${logID(origin)},right:${logID(this._right)},parent:${logID(this._parent)},parentSub:${this._parentSub})`
|
|
}
|
|
}
|
|
|
|
class YText extends YArray {
|
|
constructor (string) {
|
|
super();
|
|
if (typeof string === 'string') {
|
|
const start = new ItemString();
|
|
start._parent = this;
|
|
start._content = string;
|
|
this._start = start;
|
|
}
|
|
}
|
|
toString () {
|
|
const strBuilder = [];
|
|
let n = this._start;
|
|
while (n !== null) {
|
|
if (!n._deleted) {
|
|
strBuilder.push(n._content);
|
|
}
|
|
n = n._right;
|
|
}
|
|
return strBuilder.join('')
|
|
}
|
|
insert (pos, text) {
|
|
if (text.length <= 0) {
|
|
return
|
|
}
|
|
this._transact(y => {
|
|
let left = null;
|
|
let right = this._start;
|
|
let count = 0;
|
|
while (right !== null) {
|
|
const rightLen = right._deleted ? 0 : (right._length - 1);
|
|
if (count <= pos && pos <= count + rightLen) {
|
|
const splitDiff = pos - count;
|
|
right = right._splitAt(this._y, splitDiff);
|
|
left = right._left;
|
|
count += splitDiff;
|
|
break
|
|
}
|
|
if (!right._deleted) {
|
|
count += right._length;
|
|
}
|
|
left = right;
|
|
right = right._right;
|
|
}
|
|
if (pos > count) {
|
|
throw new Error('Position exceeds array range!')
|
|
}
|
|
let item = new ItemString();
|
|
item._origin = left;
|
|
item._left = left;
|
|
item._right = right;
|
|
item._right_origin = right;
|
|
item._parent = this;
|
|
item._content = text;
|
|
if (y !== null) {
|
|
item._integrate(this._y);
|
|
} else if (left === null) {
|
|
this._start = item;
|
|
} else {
|
|
left._right = item;
|
|
}
|
|
});
|
|
}
|
|
_logString () {
|
|
const left = this._left !== null ? this._left._lastId : null;
|
|
const origin = this._origin !== null ? this._origin._lastId : null;
|
|
return `YText(id:${logID(this._id)},start:${logID(this._start)},left:${logID(left)},origin:${logID(origin)},right:${logID(this._right)},parent:${logID(this._parent)},parentSub:${this._parentSub})`
|
|
}
|
|
}
|
|
|
|
function defaultDomFilter (node, attributes) {
|
|
return attributes
|
|
}
|
|
|
|
|
|
|
|
// get BoundingClientRect that works on text nodes
|
|
|
|
|
|
|
|
|
|
function iterateUntilUndeleted (item) {
|
|
while (item !== null && item._deleted) {
|
|
item = item._right;
|
|
}
|
|
return item
|
|
}
|
|
|
|
function _insertNodeHelper (yxml, prevExpectedNode, child) {
|
|
let insertedNodes = yxml.insertDomElementsAfter(prevExpectedNode, [child]);
|
|
if (insertedNodes.length > 0) {
|
|
return insertedNodes[0]
|
|
} else {
|
|
return prevExpectedNode
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 1. Check if any of the nodes was deleted
|
|
* 2. Iterate over the children.
|
|
* 2.1 If a node exists without _yxml property, insert a new node
|
|
* 2.2 If _contents.length < dom.childNodes.length, fill the
|
|
* rest of _content with childNodes
|
|
* 2.3 If a node was moved, delete it and
|
|
* recreate a new yxml element that is bound to that node.
|
|
* You can detect that a node was moved because expectedId
|
|
* !== actualId in the list
|
|
*/
|
|
function applyChangesFromDom (dom) {
|
|
const yxml = dom._yxml;
|
|
if (yxml.constructor === YXmlHook) {
|
|
return
|
|
}
|
|
const y = yxml._y;
|
|
let knownChildren =
|
|
new Set(
|
|
Array.prototype.map.call(dom.childNodes, child => child._yxml)
|
|
.filter(id => id !== undefined)
|
|
);
|
|
// 1. Check if any of the nodes was deleted
|
|
yxml.forEach(function (childType, i) {
|
|
if (!knownChildren.has(childType)) {
|
|
childType._delete(y);
|
|
}
|
|
});
|
|
// 2. iterate
|
|
let childNodes = dom.childNodes;
|
|
let len = childNodes.length;
|
|
let prevExpectedNode = null;
|
|
let expectedNode = iterateUntilUndeleted(yxml._start);
|
|
for (let domCnt = 0; domCnt < len; domCnt++) {
|
|
const child = childNodes[domCnt];
|
|
const childYXml = child._yxml;
|
|
if (childYXml != null) {
|
|
if (childYXml === false) {
|
|
// should be ignored or is going to be deleted
|
|
continue
|
|
}
|
|
if (expectedNode !== null) {
|
|
if (expectedNode !== childYXml) {
|
|
// 2.3 Not expected node
|
|
if (childYXml._parent !== this) {
|
|
// element is going to be deleted by its previous parent
|
|
child._yxml = null;
|
|
} else {
|
|
childYXml._delete(y);
|
|
}
|
|
prevExpectedNode = _insertNodeHelper(yxml, prevExpectedNode, child);
|
|
} else {
|
|
prevExpectedNode = expectedNode;
|
|
expectedNode = iterateUntilUndeleted(expectedNode._right);
|
|
}
|
|
// if this is the expected node id, just continue
|
|
} else {
|
|
// 2.2 fill _conten with child nodes
|
|
prevExpectedNode = _insertNodeHelper(yxml, prevExpectedNode, child);
|
|
}
|
|
} else {
|
|
// 2.1 A new node was found
|
|
prevExpectedNode = _insertNodeHelper(yxml, prevExpectedNode, child);
|
|
}
|
|
}
|
|
}
|
|
|
|
function reflectChangesOnDom (events, _document) {
|
|
// Make sure that no filtered attributes are applied to the structure
|
|
// if they were, delete them
|
|
/*
|
|
events.forEach(event => {
|
|
const target = event.target
|
|
if (event.attributesChanged === undefined) {
|
|
// event.target is Y.XmlText
|
|
return
|
|
}
|
|
const keys = this._domFilter(target.nodeName, Array.from(event.attributesChanged))
|
|
if (keys === null) {
|
|
target._delete()
|
|
} else {
|
|
const removeKeys = new Set() // is a copy of event.attributesChanged
|
|
event.attributesChanged.forEach(key => { removeKeys.add(key) })
|
|
keys.forEach(key => {
|
|
// remove all accepted keys from removeKeys
|
|
removeKeys.delete(key)
|
|
})
|
|
// remove the filtered attribute
|
|
removeKeys.forEach(key => {
|
|
target.removeAttribute(key)
|
|
})
|
|
}
|
|
})
|
|
*/
|
|
this._mutualExclude(() => {
|
|
events.forEach(event => {
|
|
const yxml = event.target;
|
|
const dom = yxml._dom;
|
|
if (dom != null) {
|
|
// TODO: do this once before applying stuff
|
|
// let anchorViewPosition = getAnchorViewPosition(yxml._scrollElement)
|
|
if (yxml.constructor === YXmlText) {
|
|
yxml._dom.nodeValue = yxml.toString();
|
|
} else if (event.attributesChanged !== undefined) {
|
|
// update attributes
|
|
event.attributesChanged.forEach(attributeName => {
|
|
const value = yxml.getAttribute(attributeName);
|
|
if (value === undefined) {
|
|
dom.removeAttribute(attributeName);
|
|
} else {
|
|
dom.setAttribute(attributeName, value);
|
|
}
|
|
});
|
|
/**
|
|
* TODO: instead of chard-checking the types, it would be best to
|
|
* specify the type's features. E.g.
|
|
* - _yxmlHasAttributes
|
|
* - _yxmlHasChildren
|
|
* Furthermore, the features shouldn't be encoded in the types,
|
|
* only in the attributes (above)
|
|
*/
|
|
if (event.childListChanged && yxml.constructor !== YXmlHook) {
|
|
let currentChild = dom.firstChild;
|
|
yxml.forEach(function (t) {
|
|
let expectedChild = t.getDom(_document);
|
|
if (expectedChild.parentNode === dom) {
|
|
// is already attached to the dom. Look for it
|
|
while (currentChild !== expectedChild) {
|
|
let del = currentChild;
|
|
currentChild = currentChild.nextSibling;
|
|
dom.removeChild(del);
|
|
}
|
|
currentChild = currentChild.nextSibling;
|
|
} else {
|
|
// this dom is not yet attached to dom
|
|
dom.insertBefore(expectedChild, currentChild);
|
|
}
|
|
});
|
|
while (currentChild !== null) {
|
|
let tmp = currentChild.nextSibling;
|
|
dom.removeChild(currentChild);
|
|
currentChild = tmp;
|
|
}
|
|
}
|
|
}
|
|
/* TODO: smartscrolling
|
|
.. else if (event.type === 'childInserted' || event.type === 'insert') {
|
|
let nodes = event.values
|
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
let node = nodes[i]
|
|
node.setDomFilter(yxml._domFilter)
|
|
node.enableSmartScrolling(yxml._scrollElement)
|
|
let dom = node.getDom()
|
|
let fixPosition = null
|
|
let nextDom = null
|
|
if (yxml._content.length > event.index + i + 1) {
|
|
nextDom = yxml.get(event.index + i + 1).getDom()
|
|
}
|
|
yxml._dom.insertBefore(dom, nextDom)
|
|
if (anchorViewPosition === null) {
|
|
// nop
|
|
} else if (anchorViewPosition.anchor !== null) {
|
|
// no scrolling when current selection
|
|
if (!dom.contains(anchorViewPosition.anchor) && !anchorViewPosition.anchor.contains(dom)) {
|
|
fixPosition = anchorViewPosition
|
|
}
|
|
} else if (getBoundingClientRect(dom).top <= 0) {
|
|
// adjust scrolling if modified element is out of view,
|
|
// there is no anchor element, and the browser did not adjust scrollTop (this is checked later)
|
|
fixPosition = anchorViewPosition
|
|
}
|
|
fixScrollPosition(yxml._scrollElement, fixPosition)
|
|
}
|
|
} else if (event.type === 'childRemoved' || event.type === 'delete') {
|
|
for (let i = event.values.length - 1; i >= 0; i--) {
|
|
let dom = event.values[i]._dom
|
|
let fixPosition = null
|
|
if (anchorViewPosition === null) {
|
|
// nop
|
|
} else if (anchorViewPosition.anchor !== null) {
|
|
// no scrolling when current selection
|
|
if (!dom.contains(anchorViewPosition.anchor) && !anchorViewPosition.anchor.contains(dom)) {
|
|
fixPosition = anchorViewPosition
|
|
}
|
|
} else if (getBoundingClientRect(dom).top <= 0) {
|
|
// adjust scrolling if modified element is out of view,
|
|
// there is no anchor element, and the browser did not adjust scrollTop (this is checked later)
|
|
fixPosition = anchorViewPosition
|
|
}
|
|
dom.remove()
|
|
fixScrollPosition(yxml._scrollElement, fixPosition)
|
|
}
|
|
}
|
|
*/
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function getRelativePosition (type, offset) {
|
|
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]
|
|
}
|
|
|
|
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);
|
|
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 (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;
|
|
|
|
let beforeTransactionSelectionFixer;
|
|
if (typeof getSelection !== 'undefined') {
|
|
beforeTransactionSelectionFixer = function _beforeTransactionSelectionFixer (y, transaction, remote) {
|
|
if (!remote) {
|
|
return
|
|
}
|
|
relativeSelection = { from: null, to: null, fromY: null, toY: null };
|
|
browserSelection = getSelection();
|
|
const anchorNode = browserSelection.anchorNode;
|
|
if (anchorNode !== null && anchorNode._yxml != null) {
|
|
const yxml = anchorNode._yxml;
|
|
relativeSelection.from = getRelativePosition(yxml, browserSelection.anchorOffset);
|
|
relativeSelection.fromY = yxml._y;
|
|
}
|
|
const focusNode = browserSelection.focusNode;
|
|
if (focusNode !== null && focusNode._yxml != null) {
|
|
const yxml = focusNode._yxml;
|
|
relativeSelection.to = getRelativePosition(yxml, browserSelection.focusOffset);
|
|
relativeSelection.toY = yxml._y;
|
|
}
|
|
};
|
|
} else {
|
|
beforeTransactionSelectionFixer = function _fakeBeforeTransactionSelectionFixer () {};
|
|
}
|
|
|
|
function afterTransactionSelectionFixer (y, 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 = sel.type.getDom();
|
|
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 = sel.type.getDom();
|
|
let offset = sel.offset;
|
|
if (node !== focusNode || offset !== focusOffset) {
|
|
focusNode = node;
|
|
focusOffset = offset;
|
|
shouldUpdate = true;
|
|
}
|
|
}
|
|
}
|
|
if (shouldUpdate) {
|
|
browserSelection.setBaseAndExtent(
|
|
anchorNode,
|
|
anchorOffset,
|
|
focusNode,
|
|
focusOffset
|
|
);
|
|
}
|
|
}
|
|
|
|
class YXmlEvent extends YEvent {
|
|
constructor (target, subs, remote) {
|
|
super(target);
|
|
this.childListChanged = false;
|
|
this.attributesChanged = new Set();
|
|
this.remote = remote;
|
|
subs.forEach((sub) => {
|
|
if (sub === null) {
|
|
this.childListChanged = true;
|
|
} else {
|
|
this.attributesChanged.add(sub);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function simpleDiff (a, b) {
|
|
let left = 0; // number of same characters counting from left
|
|
let right = 0; // number of same characters counting from right
|
|
while (left < a.length && left < b.length && a[left] === b[left]) {
|
|
left++;
|
|
}
|
|
if (left !== a.length || left !== b.length) {
|
|
// Only check right if a !== b
|
|
while (right + left < a.length && right + left < b.length && a[a.length - right - 1] === b[b.length - right - 1]) {
|
|
right++;
|
|
}
|
|
}
|
|
return {
|
|
pos: left,
|
|
remove: a.length - left - right,
|
|
insert: b.slice(left, b.length - right)
|
|
}
|
|
}
|
|
|
|
/* global MutationObserver */
|
|
|
|
function domToYXml (parent, doms, _document) {
|
|
const types = [];
|
|
doms.forEach(d => {
|
|
if (d._yxml != null && d._yxml !== false) {
|
|
d._yxml._unbindFromDom();
|
|
}
|
|
if (parent._domFilter(d.nodeName, new Map()) !== null) {
|
|
let type;
|
|
const hookName = d._yjsHook || (d.dataset != null ? d.dataset.yjsHook : undefined);
|
|
if (hookName !== undefined) {
|
|
type = new YXmlHook(hookName, d);
|
|
} else if (d.nodeType === d.TEXT_NODE) {
|
|
type = new YXmlText(d);
|
|
} else if (d.nodeType === d.ELEMENT_NODE) {
|
|
type = new YXmlFragment._YXmlElement(d, parent._domFilter, _document);
|
|
} else {
|
|
throw new Error('Unsupported node!')
|
|
}
|
|
// type.enableSmartScrolling(parent._scrollElement)
|
|
types.push(type);
|
|
} else {
|
|
d._yxml = false;
|
|
}
|
|
});
|
|
return types
|
|
}
|
|
|
|
class YXmlTreeWalker {
|
|
constructor (root, f) {
|
|
this._filter = f || (() => true);
|
|
this._root = root;
|
|
this._currentNode = root;
|
|
this._firstCall = true;
|
|
}
|
|
[Symbol.iterator] () {
|
|
return this
|
|
}
|
|
next () {
|
|
let n = this._currentNode;
|
|
if (this._firstCall) {
|
|
this._firstCall = false;
|
|
if (!n._deleted && this._filter(n)) {
|
|
return { value: n, done: false }
|
|
}
|
|
}
|
|
do {
|
|
if (!n._deleted && (n.constructor === YXmlFragment._YXmlElement || n.constructor === YXmlFragment) && n._start !== null) {
|
|
// walk down in the tree
|
|
n = n._start;
|
|
} else {
|
|
// walk right or up in the tree
|
|
while (n !== this._root) {
|
|
if (n._right !== null) {
|
|
n = n._right;
|
|
break
|
|
}
|
|
n = n._parent;
|
|
}
|
|
if (n === this._root) {
|
|
n = null;
|
|
}
|
|
}
|
|
if (n === this._root) {
|
|
break
|
|
}
|
|
} while (n !== null && (n._deleted || !this._filter(n)))
|
|
this._currentNode = n;
|
|
if (n === null) {
|
|
return { done: true }
|
|
} else {
|
|
return { value: n, done: false }
|
|
}
|
|
}
|
|
}
|
|
|
|
class YXmlFragment extends YArray {
|
|
constructor () {
|
|
super();
|
|
this._dom = null;
|
|
this._domFilter = defaultDomFilter;
|
|
this._domObserver = null;
|
|
// this function makes sure that either the
|
|
// dom event is executed, or the yjs observer is executed
|
|
var token = true;
|
|
this._mutualExclude = f => {
|
|
if (token) {
|
|
token = false;
|
|
try {
|
|
f();
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
/*
|
|
if (this._domObserver !== null) {
|
|
this._domObserver.takeRecords()
|
|
}
|
|
*/
|
|
token = true;
|
|
}
|
|
};
|
|
}
|
|
createTreeWalker (filter) {
|
|
return new YXmlTreeWalker(this, filter)
|
|
}
|
|
/**
|
|
* Retrieve first element that matches *query*
|
|
* Similar to DOM's querySelector, but only accepts a subset of its queries
|
|
*
|
|
* Query support:
|
|
* - tagname
|
|
* TODO:
|
|
* - id
|
|
* - attribute
|
|
*/
|
|
querySelector (query) {
|
|
query = query.toUpperCase();
|
|
const iterator = new YXmlTreeWalker(this, element => element.nodeName === query);
|
|
const next = iterator.next();
|
|
if (next.done) {
|
|
return null
|
|
} else {
|
|
return next.value
|
|
}
|
|
}
|
|
querySelectorAll (query) {
|
|
query = query.toUpperCase();
|
|
return Array.from(new YXmlTreeWalker(this, element => element.nodeName === query))
|
|
}
|
|
enableSmartScrolling (scrollElement) {
|
|
this._scrollElement = scrollElement;
|
|
this.forEach(xml => {
|
|
xml.enableSmartScrolling(scrollElement);
|
|
});
|
|
}
|
|
setDomFilter (f) {
|
|
this._domFilter = f;
|
|
let attributes = new Map();
|
|
if (this.getAttributes !== undefined) {
|
|
let attrs = this.getAttributes();
|
|
for (let key in attrs) {
|
|
attributes.set(key, attrs[key]);
|
|
}
|
|
}
|
|
this._y.transact(() => {
|
|
let result = this._domFilter(this.nodeName, new Map(attributes));
|
|
if (result === null) {
|
|
this._delete(this._y);
|
|
} else {
|
|
attributes.forEach((value, key) => {
|
|
if (!result.has(key)) {
|
|
this.removeAttribute(key);
|
|
}
|
|
});
|
|
}
|
|
this.forEach(xml => {
|
|
xml.setDomFilter(f);
|
|
});
|
|
});
|
|
}
|
|
_callObserver (transaction, parentSubs, remote) {
|
|
this._callEventHandler(transaction, new YXmlEvent(this, parentSubs, remote));
|
|
}
|
|
toString () {
|
|
return this.map(xml => xml.toString()).join('')
|
|
}
|
|
_delete (y, createDelete) {
|
|
this._unbindFromDom();
|
|
super._delete(y, createDelete);
|
|
}
|
|
_unbindFromDom () {
|
|
if (this._domObserver != null) {
|
|
this._domObserver.disconnect();
|
|
this._domObserver = null;
|
|
}
|
|
if (this._dom != null) {
|
|
this._dom._yxml = null;
|
|
this._dom = null;
|
|
}
|
|
if (this._beforeTransactionHandler !== undefined) {
|
|
this._y.off('beforeTransaction', this._beforeTransactionHandler);
|
|
}
|
|
}
|
|
insertDomElementsAfter (prev, doms, _document) {
|
|
const types = domToYXml(this, doms, _document);
|
|
this.insertAfter(prev, types);
|
|
return types
|
|
}
|
|
insertDomElements (pos, doms, _document) {
|
|
const types = domToYXml(this, doms, _document);
|
|
this.insert(pos, types);
|
|
return types
|
|
}
|
|
getDom () {
|
|
return this._dom
|
|
}
|
|
bindToDom (dom, _document) {
|
|
if (this._dom != null) {
|
|
this._unbindFromDom();
|
|
}
|
|
if (dom._yxml != null) {
|
|
dom._yxml._unbindFromDom();
|
|
}
|
|
dom.innerHTML = '';
|
|
this.forEach(t => {
|
|
dom.insertBefore(t.getDom(_document), null);
|
|
});
|
|
this._bindToDom(dom, _document);
|
|
}
|
|
// binds to a dom element
|
|
// Only call if dom and YXml are isomorph
|
|
_bindToDom (dom, _document) {
|
|
_document = _document || document;
|
|
this._dom = dom;
|
|
dom._yxml = this;
|
|
if (this._parent === null) {
|
|
return
|
|
}
|
|
this._y.on('beforeTransaction', beforeTransactionSelectionFixer);
|
|
this._y.on('afterTransaction', afterTransactionSelectionFixer);
|
|
const applyFilter = (type) => {
|
|
if (type._deleted) {
|
|
return
|
|
}
|
|
// check if type is a child of this
|
|
let isChild = false;
|
|
let p = type;
|
|
while (p !== this._y) {
|
|
if (p === this) {
|
|
isChild = true;
|
|
break
|
|
}
|
|
p = p._parent;
|
|
}
|
|
if (!isChild) {
|
|
return
|
|
}
|
|
// filter attributes
|
|
let attributes = new Map();
|
|
if (type.getAttributes !== undefined) {
|
|
let attrs = type.getAttributes();
|
|
for (let key in attrs) {
|
|
attributes.set(key, attrs[key]);
|
|
}
|
|
}
|
|
let result = this._domFilter(type.nodeName, new Map(attributes));
|
|
if (result === null) {
|
|
type._delete(this._y);
|
|
} else {
|
|
attributes.forEach((value, key) => {
|
|
if (!result.has(key)) {
|
|
type.removeAttribute(key);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
this._y.on('beforeObserverCalls', function (y, transaction) {
|
|
// apply dom filter to new and changed types
|
|
transaction.changedTypes.forEach(function (subs, type) {
|
|
if (subs.size > 1 || !subs.has(null)) {
|
|
// only apply changes on attributes
|
|
applyFilter(type);
|
|
}
|
|
});
|
|
transaction.newTypes.forEach(applyFilter);
|
|
});
|
|
// Apply Y.Xml events to dom
|
|
this.observeDeep(events => {
|
|
reflectChangesOnDom.call(this, events, _document);
|
|
});
|
|
// Apply Dom changes on Y.Xml
|
|
if (typeof MutationObserver !== 'undefined') {
|
|
this._beforeTransactionHandler = () => {
|
|
this._domObserverListener(this._domObserver.takeRecords());
|
|
};
|
|
this._y.on('beforeTransaction', this._beforeTransactionHandler);
|
|
this._domObserverListener = mutations => {
|
|
this._mutualExclude(() => {
|
|
this._y.transact(() => {
|
|
let diffChildren = new Set();
|
|
mutations.forEach(mutation => {
|
|
const dom = mutation.target;
|
|
const yxml = dom._yxml;
|
|
if (yxml == null || yxml.constructor === YXmlHook) {
|
|
// dom element is filtered
|
|
return
|
|
}
|
|
switch (mutation.type) {
|
|
case 'characterData':
|
|
var change = simpleDiff(yxml.toString(), dom.nodeValue);
|
|
yxml.delete(change.pos, change.remove);
|
|
yxml.insert(change.pos, change.insert);
|
|
break
|
|
case 'attributes':
|
|
if (yxml.constructor === YXmlFragment) {
|
|
break
|
|
}
|
|
let name = mutation.attributeName;
|
|
let val = dom.getAttribute(name);
|
|
// check if filter accepts attribute
|
|
let attributes = new Map();
|
|
attributes.set(name, val);
|
|
if (this._domFilter(dom.nodeName, attributes).size > 0 && yxml.constructor !== YXmlFragment) {
|
|
if (yxml.getAttribute(name) !== val) {
|
|
if (val == null) {
|
|
yxml.removeAttribute(name);
|
|
} else {
|
|
yxml.setAttribute(name, val);
|
|
}
|
|
}
|
|
}
|
|
break
|
|
case 'childList':
|
|
diffChildren.add(mutation.target);
|
|
break
|
|
}
|
|
});
|
|
for (let dom of diffChildren) {
|
|
if (dom.yOnChildrenChanged !== undefined) {
|
|
dom.yOnChildrenChanged();
|
|
}
|
|
if (dom._yxml != null && dom._yxml !== false) {
|
|
applyChangesFromDom(dom);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
};
|
|
this._domObserver = new MutationObserver(this._domObserverListener);
|
|
this._domObserver.observe(dom, {
|
|
childList: true,
|
|
attributes: true,
|
|
characterData: true,
|
|
subtree: true
|
|
});
|
|
}
|
|
return dom
|
|
}
|
|
_logString () {
|
|
const left = this._left !== null ? this._left._lastId : null;
|
|
const origin = this._origin !== null ? this._origin._lastId : null;
|
|
return `YXml(id:${logID(this._id)},left:${logID(left)},origin:${logID(origin)},right:${this._right},parent:${logID(this._parent)},parentSub:${this._parentSub})`
|
|
}
|
|
}
|
|
|
|
class YXmlElement extends YXmlFragment {
|
|
constructor (arg1, arg2, _document) {
|
|
super();
|
|
this.nodeName = null;
|
|
this._scrollElement = null;
|
|
if (typeof arg1 === 'string') {
|
|
this.nodeName = arg1.toUpperCase();
|
|
} else if (arg1 != null && arg1.nodeType != null && arg1.nodeType === arg1.ELEMENT_NODE) {
|
|
this.nodeName = arg1.nodeName;
|
|
this._setDom(arg1, _document);
|
|
} else {
|
|
this.nodeName = 'UNDEFINED';
|
|
}
|
|
if (typeof arg2 === 'function') {
|
|
this._domFilter = arg2;
|
|
}
|
|
}
|
|
_copy (undeleteChildren, copyPosition) {
|
|
let struct = super._copy(undeleteChildren, copyPosition);
|
|
struct.nodeName = this.nodeName;
|
|
return struct
|
|
}
|
|
_setDom (dom, _document) {
|
|
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 {
|
|
// tag is already set in constructor
|
|
// set attributes
|
|
let attributes = new Map();
|
|
for (let i = 0; i < dom.attributes.length; i++) {
|
|
let attr = dom.attributes[i];
|
|
attributes.set(attr.name, attr.value);
|
|
}
|
|
attributes = this._domFilter(dom, attributes);
|
|
attributes.forEach((value, name) => {
|
|
this.setAttribute(name, value);
|
|
});
|
|
this.insertDomElements(0, Array.prototype.slice.call(dom.childNodes), _document);
|
|
this._bindToDom(dom, _document);
|
|
return dom
|
|
}
|
|
}
|
|
_bindToDom (dom, _document) {
|
|
_document = _document || document;
|
|
this._dom = dom;
|
|
dom._yxml = this;
|
|
}
|
|
_fromBinary (y, decoder) {
|
|
const missing = super._fromBinary(y, decoder);
|
|
this.nodeName = decoder.readVarString();
|
|
return missing
|
|
}
|
|
_toBinary (encoder) {
|
|
super._toBinary(encoder);
|
|
encoder.writeVarString(this.nodeName);
|
|
}
|
|
_integrate (y) {
|
|
if (this.nodeName === null) {
|
|
throw new Error('nodeName must be defined!')
|
|
}
|
|
if (this._domFilter === defaultDomFilter && this._parent._domFilter !== undefined) {
|
|
this._domFilter = this._parent._domFilter;
|
|
}
|
|
super._integrate(y);
|
|
}
|
|
/**
|
|
* Returns the string representation of the XML document.
|
|
* The attributes are ordered by attribute-name, so you can easily use this
|
|
* method to compare YXmlElements
|
|
*/
|
|
toString () {
|
|
const attrs = this.getAttributes();
|
|
const stringBuilder = [];
|
|
const keys = [];
|
|
for (let key in attrs) {
|
|
keys.push(key);
|
|
}
|
|
keys.sort();
|
|
const keysLen = keys.length;
|
|
for (let i = 0; i < keysLen; i++) {
|
|
const key = keys[i];
|
|
stringBuilder.push(key + '="' + attrs[key] + '"');
|
|
}
|
|
const nodeName = this.nodeName.toLocaleLowerCase();
|
|
const attrsString = stringBuilder.length > 0 ? ' ' + stringBuilder.join(' ') : '';
|
|
return `<${nodeName}${attrsString}>${super.toString()}</${nodeName}>`
|
|
}
|
|
removeAttribute () {
|
|
return YMap.prototype.delete.apply(this, arguments)
|
|
}
|
|
|
|
setAttribute () {
|
|
return YMap.prototype.set.apply(this, arguments)
|
|
}
|
|
|
|
getAttribute () {
|
|
return YMap.prototype.get.apply(this, arguments)
|
|
}
|
|
|
|
getAttributes () {
|
|
const obj = {};
|
|
for (let [key, value] of this._map) {
|
|
if (!value._deleted) {
|
|
obj[key] = value._content[0];
|
|
}
|
|
}
|
|
return obj
|
|
}
|
|
getDom (_document) {
|
|
_document = _document || document;
|
|
let dom = this._dom;
|
|
if (dom == null) {
|
|
dom = _document.createElement(this.nodeName);
|
|
dom._yxml = this;
|
|
let attrs = this.getAttributes();
|
|
for (let key in attrs) {
|
|
dom.setAttribute(key, attrs[key]);
|
|
}
|
|
this.forEach(yxml => {
|
|
dom.appendChild(yxml.getDom(_document));
|
|
});
|
|
this._bindToDom(dom, _document);
|
|
}
|
|
return dom
|
|
}
|
|
}
|
|
|
|
const xmlHooks = {};
|
|
|
|
function addHook (name, hook) {
|
|
xmlHooks[name] = hook;
|
|
}
|
|
|
|
function getHook (name) {
|
|
const hook = xmlHooks[name];
|
|
if (hook === undefined) {
|
|
throw new Error(`The hook "${name}" is not specified! You must not access this hook!`)
|
|
}
|
|
return hook
|
|
}
|
|
|
|
class YXmlHook extends YMap {
|
|
constructor (hookName, dom) {
|
|
super();
|
|
this._dom = null;
|
|
this.hookName = null;
|
|
if (hookName !== undefined) {
|
|
this.hookName = hookName;
|
|
this._dom = dom;
|
|
dom._yjsHook = hookName;
|
|
dom._yxml = this;
|
|
getHook(hookName).fillType(dom, this);
|
|
}
|
|
}
|
|
_copy (undeleteChildren, copyPosition) {
|
|
const struct = super._copy(undeleteChildren, copyPosition);
|
|
struct.hookName = this.hookName;
|
|
return struct
|
|
}
|
|
getDom (_document) {
|
|
_document = _document || document;
|
|
if (this._dom === null) {
|
|
const dom = getHook(this.hookName).createDom(this);
|
|
this._dom = dom;
|
|
dom._yxml = this;
|
|
dom._yjsHook = this.hookName;
|
|
}
|
|
return this._dom
|
|
}
|
|
_unbindFromDom () {
|
|
this._dom._yxml = null;
|
|
this._yxml = null;
|
|
// TODO: cleanup hook?
|
|
}
|
|
_fromBinary (y, decoder) {
|
|
const missing = super._fromBinary(y, decoder);
|
|
this.hookName = decoder.readVarString();
|
|
return missing
|
|
}
|
|
_toBinary (encoder) {
|
|
super._toBinary(encoder);
|
|
encoder.writeVarString(this.hookName);
|
|
}
|
|
_integrate (y) {
|
|
if (this.hookName === null) {
|
|
throw new Error('hookName must be defined!')
|
|
}
|
|
super._integrate(y);
|
|
}
|
|
setDomFilter () {
|
|
// TODO: implement new modfilter method!
|
|
}
|
|
enableSmartScrolling () {
|
|
// TODO: implement new smartscrolling method!
|
|
}
|
|
}
|
|
YXmlHook.addHook = addHook;
|
|
|
|
class YXmlText extends YText {
|
|
constructor (arg1) {
|
|
let dom = null;
|
|
let initialText = null;
|
|
if (arg1 != null) {
|
|
if (arg1.nodeType != null && arg1.nodeType === arg1.TEXT_NODE) {
|
|
dom = arg1;
|
|
initialText = dom.nodeValue;
|
|
} else if (typeof arg1 === 'string') {
|
|
initialText = arg1;
|
|
}
|
|
}
|
|
super(initialText);
|
|
this._dom = null;
|
|
this._domObserver = null;
|
|
this._domObserverListener = null;
|
|
this._scrollElement = null;
|
|
if (dom !== null) {
|
|
this._setDom(arg1);
|
|
}
|
|
/*
|
|
var token = true
|
|
this._mutualExclude = f => {
|
|
if (token) {
|
|
token = false
|
|
try {
|
|
f()
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
this._domObserver.takeRecords()
|
|
token = true
|
|
}
|
|
}
|
|
this.observe(event => {
|
|
if (this._dom != null) {
|
|
const dom = this._dom
|
|
this._mutualExclude(() => {
|
|
let anchorViewPosition = getAnchorViewPosition(this._scrollElement)
|
|
let anchorViewFix
|
|
if (anchorViewPosition !== null && (anchorViewPosition.anchor !== null || getBoundingClientRect(this._dom).top <= 0)) {
|
|
anchorViewFix = anchorViewPosition
|
|
} else {
|
|
anchorViewFix = null
|
|
}
|
|
dom.nodeValue = this.toString()
|
|
fixScrollPosition(this._scrollElement, anchorViewFix)
|
|
})
|
|
}
|
|
})
|
|
*/
|
|
}
|
|
setDomFilter () {}
|
|
enableSmartScrolling (scrollElement) {
|
|
this._scrollElement = scrollElement;
|
|
}
|
|
_setDom (dom) {
|
|
if (this._dom != null) {
|
|
this._unbindFromDom();
|
|
}
|
|
if (dom._yxml != null) {
|
|
dom._yxml._unbindFromDom();
|
|
}
|
|
// set marker
|
|
this._dom = dom;
|
|
dom._yxml = this;
|
|
}
|
|
getDom (_document) {
|
|
_document = _document || document;
|
|
if (this._dom === null) {
|
|
const dom = _document.createTextNode(this.toString());
|
|
this._setDom(dom);
|
|
return dom
|
|
}
|
|
return this._dom
|
|
}
|
|
_delete (y, createDelete) {
|
|
this._unbindFromDom();
|
|
super._delete(y, createDelete);
|
|
}
|
|
_unbindFromDom () {
|
|
if (this._domObserver != null) {
|
|
this._domObserver.disconnect();
|
|
this._domObserver = null;
|
|
}
|
|
if (this._dom != null) {
|
|
this._dom._yxml = null;
|
|
this._dom = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
YXmlFragment._YXmlElement = YXmlElement;
|
|
YXmlFragment._YXmlHook = YXmlHook;
|
|
|
|
const structs = new Map();
|
|
const references = new Map();
|
|
|
|
function addStruct (reference, structConstructor) {
|
|
structs.set(reference, structConstructor);
|
|
references.set(structConstructor, reference);
|
|
}
|
|
|
|
function getStruct (reference) {
|
|
return structs.get(reference)
|
|
}
|
|
|
|
function getReference (typeConstructor) {
|
|
return references.get(typeConstructor)
|
|
}
|
|
|
|
addStruct(0, ItemJSON);
|
|
addStruct(1, ItemString);
|
|
addStruct(2, Delete);
|
|
|
|
addStruct(3, YArray);
|
|
addStruct(4, YMap);
|
|
addStruct(5, YText);
|
|
addStruct(6, YXmlFragment);
|
|
addStruct(7, YXmlElement);
|
|
addStruct(8, YXmlText);
|
|
addStruct(9, YXmlHook);
|
|
|
|
const RootFakeUserID = 0xFFFFFF;
|
|
|
|
class RootID {
|
|
constructor (name, typeConstructor) {
|
|
this.user = RootFakeUserID;
|
|
this.name = name;
|
|
this.type = getReference(typeConstructor);
|
|
}
|
|
equals (id) {
|
|
return id !== null && id.user === this.user && id.name === this.name && id.type === this.type
|
|
}
|
|
lessThan (id) {
|
|
if (id.constructor === RootID) {
|
|
return this.user < id.user || (this.user === id.user && (this.name < id.name || (this.name === id.name && this.type < id.type)))
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
class OperationStore extends Tree {
|
|
constructor (y) {
|
|
super();
|
|
this.y = y;
|
|
}
|
|
logTable () {
|
|
const items = [];
|
|
this.iterate(null, null, function (item) {
|
|
items.push({
|
|
id: logID(item),
|
|
origin: logID(item._origin === null ? null : item._origin._lastId),
|
|
left: logID(item._left === null ? null : item._left._lastId),
|
|
right: logID(item._right),
|
|
right_origin: logID(item._right_origin),
|
|
parent: logID(item._parent),
|
|
parentSub: item._parentSub,
|
|
deleted: item._deleted,
|
|
content: JSON.stringify(item._content)
|
|
});
|
|
});
|
|
console.table(items);
|
|
}
|
|
get (id) {
|
|
let struct = this.find(id);
|
|
if (struct === null && id instanceof RootID) {
|
|
const Constr = getStruct(id.type);
|
|
const y = this.y;
|
|
struct = new Constr();
|
|
struct._id = id;
|
|
struct._parent = y;
|
|
y.transact(() => {
|
|
struct._integrate(y);
|
|
});
|
|
this.put(struct);
|
|
}
|
|
return struct
|
|
}
|
|
// Use getItem for structs with _length > 1
|
|
getItem (id) {
|
|
var item = this.findWithUpperBound(id);
|
|
if (item === null) {
|
|
return null
|
|
}
|
|
const itemID = item._id;
|
|
if (id.user === itemID.user && id.clock < itemID.clock + item._length) {
|
|
return item
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
// Return an insertion such that id is the first element of content
|
|
// This function manipulates an item, if necessary
|
|
getItemCleanStart (id) {
|
|
var ins = this.getItem(id);
|
|
if (ins === null || ins._length === 1) {
|
|
return ins
|
|
}
|
|
const insID = ins._id;
|
|
if (insID.clock === id.clock) {
|
|
return ins
|
|
} else {
|
|
return ins._splitAt(this.y, id.clock - insID.clock)
|
|
}
|
|
}
|
|
// Return an insertion such that id is the last element of content
|
|
// This function manipulates an operation, if necessary
|
|
getItemCleanEnd (id) {
|
|
var ins = this.getItem(id);
|
|
if (ins === null || ins._length === 1) {
|
|
return ins
|
|
}
|
|
const insID = ins._id;
|
|
if (insID.clock + ins._length - 1 === id.clock) {
|
|
return ins
|
|
} else {
|
|
ins._splitAt(this.y, id.clock - insID.clock + 1);
|
|
return ins
|
|
}
|
|
}
|
|
}
|
|
|
|
class StateStore {
|
|
constructor (y) {
|
|
this.y = y;
|
|
this.state = new Map();
|
|
}
|
|
logTable () {
|
|
const entries = [];
|
|
for (let [user, state] of this.state) {
|
|
entries.push({
|
|
user, state
|
|
});
|
|
}
|
|
console.table(entries);
|
|
}
|
|
getNextID (len) {
|
|
const user = this.y.userID;
|
|
const state = this.getState(user);
|
|
this.setState(user, state + len);
|
|
return new ID(user, state)
|
|
}
|
|
updateRemoteState (struct) {
|
|
let user = struct._id.user;
|
|
let userState = this.state.get(user);
|
|
while (struct !== null && struct._id.clock === userState) {
|
|
userState += struct._length;
|
|
struct = this.y.os.get(new ID(user, userState));
|
|
}
|
|
this.state.set(user, userState);
|
|
}
|
|
getState (user) {
|
|
let state = this.state.get(user);
|
|
if (state == null) {
|
|
return 0
|
|
}
|
|
return state
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
/* global crypto */
|
|
|
|
function generateUserID () {
|
|
if (typeof crypto !== 'undefined' && crypto.getRandomValue != null) {
|
|
// browser
|
|
let arr = new Uint32Array(1);
|
|
crypto.getRandomValues(arr);
|
|
return arr[0]
|
|
} else if (typeof crypto !== 'undefined' && crypto.randomBytes != null) {
|
|
// node
|
|
let buf = crypto.randomBytes(4);
|
|
return new Uint32Array(buf.buffer)[0]
|
|
} else {
|
|
return Math.ceil(Math.random() * 0xFFFFFFFF)
|
|
}
|
|
}
|
|
|
|
class NamedEventHandler {
|
|
constructor () {
|
|
this._eventListener = new Map();
|
|
this._stateListener = new Map();
|
|
}
|
|
_getListener (name) {
|
|
let listeners = this._eventListener.get(name);
|
|
if (listeners === undefined) {
|
|
listeners = {
|
|
once: new Set(),
|
|
on: new Set()
|
|
};
|
|
this._eventListener.set(name, listeners);
|
|
}
|
|
return listeners
|
|
}
|
|
once (name, f) {
|
|
let listeners = this._getListener(name);
|
|
listeners.once.add(f);
|
|
}
|
|
on (name, f) {
|
|
let listeners = this._getListener(name);
|
|
listeners.on.add(f);
|
|
}
|
|
_initStateListener (name) {
|
|
let state = this._stateListener.get(name);
|
|
if (state === undefined) {
|
|
state = {};
|
|
state.promise = new Promise(function (resolve) {
|
|
state.resolve = resolve;
|
|
});
|
|
this._stateListener.set(name, state);
|
|
}
|
|
return state
|
|
}
|
|
when (name) {
|
|
return this._initStateListener(name).promise
|
|
}
|
|
off (name, f) {
|
|
if (name == null || f == null) {
|
|
throw new Error('You must specify event name and function!')
|
|
}
|
|
const listener = this._eventListener.get(name);
|
|
if (listener !== undefined) {
|
|
listener.on.delete(f);
|
|
listener.once.delete(f);
|
|
}
|
|
}
|
|
emit (name, ...args) {
|
|
this._initStateListener(name).resolve();
|
|
const listener = this._eventListener.get(name);
|
|
if (listener !== undefined) {
|
|
listener.on.forEach(f => f.apply(null, args));
|
|
listener.once.forEach(f => f.apply(null, args));
|
|
listener.once = new Set();
|
|
} else if (name === 'error') {
|
|
console.error(args[0]);
|
|
}
|
|
}
|
|
destroy () {
|
|
this._eventListener = null;
|
|
}
|
|
}
|
|
|
|
class ReverseOperation {
|
|
constructor (y, transaction) {
|
|
this.created = new Date();
|
|
const beforeState = 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 = transaction.deletedStructs;
|
|
}
|
|
}
|
|
|
|
function isStructInScope (y, struct, scope) {
|
|
while (struct !== y) {
|
|
if (struct === scope) {
|
|
return true
|
|
}
|
|
struct = struct._parent;
|
|
}
|
|
return false
|
|
}
|
|
|
|
function applyReverseOperation (y, scope, reverseBuffer) {
|
|
let performedUndo = false;
|
|
y.transact(() => {
|
|
while (!performedUndo && reverseBuffer.length > 0) {
|
|
let undoOp = reverseBuffer.pop();
|
|
// make sure that it is possible to iterate {from}-{to}
|
|
y.os.getItemCleanStart(undoOp.fromState);
|
|
y.os.getItemCleanEnd(undoOp.toState);
|
|
y.os.iterate(undoOp.fromState, undoOp.toState, op => {
|
|
if (!op._deleted && isStructInScope(y, op, scope)) {
|
|
performedUndo = true;
|
|
op._delete(y);
|
|
}
|
|
});
|
|
for (let op of undoOp.deletedStructs) {
|
|
if (
|
|
isStructInScope(y, op, scope) &&
|
|
op._parent !== y &&
|
|
!op._parent._deleted &&
|
|
(
|
|
op._parent._id.user !== y.userID ||
|
|
op._parent._id.clock < undoOp.fromState.clock ||
|
|
op._parent._id.clock > undoOp.fromState.clock
|
|
)
|
|
) {
|
|
performedUndo = true;
|
|
op = op._copy(undoOp.deletedStructs, true);
|
|
op._integrate(y);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
return performedUndo
|
|
}
|
|
|
|
class UndoManager {
|
|
constructor (scope, options = {}) {
|
|
this.options = options;
|
|
options.captureTimeout = options.captureTimeout == null ? 500 : options.captureTimeout;
|
|
this._undoBuffer = [];
|
|
this._redoBuffer = [];
|
|
this._scope = scope;
|
|
this._undoing = false;
|
|
this._redoing = false;
|
|
const y = scope._y;
|
|
this.y = y;
|
|
y.on('afterTransaction', (y, transaction, remote) => {
|
|
if (!remote && transaction.changedParentTypes.has(scope)) {
|
|
let reverseOperation = new ReverseOperation(y, transaction);
|
|
if (!this._undoing) {
|
|
let lastUndoOp = this._undoBuffer.length > 0 ? this._undoBuffer[this._undoBuffer.length - 1] : null;
|
|
if (lastUndoOp !== null && reverseOperation.created - lastUndoOp.created <= options.captureTimeout) {
|
|
lastUndoOp.created = reverseOperation.created;
|
|
lastUndoOp.toState = reverseOperation.toState;
|
|
reverseOperation.deletedStructs.forEach(lastUndoOp.deletedStructs.add, lastUndoOp.deletedStructs);
|
|
} else {
|
|
this._undoBuffer.push(reverseOperation);
|
|
}
|
|
if (!this._redoing) {
|
|
this._redoBuffer = [];
|
|
}
|
|
} else {
|
|
this._redoBuffer.push(reverseOperation);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
undo () {
|
|
this._undoing = true;
|
|
const performedUndo = applyReverseOperation(this.y, this._scope, this._undoBuffer);
|
|
this._undoing = false;
|
|
return performedUndo
|
|
}
|
|
redo () {
|
|
this._redoing = true;
|
|
const performedRedo = applyReverseOperation(this.y, this._scope, this._redoBuffer);
|
|
this._redoing = false;
|
|
return performedRedo
|
|
}
|
|
}
|
|
|
|
function createCommonjsModule(fn, module) {
|
|
return module = { exports: {} }, fn(module, module.exports), module.exports;
|
|
}
|
|
|
|
/**
|
|
* Helpers.
|
|
*/
|
|
|
|
var s = 1000;
|
|
var m = s * 60;
|
|
var h = m * 60;
|
|
var d = h * 24;
|
|
var y = d * 365.25;
|
|
|
|
/**
|
|
* Parse or format the given `val`.
|
|
*
|
|
* Options:
|
|
*
|
|
* - `long` verbose formatting [false]
|
|
*
|
|
* @param {String|Number} val
|
|
* @param {Object} [options]
|
|
* @throws {Error} throw an error if val is not a non-empty string or a number
|
|
* @return {String|Number}
|
|
* @api public
|
|
*/
|
|
|
|
var ms = function(val, options) {
|
|
options = options || {};
|
|
var type = typeof val;
|
|
if (type === 'string' && val.length > 0) {
|
|
return parse(val);
|
|
} else if (type === 'number' && isNaN(val) === false) {
|
|
return options.long ? fmtLong(val) : fmtShort(val);
|
|
}
|
|
throw new Error(
|
|
'val is not a non-empty string or a valid number. val=' +
|
|
JSON.stringify(val)
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Parse the given `str` and return milliseconds.
|
|
*
|
|
* @param {String} str
|
|
* @return {Number}
|
|
* @api private
|
|
*/
|
|
|
|
function parse(str) {
|
|
str = String(str);
|
|
if (str.length > 100) {
|
|
return;
|
|
}
|
|
var match = /^((?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|years?|yrs?|y)?$/i.exec(
|
|
str
|
|
);
|
|
if (!match) {
|
|
return;
|
|
}
|
|
var n = parseFloat(match[1]);
|
|
var type = (match[2] || 'ms').toLowerCase();
|
|
switch (type) {
|
|
case 'years':
|
|
case 'year':
|
|
case 'yrs':
|
|
case 'yr':
|
|
case 'y':
|
|
return n * y;
|
|
case 'days':
|
|
case 'day':
|
|
case 'd':
|
|
return n * d;
|
|
case 'hours':
|
|
case 'hour':
|
|
case 'hrs':
|
|
case 'hr':
|
|
case 'h':
|
|
return n * h;
|
|
case 'minutes':
|
|
case 'minute':
|
|
case 'mins':
|
|
case 'min':
|
|
case 'm':
|
|
return n * m;
|
|
case 'seconds':
|
|
case 'second':
|
|
case 'secs':
|
|
case 'sec':
|
|
case 's':
|
|
return n * s;
|
|
case 'milliseconds':
|
|
case 'millisecond':
|
|
case 'msecs':
|
|
case 'msec':
|
|
case 'ms':
|
|
return n;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Short format for `ms`.
|
|
*
|
|
* @param {Number} ms
|
|
* @return {String}
|
|
* @api private
|
|
*/
|
|
|
|
function fmtShort(ms) {
|
|
if (ms >= d) {
|
|
return Math.round(ms / d) + 'd';
|
|
}
|
|
if (ms >= h) {
|
|
return Math.round(ms / h) + 'h';
|
|
}
|
|
if (ms >= m) {
|
|
return Math.round(ms / m) + 'm';
|
|
}
|
|
if (ms >= s) {
|
|
return Math.round(ms / s) + 's';
|
|
}
|
|
return ms + 'ms';
|
|
}
|
|
|
|
/**
|
|
* Long format for `ms`.
|
|
*
|
|
* @param {Number} ms
|
|
* @return {String}
|
|
* @api private
|
|
*/
|
|
|
|
function fmtLong(ms) {
|
|
return plural(ms, d, 'day') ||
|
|
plural(ms, h, 'hour') ||
|
|
plural(ms, m, 'minute') ||
|
|
plural(ms, s, 'second') ||
|
|
ms + ' ms';
|
|
}
|
|
|
|
/**
|
|
* Pluralization helper.
|
|
*/
|
|
|
|
function plural(ms, n, name) {
|
|
if (ms < n) {
|
|
return;
|
|
}
|
|
if (ms < n * 1.5) {
|
|
return Math.floor(ms / n) + ' ' + name;
|
|
}
|
|
return Math.ceil(ms / n) + ' ' + name + 's';
|
|
}
|
|
|
|
var debug$1 = createCommonjsModule(function (module, exports) {
|
|
/**
|
|
* This is the common logic for both the Node.js and web browser
|
|
* implementations of `debug()`.
|
|
*
|
|
* Expose `debug()` as the module.
|
|
*/
|
|
|
|
exports = module.exports = createDebug.debug = createDebug['default'] = createDebug;
|
|
exports.coerce = coerce;
|
|
exports.disable = disable;
|
|
exports.enable = enable;
|
|
exports.enabled = enabled;
|
|
exports.humanize = ms;
|
|
|
|
/**
|
|
* The currently active debug mode names, and names to skip.
|
|
*/
|
|
|
|
exports.names = [];
|
|
exports.skips = [];
|
|
|
|
/**
|
|
* Map of special "%n" handling functions, for the debug "format" argument.
|
|
*
|
|
* Valid key names are a single, lower or upper-case letter, i.e. "n" and "N".
|
|
*/
|
|
|
|
exports.formatters = {};
|
|
|
|
/**
|
|
* Previous log timestamp.
|
|
*/
|
|
|
|
var prevTime;
|
|
|
|
/**
|
|
* Select a color.
|
|
* @param {String} namespace
|
|
* @return {Number}
|
|
* @api private
|
|
*/
|
|
|
|
function selectColor(namespace) {
|
|
var hash = 0, i;
|
|
|
|
for (i in namespace) {
|
|
hash = ((hash << 5) - hash) + namespace.charCodeAt(i);
|
|
hash |= 0; // Convert to 32bit integer
|
|
}
|
|
|
|
return exports.colors[Math.abs(hash) % exports.colors.length];
|
|
}
|
|
|
|
/**
|
|
* Create a debugger with the given `namespace`.
|
|
*
|
|
* @param {String} namespace
|
|
* @return {Function}
|
|
* @api public
|
|
*/
|
|
|
|
function createDebug(namespace) {
|
|
|
|
function debug() {
|
|
// disabled?
|
|
if (!debug.enabled) return;
|
|
|
|
var self = debug;
|
|
|
|
// set `diff` timestamp
|
|
var curr = +new Date();
|
|
var ms$$1 = curr - (prevTime || curr);
|
|
self.diff = ms$$1;
|
|
self.prev = prevTime;
|
|
self.curr = curr;
|
|
prevTime = curr;
|
|
|
|
// turn the `arguments` into a proper Array
|
|
var args = new Array(arguments.length);
|
|
for (var i = 0; i < args.length; i++) {
|
|
args[i] = arguments[i];
|
|
}
|
|
|
|
args[0] = exports.coerce(args[0]);
|
|
|
|
if ('string' !== typeof args[0]) {
|
|
// anything else let's inspect with %O
|
|
args.unshift('%O');
|
|
}
|
|
|
|
// apply any `formatters` transformations
|
|
var index = 0;
|
|
args[0] = args[0].replace(/%([a-zA-Z%])/g, function(match, format) {
|
|
// if we encounter an escaped % then don't increase the array index
|
|
if (match === '%%') return match;
|
|
index++;
|
|
var formatter = exports.formatters[format];
|
|
if ('function' === typeof formatter) {
|
|
var val = args[index];
|
|
match = formatter.call(self, val);
|
|
|
|
// now we need to remove `args[index]` since it's inlined in the `format`
|
|
args.splice(index, 1);
|
|
index--;
|
|
}
|
|
return match;
|
|
});
|
|
|
|
// apply env-specific formatting (colors, etc.)
|
|
exports.formatArgs.call(self, args);
|
|
|
|
var logFn = debug.log || exports.log || console.log.bind(console);
|
|
logFn.apply(self, args);
|
|
}
|
|
|
|
debug.namespace = namespace;
|
|
debug.enabled = exports.enabled(namespace);
|
|
debug.useColors = exports.useColors();
|
|
debug.color = selectColor(namespace);
|
|
|
|
// env-specific initialization logic for debug instances
|
|
if ('function' === typeof exports.init) {
|
|
exports.init(debug);
|
|
}
|
|
|
|
return debug;
|
|
}
|
|
|
|
/**
|
|
* Enables a debug mode by namespaces. This can include modes
|
|
* separated by a colon and wildcards.
|
|
*
|
|
* @param {String} namespaces
|
|
* @api public
|
|
*/
|
|
|
|
function enable(namespaces) {
|
|
exports.save(namespaces);
|
|
|
|
exports.names = [];
|
|
exports.skips = [];
|
|
|
|
var split = (typeof namespaces === 'string' ? namespaces : '').split(/[\s,]+/);
|
|
var len = split.length;
|
|
|
|
for (var i = 0; i < len; i++) {
|
|
if (!split[i]) continue; // ignore empty strings
|
|
namespaces = split[i].replace(/\*/g, '.*?');
|
|
if (namespaces[0] === '-') {
|
|
exports.skips.push(new RegExp('^' + namespaces.substr(1) + '$'));
|
|
} else {
|
|
exports.names.push(new RegExp('^' + namespaces + '$'));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disable debug output.
|
|
*
|
|
* @api public
|
|
*/
|
|
|
|
function disable() {
|
|
exports.enable('');
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given mode name is enabled, false otherwise.
|
|
*
|
|
* @param {String} name
|
|
* @return {Boolean}
|
|
* @api public
|
|
*/
|
|
|
|
function enabled(name) {
|
|
var i, len;
|
|
for (i = 0, len = exports.skips.length; i < len; i++) {
|
|
if (exports.skips[i].test(name)) {
|
|
return false;
|
|
}
|
|
}
|
|
for (i = 0, len = exports.names.length; i < len; i++) {
|
|
if (exports.names[i].test(name)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Coerce `val`.
|
|
*
|
|
* @param {Mixed} val
|
|
* @return {Mixed}
|
|
* @api private
|
|
*/
|
|
|
|
function coerce(val) {
|
|
if (val instanceof Error) return val.stack || val.message;
|
|
return val;
|
|
}
|
|
});
|
|
|
|
var debug_1 = debug$1.coerce;
|
|
var debug_2 = debug$1.disable;
|
|
var debug_3 = debug$1.enable;
|
|
var debug_4 = debug$1.enabled;
|
|
var debug_5 = debug$1.humanize;
|
|
var debug_6 = debug$1.names;
|
|
var debug_7 = debug$1.skips;
|
|
var debug_8 = debug$1.formatters;
|
|
|
|
var browser = createCommonjsModule(function (module, exports) {
|
|
/**
|
|
* This is the web browser implementation of `debug()`.
|
|
*
|
|
* Expose `debug()` as the module.
|
|
*/
|
|
|
|
exports = module.exports = debug$1;
|
|
exports.log = log;
|
|
exports.formatArgs = formatArgs;
|
|
exports.save = save;
|
|
exports.load = load;
|
|
exports.useColors = useColors;
|
|
exports.storage = 'undefined' != typeof chrome
|
|
&& 'undefined' != typeof chrome.storage
|
|
? chrome.storage.local
|
|
: localstorage();
|
|
|
|
/**
|
|
* Colors.
|
|
*/
|
|
|
|
exports.colors = [
|
|
'lightseagreen',
|
|
'forestgreen',
|
|
'goldenrod',
|
|
'dodgerblue',
|
|
'darkorchid',
|
|
'crimson'
|
|
];
|
|
|
|
/**
|
|
* Currently only WebKit-based Web Inspectors, Firefox >= v31,
|
|
* and the Firebug extension (any Firefox version) are known
|
|
* to support "%c" CSS customizations.
|
|
*
|
|
* TODO: add a `localStorage` variable to explicitly enable/disable colors
|
|
*/
|
|
|
|
function useColors() {
|
|
// NB: In an Electron preload script, document will be defined but not fully
|
|
// initialized. Since we know we're in Chrome, we'll just detect this case
|
|
// explicitly
|
|
if (typeof window !== 'undefined' && window.process && window.process.type === 'renderer') {
|
|
return true;
|
|
}
|
|
|
|
// is webkit? http://stackoverflow.com/a/16459606/376773
|
|
// document is undefined in react-native: https://github.com/facebook/react-native/pull/1632
|
|
return (typeof document !== 'undefined' && document.documentElement && document.documentElement.style && document.documentElement.style.WebkitAppearance) ||
|
|
// is firebug? http://stackoverflow.com/a/398120/376773
|
|
(typeof window !== 'undefined' && window.console && (window.console.firebug || (window.console.exception && window.console.table))) ||
|
|
// is firefox >= v31?
|
|
// https://developer.mozilla.org/en-US/docs/Tools/Web_Console#Styling_messages
|
|
(typeof navigator !== 'undefined' && navigator.userAgent && navigator.userAgent.toLowerCase().match(/firefox\/(\d+)/) && parseInt(RegExp.$1, 10) >= 31) ||
|
|
// double check webkit in userAgent just in case we are in a worker
|
|
(typeof navigator !== 'undefined' && navigator.userAgent && navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/));
|
|
}
|
|
|
|
/**
|
|
* Map %j to `JSON.stringify()`, since no Web Inspectors do that by default.
|
|
*/
|
|
|
|
exports.formatters.j = function(v) {
|
|
try {
|
|
return JSON.stringify(v);
|
|
} catch (err) {
|
|
return '[UnexpectedJSONParseError]: ' + err.message;
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* Colorize log arguments if enabled.
|
|
*
|
|
* @api public
|
|
*/
|
|
|
|
function formatArgs(args) {
|
|
var useColors = this.useColors;
|
|
|
|
args[0] = (useColors ? '%c' : '')
|
|
+ this.namespace
|
|
+ (useColors ? ' %c' : ' ')
|
|
+ args[0]
|
|
+ (useColors ? '%c ' : ' ')
|
|
+ '+' + exports.humanize(this.diff);
|
|
|
|
if (!useColors) return;
|
|
|
|
var c = 'color: ' + this.color;
|
|
args.splice(1, 0, c, 'color: inherit');
|
|
|
|
// the final "%c" is somewhat tricky, because there could be other
|
|
// arguments passed either before or after the %c, so we need to
|
|
// figure out the correct index to insert the CSS into
|
|
var index = 0;
|
|
var lastC = 0;
|
|
args[0].replace(/%[a-zA-Z%]/g, function(match) {
|
|
if ('%%' === match) return;
|
|
index++;
|
|
if ('%c' === match) {
|
|
// we only are interested in the *last* %c
|
|
// (the user may have provided their own)
|
|
lastC = index;
|
|
}
|
|
});
|
|
|
|
args.splice(lastC, 0, c);
|
|
}
|
|
|
|
/**
|
|
* Invokes `console.log()` when available.
|
|
* No-op when `console.log` is not a "function".
|
|
*
|
|
* @api public
|
|
*/
|
|
|
|
function log() {
|
|
// this hackery is required for IE8/9, where
|
|
// the `console.log` function doesn't have 'apply'
|
|
return 'object' === typeof console
|
|
&& console.log
|
|
&& Function.prototype.apply.call(console.log, console, arguments);
|
|
}
|
|
|
|
/**
|
|
* Save `namespaces`.
|
|
*
|
|
* @param {String} namespaces
|
|
* @api private
|
|
*/
|
|
|
|
function save(namespaces) {
|
|
try {
|
|
if (null == namespaces) {
|
|
exports.storage.removeItem('debug');
|
|
} else {
|
|
exports.storage.debug = namespaces;
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
/**
|
|
* Load `namespaces`.
|
|
*
|
|
* @return {String} returns the previously persisted debug modes
|
|
* @api private
|
|
*/
|
|
|
|
function load() {
|
|
var r;
|
|
try {
|
|
r = exports.storage.debug;
|
|
} catch(e) {}
|
|
|
|
// If debug isn't set in LS, and we're in Electron, try to load $DEBUG
|
|
if (!r && typeof process !== 'undefined' && 'env' in process) {
|
|
r = process.env.DEBUG;
|
|
}
|
|
|
|
return r;
|
|
}
|
|
|
|
/**
|
|
* Enable namespaces listed in `localStorage.debug` initially.
|
|
*/
|
|
|
|
exports.enable(load());
|
|
|
|
/**
|
|
* Localstorage attempts to return the localstorage.
|
|
*
|
|
* This is necessary because safari throws
|
|
* when a user disables cookies/localstorage
|
|
* and you attempt to access it.
|
|
*
|
|
* @return {LocalStorage}
|
|
* @api private
|
|
*/
|
|
|
|
function localstorage() {
|
|
try {
|
|
return window.localStorage;
|
|
} catch (e) {}
|
|
}
|
|
});
|
|
|
|
var browser_1 = browser.log;
|
|
var browser_2 = browser.formatArgs;
|
|
var browser_3 = browser.save;
|
|
var browser_4 = browser.load;
|
|
var browser_5 = browser.useColors;
|
|
var browser_6 = browser.storage;
|
|
var browser_7 = browser.colors;
|
|
|
|
class AbstractConnector {
|
|
constructor (y, opts) {
|
|
this.y = y;
|
|
this.opts = opts;
|
|
if (opts.role == null || opts.role === 'master') {
|
|
this.role = 'master';
|
|
} else if (opts.role === 'slave') {
|
|
this.role = 'slave';
|
|
} else {
|
|
throw new Error("Role must be either 'master' or 'slave'!")
|
|
}
|
|
this.log = browser('y:connector');
|
|
this.logMessage = browser('y:connector-message');
|
|
this._forwardAppliedStructs = opts.forwardAppliedOperations || false; // TODO: rename
|
|
this.role = opts.role;
|
|
this.connections = new Map();
|
|
this.isSynced = false;
|
|
this.userEventListeners = [];
|
|
this.whenSyncedListeners = [];
|
|
this.currentSyncTarget = null;
|
|
this.debug = opts.debug === true;
|
|
this.broadcastBuffer = new BinaryEncoder();
|
|
this.broadcastBufferSize = 0;
|
|
this.protocolVersion = 11;
|
|
this.authInfo = opts.auth || null;
|
|
this.checkAuth = opts.checkAuth || function () { return Promise.resolve('write') }; // default is everyone has write access
|
|
if (opts.maxBufferLength == null) {
|
|
this.maxBufferLength = -1;
|
|
} else {
|
|
this.maxBufferLength = opts.maxBufferLength;
|
|
}
|
|
}
|
|
|
|
reconnect () {
|
|
this.log('reconnecting..');
|
|
}
|
|
|
|
disconnect () {
|
|
this.log('discronnecting..');
|
|
this.connections = new Map();
|
|
this.isSynced = false;
|
|
this.currentSyncTarget = null;
|
|
this.whenSyncedListeners = [];
|
|
return Promise.resolve()
|
|
}
|
|
|
|
onUserEvent (f) {
|
|
this.userEventListeners.push(f);
|
|
}
|
|
|
|
removeUserEventListener (f) {
|
|
this.userEventListeners = this.userEventListeners.filter(g => f !== g);
|
|
}
|
|
|
|
userLeft (user) {
|
|
if (this.connections.has(user)) {
|
|
this.log('%s: User left %s', this.y.userID, user);
|
|
this.connections.delete(user);
|
|
// check if isSynced event can be sent now
|
|
this._setSyncedWith(null);
|
|
for (var f of this.userEventListeners) {
|
|
f({
|
|
action: 'userLeft',
|
|
user: user
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
userJoined (user, role, auth) {
|
|
if (role == null) {
|
|
throw new Error('You must specify the role of the joined user!')
|
|
}
|
|
if (this.connections.has(user)) {
|
|
throw new Error('This user already joined!')
|
|
}
|
|
this.log('%s: User joined %s', this.y.userID, user);
|
|
this.connections.set(user, {
|
|
uid: user,
|
|
isSynced: false,
|
|
role: role,
|
|
processAfterAuth: [],
|
|
processAfterSync: [],
|
|
auth: auth || null,
|
|
receivedSyncStep2: false
|
|
});
|
|
let defer = {};
|
|
defer.promise = new Promise(function (resolve) { defer.resolve = resolve; });
|
|
this.connections.get(user).syncStep2 = defer;
|
|
for (var f of this.userEventListeners) {
|
|
f({
|
|
action: 'userJoined',
|
|
user: user,
|
|
role: role
|
|
});
|
|
}
|
|
this._syncWithUser(user);
|
|
}
|
|
|
|
// Execute a function _when_ we are connected.
|
|
// If not connected, wait until connected
|
|
whenSynced (f) {
|
|
if (this.isSynced) {
|
|
f();
|
|
} else {
|
|
this.whenSyncedListeners.push(f);
|
|
}
|
|
}
|
|
|
|
_syncWithUser (userID) {
|
|
if (this.role === 'slave') {
|
|
return // "The current sync has not finished or this is controlled by a master!"
|
|
}
|
|
sendSyncStep1(this, userID);
|
|
}
|
|
|
|
_fireIsSyncedListeners () {
|
|
if (!this.isSynced) {
|
|
this.isSynced = true;
|
|
// It is safer to remove this!
|
|
// call whensynced listeners
|
|
for (var f of this.whenSyncedListeners) {
|
|
f();
|
|
}
|
|
this.whenSyncedListeners = [];
|
|
this.y._setContentReady();
|
|
this.y.emit('synced');
|
|
}
|
|
}
|
|
|
|
send (uid, buffer) {
|
|
const y = this.y;
|
|
if (!(buffer instanceof ArrayBuffer || buffer instanceof Uint8Array)) {
|
|
throw new Error('Expected Message to be an ArrayBuffer or Uint8Array - don\'t use this method to send custom messages')
|
|
}
|
|
this.log('User%s to User%s: Send \'%y\'', y.userID, uid, buffer);
|
|
this.logMessage('User%s to User%s: Send %Y', y.userID, uid, [y, buffer]);
|
|
}
|
|
|
|
broadcast (buffer) {
|
|
const y = this.y;
|
|
if (!(buffer instanceof ArrayBuffer || buffer instanceof Uint8Array)) {
|
|
throw new Error('Expected Message to be an ArrayBuffer or Uint8Array - don\'t use this method to send custom messages')
|
|
}
|
|
this.log('User%s: Broadcast \'%y\'', y.userID, buffer);
|
|
this.logMessage('User%s: Broadcast: %Y', y.userID, [y, buffer]);
|
|
}
|
|
|
|
/*
|
|
Buffer operations, and broadcast them when ready.
|
|
*/
|
|
broadcastStruct (struct) {
|
|
const firstContent = this.broadcastBuffer.length === 0;
|
|
if (firstContent) {
|
|
this.broadcastBuffer.writeVarString(this.y.room);
|
|
this.broadcastBuffer.writeVarString('update');
|
|
this.broadcastBufferSize = 0;
|
|
this.broadcastBufferSizePos = this.broadcastBuffer.pos;
|
|
this.broadcastBuffer.writeUint32(0);
|
|
}
|
|
this.broadcastBufferSize++;
|
|
struct._toBinary(this.broadcastBuffer);
|
|
if (this.maxBufferLength > 0 && this.broadcastBuffer.length > this.maxBufferLength) {
|
|
// it is necessary to send the buffer now
|
|
// cache the buffer and check if server is responsive
|
|
const buffer = this.broadcastBuffer;
|
|
buffer.setUint32(this.broadcastBufferSizePos, this.broadcastBufferSize);
|
|
this.broadcastBuffer = new BinaryEncoder();
|
|
this.whenRemoteResponsive().then(() => {
|
|
this.broadcast(buffer.createBuffer());
|
|
});
|
|
} else if (firstContent) {
|
|
// send the buffer when all transactions are finished
|
|
// (or buffer exceeds maxBufferLength)
|
|
setTimeout(() => {
|
|
if (this.broadcastBuffer.length > 0) {
|
|
const buffer = this.broadcastBuffer;
|
|
buffer.setUint32(this.broadcastBufferSizePos, this.broadcastBufferSize);
|
|
this.broadcast(buffer.createBuffer());
|
|
this.broadcastBuffer = new BinaryEncoder();
|
|
}
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Somehow check the responsiveness of the remote clients/server
|
|
* Default behavior:
|
|
* Wait 100ms before broadcasting the next batch of operations
|
|
*
|
|
* Only used when maxBufferLength is set
|
|
*
|
|
*/
|
|
whenRemoteResponsive () {
|
|
return new Promise(function (resolve) {
|
|
setTimeout(resolve, 100);
|
|
})
|
|
}
|
|
|
|
/*
|
|
You received a raw message, and you know that it is intended for Yjs. Then call this function.
|
|
*/
|
|
receiveMessage (sender, buffer, skipAuth) {
|
|
const y = this.y;
|
|
const userID = y.userID;
|
|
skipAuth = skipAuth || false;
|
|
if (!(buffer instanceof ArrayBuffer || buffer instanceof Uint8Array)) {
|
|
return Promise.reject(new Error('Expected Message to be an ArrayBuffer or Uint8Array!'))
|
|
}
|
|
if (sender === userID) {
|
|
return Promise.resolve()
|
|
}
|
|
let decoder = new BinaryDecoder(buffer);
|
|
let encoder = new BinaryEncoder();
|
|
let roomname = decoder.readVarString(); // read room name
|
|
encoder.writeVarString(roomname);
|
|
let messageType = decoder.readVarString();
|
|
let senderConn = this.connections.get(sender);
|
|
this.log('User%s from User%s: Receive \'%s\'', userID, sender, messageType);
|
|
this.logMessage('User%s from User%s: Receive %Y', userID, sender, [y, buffer]);
|
|
if (senderConn == null && !skipAuth) {
|
|
throw new Error('Received message from unknown peer!')
|
|
}
|
|
if (messageType === 'sync step 1' || messageType === 'sync step 2') {
|
|
let auth = decoder.readVarUint();
|
|
if (senderConn.auth == null) {
|
|
senderConn.processAfterAuth.push([messageType, senderConn, decoder, encoder, sender]);
|
|
// check auth
|
|
return this.checkAuth(auth, y, sender).then(authPermissions => {
|
|
if (senderConn.auth == null) {
|
|
senderConn.auth = authPermissions;
|
|
y.emit('userAuthenticated', {
|
|
user: senderConn.uid,
|
|
auth: authPermissions
|
|
});
|
|
}
|
|
let messages = senderConn.processAfterAuth;
|
|
senderConn.processAfterAuth = [];
|
|
|
|
messages.forEach(m =>
|
|
this.computeMessage(m[0], m[1], m[2], m[3], m[4])
|
|
);
|
|
})
|
|
}
|
|
}
|
|
if ((skipAuth || senderConn.auth != null) && (messageType !== 'update' || senderConn.isSynced)) {
|
|
this.computeMessage(messageType, senderConn, decoder, encoder, sender, skipAuth);
|
|
} else {
|
|
senderConn.processAfterSync.push([messageType, senderConn, decoder, encoder, sender, false]);
|
|
}
|
|
}
|
|
|
|
computeMessage (messageType, senderConn, decoder, encoder, sender, skipAuth) {
|
|
if (messageType === 'sync step 1' && (senderConn.auth === 'write' || senderConn.auth === 'read')) {
|
|
// cannot wait for sync step 1 to finish, because we may wait for sync step 2 in sync step 1 (->lock)
|
|
readSyncStep1(decoder, encoder, this.y, senderConn, sender);
|
|
} else {
|
|
const y = this.y;
|
|
y.transact(function () {
|
|
if (messageType === 'sync step 2' && senderConn.auth === 'write') {
|
|
readSyncStep2(decoder, encoder, y, senderConn, sender);
|
|
} else if (messageType === 'update' && (skipAuth || senderConn.auth === 'write')) {
|
|
integrateRemoteStructs(y, decoder);
|
|
} else {
|
|
throw new Error('Unable to receive message')
|
|
}
|
|
}, true);
|
|
}
|
|
}
|
|
|
|
_setSyncedWith (user) {
|
|
if (user != null) {
|
|
const userConn = this.connections.get(user);
|
|
userConn.isSynced = true;
|
|
const messages = userConn.processAfterSync;
|
|
userConn.processAfterSync = [];
|
|
messages.forEach(m => {
|
|
this.computeMessage(m[0], m[1], m[2], m[3], m[4]);
|
|
});
|
|
}
|
|
const conns = Array.from(this.connections.values());
|
|
if (conns.length > 0 && conns.every(u => u.isSynced)) {
|
|
this._fireIsSyncedListeners();
|
|
}
|
|
}
|
|
}
|
|
|
|
function fromBinary (y, decoder) {
|
|
integrateRemoteStructs(y, decoder);
|
|
readDeleteSet(y, decoder);
|
|
}
|
|
|
|
function toBinary (y) {
|
|
let encoder = new BinaryEncoder();
|
|
writeStructs(y, encoder, new Map());
|
|
writeDeleteSet(y, encoder);
|
|
return encoder
|
|
}
|
|
|
|
function createMutualExclude () {
|
|
var token = true;
|
|
return function mutualExclude (f) {
|
|
if (token) {
|
|
token = false;
|
|
try {
|
|
f();
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
token = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
function getFreshCnf () {
|
|
let buffer = new BinaryEncoder();
|
|
buffer.writeUint32(0);
|
|
return {
|
|
len: 0,
|
|
buffer
|
|
}
|
|
}
|
|
|
|
class AbstractPersistence {
|
|
constructor (opts) {
|
|
this.opts = opts;
|
|
this.ys = new Map();
|
|
}
|
|
|
|
_init (y) {
|
|
let cnf = this.ys.get(y);
|
|
if (cnf === undefined) {
|
|
cnf = getFreshCnf();
|
|
cnf.mutualExclude = createMutualExclude();
|
|
this.ys.set(y, cnf);
|
|
return this.init(y).then(() => {
|
|
y.on('afterTransaction', (y, transaction) => {
|
|
let cnf = this.ys.get(y);
|
|
if (cnf.len > 0) {
|
|
cnf.buffer.setUint32(0, cnf.len);
|
|
this.saveUpdate(y, cnf.buffer.createBuffer(), transaction);
|
|
let _cnf = getFreshCnf();
|
|
for (let key in _cnf) {
|
|
cnf[key] = _cnf[key];
|
|
}
|
|
}
|
|
});
|
|
return this.retrieve(y)
|
|
}).then(function () {
|
|
return Promise.resolve(cnf)
|
|
})
|
|
} else {
|
|
return Promise.resolve(cnf)
|
|
}
|
|
}
|
|
deinit (y) {
|
|
this.ys.delete(y);
|
|
y.persistence = null;
|
|
}
|
|
|
|
destroy () {
|
|
this.ys = null;
|
|
}
|
|
|
|
/**
|
|
* Remove all persisted data that belongs to a room.
|
|
* Automatically destroys all Yjs all Yjs instances that persist to
|
|
* the room. If `destroyYjsInstances = false` the persistence functionality
|
|
* will be removed from the Yjs instances.
|
|
*
|
|
* ** Must be overwritten! **
|
|
*/
|
|
removePersistedData (room, destroyYjsInstances = true) {
|
|
this.ys.forEach((cnf, y) => {
|
|
if (y.room === room) {
|
|
if (destroyYjsInstances) {
|
|
y.destroy();
|
|
} else {
|
|
this.deinit(y);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/* overwrite */
|
|
saveUpdate (buffer) {
|
|
}
|
|
|
|
/**
|
|
* Save struct to update buffer.
|
|
* saveUpdate is called when transaction ends
|
|
*/
|
|
saveStruct (y, struct) {
|
|
let cnf = this.ys.get(y);
|
|
if (cnf !== undefined) {
|
|
cnf.mutualExclude(function () {
|
|
struct._toBinary(cnf.buffer);
|
|
cnf.len++;
|
|
});
|
|
}
|
|
}
|
|
|
|
/* overwrite */
|
|
retrieve (y, model, updates) {
|
|
let cnf = this.ys.get(y);
|
|
if (cnf !== undefined) {
|
|
cnf.mutualExclude(function () {
|
|
y.transact(function () {
|
|
if (model != null) {
|
|
fromBinary(y, new BinaryDecoder(new Uint8Array(model)));
|
|
}
|
|
if (updates != null) {
|
|
for (let i = 0; i < updates.length; i++) {
|
|
integrateRemoteStructs(y, new BinaryDecoder(new Uint8Array(updates[i])));
|
|
}
|
|
}
|
|
});
|
|
y.emit('persistenceReady');
|
|
});
|
|
}
|
|
}
|
|
|
|
/* overwrite */
|
|
persist (y) {
|
|
return toBinary(y).createBuffer()
|
|
}
|
|
}
|
|
|
|
class Binding {
|
|
constructor (type, target) {
|
|
this.type = type;
|
|
this.target = target;
|
|
this._mutualExclude = createMutualExclude();
|
|
}
|
|
destroy () {
|
|
this.type = null;
|
|
this.target = null;
|
|
}
|
|
}
|
|
|
|
function typeObserver () {
|
|
this._mutualExclude(() => {
|
|
const textarea = this.target;
|
|
const textType = this.type;
|
|
const relativeStart = getRelativePosition(textType, textarea.selectionStart);
|
|
const relativeEnd = getRelativePosition(textType, textarea.selectionEnd);
|
|
textarea.value = textType.toString();
|
|
const start = fromRelativePosition(textType._y, relativeStart);
|
|
const end = fromRelativePosition(textType._y, relativeEnd);
|
|
textarea.setSelectionRange(start, end);
|
|
});
|
|
}
|
|
|
|
function domObserver () {
|
|
this._mutualExclude(() => {
|
|
let diff = simpleDiff(this.type.toString(), this.target.value);
|
|
this.type.delete(diff.pos, diff.remove);
|
|
this.type.insert(diff.pos, diff.insert);
|
|
});
|
|
}
|
|
|
|
class TextareaBinding extends Binding {
|
|
constructor (textType, domTextarea) {
|
|
// Binding handles textType as this.type and domTextarea as this.target
|
|
super(textType, domTextarea);
|
|
// set initial value
|
|
domTextarea.value = textType.toString();
|
|
// Observers are handled by this class
|
|
this._typeObserver = typeObserver.bind(this);
|
|
this._domObserver = domObserver.bind(this);
|
|
textType.observe(this._typeObserver);
|
|
domTextarea.addEventListener('input', this._domObserver);
|
|
}
|
|
destroy () {
|
|
// Remove everything that is handled by this class
|
|
this.type.unobserve(this._typeObserver);
|
|
this.target.unobserve(this._domObserver);
|
|
super.destroy();
|
|
}
|
|
}
|
|
|
|
class Y$1 extends NamedEventHandler {
|
|
constructor (room, opts, persistence) {
|
|
super();
|
|
this.room = room;
|
|
if (opts != null) {
|
|
opts.connector.room = room;
|
|
}
|
|
this._contentReady = false;
|
|
this._opts = opts;
|
|
this.userID = generateUserID();
|
|
this.share = {};
|
|
this.ds = new DeleteStore(this);
|
|
this.os = new OperationStore(this);
|
|
this.ss = new StateStore(this);
|
|
this._missingStructs = new Map();
|
|
this._readyToIntegrate = [];
|
|
this._transaction = null;
|
|
this.connector = null;
|
|
this.connected = false;
|
|
let initConnection = () => {
|
|
if (opts != null) {
|
|
this.connector = new Y$1[opts.connector.name](this, opts.connector);
|
|
this.connected = true;
|
|
this.emit('connectorReady');
|
|
}
|
|
};
|
|
if (persistence != null) {
|
|
this.persistence = persistence;
|
|
persistence._init(this).then(initConnection);
|
|
} else {
|
|
this.persistence = null;
|
|
initConnection();
|
|
}
|
|
}
|
|
_setContentReady () {
|
|
if (!this._contentReady) {
|
|
this._contentReady = true;
|
|
this.emit('content');
|
|
}
|
|
}
|
|
whenContentReady () {
|
|
if (this._contentReady) {
|
|
return Promise.resolve()
|
|
} else {
|
|
return new Promise(resolve => {
|
|
this.once('content', resolve);
|
|
})
|
|
}
|
|
}
|
|
_beforeChange () {}
|
|
transact (f, remote = false) {
|
|
let initialCall = this._transaction === null;
|
|
if (initialCall) {
|
|
this._transaction = new Transaction(this);
|
|
this.emit('beforeTransaction', this, this._transaction, remote);
|
|
}
|
|
try {
|
|
f(this);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
if (initialCall) {
|
|
this.emit('beforeObserverCalls', this, this._transaction, remote);
|
|
const transaction = this._transaction;
|
|
this._transaction = null;
|
|
// emit change events on changed types
|
|
transaction.changedTypes.forEach(function (subs, type) {
|
|
if (!type._deleted) {
|
|
type._callObserver(transaction, subs, remote);
|
|
}
|
|
});
|
|
transaction.changedParentTypes.forEach(function (events, type) {
|
|
if (!type._deleted) {
|
|
events = events
|
|
.filter(event =>
|
|
!event.target._deleted
|
|
);
|
|
events
|
|
.forEach(event => {
|
|
event.currentTarget = type;
|
|
});
|
|
// we don't have to check for events.length
|
|
// because there is no way events is empty..
|
|
type._deepEventHandler.callEventListeners(transaction, events);
|
|
}
|
|
});
|
|
// when all changes & events are processed, emit afterTransaction event
|
|
this.emit('afterTransaction', this, transaction, remote);
|
|
}
|
|
}
|
|
// fake _start for root properties (y.set('name', type))
|
|
get _start () {
|
|
return null
|
|
}
|
|
set _start (start) {
|
|
return null
|
|
}
|
|
define (name, TypeConstructor) {
|
|
let id = new RootID(name, TypeConstructor);
|
|
let type = this.os.get(id);
|
|
if (this.share[name] === undefined) {
|
|
this.share[name] = type;
|
|
} else if (this.share[name] !== type) {
|
|
throw new Error('Type is already defined with a different constructor')
|
|
}
|
|
return type
|
|
}
|
|
get (name) {
|
|
return this.share[name]
|
|
}
|
|
disconnect () {
|
|
if (this.connected) {
|
|
this.connected = false;
|
|
return this.connector.disconnect()
|
|
} else {
|
|
return Promise.resolve()
|
|
}
|
|
}
|
|
reconnect () {
|
|
if (!this.connected) {
|
|
this.connected = true;
|
|
return this.connector.reconnect()
|
|
} else {
|
|
return Promise.resolve()
|
|
}
|
|
}
|
|
destroy () {
|
|
super.destroy();
|
|
this.share = null;
|
|
if (this.connector != null) {
|
|
if (this.connector.destroy != null) {
|
|
this.connector.destroy();
|
|
} else {
|
|
this.connector.disconnect();
|
|
}
|
|
}
|
|
if (this.persistence !== null) {
|
|
this.persistence.deinit(this);
|
|
this.persistence = null;
|
|
}
|
|
this.os = null;
|
|
this.ds = null;
|
|
this.ss = null;
|
|
}
|
|
whenSynced () {
|
|
return new Promise(resolve => {
|
|
this.once('synced', () => {
|
|
resolve();
|
|
});
|
|
})
|
|
}
|
|
}
|
|
|
|
Y$1.extend = function extendYjs () {
|
|
for (var i = 0; i < arguments.length; i++) {
|
|
var f = arguments[i];
|
|
if (typeof f === 'function') {
|
|
f(Y$1);
|
|
} else {
|
|
throw new Error('Expected a function!')
|
|
}
|
|
}
|
|
};
|
|
|
|
// TODO: The following assignments should be moved to yjs-dist
|
|
Y$1.AbstractConnector = AbstractConnector;
|
|
Y$1.AbstractPersistence = AbstractPersistence;
|
|
Y$1.Array = YArray;
|
|
Y$1.Map = YMap;
|
|
Y$1.Text = YText;
|
|
Y$1.XmlElement = YXmlElement;
|
|
Y$1.XmlFragment = YXmlFragment;
|
|
Y$1.XmlText = YXmlText;
|
|
Y$1.XmlHook = YXmlHook;
|
|
|
|
Y$1.TextareaBinding = TextareaBinding;
|
|
|
|
Y$1.utils = {
|
|
BinaryDecoder,
|
|
UndoManager,
|
|
getRelativePosition,
|
|
fromRelativePosition,
|
|
addType: addStruct,
|
|
integrateRemoteStructs,
|
|
toBinary,
|
|
fromBinary
|
|
};
|
|
|
|
Y$1.debug = browser;
|
|
browser.formatters.Y = messageToString;
|
|
browser.formatters.y = messageToRoomname;
|
|
|
|
module.exports = Y$1;
|
|
//# sourceMappingURL=y.node.js.map
|