restructuring the project

This commit is contained in:
Kevin Jahns
2019-03-01 23:26:40 +01:00
parent f6b4819ae3
commit 75f4a0a5f0
163 changed files with 6423 additions and 10770 deletions

28
src/index.js Normal file
View File

@@ -0,0 +1,28 @@
export { Y } from './utils/Y.js'
export { UndoManager } from './utils/UndoManager.js'
export { Transaction } from './utils/Transaction.js'
export { Item } from './structs/Item.js'
export { Delete } from './structs/Delete.js'
export { ItemJSON } from './structs/ItemJSON.js'
export { ItemString } from './structs/ItemString.js'
export { ItemFormat } from './structs/ItemFormat.js'
export { ItemEmbed } from './structs/ItemEmbed.js'
export { ItemBinary } from './structs/ItemBinary.js'
export { GC } from './structs/GC.js'
export { YArray as Array } from './types/YArray.js'
export { YMap as Map } from './types/YMap.js'
export { YText as Text } from './types/YText.js'
export { YXmlText as XmlText } from './types/YXmlText.js'
export { YXmlHook as XmlHook } from './types/YXmlHook.js'
export { YXmlElement as XmlElement, YXmlFragment as XmlFragment } from './types/YXmlElement.js'
export { getRelativePosition, fromRelativePosition, equal as equalRelativePosition } from './utils/relativePosition.js'
export { ID, createID, RootFakeUserID } from './utils/ID.js'
export { DeleteStore, DSNode } from './utils/DeleteStore.js'
export { deleteItemRange } from './utils/structManipulation.js'
export { integrateRemoteStruct, integrateRemoteStructs } from './utils/integrateRemoteStructs.js'
export { isParentOf } from './utils/isParentOf.js'

104
src/structs/Delete.js Normal file
View File

@@ -0,0 +1,104 @@
/**
* @module structs
*/
import { getStructReference } from 'y-protocols/sync.js'
import * as ID from '../utils/ID.js'
import { writeStructToTransaction } from '../utils/structEncoding.js'
import * as decoding from 'funlib/decoding.js'
import * as encoding from 'funlib/encoding.js'
// import { Item } from './Item.js' // eslint-disable-line
// import { Y } from '../utils/Y.js' // eslint-disable-line
import { deleteItemRange } from '../utils/structManipulation.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
/**
* @private
* A Delete change is not a real Item, but it provides the same interface as an
* Item. The only difference is that it will not be saved in the ItemStore
* (OperationStore), but instead it is safed in the DeleteStore.
*/
export class Delete {
constructor () {
/**
* @type {ID.ID}
*/
this._targetID = null
/**
* @type {Item}
*/
this._target = null
this._length = null
}
/**
* @private
* Read the next Item in a Decoder and fill this Item with the read data.
*
* This is called when data is received from a remote peer.
*
* @param {Y} y The Yjs instance that this Item belongs to.
* @param {decoding.Decoder} decoder The decoder object to read data from.
*/
_fromBinary (y, decoder) {
// TODO: set target, and add it to missing if not found
// There is an edge case in p2p networks!
/**
* @type {any}
*/
const targetID = ID.decode(decoder)
this._targetID = targetID
this._length = decoding.readVarUint(decoder)
if (y.os.getItem(targetID) === null) {
return [targetID]
} else {
return []
}
}
/**
* @private
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @param {encoding.Encoder} encoder The encoder to write data to.
*/
_toBinary (encoder) {
encoding.writeUint8(encoder, getStructReference(this.constructor))
this._targetID.encode(encoder)
encoding.writeVarUint(encoder, this._length)
}
/**
* @private
* Integrates this Item into the shared structure.
*
* This method actually applies the change to the Yjs instance. In the case of
* Delete it marks the delete target as deleted.
*
* * 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, false)
}
writeStructToTransaction(y._transaction, this)
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return `Delete - target: ${stringify.stringifyID(this._targetID)}, len: ${this._length}`
}
}

106
src/structs/GC.js Normal file
View File

@@ -0,0 +1,106 @@
/**
* @module structs
*/
import { getStructReference } from 'y-protocols/sync.js'
import * as ID from '../utils/ID.js'
import { writeStructToTransaction } from '../utils/structEncoding.js'
import * as decoding from 'funlib/decoding.js'
import * as encoding from 'funlib/encoding.js'
// import { Y } from '../utils/Y.js' // eslint-disable-line
// TODO should have the same base class as Item
export class GC {
constructor () {
/**
* @type {ID.ID}
*/
this._id = null
this._length = 0
}
get _redone () {
return null
}
get _deleted () {
return true
}
_integrate (y) {
const id = this._id
const userState = y.ss.getState(id.user)
if (id.clock === userState) {
y.ss.setState(id.user, id.clock + this._length)
}
y.ds.mark(this._id, this._length, true)
let n = y.os.put(this)
const prev = n.prev().val
if (prev !== null && prev.constructor === GC && prev._id.user === n.val._id.user && prev._id.clock + prev._length === n.val._id.clock) {
// TODO: do merging for all items!
prev._length += n.val._length
y.os.delete(n.val._id)
n = prev
}
if (n.val) {
n = n.val
}
const next = y.os.findNext(n._id)
if (next !== null && next.constructor === GC && next._id.user === n._id.user && next._id.clock === n._id.clock + n._length) {
n._length += next._length
y.os.delete(next._id)
}
if (id.user !== ID.RootFakeUserID) {
writeStructToTransaction(y._transaction, this)
}
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @param {encoding.Encoder} encoder The encoder to write data to.
* @private
*/
_toBinary (encoder) {
encoding.writeUint8(encoder, getStructReference(this.constructor))
this._id.encode(encoder)
encoding.writeVarUint(encoder, this._length)
}
/**
* Read the next Item in a Decoder and fill this Item with the read data.
*
* This is called when data is received from a remote peer.
*
* @param {Y} y The Yjs instance that this Item belongs to.
* @param {decoding.Decoder} decoder The decoder object to read data from.
* @private
*/
_fromBinary (y, decoder) {
/**
* @type {any}
*/
const id = ID.decode(decoder)
this._id = id
this._length = decoding.readVarUint(decoder)
const missing = []
if (y.ss.getState(id.user) < id.clock) {
missing.push(ID.createID(id.user, id.clock - 1))
}
return missing
}
_splitAt () {
return this
}
_clonePartial (diff) {
const gc = new GC()
gc._id = ID.createID(this._id.user, this._id.clock + diff)
gc._length = this._length - diff
return gc
}
}

601
src/structs/Item.js Normal file
View File

@@ -0,0 +1,601 @@
/**
* @module structs
*/
import { getStructReference } from 'y-protocols/sync.js'
import * as ID from '../utils/ID.js'
import { Delete } from './Delete.js'
import { writeStructToTransaction } from '../utils/structEncoding.js'
import { GC } from './GC.js'
import * as encoding from 'funlib/encoding.js'
import * as decoding from 'funlib/decoding.js'
/**
* @private
*/
export const 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 {@link Item#_splitAt})
* - copies all properties from a to b
* - connects a to b
* - assigns the correct _id
* - saves b to os
* @private
*/
export const splitHelper = (y, a, b, diff) => {
const aID = a._id
b._id = ID.createID(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)
if (y._transaction !== null) {
if (y._transaction.newTypes.has(a)) {
y._transaction.newTypes.add(b)
} else if (y._transaction.deletedStructs.has(a)) {
y._transaction.deletedStructs.add(b)
}
}
}
/**
* Abstract class that represents any content.
*/
export class Item {
constructor () {
/**
* The uniqe identifier of this type.
* @type {ID.ID | ID.RootID | null}
*/
this._id = null
/**
* The item that was originally to the left of this item.
* @type {Item | null}
*/
this._origin = null
/**
* The item that is currently to the left of this item.
* @type {Item | null}
*/
this._left = null
/**
* The item that is currently to the right of this item.
* @type {Item | null}
*/
this._right = null
/**
* The item that was originally to the right of this item.
* @type {Item | null}
*/
this._right_origin = null
/**
* The parent type.
* @type {Y | Type | null}
*/
this._parent = null
/**
* If the parent refers to this item with some kind of key (e.g. YMap, the
* key is specified here. The key is then used to refer to the list in which
* to insert this item. If `parentSub = null` type._start is the list in
* which to insert to. Otherwise it is `parent._map`.
* @type {String | null}
*/
this._parentSub = null
/**
* Whether this item was deleted or not.
* @type {Boolean}
*/
this._deleted = false
/**
* If this type's effect is reundone this type refers to the type that undid
* this operation.
* @type {Type | null}
*/
this._redone = null
}
/**
* Returns the next non-deleted item
* @private
*/
get _next () {
let n = this._right
while (n !== null && n._deleted) {
n = n._right
}
return n
}
/**
* Returns the previous non-deleted item
* @private
*/
get _prev () {
let n = this._left
while (n !== null && n._deleted) {
n = n._left
}
return n
}
/**
* Creates an Item with the same effect as this Item (without position effect)
*
* @private
*/
_copy () {
const C = this.constructor
// @ts-ignore
return new C()
}
/**
* Redoes the effect of this operation.
*
* @param {Y} y The Yjs instance.
* @param {Set<Item>} redoitems
*
* @private
*/
_redo (y, redoitems) {
if (this._redone !== null) {
return this._redone
}
if (!(this._parent instanceof Item)) {
return
}
let struct = this._copy()
let left, right
if (this._parentSub === null) {
// Is an array item. Insert at the old position
left = this._left
right = this
} else {
// Is a map item. Insert at the start
left = null
right = this._parent._map.get(this._parentSub)
right._delete(y)
}
let parent = this._parent
// make sure that parent is redone
if (parent._deleted === true && parent._redone === null) {
// try to undo parent if it will be undone anyway
if (!redoitems.has(parent) || !parent._redo(y, redoitems)) {
return false
}
}
if (parent._redone !== null) {
parent = parent._redone
// find next cloned_redo items
while (left !== null) {
if (left._redone !== null && left._redone._parent === parent) {
left = left._redone
break
}
left = left._left
}
while (right !== null) {
if (right._redone !== null && right._redone._parent === parent) {
right = right._redone
}
right = right._right
}
}
struct._origin = left
struct._left = left
struct._right = right
struct._right_origin = right
struct._parent = parent
struct._parentSub = this._parentSub
struct._integrate(y)
this._redone = struct
return true
}
/**
* Computes the last content address of this Item.
*
* @private
*/
get _lastId () {
/**
* @type {any}
*/
const id = this._id
return ID.createID(id.user, id.clock + this._length - 1)
}
/**
* Computes the length of this Item.
*
* @private
*/
get _length () {
return 1
}
/**
* Should return false if this Item is some kind of meta information
* (e.g. format information).
*
* * Whether this Item should be addressable via `yarray.get(i)`
* * Whether this Item should be counted when computing yarray.length
*
* @private
*/
get _countable () {
return true
}
/**
* Splits this Item so that another Items 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 {@link ItemJSON}/{@link ItemString} for implementation)
*
* @private
*/
_splitAt (y, diff) {
if (diff === 0) {
return this
}
return this._right
}
/**
* Mark this Item as deleted.
*
* @param {Y} y The Yjs instance
* @param {boolean} createDelete Whether to propagate a message that this
* Type was deleted.
* @param {boolean} [gcChildren]
*
* @private
*/
_delete (y, createDelete = true, gcChildren) {
if (!this._deleted) {
/**
* @type { Type }
*/
const parent = this._parent
const len = this._length
// adjust the length of parent
if (parent.length !== undefined && this._countable) {
parent.length -= len
}
this._deleted = true
y.ds.mark(this._id, this._length, false)
let del = new Delete()
del._targetID = this._id
del._length = len
if (createDelete) {
// broadcast and persists Delete
del._integrate(y, true)
}
transactionTypeChanged(y, parent, this._parentSub)
y._transaction.deletedStructs.add(this)
}
}
_gcChildren (y) {}
_gc (y) {
const gc = new GC()
gc._id = this._id
gc._length = this._length
y.os.delete(this._id)
gc._integrate(y)
}
/**
* This is called right before this Item receives any children.
* It can be overwritten to apply pending changes before applying remote changes
*
* @private
*/
_beforeChange () {
// nop
}
/**
* Integrates this Item into the shared structure.
*
* This method actually applies the change to the Yjs instance. In case of
* Item it connects _left and _right to this Item and calls the
* {@link Item#beforeChange} method.
*
* * Integrate the struct so that other types/structs can see it
* * Add this struct to y.os
* * Check if this is struct deleted
*
* @param {Y} y
*
* @private
*/
_integrate (y) {
y._transaction.newTypes.add(this)
/**
* @type {any}
*/
const parent = this._parent
/**
* @type {any}
*/
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 === ID.RootFakeUserID) {
// is parent
return
} 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
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 = parent._map.get(this._parentSub) || null
} else {
o = 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
}
}
// adjust the length of parent
if (parentSub === null && parent.length !== undefined && this._countable) {
parent.length += this._length
}
if (parent._deleted) {
this._delete(y, false, true)
}
y.os.put(this)
transactionTypeChanged(y, parent, parentSub)
if (this._id.user !== ID.RootFakeUserID) {
writeStructToTransaction(y._transaction, this)
}
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @param {encoding.Encoder} encoder The encoder to write data to.
*
* @private
*/
_toBinary (encoder) {
encoding.writeUint8(encoder, getStructReference(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
}
encoding.writeUint8(encoder, info)
this._id.encode(encoder)
if (info & 0b1) {
this._origin._lastId.encode(encoder)
}
// TODO: remove
/* see above
if (info & 0b10) {
encoder.writeID(this._left._lastId)
}
*/
if (info & 0b100) {
this._right_origin._id.encode(encoder)
}
if ((info & 0b101) === 0) {
// neither origin nor right is defined
this._parent._id.encode(encoder)
}
if (info & 0b1000) {
encoding.writeVarString(encoder, JSON.stringify(this._parentSub))
}
}
/**
* Read the next Item in a Decoder and fill this Item with the read data.
*
* This is called when data is received from a remote peer.
*
* @param {Y} y The Yjs instance that this Item belongs to.
* @param {decoding.Decoder} decoder The decoder object to read data from.
*
* @private
*/
_fromBinary (y, decoder) {
let missing = []
const info = decoding.readUint8(decoder)
const id = ID.decode(decoder)
this._id = id
// read origin
if (info & 0b1) {
// origin != null
const originID = ID.decode(decoder)
// 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 = ID.decode(decoder)
// 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 = ID.decode(decoder)
// parent does not change, so we don't have to search for it again
if (this._parent === null) {
let parent
if (parentID.constructor === ID.RootID) {
parent = y.os.get(parentID)
} else {
parent = y.os.getItem(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(decoding.readVarString(decoder))
}
if (id instanceof ID.ID && y.ss.getState(id.user) < id.clock) {
missing.push(ID.createID(id.user, id.clock - 1))
}
return missing
}
}

48
src/structs/ItemBinary.js Normal file
View File

@@ -0,0 +1,48 @@
/**
* @module structs
*/
// TODO: ItemBinary should be able to merge with right (similar to other items). Or the other items (ItemJSON) should not be able to merge - extra byte + consistency
import { Item } from './Item.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
import * as encoding from 'funlib/encoding.js'
import * as decoding from 'funlib/decoding.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
export class ItemBinary extends Item {
constructor () {
super()
this._content = null
}
_copy () {
let struct = super._copy()
struct._content = this._content
return struct
}
/**
* @param {Y} y
* @param {decoding.Decoder} decoder
*/
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this._content = decoding.readPayload(decoder)
return missing
}
/**
* @param {encoding.Encoder} encoder
*/
_toBinary (encoder) {
super._toBinary(encoder)
encoding.writePayload(encoder, this._content)
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return stringify.logItemHelper('ItemBinary', this)
}
}

49
src/structs/ItemEmbed.js Normal file
View File

@@ -0,0 +1,49 @@
/**
* @module structs
*/
import { Item } from './Item.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
import * as encoding from 'funlib/encoding.js'
import * as decoding from 'funlib/decoding.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
export class ItemEmbed extends Item {
constructor () {
super()
this.embed = null
}
_copy (undeleteChildren, copyPosition) {
let struct = super._copy()
struct.embed = this.embed
return struct
}
get _length () {
return 1
}
/**
* @param {Y} y
* @param {decoding.Decoder} decoder
*/
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this.embed = JSON.parse(decoding.readVarString(decoder))
return missing
}
/**
* @param {encoding.Encoder} encoder
*/
_toBinary (encoder) {
super._toBinary(encoder)
encoding.writeVarString(encoder, JSON.stringify(this.embed))
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return stringify.logItemHelper('ItemEmbed', this, `embed:${JSON.stringify(this.embed)}`)
}
}

56
src/structs/ItemFormat.js Normal file
View File

@@ -0,0 +1,56 @@
/**
* @module structs
*/
import { Item } from './Item.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
import * as encoding from 'funlib/encoding.js'
import * as decoding from 'funlib/decoding.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
export class ItemFormat extends Item {
constructor () {
super()
this.key = null
this.value = null
}
_copy (undeleteChildren, copyPosition) {
let struct = super._copy()
struct.key = this.key
struct.value = this.value
return struct
}
get _length () {
return 1
}
get _countable () {
return false
}
/**
* @param {Y} y
* @param {decoding.Decoder} decoder
*/
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this.key = decoding.readVarString(decoder)
this.value = JSON.parse(decoding.readVarString(decoder))
return missing
}
/**
* @param {encoding.Encoder} encoder
*/
_toBinary (encoder) {
super._toBinary(encoder)
encoding.writeVarString(encoder, this.key)
encoding.writeVarString(encoder, JSON.stringify(this.value))
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return stringify.logItemHelper('ItemFormat', this, `key:${JSON.stringify(this.key)},value:${JSON.stringify(this.value)}`)
}
}

83
src/structs/ItemJSON.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* @module structs
*/
import { Item, splitHelper } from './Item.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
import * as encoding from 'funlib/encoding.js'
import * as decoding from 'funlib/decoding.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
export class ItemJSON extends Item {
constructor () {
super()
this._content = null
}
_copy () {
let struct = super._copy()
struct._content = this._content
return struct
}
get _length () {
const c = this._content
return c !== null ? c.length : 0
}
/**
* @param {Y} y
* @param {decoding.Decoder} decoder
*/
_fromBinary (y, decoder) {
let missing = super._fromBinary(y, decoder)
let len = decoding.readVarUint(decoder)
this._content = new Array(len)
for (let i = 0; i < len; i++) {
const ctnt = decoding.readVarString(decoder)
let parsed
if (ctnt === 'undefined') {
parsed = undefined
} else {
parsed = JSON.parse(ctnt)
}
this._content[i] = parsed
}
return missing
}
/**
* @param {encoding.Encoder} encoder
*/
_toBinary (encoder) {
super._toBinary(encoder)
const len = this._length
encoding.writeVarUint(encoder, len)
for (let i = 0; i < len; i++) {
let encoded
const content = this._content[i]
if (content === undefined) {
encoded = 'undefined'
} else {
encoded = JSON.stringify(content)
}
encoding.writeVarString(encoder, encoded)
}
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return stringify.logItemHelper('ItemJSON', this, `content:${JSON.stringify(this._content)}`)
}
_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
}
}

61
src/structs/ItemString.js Normal file
View File

@@ -0,0 +1,61 @@
/**
* @module structs
*/
import { Item, splitHelper } from './Item.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
import * as encoding from 'funlib/encoding.js'
import * as decoding from 'funlib/decoding.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
export class ItemString extends Item {
constructor () {
super()
this._content = null
}
_copy () {
let struct = super._copy()
struct._content = this._content
return struct
}
get _length () {
return this._content.length
}
/**
* @param {Y} y
* @param {decoding.Decoder} decoder
*/
_fromBinary (y, decoder) {
let missing = super._fromBinary(y, decoder)
this._content = decoding.readVarString(decoder)
return missing
}
/**
* @param {encoding.Encoder} encoder
*/
_toBinary (encoder) {
super._toBinary(encoder)
encoding.writeVarString(encoder, this._content)
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return stringify.logItemHelper('ItemString', this, `content:"${this._content}"`)
}
_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
}
}

277
src/structs/Type.js Normal file
View File

@@ -0,0 +1,277 @@
/**
* @module structs
*/
import { Item } from './Item.js'
import { EventHandler } from '../utils/EventHandler.js'
import { createID } from '../utils/ID.js'
import { YEvent } from '../utils/YEvent.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
// restructure children as if they were inserted one after another
const 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)
}
export const getListItemIDByPosition = (type, i) => {
let pos = 0
let n = type._start
while (n !== null) {
if (!n._deleted) {
if (pos <= i && i < pos + n._length) {
const id = n._id
return createID(id.user, id.clock + i - pos)
}
pos++
}
n = n._right
}
}
const gcChildren = (y, item) => {
while (item !== null) {
item._delete(y, false, true)
item._gc(y)
item = item._right
}
}
/**
* Abstract Yjs Type class
*/
export class Type extends Item {
constructor () {
super()
this._map = new Map()
this._start = null
this._y = null
this._eventHandler = new EventHandler()
this._deepEventHandler = new EventHandler()
}
/**
* The first non-deleted item
*/
get _first () {
let n = this._start
while (n !== null && n._deleted) {
n = n._right
}
return n
}
/**
* Compute the path from this type to the specified target.
*
* @example
* It should be accessible via `this.get(result[0]).get(result[1])..`
* const path = type.getPathTo(child)
* // assuming `type instanceof YArray`
* console.log(path) // might look like => [2, 'key1']
* child === type.get(path[0]).get(path[1])
*
* @param {Type | Y | any} type Type target
* @return {Array<string>} Path to the target
*/
getPathTo (type) {
if (type === this) {
return []
}
const path = []
const y = this._y
while (type !== this && 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
}
if (type !== this) {
throw new Error('The type is not a child of this node')
}
return path
}
/**
* Creates YArray Event and calls observers.
* @private
*/
_callObserver (transaction, parentSubs, remote) {
this._callEventHandler(transaction, new YEvent(this))
}
/**
* Call event listeners with an event. This will also add an event to all
* parents (for `.observeDeep` handlers).
* @private
*/
_callEventHandler (transaction, event) {
const changedParentTypes = transaction.changedParentTypes
this._eventHandler.callEventListeners(transaction, event)
/**
* @type {any}
*/
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
}
}
/**
* Helper method to transact if the y instance is available.
*
* TODO: Currently event handlers are not thrown when a type is not registered
* with a Yjs instance.
* @private
*/
_transact (f) {
const y = this._y
if (y !== null) {
y.transact(f)
} else {
f(y)
}
}
/**
* Observe all events that are created on this type.
*
* @param {Function} f Observer function
*/
observe (f) {
this._eventHandler.addEventListener(f)
}
/**
* Observe all events that are created by this type and its children.
*
* @param {Function} f Observer function
*/
observeDeep (f) {
this._deepEventHandler.addEventListener(f)
}
/**
* Unregister an observer function.
*
* @param {Function} f Observer function
*/
unobserve (f) {
this._eventHandler.removeEventListener(f)
}
/**
* Unregister an observer function.
*
* @param {Function} f Observer function
*/
unobserveDeep (f) {
this._deepEventHandler.removeEventListener(f)
}
/**
* Integrate this type into the Yjs instance.
*
* * Save this struct in the os
* * This type is sent to other client
* * Observer functions are fired
*
* @param {Y} y The Yjs instance
* @private
*/
_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_integrate
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)
}
}
_gcChildren (y) {
gcChildren(y, this._start)
this._start = null
this._map.forEach(item => {
gcChildren(y, item)
})
this._map = new Map()
}
_gc (y) {
this._gcChildren(y)
super._gc(y)
}
/**
* @abstract
* @return {Object | Array | number | string}
*/
toJSON () {}
/**
* Mark this Item as deleted.
*
* @param {Y} y The Yjs instance
* @param {boolean} createDelete Whether to propagate a message that this
* Type was deleted.
* @param {boolean} [gcChildren=(y._hasUndoManager===false)] Whether to garbage
* collect the children of this type.
* @private
*/
_delete (y, createDelete, gcChildren) {
if (gcChildren === undefined || !y.gcEnabled) {
gcChildren = y._hasUndoManager === false && y.gcEnabled
}
super._delete(y, createDelete, gcChildren)
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, gcChildren)
}
}
// delete array types
let t = this._start
while (t !== null) {
if (!t._deleted) {
t._delete(y, false, gcChildren)
}
t = t._right
}
if (gcChildren) {
this._gcChildren(y)
}
}
}

407
src/types/YArray.js Normal file
View File

@@ -0,0 +1,407 @@
/**
* @module types
*/
import { Type } from '../structs/Type.js'
import { ItemJSON } from '../structs/ItemJSON.js'
import { ItemString } from '../structs/ItemString.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
import { YEvent } from '../utils/YEvent.js'
import { Transaction } from '../utils/Transaction.js' // eslint-disable-line
import { Item } from '../structs/Item.js' // eslint-disable-line
import { ItemBinary } from '../structs/ItemBinary.js'
import { isVisible } from '../utils/snapshot.js'
/**
* Event that describes the changes on a YArray
*/
export class YArrayEvent extends YEvent {
/**
* @param {YArray} yarray The changed type
* @param {Boolean} remote Whether the changed was caused by a remote peer
* @param {Transaction} transaction The transaction object
*/
constructor (yarray, remote, transaction) {
super(yarray)
this.remote = remote
this._transaction = transaction
this._addedElements = null
this._removedElements = null
}
/**
* Child elements that were added in this transaction.
*
* @return {Set}
*/
get addedElements () {
if (this._addedElements === null) {
const target = this.target
const transaction = this._transaction
const addedElements = new Set()
transaction.newTypes.forEach(type => {
if (type._parent === target && !transaction.deletedStructs.has(type)) {
addedElements.add(type)
}
})
this._addedElements = addedElements
}
return this._addedElements
}
/**
* Child elements that were removed in this transaction.
*
* @return {Set}
*/
get removedElements () {
if (this._removedElements === null) {
const target = this.target
const transaction = this._transaction
const removedElements = new Set()
transaction.deletedStructs.forEach(struct => {
if (struct._parent === target && !transaction.newTypes.has(struct)) {
removedElements.add(struct)
}
})
this._removedElements = removedElements
}
return this._removedElements
}
}
/**
* A shared Array implementation.
*/
export class YArray extends Type {
constructor () {
super()
this.length = 0
}
/**
* Creates YArray Event and calls observers.
*
* @private
*/
_callObserver (transaction, parentSubs, remote) {
this._callEventHandler(transaction, new YArrayEvent(this, remote, transaction))
}
/**
* Returns the i-th element from a YArray.
*
* @param {number} index The index of the element to return from the YArray
* @return {any}
*/
get (index) {
let n = this._start
while (n !== null) {
if (!n._deleted && n._countable) {
if (index < n._length) {
switch (n.constructor) {
case ItemJSON:
case ItemString:
return n._content[index]
default:
return n
}
}
index -= n._length
}
n = n._right
}
}
/**
* Transforms this YArray to a JavaScript Array.
*
* @param {Object} [snapshot]
* @return {Array}
*/
toArray (snapshot) {
return this.map(c => c, snapshot)
}
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Array}
*/
toJSON () {
return this.map(c => {
if (c instanceof Type) {
return c.toJSON()
}
return c
})
}
/**
* Returns an Array with the result of calling a provided function on every
* element of this YArray.
*
* @param {Function} f Function that produces an element of the new Array
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
* @return {Array} A new array with each element being the result of the
* callback function
*/
map (f, snapshot) {
const res = []
this.forEach((c, i) => {
res.push(f(c, i, this))
}, snapshot)
return res
}
/**
* Executes a provided function on once on overy element of this YArray.
*
* @param {Function} f A function to execute on every element of this YArray.
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
*/
forEach (f, snapshot) {
let index = 0
let n = this._start
while (n !== null) {
if (isVisible(n, snapshot) && n._countable) {
if (n instanceof Type) {
f(n, index++, this)
} else if (n.constructor === ItemBinary) {
f(n._content, index++, this)
} else {
const content = n._content
const contentLen = content.length
for (let i = 0; i < contentLen; i++) {
index++
f(content[i], index, this)
}
}
}
n = n._right
}
}
[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
this._item = this._item._right
} else {
content = this._item._content[this._itemElement++]
}
return {
value: content,
done: false
}
},
_item: this._start,
_itemElement: 0,
_count: 0
}
}
/**
* Deletes elements starting from an index.
*
* @param {number} index Index at which to start deleting elements
* @param {number} length The number of elements to remove. Defaults to 1.
*/
delete (index, length = 1) {
this._y.transact(() => {
let item = this._start
let count = 0
while (item !== null && length > 0) {
if (!item._deleted && item._countable) {
if (count <= index && index < count + item._length) {
const diffDel = index - 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')
}
}
/**
* Inserts content after an element container.
*
* @private
* @param {Item} left The element container to use as a reference.
* @param {Array<number|string|Object|ArrayBuffer>} content The Array of content to insert (see {@see insert})
*/
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 (c.constructor === ArrayBuffer) {
if (prevJsonIns !== null) {
if (y !== null) {
prevJsonIns._integrate(y)
}
left = prevJsonIns
prevJsonIns = null
}
const itemBinary = new ItemBinary()
itemBinary._origin = left
itemBinary._left = left
itemBinary._right = right
itemBinary._right_origin = right
itemBinary._parent = this
itemBinary._content = c
if (y !== null) {
itemBinary._integrate(y)
} else if (left === null) {
this._start = itemBinary
} else {
left._right = itemBinary
}
left = itemBinary
} 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
} else {
left._right = prevJsonIns
}
}
})
return content
}
/**
* Inserts new content at an index.
*
* Important: This function expects an array of content. Not just a content
* object. The reason for this "weirdness" is that inserting several elements
* is very efficient when it is done as a single operation.
*
* @example
* // Insert character 'a' at position 0
* yarray.insert(0, ['a'])
* // Insert numbers 1, 2 at position 1
* yarray.insert(2, [1, 2])
*
* @param {number} index The index to insert content at.
* @param {Array<number|string|ArrayBuffer|Type>} content The array of content
*/
insert (index, content) {
this._transact(() => {
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 <= index && index <= count + rightLen) {
const splitDiff = index - count
right = right._splitAt(y, splitDiff)
left = right._left
count += splitDiff
break
}
if (!right._deleted) {
count += right._length
}
left = right
right = right._right
}
if (index > count) {
throw new Error('Index exceeds array range!')
}
this.insertAfter(left, content)
})
}
/**
* Appends content to this YArray.
*
* @param {Array<number|string|ArrayBuffer|Type>} content Array of content to append.
*/
push (content) {
let n = this._start
let lastUndeleted = null
while (n !== null) {
if (!n._deleted) {
lastUndeleted = n
}
n = n._right
}
this.insertAfter(lastUndeleted, content)
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return stringify.logItemHelper('YArray', this, `start:${stringify.stringifyItemID(this._start)}"`)
}
}

213
src/types/YMap.js Normal file
View File

@@ -0,0 +1,213 @@
/**
* @module types
*/
import { Item } from '../structs/Item.js'
import { Type } from '../structs/Type.js'
import { ItemJSON } from '../structs/ItemJSON.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
import { YEvent } from '../utils/YEvent.js'
import { ItemBinary } from '../structs/ItemBinary.js'
import { isVisible } from '../utils/snapshot.js'
/**
* Event that describes the changes on a YMap.
*/
export class YMapEvent extends YEvent {
/**
* @param {YMap} ymap The YArray that changed.
* @param {Set<any>} subs The keys that changed.
* @param {boolean} remote Whether the change was created by a remote peer.
*/
constructor (ymap, subs, remote) {
super(ymap)
this.keysChanged = subs
this.remote = remote
}
}
/**
* A shared Map implementation.
*/
export class YMap extends Type {
/**
* Creates YMap Event and calls observers.
*
* @private
*/
_callObserver (transaction, parentSubs, remote) {
this._callEventHandler(transaction, new YMapEvent(this, parentSubs, remote))
}
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Object}
*/
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 if (item.constructor === ItemBinary) {
res = item._content
} else {
res = item._content[0]
}
map[key] = res
}
}
return map
}
/**
* Returns the keys for each element in the YMap Type.
*
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
* @return {Array}
*/
keys (snapshot) {
// TODO: Should return either Iterator or Set!
let keys = []
if (snapshot === undefined) {
for (let [key, value] of this._map) {
if (value._deleted) {
keys.push(key)
}
}
} else {
this._map.forEach((_, key) => {
if (YMap.prototype.has.call(this, key, snapshot)) {
keys.push(key)
}
})
}
return keys
}
/**
* Remove a specified element from this YMap.
*
* @param {string} key The key of the element to remove.
*/
delete (key) {
this._transact((y) => {
let c = this._map.get(key)
if (y !== null && c !== undefined) {
c._delete(y)
}
})
}
/**
* Adds or updates an element with a specified key and value.
*
* @param {string} key The key of the element to add to this YMap
* @param {Object | string | number | Type | ArrayBuffer } value The value of the element to add
*/
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 if (value.constructor === ArrayBuffer) {
v = new ItemBinary()
v._content = 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
}
/**
* Returns a specified element from this YMap.
*
* @param {string} key The key of the element to return.
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
*/
get (key, snapshot) {
let v = this._map.get(key)
if (v === undefined) {
return undefined
}
if (snapshot !== undefined) {
// iterate until found element that exists
while (!snapshot.sm.has(v._id.user) || v._id.clock >= snapshot.sm.get(v._id.user)) {
v = v._right
}
}
if (isVisible(v, snapshot)) {
if (v instanceof Type) {
return v
} else if (v.constructor === ItemBinary) {
return v._content
} else {
return v._content[v._content.length - 1]
}
}
}
/**
* Returns a boolean indicating whether the specified key exists or not.
*
* @param {string} key The key to test.
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
*/
has (key, snapshot) {
let v = this._map.get(key)
if (v === undefined) {
return false
}
if (snapshot !== undefined) {
// iterate until found element that exists
while (!snapshot.sm.has(v._id.user) || v._id.clock >= snapshot.sm.get(v._id.user)) {
v = v._right
}
}
return isVisible(v, snapshot)
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return stringify.logItemHelper('YMap', this, `mapSize:${this._map.size}`)
}
}

725
src/types/YText.js Normal file
View File

@@ -0,0 +1,725 @@
/**
* @module types
*/
import { ItemEmbed } from '../structs/ItemEmbed.js'
import { ItemString } from '../structs/ItemString.js'
import { ItemFormat } from '../structs/ItemFormat.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
import { YArrayEvent, YArray } from './YArray.js'
import { isVisible } from '../utils/snapshot.js'
/**
* @private
*/
const integrateItem = (item, parent, y, left, right) => {
item._origin = left
item._left = left
item._right = right
item._right_origin = right
item._parent = parent
if (y !== null) {
item._integrate(y)
} else if (left === null) {
parent._start = item
} else {
left._right = item
}
}
/**
* @private
*/
const findNextPosition = (currentAttributes, parent, left, right, count) => {
while (right !== null && count > 0) {
switch (right.constructor) {
case ItemEmbed:
case ItemString:
const rightLen = right._deleted ? 0 : (right._length - 1)
if (count <= rightLen) {
right = right._splitAt(parent._y, count)
left = right._left
return [left, right, currentAttributes]
}
if (right._deleted === false) {
count -= right._length
}
break
case ItemFormat:
if (right._deleted === false) {
updateCurrentAttributes(currentAttributes, right)
}
break
}
left = right
right = right._right
}
return [left, right, currentAttributes]
}
/**
* @private
*/
const findPosition = (parent, index) => {
let currentAttributes = new Map()
let left = null
let right = parent._start
return findNextPosition(currentAttributes, parent, left, right, index)
}
/**
* Negate applied formats
*
* @private
*/
const insertNegatedAttributes = (y, parent, left, right, negatedAttributes) => {
// check if we really need to remove attributes
while (
right !== null && (
right._deleted === true || (
right.constructor === ItemFormat &&
(negatedAttributes.get(right.key) === right.value)
)
)
) {
if (right._deleted === false) {
negatedAttributes.delete(right.key)
}
left = right
right = right._right
}
for (let [key, val] of negatedAttributes) {
let format = new ItemFormat()
format.key = key
format.value = val
integrateItem(format, parent, y, left, right)
left = format
}
return [left, right]
}
/**
* @private
*/
const updateCurrentAttributes = (currentAttributes, item) => {
const value = item.value
const key = item.key
if (value === null) {
currentAttributes.delete(key)
} else {
currentAttributes.set(key, value)
}
}
/**
* @private
*/
const minimizeAttributeChanges = (left, right, currentAttributes, attributes) => {
// go right while attributes[right.key] === right.value (or right is deleted)
while (true) {
if (right === null) {
break
} else if (right._deleted === true) {
// continue
} else if (right.constructor === ItemFormat && (attributes[right.key] || null) === right.value) {
// found a format, update currentAttributes and continue
updateCurrentAttributes(currentAttributes, right)
} else {
break
}
left = right
right = right._right
}
return [left, right]
}
/**
* @private
*/
const insertAttributes = (y, parent, left, right, attributes, currentAttributes) => {
const negatedAttributes = new Map()
// insert format-start items
for (let key in attributes) {
const val = attributes[key]
const currentVal = currentAttributes.get(key)
if (currentVal !== val) {
// save negated attribute (set null if currentVal undefined)
negatedAttributes.set(key, currentVal || null)
let format = new ItemFormat()
format.key = key
format.value = val
integrateItem(format, parent, y, left, right)
left = format
}
}
return [left, right, negatedAttributes]
}
/**
* @private
*/
const insertText = (y, text, parent, left, right, currentAttributes, attributes) => {
for (let [key] of currentAttributes) {
if (attributes[key] === undefined) {
attributes[key] = null
}
}
[left, right] = minimizeAttributeChanges(left, right, currentAttributes, attributes)
let negatedAttributes
[left, right, negatedAttributes] = insertAttributes(y, parent, left, right, attributes, currentAttributes)
// insert content
let item
if (text.constructor === String) {
item = new ItemString()
item._content = text
} else {
item = new ItemEmbed()
item.embed = text
}
integrateItem(item, parent, y, left, right)
left = item
return insertNegatedAttributes(y, parent, left, right, negatedAttributes)
}
/**
* @private
*/
const formatText = (y, length, parent, left, right, currentAttributes, attributes) => {
[left, right] = minimizeAttributeChanges(left, right, currentAttributes, attributes)
let negatedAttributes
[left, right, negatedAttributes] = insertAttributes(y, parent, left, right, attributes, currentAttributes)
// iterate until first non-format or null is found
// delete all formats with attributes[format.key] != null
while (length > 0 && right !== null) {
if (right._deleted === false) {
switch (right.constructor) {
case ItemFormat:
const attr = attributes[right.key]
if (attr !== undefined) {
if (attr === right.value) {
negatedAttributes.delete(right.key)
} else {
negatedAttributes.set(right.key, right.value)
}
right._delete(y)
}
updateCurrentAttributes(currentAttributes, right)
break
case ItemEmbed:
case ItemString:
right._splitAt(y, length)
length -= right._length
break
}
}
left = right
right = right._right
}
return insertNegatedAttributes(y, parent, left, right, negatedAttributes)
}
/**
* @private
*/
const deleteText = (y, length, parent, left, right, currentAttributes) => {
while (length > 0 && right !== null) {
if (right._deleted === false) {
switch (right.constructor) {
case ItemFormat:
updateCurrentAttributes(currentAttributes, right)
break
case ItemEmbed:
case ItemString:
right._splitAt(y, length)
length -= right._length
right._delete(y)
break
}
}
left = right
right = right._right
}
return [left, right]
}
// TODO: In the quill delta representation we should also use the format {ops:[..]}
/**
* The Quill Delta format represents changes on a text document with
* formatting information. For mor information visit {@link https://quilljs.com/docs/delta/|Quill Delta}
*
* @example
* {
* ops: [
* { insert: 'Gandalf', attributes: { bold: true } },
* { insert: ' the ' },
* { insert: 'Grey', attributes: { color: '#cccccc' } }
* ]
* }
*
* @typedef {Array<Object>} Delta
*/
/**
* Attributes that can be assigned to a selection of text.
*
* @example
* {
* bold: true,
* font-size: '40px'
* }
*
* @typedef {Object} TextAttributes
*/
/**
* Event that describes the changes on a YText type.
*
* @private
*/
class YTextEvent extends YArrayEvent {
constructor (ytext, remote, transaction) {
super(ytext, remote, transaction)
this._delta = null
}
// TODO: Should put this in a separate function. toDelta shouldn't be included
// in every Yjs distribution
/**
* Compute the changes in the delta format.
*
* @return {Delta} A {@link https://quilljs.com/docs/delta/|Quill Delta}) that
* represents the changes on the document.
*
* @public
*/
get delta () {
if (this._delta === null) {
const y = this.target._y
y.transact(() => {
let item = this.target._start
const delta = []
const added = this.addedElements
const removed = this.removedElements
this._delta = delta
let action = null
let attributes = {} // counts added or removed new attributes for retain
const currentAttributes = new Map() // saves all current attributes for insert
const oldAttributes = new Map()
let insert = ''
let retain = 0
let deleteLen = 0
const addOp = function addOp () {
if (action !== null) {
/**
* @type {any}
*/
let op
switch (action) {
case 'delete':
op = { delete: deleteLen }
deleteLen = 0
break
case 'insert':
op = { insert }
if (currentAttributes.size > 0) {
op.attributes = {}
for (let [key, value] of currentAttributes) {
if (value !== null) {
op.attributes[key] = value
}
}
}
insert = ''
break
case 'retain':
op = { retain }
if (Object.keys(attributes).length > 0) {
op.attributes = {}
for (let key in attributes) {
op.attributes[key] = attributes[key]
}
}
retain = 0
break
}
delta.push(op)
action = null
}
}
while (item !== null) {
switch (item.constructor) {
case ItemEmbed:
if (added.has(item)) {
addOp()
action = 'insert'
insert = item.embed
addOp()
} else if (removed.has(item)) {
if (action !== 'delete') {
addOp()
action = 'delete'
}
deleteLen += 1
} else if (item._deleted === false) {
if (action !== 'retain') {
addOp()
action = 'retain'
}
retain += 1
}
break
case ItemString:
if (added.has(item)) {
if (action !== 'insert') {
addOp()
action = 'insert'
}
insert += item._content
} else if (removed.has(item)) {
if (action !== 'delete') {
addOp()
action = 'delete'
}
deleteLen += item._length
} else if (item._deleted === false) {
if (action !== 'retain') {
addOp()
action = 'retain'
}
retain += item._length
}
break
case ItemFormat:
if (added.has(item)) {
const curVal = currentAttributes.get(item.key) || null
if (curVal !== item.value) {
if (action === 'retain') {
addOp()
}
if (item.value === (oldAttributes.get(item.key) || null)) {
delete attributes[item.key]
} else {
attributes[item.key] = item.value
}
} else {
item._delete(y)
}
} else if (removed.has(item)) {
oldAttributes.set(item.key, item.value)
const curVal = currentAttributes.get(item.key) || null
if (curVal !== item.value) {
if (action === 'retain') {
addOp()
}
attributes[item.key] = curVal
}
} else if (item._deleted === false) {
oldAttributes.set(item.key, item.value)
const attr = attributes[item.key]
if (attr !== undefined) {
if (attr !== item.value) {
if (action === 'retain') {
addOp()
}
if (item.value === null) {
attributes[item.key] = item.value
} else {
delete attributes[item.key]
}
} else {
item._delete(y)
}
}
}
if (item._deleted === false) {
if (action === 'insert') {
addOp()
}
updateCurrentAttributes(currentAttributes, item)
}
break
}
item = item._right
}
addOp()
while (this._delta.length > 0) {
let lastOp = this._delta[this._delta.length - 1]
if (lastOp.retain !== undefined && lastOp.attributes === undefined) {
// retain delta's if they don't assign attributes
this._delta.pop()
} else {
break
}
}
})
}
return this._delta
}
}
/**
* Type that represents text with formatting information.
*
* This type replaces y-richtext as this implementation is able to handle
* block formats (format information on a paragraph), embeds (complex elements
* like pictures and videos), and text formats (**bold**, *italic*).
*/
export class YText extends YArray {
/**
* @param {String} [string] The initial value of the YText.
*/
constructor (string) {
super()
if (typeof string === 'string') {
const start = new ItemString()
start._parent = this
start._content = string
this._start = start
}
}
/**
* Creates YMap Event and calls observers.
*
* @private
*/
_callObserver (transaction, parentSubs, remote) {
this._callEventHandler(transaction, new YTextEvent(this, remote, transaction))
}
toDom () {
return document.createTextNode(this.toString())
}
/**
* Returns the unformatted string representation of this YText type.
*
* @public
*/
toString () {
let str = ''
/**
* @type {any}
*/
let n = this._start
while (n !== null) {
if (!n._deleted && n._countable) {
str += n._content
}
n = n._right
}
return str
}
toDomString () {
return this.toDelta().map(delta => {
const nestedNodes = []
for (let nodeName in delta.attributes) {
const attrs = []
for (let key in delta.attributes[nodeName]) {
attrs.push({ key, value: delta.attributes[nodeName][key] })
}
// sort attributes to get a unique order
attrs.sort((a, b) => a.key < b.key ? -1 : 1)
nestedNodes.push({ nodeName, attrs })
}
// sort node order to get a unique order
nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
// now convert to dom string
let str = ''
for (let i = 0; i < nestedNodes.length; i++) {
const node = nestedNodes[i]
str += `<${node.nodeName}`
for (let j = 0; j < node.attrs.length; j++) {
const attr = node.attrs[i]
str += ` ${attr.key}="${attr.value}"`
}
str += '>'
}
str += delta.insert
for (let i = nestedNodes.length - 1; i >= 0; i--) {
str += `</${nestedNodes[i].nodeName}>`
}
return str
})
}
/**
* Apply a {@link Delta} on this shared YText type.
*
* @param {Delta} delta The changes to apply on this element.
*
* @public
*/
applyDelta (delta) {
this._transact(y => {
let left = null
let right = this._start
const currentAttributes = new Map()
for (let i = 0; i < delta.length; i++) {
let op = delta[i]
if (op.insert !== undefined) {
;[left, right] = insertText(y, op.insert, this, left, right, currentAttributes, op.attributes || {})
} else if (op.retain !== undefined) {
;[left, right] = formatText(y, op.retain, this, left, right, currentAttributes, op.attributes || {})
} else if (op.delete !== undefined) {
;[left, right] = deleteText(y, op.delete, this, left, right, currentAttributes)
}
}
})
}
/**
* Returns the Delta representation of this YText type.
*
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
* @param {import('../protocols/history.js').HistorySnapshot} [prevSnapshot]
* @return {Delta} The Delta representation of this type.
*
* @public
*/
toDelta (snapshot, prevSnapshot) {
let ops = []
let currentAttributes = new Map()
let str = ''
/**
* @type {any}
*/
let n = this._start
function packStr () {
if (str.length > 0) {
// pack str with attributes to ops
let attributes = {}
let addAttributes = false
for (let [key, value] of currentAttributes) {
addAttributes = true
attributes[key] = value
}
let op = { insert: str }
if (addAttributes) {
op.attributes = attributes
}
ops.push(op)
str = ''
}
}
while (n !== null) {
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
switch (n.constructor) {
case ItemString:
const cur = currentAttributes.get('ychange')
if (snapshot !== undefined && !isVisible(n, snapshot)) {
if (cur === undefined || cur.user !== n._id.user || cur.state !== 'removed') {
packStr()
currentAttributes.set('ychange', { user: n._id.user, state: 'removed' })
}
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
if (cur === undefined || cur.user !== n._id.user || cur.state !== 'added') {
packStr()
currentAttributes.set('ychange', { user: n._id.user, state: 'added' })
}
} else if (cur !== undefined) {
packStr()
currentAttributes.delete('ychange')
}
str += n._content
break
case ItemFormat:
packStr()
updateCurrentAttributes(currentAttributes, n)
break
}
}
n = n._right
}
packStr()
return ops
}
/**
* Insert text at a given index.
*
* @param {number} index The index at which to start inserting.
* @param {String} text The text to insert at the specified position.
* @param {TextAttributes} attributes Optionally define some formatting
* information to apply on the inserted
* Text.
* @public
*/
insert (index, text, attributes = {}) {
if (text.length <= 0) {
return
}
this._transact(y => {
let [left, right, currentAttributes] = findPosition(this, index)
insertText(y, text, this, left, right, currentAttributes, attributes)
})
}
/**
* Inserts an embed at a index.
*
* @param {number} index The index to insert the embed at.
* @param {Object} embed The Object that represents the embed.
* @param {TextAttributes} attributes Attribute information to apply on the
* embed
*
* @public
*/
insertEmbed (index, embed, attributes = {}) {
if (embed.constructor !== Object) {
throw new Error('Embed must be an Object')
}
this._transact(y => {
let [left, right, currentAttributes] = findPosition(this, index)
insertText(y, embed, this, left, right, currentAttributes, attributes)
})
}
/**
* Deletes text starting from an index.
*
* @param {number} index Index at which to start deleting.
* @param {number} length The number of characters to remove. Defaults to 1.
*
* @public
*/
delete (index, length) {
if (length === 0) {
return
}
this._transact(y => {
let [left, right, currentAttributes] = findPosition(this, index)
deleteText(y, length, this, left, right, currentAttributes)
})
}
/**
* Assigns properties to a range of text.
*
* @param {number} index The position where to start formatting.
* @param {number} length The amount of characters to assign properties to.
* @param {TextAttributes} attributes Attribute information to apply on the
* text.
*
* @public
*/
format (index, length, attributes) {
this._transact(y => {
let [left, right, currentAttributes] = findPosition(this, index)
if (right === null) {
return
}
formatText(y, length, this, left, right, currentAttributes, attributes)
})
}
// TODO: De-duplicate code. The following code is in every type.
/**
* Transform this YText to a readable format.
* Useful for logging as all Items implement this method.
*
* @private
*/
_logString () {
return stringify.logItemHelper('YText', this)
}
}

435
src/types/YXmlElement.js Normal file
View File

@@ -0,0 +1,435 @@
/**
* @module types
*/
import { YMap } from './YMap.js'
import * as encoding from 'funlib/encoding.js'
import * as decoding from 'funlib/decoding.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
import { YArray } from './YArray.js'
import { YXmlEvent } from './YXmlEvent.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
/**
* Define the elements to which a set of CSS queries apply.
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors|CSS_Selectors}
*
* @example
* query = '.classSelector'
* query = 'nodeSelector'
* query = '#idSelector'
*
* @typedef {string} CSS_Selector
*/
/**
* Represents a subset of the nodes of a YXmlElement / YXmlFragment and a
* position within them.
*
* Can be created with {@link YXmlFragment#createTreeWalker}
*
* @public
*/
export class YXmlTreeWalker {
constructor (root, f) {
this._filter = f || (() => true)
this._root = root
this._currentNode = root
this._firstCall = true
}
[Symbol.iterator] () {
return this
}
/**
* Get the next node.
*
* @return {YXmlElement} The next node.
*
* @public
*/
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 === 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 }
}
}
}
/**
* Dom filter function.
*
* @callback domFilter
* @param {string} nodeName The nodeName of the element
* @param {Map} attributes The map of attributes.
* @return {boolean} Whether to include the Dom node in the YXmlElement.
*/
/**
* Define the elements to which a set of CSS queries apply.
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors|CSS_Selectors}
*
* @example
* query = '.classSelector'
* query = 'nodeSelector'
* query = '#idSelector'
*
* @typedef {string} CSS_Selector
*//**
* @module types
*/
/**
* Represents a list of {@link YXmlElement}.and {@link YXmlText} types.
* A YxmlFragment is similar to a {@link YXmlElement}, but it does not have a
* nodeName and it does not have attributes. Though it can be bound to a DOM
* element - in this case the attributes and the nodeName are not shared.
*
* @public
*/
export class YXmlFragment extends YArray {
/**
* Create a subtree of childNodes.
*
* @example
* const walker = elem.createTreeWalker(dom => dom.nodeName === 'div')
* for (let node in walker) {
* // `node` is a div node
* nop(node)
* }
*
* @param {Function} filter Function that is called on each child element and
* returns a Boolean indicating whether the child
* is to be included in the subtree.
* @return {YXmlTreeWalker} A subtree and a position within it.
*
* @public
*/
createTreeWalker (filter) {
return new YXmlTreeWalker(this, filter)
}
/**
* Returns the first YXmlElement that matches the query.
* Similar to DOM's {@link querySelector}.
*
* Query support:
* - tagname
* TODO:
* - id
* - attribute
*
* @param {CSS_Selector} query The query on the children.
* @return {YXmlElement} The first element that matches the query or null.
*
* @public
*/
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
}
}
/**
* Returns all YXmlElements that match the query.
* Similar to Dom's {@link querySelectorAll}.
*
* TODO: Does not yet support all queries. Currently only query by tagName.
*
* @param {CSS_Selector} query The query on the children
* @return {Array<YXmlElement>} The elements that match this query.
*
* @public
*/
querySelectorAll (query) {
query = query.toUpperCase()
return Array.from(new YXmlTreeWalker(this, element => element.nodeName === query))
}
/**
* Creates YArray Event and calls observers.
*
* @private
*/
_callObserver (transaction, parentSubs, remote) {
this._callEventHandler(transaction, new YXmlEvent(this, parentSubs, remote, transaction))
}
toString () {
return this.toDomString()
}
/**
* Get the string representation of all the children of this YXmlFragment.
*
* @return {string} The string representation of all children.
*/
toDomString () {
return this.map(xml => xml.toDomString()).join('')
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
* are presented in the DOM
* @param {DomBinding} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {DocumentFragment} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDom (_document = document, hooks = {}, binding) {
const fragment = _document.createDocumentFragment()
if (binding !== undefined) {
binding._createAssociation(fragment, this)
}
this.forEach(xmlType => {
fragment.insertBefore(xmlType.toDom(_document, hooks, binding), null)
})
return fragment
}
/**
* Transform this YXml Type to a readable format.
* Useful for logging as all Items and Delete implement this method.
*
* @private
*/
_logString () {
return stringify.logItemHelper('YXml', this)
}
}
/**
* An YXmlElement imitates the behavior of a
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}.
*
* * An YXmlElement has attributes (key value pairs)
* * An YXmlElement has childElements that must inherit from YXmlElement
*/
export class YXmlElement extends YXmlFragment {
constructor (nodeName = 'UNDEFINED') {
super()
this.nodeName = nodeName.toUpperCase()
}
/**
* Creates an Item with the same effect as this Item (without position effect)
*
* @private
*/
_copy () {
let struct = super._copy()
struct.nodeName = this.nodeName
return struct
}
/**
* Read the next Item in a Decoder and fill this Item with the read data.
*
* This is called when data is received from a remote peer.
*
* @private
* @param {Y} y The Yjs instance that this Item belongs to.
* @param {decoding.Decoder} decoder The decoder object to read data from.
*/
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this.nodeName = decoding.readVarString(decoder)
return missing
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @private
* @param {encoding.Encoder} encoder The encoder to write data to.
*/
_toBinary (encoder) {
super._toBinary(encoder)
encoding.writeVarString(encoder, this.nodeName)
}
/**
* Integrates this Item into the shared structure.
*
* This method actually applies the change to the Yjs instance. In case of
* Item it connects _left and _right to this Item and calls the
* {@link Item#beforeChange} method.
*
* * Checks for nodeName
* * Sets domFilter
*
* @private
* @param {Y} y The Yjs instance
*/
_integrate (y) {
if (this.nodeName === null) {
throw new Error('nodeName must be defined!')
}
super._integrate(y)
}
toString () {
return this.toDomString()
}
/**
* Returns the string representation of this YXmlElement.
* The attributes are ordered by attribute-name, so you can easily use this
* method to compare YXmlElements
*
* @return {String} The string representation of this type.
*
* @public
*/
toDomString () {
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.toDomString()}</${nodeName}>`
}
/**
* Removes an attribute from this YXmlElement.
*
* @param {String} attributeName The attribute name that is to be removed.
*
* @public
*/
removeAttribute (attributeName) {
return YMap.prototype.delete.call(this, attributeName)
}
/**
* Sets or updates an attribute.
*
* @param {String} attributeName The attribute name that is to be set.
* @param {String} attributeValue The attribute value that is to be set.
*
* @public
*/
setAttribute (attributeName, attributeValue) {
return YMap.prototype.set.call(this, attributeName, attributeValue)
}
/**
* Returns an attribute value that belongs to the attribute name.
*
* @param {String} attributeName The attribute name that identifies the
* queried value.
* @param {HistorySnapshot} [snapshot]
* @return {String} The queried attribute value.
*
* @public
*/
getAttribute (attributeName, snapshot) {
return YMap.prototype.get.call(this, attributeName, snapshot)
}
/**
* Returns all attribute name/value pairs in a JSON Object.
*
* @param {HistorySnapshot} [snapshot]
* @return {Object} A JSON Object that describes the attributes.
*
* @public
*/
getAttributes (snapshot) {
const obj = {}
if (snapshot === undefined) {
for (let [key, value] of this._map) {
if (!value._deleted) {
obj[key] = value._content[0]
}
}
} else {
YMap.prototype.keys.call(this, snapshot).forEach(key => {
obj[key] = YMap.prototype.get.call(this, key, snapshot)
})
}
return obj
}
// TODO: outsource the binding property.
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
* are presented in the DOM
* @param {DomBinding} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Element} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDom (_document = document, hooks = {}, binding) {
const dom = _document.createElement(this.nodeName)
let attrs = this.getAttributes()
for (let key in attrs) {
dom.setAttribute(key, attrs[key])
}
this.forEach(yxml => {
dom.appendChild(yxml.toDom(_document, hooks, binding))
})
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
}

54
src/types/YXmlEvent.js Normal file
View File

@@ -0,0 +1,54 @@
/**
* @module types
*/
import { YEvent } from '../utils/YEvent.js'
import { Type } from '../structs/Type.js' // eslint-disable-line
import { Transaction } from '../utils/Transaction.js' // eslint-disable-line
/**
* An Event that describes changes on a YXml Element or Yxml Fragment
*
* @protected
*/
export class YXmlEvent extends YEvent {
/**
* @param {Type} target The target on which the event is created.
* @param {Set} subs The set of changed attributes. `null` is included if the
* child list changed.
* @param {Boolean} remote Whether this change was created by a remote peer.
* @param {Transaction} transaction The transaction instance with wich the
* change was created.
*/
constructor (target, subs, remote, transaction) {
super(target)
/**
* The transaction instance for the computed change.
* @type {Transaction}
*/
this._transaction = transaction
/**
* Whether the children changed.
* @type {Boolean}
*/
this.childListChanged = false
/**
* Set of all changed attributes.
* @type {Set}
*/
this.attributesChanged = new Set()
/**
* Whether this change was created by a remote peer.
* @type {Boolean}
*/
this.remote = remote
subs.forEach((sub) => {
if (sub === null) {
this.childListChanged = true
} else {
this.attributesChanged.add(sub)
}
})
}
}

116
src/types/YXmlHook.js Normal file
View File

@@ -0,0 +1,116 @@
/**
* @module types
*/
import { YMap } from './YMap.js'
import * as encoding from 'funlib/encoding.js'
import * as decoding from 'funlib/decoding.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
/**
* You can manage binding to a custom type with YXmlHook.
*
* @public
*/
export class YXmlHook extends YMap {
/**
* @param {String} [hookName] nodeName of the Dom Node.
*/
constructor (hookName) {
super()
this.hookName = null
if (hookName !== undefined) {
this.hookName = hookName
}
}
/**
* Creates an Item with the same effect as this Item (without position effect)
*
* @private
*/
_copy () {
const struct = super._copy()
struct.hookName = this.hookName
return struct
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object.<string, any>} [hooks] Optional property to customize how hooks
* are presented in the DOM
* @param {DomBinding} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type
* @return {Element} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDom (_document = document, hooks = {}, binding) {
const hook = hooks[this.hookName]
let dom
if (hook !== undefined) {
dom = hook.createDom(this)
} else {
dom = document.createElement(this.hookName)
}
dom.setAttribute('data-yjs-hook', this.hookName)
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
/**
* Read the next Item in a Decoder and fill this Item with the read data.
*
* This is called when data is received from a remote peer.
*
* @param {Y} y The Yjs instance that this Item belongs to.
* @param {decoding.Decoder} decoder The decoder object to read data from.
*
* @private
*/
_fromBinary (y, decoder) {
const missing = super._fromBinary(y, decoder)
this.hookName = decoding.readVarString(decoder)
return missing
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @param {encoding.Encoder} encoder The encoder to write data to.
*
* @private
*/
_toBinary (encoder) {
super._toBinary(encoder)
encoding.writeVarString(encoder, this.hookName)
}
/**
* Integrate this type into the Yjs instance.
*
* * Save this struct in the os
* * This type is sent to other client
* * Observer functions are fired
*
* @param {Y} y The Yjs instance
*
* @private
*/
_integrate (y) {
if (this.hookName === null) {
throw new Error('hookName must be defined!')
}
super._integrate(y)
}
}

50
src/types/YXmlText.js Normal file
View File

@@ -0,0 +1,50 @@
/**
* @module types
*/
import { YText } from './YText.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
/**
* Represents text in a Dom Element. In the future this type will also handle
* simple formatting information like bold and italic.
*/
export class YXmlText extends YText {
/**
* Creates a Dom Element that mirrors this YXmlText.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks] Optional property to customize how hooks
* are presented in the DOM
* @param {DomBinding} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Text} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDom (_document = document, hooks, binding) {
const dom = _document.createTextNode(this.toString())
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
/**
* Mark this Item as deleted.
*
* @param {Y} y The Yjs instance
* @param {boolean} createDelete Whether to propagate a message that this
* Type was deleted.
* @param {boolean} [gcChildren=y._hasUndoManager===false] Whether to garbage
* collect the children of this type.
*
* @private
*/
_delete (y, createDelete, gcChildren) {
super._delete(y, createDelete, gcChildren)
}
}

67
src/utils/BindMapping.js Normal file
View File

@@ -0,0 +1,67 @@
/**
* Type that maps from Yjs type to Target type.
* Used to implement double bindings.
*
* @private
* @template Y
* @template T
*/
export class BindMapping {
/**
*/
constructor () {
/**
* @type Map<Y, T>
*/
this.yt = new Map()
/**
* @type Map<T, Y>
*/
this.ty = new Map()
}
/**
* Map y to t. Removes all existing bindings from y and t
* @param {Y} y
* @param {T} t
*/
bind (y, t) {
const existingT = this.yt.get(y)
if (existingT !== undefined) {
this.ty.delete(existingT)
}
const existingY = this.ty.get(t)
if (existingY !== undefined) {
this.yt.delete(existingY)
}
this.yt.set(y, t)
this.ty.set(t, y)
}
/**
* @param {Y} y
* @return {boolean}
*/
hasY (y) {
return this.yt.has(y)
}
/**
* @param {T} t
* @return {boolean}
*/
hasT (t) {
return this.ty.has(t)
}
/**
* @param {Y} y
* @return {T}
*/
getY (y) {
return this.yt.get(y)
}
/**
* @param {T} t
* @return {Y}
*/
getT (t) {
return this.ty.get(t)
}
}

89
src/utils/DeleteStore.js Normal file
View File

@@ -0,0 +1,89 @@
/**
* @module utils
*/
import { Tree } from 'funlib/tree.js'
import * as ID from './ID.js'
export 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)
}
}
export class DeleteStore extends Tree {
logTable () {
const deletes = []
this.iterate(null, null, 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 (id, length, gc) {
if (length === 0) return
// Step 1. Unmark range
const leftD = this.findWithUpperBound(ID.createID(id.user, id.clock - 1))
// Resize left DSNode if necessary
if (leftD !== null && leftD._id.user === id.user) {
if (leftD._id.clock < id.clock && id.clock < leftD._id.clock + leftD.len) {
// node is overlapping. need to resize
if (id.clock + length < leftD._id.clock + leftD.len) {
// overlaps new mark range and some more
// create another DSNode to the right of new mark
this.put(new DSNode(ID.createID(id.user, id.clock + length), leftD._id.clock + leftD.len - id.clock - length, leftD.gc))
}
// resize left DSNode
leftD.len = id.clock - leftD._id.clock
} // Otherwise there is no overlapping
}
// Resize right DSNode if necessary
const upper = ID.createID(id.user, id.clock + length - 1)
const rightD = this.findWithUpperBound(upper)
if (rightD !== null && rightD._id.user === id.user) {
if (rightD._id.clock < id.clock + length && id.clock <= rightD._id.clock && id.clock + length < rightD._id.clock + rightD.len) { // we only consider the case where we resize the node
const d = id.clock + length - rightD._id.clock
rightD._id = ID.createID(rightD._id.user, rightD._id.clock + d)
rightD.len -= d
}
}
// Now we only have to delete all inner marks
const deleteNodeIds = []
this.iterate(id, upper, m => {
deleteNodeIds.push(m._id)
})
for (let i = deleteNodeIds.length - 1; i >= 0; i--) {
this.delete(deleteNodeIds[i])
}
let newMark = new DSNode(id, length, gc)
// Step 2. Check if we can extend left or right
if (leftD !== null && leftD._id.user === id.user && leftD._id.clock + leftD.len === id.clock && leftD.gc === gc) {
// We can extend left
leftD.len += length
newMark = leftD
}
const rightNext = this.find(ID.createID(id.user, id.clock + length))
if (rightNext !== null && rightNext._id.user === id.user && id.clock + length === rightNext._id.clock && gc === rightNext.gc) {
// We can merge newMark and rightNext
newMark.len += rightNext.len
this.delete(rightNext._id)
}
if (leftD !== newMark) {
// only put if we didn't extend left
this.put(newMark)
}
}
}

70
src/utils/EventHandler.js Normal file
View File

@@ -0,0 +1,70 @@
/**
* @module utils
*/
/**
* General event handler implementation.
*/
export class EventHandler {
constructor () {
this.eventListeners = []
}
/**
* To prevent memory leaks, call this method when the eventListeners won't be
* used anymore.
*/
destroy () {
this.eventListeners = null
}
/**
* Adds an event listener that is called when
* {@link EventHandler#callEventListeners} is called.
*
* @param {Function} f The event handler.
*/
addEventListener (f) {
this.eventListeners.push(f)
}
/**
* Removes an event listener.
*
* @param {Function} f The event handler that was added with
* {@link EventHandler#addEventListener}
*/
removeEventListener (f) {
this.eventListeners = this.eventListeners.filter(g => f !== g)
}
/**
* Removes all event listeners.
*/
removeAllEventListeners () {
this.eventListeners = []
}
/**
* Call all event listeners that were added via
* {@link EventHandler#addEventListener}.
*
* @param {Transaction} transaction The transaction object
* @param {YEvent} event An event object that describes the change on a type.
*/
callEventListeners (transaction, event) {
for (var i = 0; i < this.eventListeners.length; i++) {
try {
const f = this.eventListeners[i]
f(event, transaction)
} 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)
}
}
}
}

94
src/utils/ID.js Normal file
View File

@@ -0,0 +1,94 @@
/**
* @module utils
*/
import { getStructReference } from 'y-protocols/sync.js'
import * as decoding from 'funlib/decoding.js'
import * as encoding from 'funlib/encoding.js'
export class ID {
constructor (user, clock) {
this.user = user // TODO: rename to client
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
}
}
/**
* @param {encoding.Encoder} encoder
*/
encode (encoder) {
encoding.writeVarUint(encoder, this.user)
encoding.writeVarUint(encoder, this.clock)
}
}
export const createID = (user, clock) => new ID(user, clock)
export const RootFakeUserID = 0xFFFFFF
export class RootID {
constructor (name, typeConstructor) {
this.user = RootFakeUserID
this.name = name
this.type = getStructReference(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
}
}
/**
* @param {encoding.Encoder} encoder
*/
encode (encoder) {
encoding.writeVarUint(encoder, this.user)
encoding.writeVarString(encoder, this.name)
encoding.writeVarUint(encoder, this.type)
}
}
/**
* Create a new root id.
*
* @example
* y.define('name', Y.Array) // name, and typeConstructor
*
* @param {string} name
* @param {Function} typeConstructor must be defined in structReferences
*/
export const createRootID = (name, typeConstructor) => new RootID(name, typeConstructor)
/**
* Read ID.
* * If first varUint read is 0xFFFFFF a RootID is returned.
* * Otherwise an ID is returned
*
* @param {decoding.Decoder} decoder
* @return {ID|RootID}
*/
export const decode = decoder => {
const user = decoding.readVarUint(decoder)
if (user === RootFakeUserID) {
// read property name and type id
const rid = createRootID(decoding.readVarString(decoder), null)
rid.type = decoding.readVarUint(decoder)
return rid
}
return createID(user, decoding.readVarUint(decoder))
}

View File

@@ -0,0 +1,98 @@
/**
* @module utils
*/
import { Tree } from 'funlib/tree.js'
import * as ID from '../utils/ID.js'
import { getStruct } from 'y-protocols/sync.js'
import { GC } from '../structs/GC.js'
import * as stringify from 'y-protocols/utils/structStringify.js'
export class OperationStore extends Tree {
constructor (y) {
super()
this.y = y
}
logTable () {
const items = []
this.iterate(null, null, item => {
if (item.constructor === GC) {
items.push({
id: stringify.stringifyItemID(item),
content: item._length,
deleted: 'GC'
})
} else {
items.push({
id: stringify.stringifyItemID(item),
origin: item._origin === null ? '()' : stringify.stringifyID(item._origin._lastId),
left: item._left === null ? '()' : stringify.stringifyID(item._left._lastId),
right: stringify.stringifyItemID(item._right),
right_origin: stringify.stringifyItemID(item._right_origin),
parent: stringify.stringifyItemID(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 ID.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
}
}
}

53
src/utils/StateStore.js Normal file
View File

@@ -0,0 +1,53 @@
/**
* @module utils
*/
import * as ID from '../utils/ID.js'
/**
*/
export 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 ID.createID(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(ID.createID(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)
}
}

69
src/utils/Transaction.js Normal file
View File

@@ -0,0 +1,69 @@
/**
* @module utils
*/
import * as encoding from 'funlib/encoding.js'
/**
* A transaction is created for every change on the Yjs model. It is possible
* to bundle changes on the Yjs model in a single transaction to
* minimize the number on messages sent and the number of observer calls.
* If possible the user of this library should bundle as many changes as
* possible. Here is an example to illustrate the advantages of bundling:
*
* @example
* const map = y.define('map', YMap)
* // Log content when change is triggered
* map.observe(() => {
* console.log('change triggered')
* })
* // Each change on the map type triggers a log message:
* map.set('a', 0) // => "change triggered"
* map.set('b', 0) // => "change triggered"
* // When put in a transaction, it will trigger the log after the transaction:
* y.transact(() => {
* map.set('a', 1)
* map.set('b', 1)
* }) // => "change triggered"
*
*/
export class Transaction {
constructor (y) {
/**
* @type {Y} The Yjs instance.
*/
this.y = y
/**
* All new types that are added during a transaction.
* @type {Set<Item>}
*/
this.newTypes = new Set()
/**
* All types that were directly modified (property added or child
* inserted/deleted). New types are not included in this Set.
* Maps from type to parentSubs (`item._parentSub = null` for YArray)
* @type {Map<Type|Y,String>}
*/
this.changedTypes = new Map()
// TODO: rename deletedTypes
/**
* Set of all deleted Types and Structs.
* @type {Set<Item>}
*/
this.deletedStructs = new Set()
/**
* Saves the old state set of the Yjs instance. If a state was modified,
* the original value is saved here.
* @type {Map<Number,Number>}
*/
this.beforeState = new Map()
/**
* Stores the events for the types that observe also child elements.
* It is mainly used by `observeDeep`.
* @type {Map<Type,Array<YEvent>>}
*/
this.changedParentTypes = new Map()
this.encodedStructsLen = 0
this.encodedStructs = encoding.createEncoder()
}
}

180
src/utils/UndoManager.js Normal file
View File

@@ -0,0 +1,180 @@
import * as ID from './ID.js'
import { isParentOf } from './isParentOf.js'
class ReverseOperation {
constructor (y, transaction, bindingInfos) {
this.created = new Date()
const beforeState = transaction.beforeState
if (beforeState.has(y.userID)) {
this.toState = ID.createID(y.userID, y.ss.getState(y.userID) - 1)
this.fromState = ID.createID(y.userID, beforeState.get(y.userID))
} else {
this.toState = null
this.fromState = null
}
this.deletedStructs = new Set()
transaction.deletedStructs.forEach(struct => {
this.deletedStructs.add({
from: struct._id,
len: struct._length
})
})
/**
* Maps from binding to binding information (e.g. cursor information)
*/
this.bindingInfos = bindingInfos
}
}
function applyReverseOperation (y, scope, reverseBuffer) {
let performedUndo = false
let undoOp = null
y.transact(() => {
while (!performedUndo && reverseBuffer.length > 0) {
undoOp = reverseBuffer.pop()
// make sure that it is possible to iterate {from}-{to}
if (undoOp.fromState !== null) {
y.os.getItemCleanStart(undoOp.fromState)
y.os.getItemCleanEnd(undoOp.toState)
y.os.iterate(undoOp.fromState, undoOp.toState, op => {
while (op._deleted && op._redone !== null) {
op = op._redone
}
if (op._deleted === false && isParentOf(scope, op)) {
performedUndo = true
op._delete(y)
}
})
}
const redoitems = new Set()
for (let del of undoOp.deletedStructs) {
const fromState = del.from
const toState = ID.createID(fromState.user, fromState.clock + del.len - 1)
y.os.getItemCleanStart(fromState)
y.os.getItemCleanEnd(toState)
y.os.iterate(fromState, toState, op => {
if (
isParentOf(scope, op) &&
op._parent !== y &&
(
op._id.user !== y.userID ||
undoOp.fromState === null ||
op._id.clock < undoOp.fromState.clock ||
op._id.clock > undoOp.toState.clock
)
) {
redoitems.add(op)
}
})
}
redoitems.forEach(op => {
const opUndone = op._redo(y, redoitems)
performedUndo = performedUndo || opUndone
})
}
})
if (performedUndo && undoOp !== null) {
// should be performed after the undo transaction
undoOp.bindingInfos.forEach((info, binding) => {
binding._restoreUndoStackInfo(info)
})
}
return performedUndo
}
/**
* Saves a history of locally applied operations. The UndoManager handles the
* undoing and redoing of locally created changes.
*/
export class UndoManager {
/**
* @param {YType} scope The scope on which to listen for changes.
* @param {Object} options Optionally provided configuration.
*/
constructor (scope, options = {}) {
this.options = options
this._bindings = new Set(options.bindings)
options.captureTimeout = options.captureTimeout == null ? 500 : options.captureTimeout
this._undoBuffer = []
this._redoBuffer = []
this._scope = scope
this._undoing = false
this._redoing = false
this._lastTransactionWasUndo = false
const y = scope._y
this.y = y
y._hasUndoManager = true
let bindingInfos
y.on('beforeTransaction', (y, transaction, remote) => {
if (!remote) {
// Store binding information before transaction is executed
// By restoring the binding information, we can make sure that the state
// before the transaction can be recovered
bindingInfos = new Map()
this._bindings.forEach(binding => {
bindingInfos.set(binding, binding._getUndoStackInfo())
})
}
})
y.on('afterTransaction', (y, transaction, remote) => {
if (!remote && transaction.changedParentTypes.has(scope)) {
let reverseOperation = new ReverseOperation(y, transaction, bindingInfos)
if (!this._undoing) {
let lastUndoOp = this._undoBuffer.length > 0 ? this._undoBuffer[this._undoBuffer.length - 1] : null
if (
this._redoing === false &&
this._lastTransactionWasUndo === false &&
lastUndoOp !== null &&
(options.captureTimeout < 0 || reverseOperation.created - lastUndoOp.created <= options.captureTimeout)
) {
lastUndoOp.created = reverseOperation.created
if (reverseOperation.toState !== null) {
lastUndoOp.toState = reverseOperation.toState
if (lastUndoOp.fromState === null) {
lastUndoOp.fromState = reverseOperation.fromState
}
}
reverseOperation.deletedStructs.forEach(lastUndoOp.deletedStructs.add, lastUndoOp.deletedStructs)
} else {
this._lastTransactionWasUndo = false
this._undoBuffer.push(reverseOperation)
}
if (!this._redoing) {
this._redoBuffer = []
}
} else {
this._lastTransactionWasUndo = true
this._redoBuffer.push(reverseOperation)
}
}
})
}
/**
* Enforce that the next change is created as a separate item in the undo stack
*/
flushChanges () {
this._lastTransactionWasUndo = true
}
/**
* Undo the last locally created change.
*/
undo () {
this._undoing = true
const performedUndo = applyReverseOperation(this.y, this._scope, this._undoBuffer)
this._undoing = false
return performedUndo
}
/**
* Redo the last locally created change.
*/
redo () {
this._redoing = true
const performedRedo = applyReverseOperation(this.y, this._scope, this._redoBuffer)
this._redoing = false
return performedRedo
}
}

181
src/utils/Y.js Normal file
View File

@@ -0,0 +1,181 @@
import { DeleteStore } from './DeleteStore.js'
import { OperationStore } from './OperationStore.js'
import { StateStore } from './StateStore.js'
import * as random from 'funlib/random.js'
import { createRootID } from './ID.js'
import { Observable } from 'funlib/observable.js'
import { Transaction } from './Transaction.js'
/**
* Anything that can be encoded with `JSON.stringify` and can be decoded with
* `JSON.parse`.
*
* The following property should hold:
* `JSON.parse(JSON.stringify(key))===key`
*
* At the moment the only safe values are number and string.
*
* @typedef {(number|string|Object)} encodable
*/
/**
* A Yjs instance handles the state of shared data.
*/
export class Y extends Observable {
/**
* @param {Object} [conf] configuration
*/
constructor (conf = {}) {
super()
this.gcEnabled = conf.gc || false
this._contentReady = false
this.userID = random.uint32()
// TODO: This should be a Map so we can use encodables as keys
this._map = new Map()
this.ds = new DeleteStore()
this.os = new OperationStore(this)
this.ss = new StateStore(this)
this._missingStructs = new Map()
this._readyToIntegrate = []
this._transaction = null
this.connected = false
// for compatibility with isParentOf
this._parent = null
this._hasUndoManager = false
this._deleted = false // for compatiblity of having this as a parent for types
this._id = null
}
_beforeChange () {}
_callObserver (transaction, subs, remote) {}
/**
* Changes that happen inside of a transaction are bundled. This means that
* the observer fires _after_ the transaction is finished and that all changes
* that happened inside of the transaction are sent as one message to the
* other peers.
*
* @param {Function} f The function that should be executed as a transaction
* @param {?Boolean} remote Optional. Whether this transaction is initiated by
* a remote peer. This should not be set manually!
* Defaults to false.
*/
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((subs, type) => {
if (!type._deleted) {
type._callObserver(transaction, subs, remote)
}
})
transaction.changedParentTypes.forEach((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))
*
* @private
*/
get _start () {
return null
}
/**
* Fake _start for root properties (y.set('name', type))
*
* @private
*/
set _start (start) {}
/**
* Define a shared data type.
*
* Multiple calls of `y.define(name, TypeConstructor)` yield the same result
* and do not overwrite each other. I.e.
* `y.define(name, type) === y.define(name, type)`
*
* After this method is called, the type is also available on `y._map.get(name)`.
*
* *Best Practices:*
* Either define all types right after the Yjs instance is created or always
* use `y.define(..)` when accessing a type.
*
* @example
* // Option 1
* const y = new Y(..)
* y.define('myArray', YArray)
* y.define('myMap', YMap)
* // .. when accessing the type use y._map.get(name)
* y.share.myArray.insert(..)
* y.share.myMap.set(..)
*
* // Option2
* const y = new Y(..)
* // .. when accessing the type use `y.define(..)`
* y.define('myArray', YArray).insert(..)
* y.define('myMap', YMap).set(..)
*
* @param {String} name
* @param {Function} TypeConstructor The constructor of the type definition
* @returns {any} The created type. Constructed with TypeConstructor
*/
define (name, TypeConstructor) {
let id = createRootID(name, TypeConstructor)
let type = this.os.get(id)
if (this._map.get(name) === undefined) {
this._map.set(name, type)
} else if (this._map.get(name) !== type) {
throw new Error('Type is already defined with a different constructor')
}
return type
}
/**
* Get a defined type. The type must be defined locally. First define the
* type with {@link define}.
*
* This returns the same value as `y.share[name]`
*
* @param {String} name The typename
* @return {any}
*/
get (name) {
return this._map.get(name)
}
/**
* Disconnect from the room, and destroy all traces of this Yjs instance.
*/
destroy () {
this.emit('destroyed', [true])
super.destroy()
}
}

39
src/utils/YEvent.js Normal file
View File

@@ -0,0 +1,39 @@
/**
* @module utils
*/
/**
* YEvent describes the changes on a YType.
*/
export class YEvent {
/**
* @param {Type} target The changed type.
*/
constructor (target) {
/**
* The type on which this event was created on.
* @type {Type}
*/
this.target = target
/**
* The current target on which the observe callback is called.
* @type {Type}
*/
this.currentTarget = target
}
/**
* Computes the path from `y` to the changed type.
*
* The following property holds:
* @example
* let type = y
* event.path.forEach(dir => {
* type = type.get(dir)
* })
* type === event.target // => true
*/
get path () {
return this.currentTarget.getPathTo(this.target)
}
}

View File

@@ -0,0 +1,61 @@
/**
* @module utils
*/
import * as ID from '../utils/ID.js'
import { ItemJSON } from '../structs/ItemJSON.js'
import { ItemString } from '../structs/ItemString.js'
/**
* Try to merge all items in os with their successors.
*
* Some transformations (like delete) fragment items.
* Item(c: 'ab') + Delete(1,1) + Delete(0, 1) -> Item(c: 'a',deleted);Item(c: 'b',deleted)
*
* This functions merges the fragmented nodes together:
* Item(c: 'a',deleted);Item(c: 'b',deleted) -> Item(c: 'ab', deleted)
*
* TODO: The Tree implementation does not support deletions in-spot.
* This is why all deletions must be performed after the traversal.
*
*/
export const defragmentItemContent = y => {
const os = y.os
if (os.length < 2) {
return
}
let deletes = []
let node = os.findSmallestNode()
let next = node.next()
while (next !== null) {
let a = node.val
let b = next.val
if (
(a instanceof ItemJSON || a instanceof ItemString) &&
a.constructor === b.constructor &&
a._deleted === b._deleted &&
a._right === b &&
(ID.createID(a._id.user, a._id.clock + a._length)).equals(b._id)
) {
a._right = b._right
if (a instanceof ItemJSON) {
a._content = a._content.concat(b._content)
} else if (a instanceof ItemString) {
a._content += b._content
}
// delete b later
deletes.push(b._id)
// do not iterate node!
// !(node = next)
} else {
// not able to merge node, get next node
node = next
}
// update next
next = next.next()
}
for (let i = deletes.length - 1; i >= 0; i--) {
os.delete(deletes[i])
}
}

View File

@@ -0,0 +1,146 @@
/**
* @module utils
*/
import { getStruct } from 'y-protocols/sync.js'
import * as decoding from 'funlib/decoding.js'
import { GC } from '../structs/GC.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
import { Item } from '../structs/Item.js' // eslint-disable-line
class MissingEntry {
constructor (decoder, missing, struct) {
this.decoder = decoder
this.missing = missing.length
this.struct = struct
}
}
/**
* @private
* Integrate remote struct
* When a remote struct is integrated, other structs might be ready to ready to
* integrate.
* @param {Y} y
* @param {Item} struct
*/
function _integrateRemoteStructHelper (y, struct) {
const id = struct._id
if (id === undefined) {
struct._integrate(y)
} else {
if (y.ss.getState(id.user) > id.clock) {
return
}
if (!y.gcEnabled || struct.constructor === GC || (struct._parent.constructor !== GC && struct._parent._deleted === false)) {
// Is either a GC or Item with an undeleted parent
// save to integrate
struct._integrate(y)
} else {
// Is an Item. parent was deleted.
struct._gc(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)
} else {
// TODO: throw error here
}
}
})
msu.delete(clock)
}
}
if (msu.size === 0) {
y._missingStructs.delete(id.user)
}
}
}
}
/**
* @param {decoding.Decoder} decoder
* @param {Y} y
*/
export const integrateRemoteStructs = (decoder, y) => {
const len = decoding.readUint32(decoder)
for (let i = 0; i < len; i++) {
let reference = decoding.readVarUint(decoder)
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 = decoding.createDecoder(decoder.arr.buffer)
_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)
}
}
}
}
// TODO: use this above / refactor
/**
* @param {decoding.Decoder} decoder
* @param {Y} y
*/
export const integrateRemoteStruct = (decoder, y) => {
let reference = decoding.readVarUint(decoder)
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 = decoding.createDecoder(decoder.arr.buffer)
_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)
}
}
}

26
src/utils/isParentOf.js Normal file
View File

@@ -0,0 +1,26 @@
/**
* @module utils
*/
import { Y } from '../utils/Y.js' // eslint-disable-line
import { Type } from '../structs/Type.js' // eslint-disable-line
/**
* Check if `parent` is a parent of `child`.
*
* @param {Type | Y} parent
* @param {Type | Y} child
* @return {Boolean} Whether `parent` is a parent of `child`.
*
* @public
*/
export const isParentOf = (parent, child) => {
child = child._parent
while (child !== null) {
if (child === parent) {
return true
}
child = child._parent
}
return false
}

View File

@@ -0,0 +1,130 @@
/**
* @module utils
*/
import * as ID from './ID.js'
import { GC } from '../structs/GC.js'
// TODO: Implement function to describe ranges
/**
* A relative position that is based on the Yjs model. In contrast to an
* absolute position (position by index), the relative position can be
* recomputed when remote changes are received. For example:
*
* ```Insert(0, 'x')('a|bc') = 'xa|bc'``` Where | is the cursor position.
*
* A relative cursor position can be obtained with the function
* {@link getRelativePosition} and it can be transformed to an absolute position
* with {@link fromRelativePosition}.
*
* Pro tip: Use this to implement shared cursor locations in YText or YXml!
* The relative position is {@link encodable}, so you can send it to other
* clients.
*
* @example
* // Current cursor position is at position 10
* let relativePosition = getRelativePosition(yText, 10)
* // modify yText
* yText.insert(0, 'abc')
* yText.delete(3, 10)
* // Compute the cursor position
* let absolutePosition = fromRelativePosition(y, relativePosition)
* absolutePosition.type // => yText
* console.log('cursor location is ' + absolutePosition.offset) // => cursor location is 3
*
* @typedef {encodable} RelativePosition
*/
/**
* Create a relativePosition based on a absolute position.
*
* @param {YType} type The base type (e.g. YText or YArray).
* @param {Integer} offset The absolute position.
*/
export const getRelativePosition = (type, offset) => {
// TODO: rename to createRelativePosition
let t = type._start
while (t !== null) {
if (!t._deleted && t._countable) {
if (t._length > offset) {
return [t._id.user, t._id.clock + offset]
}
offset -= t._length
}
t = t._right
}
return ['endof', type._id.user, type._id.clock || null, type._id.name || null, type._id.type || null]
}
/**
* @typedef {Object} AbsolutePosition The result of {@link fromRelativePosition}
* @property {YType} type The type on which to apply the absolute position.
* @property {number} offset The absolute offset.r
*/
/**
* Transforms a relative position back to a relative position.
*
* @param {Y} y The Yjs instance in which to query for the absolute position.
* @param {RelativePosition} rpos The relative position.
* @return {AbsolutePosition} The absolute position in the Yjs model
* (type + offset).
*/
export const fromRelativePosition = (y, rpos) => {
if (rpos === null) {
return null
}
if (rpos[0] === 'endof') {
let id
if (rpos[3] === null) {
id = ID.createID(rpos[1], rpos[2])
} else {
id = ID.createRootID(rpos[3], rpos[4])
}
let type = y.os.get(id)
if (type === null) {
return null
}
while (type._redone !== null) {
type = type._redone
}
if (type === null || type.constructor === GC) {
return null
}
return {
type,
offset: type.length
}
} else {
let offset = 0
let struct = y.os.findNodeWithUpperBound(ID.createID(rpos[0], rpos[1])).val
if (struct === null || struct._id.user === ID.RootFakeUserID) {
return null // TODO: support fake ids?
}
const diff = rpos[1] - struct._id.clock
while (struct._redone !== null) {
struct = struct._redone
}
const parent = struct._parent
if (struct.constructor === GC || parent._deleted) {
return null
}
if (!struct._deleted && struct._countable) {
offset = diff
}
struct = struct._left
while (struct !== null) {
if (!struct._deleted && struct._countable) {
offset += struct._length
}
struct = struct._left
}
return {
type: parent,
offset: offset
}
}
}
export const equal = (posa, posb) => posa === posb || (posa !== null && posb !== null && posa.length === posb.length && posa.every((v, i) => v === posb[i]))

7
src/utils/snapshot.js Normal file
View File

@@ -0,0 +1,7 @@
/**
*
* @param {Item} item
* @param {import("../protocols/history").HistorySnapshot} [snapshot]
*/
export const isVisible = (item, snapshot) => snapshot === undefined ? !item._deleted : (snapshot.sm.has(item._id.user) && snapshot.sm.get(item._id.user) > item._id.clock && !snapshot.ds.isDeleted(item._id))

View File

@@ -0,0 +1,5 @@
export const writeStructToTransaction = (transaction, struct) => {
transaction.encodedStructsLen++
struct._toBinary(transaction.encodedStructs)
}

View File

@@ -0,0 +1,35 @@
import * as ID from '../utils/ID.js'
/**
* @private
* Delete all items in an ID-range.
* Does not create delete operations!
* TODO: implement getItemCleanStartNode for better performance (only one lookup).
*/
export const deleteItemRange = (y, user, clock, range, gcChildren) => {
let item = y.os.getItemCleanStart(ID.createID(user, clock))
if (item !== null) {
if (!item._deleted) {
item._splitAt(y, range)
item._delete(y, false, true)
}
let itemLen = item._length
range -= itemLen
clock += itemLen
if (range > 0) {
let node = y.os.findNode(ID.createID(user, clock))
while (node !== null && node.val !== null && range > 0 && node.val._id.equals(ID.createID(user, clock))) {
const nodeVal = node.val
if (!nodeVal._deleted) {
nodeVal._splitAt(y, range)
nodeVal._delete(y, false, gcChildren)
}
const nodeLen = nodeVal._length
range -= nodeLen
clock += nodeLen
node = node.next()
}
}
}
}