Compare commits

..

10 Commits

Author SHA1 Message Date
Kevin Jahns
805acbb9f5 13.0.0-80 2019-04-26 19:55:14 +02:00
Kevin Jahns
32c4c09072 update parent._map when splitting an item 2019-04-26 19:54:00 +02:00
Kevin Jahns
8c5a06bbf8 fix gc when item is deleted in observer call 2019-04-26 18:37:38 +02:00
Kevin Jahns
a336cc167c order observer and transaction cleanups after one another 2019-04-26 13:31:00 +02:00
Kevin Jahns
21d86cd2be Delete all children of ItemType when it is deleted 2019-04-26 12:29:28 +02:00
Kevin Jahns
1d0f9faa91 AbstractItem.mergeWith helper outsourced into separate function 2019-04-24 18:10:33 +02:00
Kevin Jahns
45237571b7 gc more efficiently 2019-04-23 20:51:32 +02:00
Kevin Jahns
bb6f6cd141 13.0.0-79 2019-04-20 00:03:30 +02:00
Kevin Jahns
729c1f16b8 fix test provider 2019-04-20 00:02:40 +02:00
Kevin Jahns
b6059704aa update dependencies 2019-04-20 00:00:09 +02:00
26 changed files with 1703 additions and 2647 deletions

View File

@@ -76,11 +76,17 @@
img[ychange_state='removed'] { img[ychange_state='removed'] {
padding: 2px; padding: 2px;
} }
.y-connect-btn {
position: absolute;
top: 20px;
right: 20px;
}
</style> </style>
</head> </head>
<body> <body>
<p>This example shows how to bind a YXmlFragment type to a <a href="http://prosemirror.net">Prosemirror</a> editor.</p> <button type="button" class="y-connect-btn">Disconnect</button>
<p>The content of this editor is shared with every client who visits this domain.</p> <p>This example shows how to bind a YXmlFragment to a <a href="http://prosemirror.net">Prosemirror</a> editor using <a href="https://github.com/y-js/y-prosemirror">y-prosemirror</a>.</p>
<p>The content of this editor is shared with every client that visits this domain.</p>
<div class="code-html"> <div class="code-html">
<div id="editor" style="margin-bottom: 23px"></div> <div id="editor" style="margin-bottom: 23px"></div>

View File

@@ -12,8 +12,8 @@ import { exampleSetup } from 'prosemirror-example-setup'
// import { noteHistoryPlugin } from './prosemirror-history.js' // import { noteHistoryPlugin } from './prosemirror-history.js'
const provider = new WebsocketProvider(conf.serverAddress) const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('prosemirror', { gc: false }) const ydocument = provider.get('prosemirror' /*, { gc: false } */)
const type = ydocument.define('prosemirror', Y.XmlFragment) const type = ydocument.get('prosemirror', Y.XmlFragment)
const prosemirrorView = new EditorView(document.querySelector('#editor'), { const prosemirrorView = new EditorView(document.querySelector('#editor'), {
state: EditorState.create({ state: EditorState.create({
@@ -22,4 +22,15 @@ const prosemirrorView = new EditorView(document.querySelector('#editor'), {
}) })
}) })
const connectBtn = document.querySelector('.y-connect-btn')
connectBtn.addEventListener('click', () => {
if (ydocument.wsconnected) {
ydocument.disconnect()
connectBtn.textContent = 'Connect'
} else {
ydocument.connect()
connectBtn.textContent = 'Disconnect'
}
})
window.example = { provider, ydocument, type, prosemirrorView } window.example = { provider, ydocument, type, prosemirrorView }

3086
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,21 @@
{ {
"name": "yjs", "name": "yjs",
"version": "13.0.0-78", "version": "13.0.0-80",
"description": "A ", "description": "A ",
"main": "./dist/yjs.js", "main": "./dist/yjs.js",
"module": "./dist/yjs.mjs'", "module": "./src/index.js",
"sideEffects": false, "sideEffects": false,
"scripts": { "scripts": {
"test": "npm run dist && PRODUCTION=1 node ./dist/tests.js --repitition-time 50 --production", "test": "npm run dist && PRODUCTION=1 node ./dist/tests.js --repitition-time 50 --production",
"test-exhaustive": "npm run lint && npm run dist && node ./dist/tests.js --repitition-time 10000", "test-exhaustive": "npm run lint && npm run dist && node ./dist/tests.js --repitition-time 10000",
"dist": "rm -rf dist examples/build && rollup -c", "dist": "rm -rf dist examples/build && rollup -c",
"serve-examples": "concurrently 'npm run watch' 'serve examples'",
"watch": "rollup -wc", "watch": "rollup -wc",
"lint": "standard && tsc", "lint": "standard && tsc",
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.v13.md --package ./package.json || true", "docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.v13.md --package ./package.json || true",
"serve-docs": "npm run docs && serve ./docs/", "serve-docs": "npm run docs && serve ./docs/",
"postversion": "npm run lint && PRODUCTION=1 npm run dist && node ./dist/tests.js --repitition-time 1000", "preversion": "PRODUCTION=1 npm run dist && node ./dist/tests.js --repitition-time 1000",
"postversion": "git push && git push --tags",
"debug": "concurrently 'live-server --port=3443 --entry-file=test.html' 'npm run watch'", "debug": "concurrently 'live-server --port=3443 --entry-file=test.html' 'npm run watch'",
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.js", "trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.js",
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.js" "trace-opt": "clear && rollup -c && node --trace-opt dist/test.js"
@@ -53,13 +55,12 @@
}, },
"homepage": "http://y-js.org", "homepage": "http://y-js.org",
"dependencies": { "dependencies": {
"lib0": "0.0.0" "lib0": "0.0.2"
}, },
"devDependencies": { "devDependencies": {
"y-protocols": "0.0.2",
"codemirror": "^5.42.0", "codemirror": "^5.42.0",
"concurrently": "^3.6.1", "concurrently": "^3.6.1",
"esdoc": "^1.1.0",
"esdoc-standard-plugin": "^1.0.0",
"jsdoc": "^3.5.5", "jsdoc": "^3.5.5",
"live-server": "^1.2.1", "live-server": "^1.2.1",
"prosemirror-example-setup": "^1.0.1", "prosemirror-example-setup": "^1.0.1",

View File

@@ -86,7 +86,7 @@ export default [{
commonjs() commonjs()
] ]
}, { }, {
input: ['./examples/codemirror.js', './examples/textarea.js'], // './examples/quill.js', './examples/dom.js', './examples/prosemirror.js' input: ['./examples/textarea.js', './examples/prosemirror.js'], // './examples/quill.js', './examples/dom.js', './examples/codemirror.js'
output: { output: {
dir: 'examples/build', dir: 'examples/build',
format: 'esm', format: 'esm',

View File

@@ -9,6 +9,23 @@ export {
YXmlHook as XmlHook, YXmlHook as XmlHook,
YXmlElement as XmlElement, YXmlElement as XmlElement,
YXmlFragment as XmlFragment, YXmlFragment as XmlFragment,
YXmlEvent,
YMapEvent,
YArrayEvent,
YEvent,
AbstractItem,
AbstractStruct,
GC,
ItemBinary,
ItemDeleted,
ItemEmbed,
ItemFormat,
ItemJSON,
ItemString,
ItemType,
AbstractType,
compareCursors,
Cursor,
createCursorFromTypeOffset, createCursorFromTypeOffset,
createCursorFromJSON, createCursorFromJSON,
createAbsolutePositionFromCursor, createAbsolutePositionFromCursor,
@@ -22,5 +39,10 @@ export {
readStatesAsMap, readStatesAsMap,
writeStates, writeStates,
writeModel, writeModel,
readModel readModel,
Snapshot,
findRootTypeKey,
typeArrayToArraySnapshot,
typeMapGetSnapshot,
iterateDeletedStructs
} from './internals.js' } from './internals.js'

View File

@@ -14,6 +14,7 @@ export * from './types/AbstractType.js'
export * from './types/YArray.js' export * from './types/YArray.js'
export * from './types/YMap.js' export * from './types/YMap.js'
export * from './types/YText.js' export * from './types/YText.js'
export * from './types/YXmlFragment.js'
export * from './types/YXmlElement.js' export * from './types/YXmlElement.js'
export * from './types/YXmlEvent.js' export * from './types/YXmlEvent.js'
export * from './types/YXmlHook.js' export * from './types/YXmlHook.js'

View File

@@ -27,6 +27,22 @@ import * as maplib from 'lib0/map.js'
import * as set from 'lib0/set.js' import * as set from 'lib0/set.js'
import * as binary from 'lib0/binary.js' import * as binary from 'lib0/binary.js'
/**
* @param {AbstractItem} left
* @param {AbstractItem} right
* @return {boolean} If true, right is removed from the linked list and should be discarded
*/
export const mergeItemWith = (left, right) => {
if (compareIDs(right.origin, left.lastId) && left.right === right && compareIDs(left.rightOrigin, right.rightOrigin)) {
left.right = right.right
if (left.right !== null) {
left.right.left = left
}
return true
}
return false
}
/** /**
* Split leftItem into two items * Split leftItem into two items
* @param {Transaction} transaction * @param {Transaction} transaction
@@ -60,6 +76,10 @@ export const splitItem = (transaction, leftItem, diff) => {
} }
// right is more specific. // right is more specific.
transaction._mergeStructs.add(rightItem.id) transaction._mergeStructs.add(rightItem.id)
// update parent._map
if (rightItem.parentSub !== null && rightItem.right === null) {
rightItem.parent._map.set(rightItem.parentSub, rightItem)
}
return rightItem return rightItem
} }
@@ -376,22 +396,6 @@ export class AbstractItem extends AbstractStruct {
throw new Error('unimplemented') throw new Error('unimplemented')
} }
/**
* @param {AbstractItem} right
* @return {boolean}
*
* @private
*/
mergeWith (right) {
if (compareIDs(right.origin, this.lastId) && this.right === right && compareIDs(this.rightOrigin, right.rightOrigin)) {
this.right = right.right
if (this.right !== null) {
this.right.left = this
}
return true
}
return false
}
/** /**
* Mark this Item as deleted. * Mark this Item as deleted.
* *
@@ -421,12 +425,14 @@ export class AbstractItem extends AbstractStruct {
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {StructStore} store * @param {StructStore} store
* @param {boolean} parentGCd
* *
* @private * @private
*/ */
gc (transaction, store) { gc (transaction, store, parentGCd) {
this.delete(transaction) // @todo: shouldn't be necessary
let r let r
if (this.parent._item !== null && this.parent._item.deleted) { if (parentGCd) {
r = new GC(this.id, this.length) r = new GC(this.id, this.length)
} else { } else {
r = new ItemDeleted(this.id, this.left, this.origin, this.right, this.rightOrigin, this.parent, this.parentSub, this.length) r = new ItemDeleted(this.id, this.left, this.origin, this.right, this.rightOrigin, this.parent, this.parentSub, this.length)
@@ -610,7 +616,9 @@ export const computeItemParams = (transaction, store, leftid, rightid, parentid,
case GC: case GC:
break break
default: default:
parent = parentItem.type if (!parentItem.deleted) {
parent = parentItem.type
}
} }
} else if (parentYKey !== null) { } else if (parentYKey !== null) {
parent = transaction.y.get(parentYKey) parent = transaction.y.get(parentYKey)

View File

@@ -7,6 +7,7 @@ import {
GC, GC,
splitItem, splitItem,
addToDeleteSet, addToDeleteSet,
mergeItemWith,
Y, StructStore, Transaction, ID, AbstractType // eslint-disable-line Y, StructStore, Transaction, ID, AbstractType // eslint-disable-line
} from '../internals.js' } from '../internals.js'
@@ -78,12 +79,25 @@ export class ItemDeleted extends AbstractItem {
* @return {boolean} * @return {boolean}
*/ */
mergeWith (right) { mergeWith (right) {
if (super.mergeWith(right)) { if (mergeItemWith(this, right)) {
this._len += right._len this._len += right._len
return true return true
} }
return false return false
} }
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {boolean} parentGCd
*
* @private
*/
gc (transaction, store, parentGCd) {
if (parentGCd) {
super.gc(transaction, store, parentGCd)
}
}
/** /**
* @param {encoding.Encoder} encoder * @param {encoding.Encoder} encoder
* @param {number} offset * @param {number} offset

View File

@@ -6,6 +6,7 @@ import {
splitItem, splitItem,
changeItemRefOffset, changeItemRefOffset,
GC, GC,
mergeItemWith,
Transaction, StructStore, Y, ID, AbstractType // eslint-disable-line Transaction, StructStore, Y, ID, AbstractType // eslint-disable-line
} from '../internals.js' } from '../internals.js'
@@ -74,7 +75,7 @@ export class ItemJSON extends AbstractItem {
* @return {boolean} * @return {boolean}
*/ */
mergeWith (right) { mergeWith (right) {
if (super.mergeWith(right)) { if (mergeItemWith(this, right)) {
this.content = this.content.concat(right.content) this.content = this.content.concat(right.content)
return true return true
} }

View File

@@ -6,6 +6,7 @@ import {
splitItem, splitItem,
changeItemRefOffset, changeItemRefOffset,
GC, GC,
mergeItemWith,
Transaction, StructStore, Y, ID, AbstractType // eslint-disable-line Transaction, StructStore, Y, ID, AbstractType // eslint-disable-line
} from '../internals.js' } from '../internals.js'
@@ -73,7 +74,7 @@ export class ItemString extends AbstractItem {
* @return {boolean} * @return {boolean}
*/ */
mergeWith (right) { mergeWith (right) {
if (super.mergeWith(right)) { if (mergeItemWith(this, right)) {
this.string += right.string this.string += right.string
return true return true
} }

View File

@@ -100,10 +100,21 @@ export class ItemType extends AbstractItem {
* @private * @private
*/ */
delete (transaction) { delete (transaction) {
super.delete(transaction) if (!this.deleted) {
transaction.changed.delete(this.type) super.delete(transaction)
transaction.changedParentTypes.delete(this.type) let item = this.type._start
this.gcChildren(transaction, transaction.y.store) while (item !== null) {
if (!item.deleted) {
item.delete(transaction)
}
item = item.right
}
this.type._map.forEach(item => {
item.delete(transaction)
})
transaction.changed.delete(this.type)
transaction.changedParentTypes.delete(this.type)
}
} }
/** /**
@@ -113,13 +124,13 @@ export class ItemType extends AbstractItem {
gcChildren (transaction, store) { gcChildren (transaction, store) {
let item = this.type._start let item = this.type._start
while (item !== null) { while (item !== null) {
item.gc(transaction, store) item.gc(transaction, store, true)
item = item.right item = item.right
} }
this.type._start = null this.type._start = null
this.type._map.forEach(item => { this.type._map.forEach(item => {
while (item !== null) { while (item !== null) {
item.gc(transaction, store) item.gc(transaction, store, true)
// @ts-ignore // @ts-ignore
item = item.left item = item.left
} }
@@ -130,10 +141,11 @@ export class ItemType extends AbstractItem {
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {StructStore} store * @param {StructStore} store
* @param {boolean} parentGCd
*/ */
gc (transaction, store) { gc (transaction, store, parentGCd) {
super.gc(transaction, store)
this.gcChildren(transaction, store) this.gcChildren(transaction, store)
super.gc(transaction, store, parentGCd)
} }
} }

View File

@@ -197,6 +197,29 @@ export const typeArrayToArray = type => {
return cs return cs
} }
/**
* @param {AbstractType<any>} type
* @param {Snapshot} snapshot
* @return {Array<any>}
*
* @private
* @function
*/
export const typeArrayToArraySnapshot = (type, snapshot) => {
const cs = []
let n = type._start
while (n !== null) {
if (n.countable && isVisible(n, snapshot)) {
const c = n.getContent()
for (let i = 0; i < c.length; i++) {
cs.push(c[i])
}
}
n = n.right
}
return cs
}
/** /**
* Executes a provided function on once on overy element of this YArray. * Executes a provided function on once on overy element of this YArray.
* *
@@ -431,13 +454,10 @@ export const typeArrayDelete = (transaction, parent, index, length) => {
if (length === 0) { return } if (length === 0) { return }
let n = parent._start let n = parent._start
// compute the first item to be deleted // compute the first item to be deleted
for (; n !== null; n = n.right) { for (; n !== null && index > 0; n = n.right) {
if (!n.deleted && n.countable) { if (!n.deleted && n.countable) {
if (index <= n.length) { if (index < n.length) {
if (index < n.length && index > 0) { getItemCleanStart(transaction, transaction.y.store, createID(n.id.client, n.id.clock + index))
n = getItemCleanStart(transaction, transaction.y.store, createID(n.id.client, n.id.clock + index))
}
break
} }
index -= n.length index -= n.length
} }

View File

@@ -147,7 +147,7 @@ const insertNegatedAttributes = (transaction, parent, left, right, negatedAttrib
left = new ItemFormat(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, key, val) left = new ItemFormat(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, key, val)
left.integrate(transaction) left.integrate(transaction)
} }
return {left, right} return { left, right }
} }
/** /**
@@ -215,10 +215,10 @@ const insertAttributes = (transaction, parent, left, right, currentAttributes, a
// insert format-start items // insert format-start items
for (let key in attributes) { for (let key in attributes) {
const val = attributes[key] const val = attributes[key]
const currentVal = currentAttributes.get(key) const currentVal = currentAttributes.get(key) || null
if (currentVal !== val) { if (currentVal !== val) {
// save negated attribute (set null if currentVal undefined) // save negated attribute (set null if currentVal undefined)
negatedAttributes.set(key, currentVal || null) negatedAttributes.set(key, currentVal)
left = new ItemFormat(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, key, val) left = new ItemFormat(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, key, val)
left.integrate(transaction) left.integrate(transaction)
} }
@@ -617,10 +617,11 @@ export class YText extends AbstractType {
constructor (string) { constructor (string) {
super() super()
/** /**
* @type {Array<string>?} * Array of pending operations on this type
* @type {Array<function():void>?}
* @private * @private
*/ */
this._prelimContent = string !== undefined ? [string] : [] this._pending = string !== undefined ? [() => this.insert(0, string)] : []
} }
get length () { get length () {
@@ -635,9 +636,13 @@ export class YText extends AbstractType {
*/ */
_integrate (y, item) { _integrate (y, item) {
super._integrate(y, item) super._integrate(y, item)
// @ts-ignore this._prelimContent is still defined try {
this.insert(0, this._prelimContent.join('')) // @ts-ignore this._prelimContent is still defined
this._prelimContent = null this._pending.forEach(f => f())
} catch (e) {
console.error(e)
}
this._pending = null
} }
/** /**
@@ -652,10 +657,6 @@ export class YText extends AbstractType {
callTypeObservers(this, transaction, new YTextEvent(this, transaction)) callTypeObservers(this, transaction, new YTextEvent(this, transaction))
} }
toDom () {
return document.createTextNode(this.toString())
}
/** /**
* Returns the unformatted string representation of this YText type. * Returns the unformatted string representation of this YText type.
* *
@@ -677,40 +678,6 @@ export class YText extends AbstractType {
return str return str
} }
toDomString () {
// @ts-ignore
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. * Apply a {@link Delta} on this shared YText type.
* *
@@ -737,6 +704,9 @@ export class YText extends AbstractType {
} }
} }
}) })
} else {
// @ts-ignore
this._pending.push(() => this.applyDelta(delta))
} }
} }
@@ -836,9 +806,12 @@ export class YText extends AbstractType {
const y = this._y const y = this._y
if (y !== null) { if (y !== null) {
transact(y, transaction => { transact(y, transaction => {
const {left, right, currentAttributes} = findPosition(transaction, y.store, this, index) const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
insertText(transaction, this, left, right, currentAttributes, text, attributes) insertText(transaction, this, left, right, currentAttributes, text, attributes)
}) })
} else {
// @ts-ignore
this._pending.push(() => this.insert(index, text, attributes))
} }
} }
@@ -862,6 +835,9 @@ export class YText extends AbstractType {
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index) const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
insertText(transaction, this, left, right, currentAttributes, embed, attributes) insertText(transaction, this, left, right, currentAttributes, embed, attributes)
}) })
} else {
// @ts-ignore
this._pending.push(() => this.insertEmbed(index, embed, attributes))
} }
} }
@@ -883,6 +859,9 @@ export class YText extends AbstractType {
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index) const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
deleteText(transaction, left, right, currentAttributes, length) deleteText(transaction, left, right, currentAttributes, length)
}) })
} else {
// @ts-ignore
this._pending.push(() => this.delete(index, length))
} }
} }
@@ -906,6 +885,9 @@ export class YText extends AbstractType {
} }
formatText(transaction, this, left, right, currentAttributes, length, attributes) formatText(transaction, this, left, right, currentAttributes, length, attributes)
}) })
} else {
// @ts-ignore
this._pending.push(() => this.format(index, length, attributes))
} }
} }

View File

@@ -1,244 +1,19 @@
/**
* @module YXml
*/
import { import {
YXmlEvent, YXmlFragment,
AbstractType, transact,
typeArrayMap, typeMapDelete,
typeArrayForEach, typeMapSet,
typeMapGet, typeMapGet,
typeMapGetAll, typeMapGetAll,
typeArrayInsertGenerics, typeArrayForEach,
typeArrayDelete,
typeMapSet,
typeMapDelete,
YXmlElementRefID, YXmlElementRefID,
callTypeObservers, Snapshot, Y, ItemType // eslint-disable-line
transact,
Y, Transaction, ItemType, YXmlText, YXmlHook, Snapshot // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as encoding from 'lib0/encoding.js' import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js' import * as decoding from 'lib0/decoding.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
*/
/**
* 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.
*/
/**
* Represents a subset of the nodes of a YXmlElement / YXmlFragment and a
* position within them.
*
* Can be created with {@link YXmlFragment#createTreeWalker}
*
* @public
* @implements {IterableIterator}
*/
export class YXmlTreeWalker {
/**
* @param {YXmlFragment | YXmlElement} root
* @param {function(AbstractType<any>):boolean} [f]
*/
constructor (root, f = () => true) {
this._filter = f
this._root = root
/**
* @type {ItemType | null}
*/
// @ts-ignore
this._currentNode = root._start
this._firstCall = true
}
[Symbol.iterator] () {
return this
}
/**
* Get the next node.
*
* @return {IteratorResult<YXmlElement|YXmlText|YXmlHook>} The next node.
*
* @public
*/
next () {
let n = this._currentNode
if (n !== null && (!this._firstCall || n.deleted || !this._filter(n.type))) { // if first call, we check if we can use the first item
do {
if (!n.deleted && (n.type.constructor === YXmlElement || n.type.constructor === YXmlFragment) && n.type._start !== null) {
// walk down in the tree
// @ts-ignore
n = n.type._start
} else {
// walk right or up in the tree
while (n !== null) {
if (n.right !== null) {
// @ts-ignore
n = n.right
break
} else if (n.parent === this._root) {
n = null
} else {
n = n.parent._item
}
}
}
} while (n !== null && (n.deleted || !this._filter(n.type)))
}
this._firstCall = false
this._currentNode = n
if (n === null) {
// @ts-ignore return undefined if done=true (the expected result)
return { value: undefined, done: true }
}
// @ts-ignore
return { value: n.type, done: false }
}
}
/**
* 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
* @extends AbstractType<YXmlEvent>
*/
export class YXmlFragment extends AbstractType {
/**
* 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(AbstractType<any>):boolean} 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|YXmlText|YXmlHook|null} The first element that matches the query or null.
*
* @public
*/
querySelector (query) {
query = query.toUpperCase()
// @ts-ignore
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|YXmlText|YXmlHook|null>} The elements that match this query.
*
* @public
*/
querySelectorAll (query) {
query = query.toUpperCase()
// @ts-ignore
return Array.from(new YXmlTreeWalker(this, element => element.nodeName === query))
}
/**
* Creates YXmlEvent and calls observers.
* @private
*
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*/
_callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YXmlEvent(this, parentSubs, 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 typeArrayMap(this, 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 {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Node} 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)
}
typeArrayForEach(this, xmlType => {
fragment.insertBefore(xmlType.toDom(_document, hooks, binding), null)
})
return fragment
}
}
/** /**
* An YXmlElement imitates the behavior of a * An YXmlElement imitates the behavior of a
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}. * {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}.
@@ -250,11 +25,6 @@ export class YXmlElement extends YXmlFragment {
constructor (nodeName = 'UNDEFINED') { constructor (nodeName = 'UNDEFINED') {
super() super()
this.nodeName = nodeName.toUpperCase() this.nodeName = nodeName.toUpperCase()
/**
* @type {Array<any>|null}
* @private
*/
this._prelimContent = []
/** /**
* @type {Map<string, any>|null} * @type {Map<string, any>|null}
* @private * @private
@@ -389,44 +159,6 @@ export class YXmlElement extends YXmlFragment {
return typeMapGetAll(this) return typeMapGetAll(this)
} }
/**
* Inserts new content at an index.
*
* @example
* // Insert character 'a' at position 0
* xml.insert(0, [new Y.XmlText('text')])
*
* @param {number} index The index to insert content at
* @param {Array<YXmlElement|YXmlText>} content The array of content
*/
insert (index, content) {
if (this._y !== null) {
transact(this._y, transaction => {
typeArrayInsertGenerics(transaction, this, index, content)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, 0, ...content)
}
}
/**
* Deletes elements starting from an index.
*
* @param {number} index Index at which to start deleting elements
* @param {number} [length=1] The number of elements to remove. Defaults to 1.
*/
delete (index, length = 1) {
if (this._y !== null) {
transact(this._y, transaction => {
typeArrayDelete(transaction, this, index, length)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, length)
}
}
/** /**
* Creates a Dom Element that mirrors this YXmlElement. * Creates a Dom Element that mirrors this YXmlElement.
* *
@@ -480,11 +212,3 @@ export class YXmlElement extends YXmlFragment {
* @function * @function
*/ */
export const readYXmlElement = decoder => new YXmlElement(decoding.readVarString(decoder)) export const readYXmlElement = decoder => new YXmlElement(decoding.readVarString(decoder))
/**
* @param {decoding.Decoder} decoder
* @return {YXmlFragment}
*
* @private
* @function
*/
export const readYXmlFragment = decoder => new YXmlFragment()

313
src/types/YXmlFragment.js Normal file
View File

@@ -0,0 +1,313 @@
/**
* @module YXml
*/
import {
YXmlEvent,
YXmlElement,
AbstractType,
typeArrayMap,
typeArrayForEach,
typeArrayInsertGenerics,
typeArrayDelete,
typeArrayToArray,
YXmlFragmentRefID,
callTypeObservers,
transact,
Transaction, ItemType, YXmlText, YXmlHook, Snapshot // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
/**
* 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
*/
/**
* 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.
*/
/**
* Represents a subset of the nodes of a YXmlElement / YXmlFragment and a
* position within them.
*
* Can be created with {@link YXmlFragment#createTreeWalker}
*
* @public
* @implements {IterableIterator}
*/
export class YXmlTreeWalker {
/**
* @param {YXmlFragment | YXmlElement} root
* @param {function(AbstractType<any>):boolean} [f]
*/
constructor (root, f = () => true) {
this._filter = f
this._root = root
/**
* @type {ItemType | null}
*/
// @ts-ignore
this._currentNode = root._start
this._firstCall = true
}
[Symbol.iterator] () {
return this
}
/**
* Get the next node.
*
* @return {IteratorResult<YXmlElement|YXmlText|YXmlHook>} The next node.
*
* @public
*/
next () {
let n = this._currentNode
if (n !== null && (!this._firstCall || n.deleted || !this._filter(n.type))) { // if first call, we check if we can use the first item
do {
if (!n.deleted && (n.type.constructor === YXmlElement || n.type.constructor === YXmlFragment) && n.type._start !== null) {
// walk down in the tree
// @ts-ignore
n = n.type._start
} else {
// walk right or up in the tree
while (n !== null) {
if (n.right !== null) {
// @ts-ignore
n = n.right
break
} else if (n.parent === this._root) {
n = null
} else {
n = n.parent._item
}
}
}
} while (n !== null && (n.deleted || !this._filter(n.type)))
}
this._firstCall = false
this._currentNode = n
if (n === null) {
// @ts-ignore return undefined if done=true (the expected result)
return { value: undefined, done: true }
}
// @ts-ignore
return { value: n.type, done: false }
}
}
/**
* 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
* @extends AbstractType<YXmlEvent>
*/
export class YXmlFragment extends AbstractType {
constructor () {
super()
/**
* @type {Array<any>|null}
* @private
*/
this._prelimContent = []
}
/**
* 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(AbstractType<any>):boolean} 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|YXmlText|YXmlHook|null} The first element that matches the query or null.
*
* @public
*/
querySelector (query) {
query = query.toUpperCase()
// @ts-ignore
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|YXmlText|YXmlHook|null>} The elements that match this query.
*
* @public
*/
querySelectorAll (query) {
query = query.toUpperCase()
// @ts-ignore
return Array.from(new YXmlTreeWalker(this, element => element.nodeName === query))
}
/**
* Creates YXmlEvent and calls observers.
* @private
*
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*/
_callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YXmlEvent(this, parentSubs, 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 typeArrayMap(this, 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 {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Node} 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)
}
typeArrayForEach(this, xmlType => {
fragment.insertBefore(xmlType.toDom(_document, hooks, binding), null)
})
return fragment
}
/**
* Inserts new content at an index.
*
* @example
* // Insert character 'a' at position 0
* xml.insert(0, [new Y.XmlText('text')])
*
* @param {number} index The index to insert content at
* @param {Array<YXmlElement|YXmlText>} content The array of content
*/
insert (index, content) {
if (this._y !== null) {
transact(this._y, transaction => {
typeArrayInsertGenerics(transaction, this, index, content)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, 0, ...content)
}
}
/**
* Deletes elements starting from an index.
*
* @param {number} index Index at which to start deleting elements
* @param {number} [length=1] The number of elements to remove. Defaults to 1.
*/
delete (index, length = 1) {
if (this._y !== null) {
transact(this._y, transaction => {
typeArrayDelete(transaction, this, index, length)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, length)
}
}
/**
* Transforms this YArray to a JavaScript Array.
*
* @return {Array<YXmlElement|YXmlText|YXmlHook>}
*/
toArray () {
return typeArrayToArray(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.
*
* @private
* @param {encoding.Encoder} encoder The encoder to write data to.
*/
_write (encoder) {
encoding.writeVarUint(encoder, YXmlFragmentRefID)
}
}
/**
* @param {decoding.Decoder} decoder
* @return {YXmlFragment}
*
* @private
* @function
*/
export const readYXmlFragment = decoder => new YXmlFragment()

View File

@@ -31,6 +31,41 @@ export class YXmlText extends YText {
} }
return dom return dom
} }
toDomString () {
// @ts-ignore
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
}).join('')
}
/** /**
* @param {encoding.Encoder} encoder * @param {encoding.Encoder} encoder
* *

View File

@@ -3,7 +3,7 @@ import {
findIndexSS, findIndexSS,
createID, createID,
getState, getState,
AbstractItem, StructStore, Transaction, ID // eslint-disable-line AbstractStruct, AbstractItem, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as math from 'lib0/math.js' import * as math from 'lib0/math.js'
@@ -11,9 +11,6 @@ import * as map from 'lib0/map.js'
import * as encoding from 'lib0/encoding.js' import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js' import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
class DeleteItem { class DeleteItem {
/** /**
* @param {number} clock * @param {number} clock
@@ -37,8 +34,6 @@ class DeleteItem {
* - This DeleteSet is send to other clients * - This DeleteSet is send to other clients
* - We do not create a DeleteSet when we send a sync message. The DeleteSet message is created directly from StructStore * - We do not create a DeleteSet when we send a sync message. The DeleteSet message is created directly from StructStore
* - We read a DeleteSet as part of a sync/update message. In this case the DeleteSet is already sorted and merged. * - We read a DeleteSet as part of a sync/update message. In this case the DeleteSet is already sorted and merged.
*
* @private
*/ */
export class DeleteSet { export class DeleteSet {
constructor () { constructor () {
@@ -50,6 +45,33 @@ export class DeleteSet {
} }
} }
/**
* Iterate over all structs that were deleted.
*
* This function expects that the deletes structs are not deleted. Hence, you can
* probably only use it in type observes and `afterTransaction` events. But not
* in `afterTransactionCleanup`.
*
* @param {DeleteSet} ds
* @param {StructStore} store
* @param {function(AbstractStruct):void} f
*
* @function
*/
export const iterateDeletedStructs = (ds, store, f) =>
ds.clients.forEach((deletes, clientid) => {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(clientid))
for (let i = 0; i < deletes.length; i++) {
const del = deletes[i]
let index = findIndexSS(structs, del.clock)
let struct
do {
struct = structs[index++]
f(struct)
} while (index < structs.length && structs[index].id.clock < del.clock + del.len)
}
})
/** /**
* @param {Array<DeleteItem>} dis * @param {Array<DeleteItem>} dis
* @param {number} clock * @param {number} clock

View File

@@ -1,8 +1,7 @@
import { import {
DeleteSet,
isDeleted, isDeleted,
AbstractItem // eslint-disable-line DeleteSet, AbstractItem // eslint-disable-line
} from '../internals.js' } from '../internals.js'
export class Snapshot { export class Snapshot {
@@ -17,7 +16,7 @@ export class Snapshot {
* @type {DeleteSet} * @type {DeleteSet}
* @private * @private
*/ */
this.ds = new DeleteSet() this.ds = ds
/** /**
* State Map * State Map
* @type {Map<number,number>} * @type {Map<number,number>}

View File

@@ -10,7 +10,6 @@ import {
findIndexSS, findIndexSS,
callEventHandlerListeners, callEventHandlerListeners,
AbstractItem, AbstractItem,
ItemDeleted,
ID, AbstractType, AbstractStruct, YEvent, Y // eslint-disable-line ID, AbstractType, AbstractStruct, YEvent, Y // eslint-disable-line
} from '../internals.js' } from '../internals.js'
@@ -45,8 +44,9 @@ import * as math from 'lib0/math.js'
export class Transaction { export class Transaction {
/** /**
* @param {Y} y * @param {Y} y
* @param {any} origin
*/ */
constructor (y) { constructor (y, origin) {
/** /**
* The Yjs instance. * The Yjs instance.
* @type {Y} * @type {Y}
@@ -90,6 +90,10 @@ export class Transaction {
* @private * @private
*/ */
this._mergeStructs = new Set() this._mergeStructs = new Set()
/**
* @type {any}
*/
this.origin = origin
} }
/** /**
* @type {encoding.Encoder|null} * @type {encoding.Encoder|null}
@@ -124,121 +128,129 @@ export const nextID = transaction => {
* *
* @param {Y} y * @param {Y} y
* @param {function(Transaction):void} f * @param {function(Transaction):void} f
* @param {any} [origin]
* *
* @private * @private
* @function * @function
*/ */
export const transact = (y, f) => { export const transact = (y, f, origin = null) => {
const transactionCleanups = y._transactionCleanups
let initialCall = false let initialCall = false
if (y._transaction === null) { if (y._transaction === null) {
initialCall = true initialCall = true
y._transaction = new Transaction(y) y._transaction = new Transaction(y, origin)
transactionCleanups.push(y._transaction)
y.emit('beforeTransaction', [y._transaction, y]) y.emit('beforeTransaction', [y._transaction, y])
} }
const transaction = y._transaction
try { try {
f(transaction) f(y._transaction)
} finally { } finally {
if (initialCall) { // @todo set after state here
y._transaction = null if (initialCall && transactionCleanups[0] === y._transaction) {
y.emit('beforeObserverCalls', [transaction, y]) // The first transaction ended, now process observer calls.
// emit change events on changed types // Observer call may create new transactions for which we need to call the observers and do cleanup.
transaction.changed.forEach((subs, itemtype) => { // We don't want to nest these calls, so we execute these calls one after another
itemtype._callObserver(transaction, subs) for (let i = 0; i < transactionCleanups.length; i++) {
}) const transaction = transactionCleanups[i]
transaction.changedParentTypes.forEach((events, type) => { const store = transaction.y.store
events = events const ds = transaction.deleteSet
.filter(event => sortAndMergeDeleteSet(ds)
event.target._item === null || !event.target._item.deleted transaction.afterState = getStates(transaction.y.store)
) y._transaction = null
events y.emit('beforeObserverCalls', [transaction, y])
.forEach(event => { // emit change events on changed types
event.currentTarget = type transaction.changed.forEach((subs, itemtype) => {
}) itemtype._callObserver(transaction, subs)
// we don't need to check for events.length })
// because we know it has at least one element transaction.changedParentTypes.forEach((events, type) => {
callEventHandlerListeners(type._dEH, events, transaction) events = events
}) .filter(event =>
// only call afterTransaction listeners if anything changed event.target._item === null || !event.target._item.deleted
transaction.afterState = getStates(transaction.y.store) )
// when all changes & events are processed, emit afterTransaction event events
// transaction cleanup .forEach(event => {
const store = transaction.y.store event.currentTarget = type
const ds = transaction.deleteSet })
// replace deleted items with ItemDeleted / GC // we don't need to check for events.length
sortAndMergeDeleteSet(ds) // because we know it has at least one element
y.emit('afterTransaction', [transaction, y]) callEventHandlerListeners(type._dEH, events, transaction)
for (const [client, deleteItems] of ds.clients) { })
/** y.emit('afterTransaction', [transaction, y])
* @type {Array<AbstractStruct>} // replace deleted items with ItemDeleted / GC
*/ for (const [client, deleteItems] of ds.clients) {
// @ts-ignore
const structs = store.clients.get(client)
for (let di = 0; di < deleteItems.length; di++) {
const deleteItem = deleteItems[di]
for (let si = findIndexSS(structs, deleteItem.clock); si < structs.length; si++) {
const struct = structs[si]
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
break
}
if (struct.deleted && struct instanceof AbstractItem && (struct.constructor !== ItemDeleted || (struct.parent._item !== null && struct.parent._item.deleted))) {
// check if we can GC
struct.gc(transaction, store)
}
}
}
}
/**
* @param {Array<AbstractStruct>} structs
* @param {number} pos
*/
const tryToMergeWithLeft = (structs, pos) => {
const left = structs[pos - 1]
const right = structs[pos]
if (left.deleted === right.deleted && left.constructor === right.constructor) {
if (left.mergeWith(right)) {
structs.splice(pos, 1)
if (right instanceof AbstractItem && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
// @ts-ignore we already did a constructor check above
right.parent._map.set(right.parentSub, left)
}
}
}
}
// on all affected store.clients props, try to merge
for (const [client, clock] of transaction.afterState) {
const beforeClock = transaction.beforeState.get(client) || 0
if (beforeClock !== clock) {
/** /**
* @type {Array<AbstractStruct>} * @type {Array<AbstractStruct>}
*/ */
// @ts-ignore // @ts-ignore
const structs = store.clients.get(client) const structs = store.clients.get(client)
// we iterate from right to left so we can safely remove entries for (let di = 0; di < deleteItems.length; di++) {
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1) const deleteItem = deleteItems[di]
for (let i = structs.length - 1; i >= firstChangePos; i--) { for (let si = findIndexSS(structs, deleteItem.clock); si < structs.length; si++) {
tryToMergeWithLeft(structs, i) const struct = structs[si]
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
break
}
if (struct.deleted && struct instanceof AbstractItem) {
struct.gc(transaction, store, false)
}
}
} }
} }
}
// try to merge mergeStructs
for (const mid of transaction._mergeStructs) {
const client = mid.client
const clock = mid.clock
/** /**
* @type {Array<AbstractStruct>} * @param {Array<AbstractStruct>} structs
* @param {number} pos
*/ */
// @ts-ignore const tryToMergeWithLeft = (structs, pos) => {
const structs = store.clients.get(client) const left = structs[pos - 1]
const replacedStructPos = findIndexSS(structs, clock) const right = structs[pos]
if (replacedStructPos + 1 < structs.length) { if (left.deleted === right.deleted && left.constructor === right.constructor) {
tryToMergeWithLeft(structs, replacedStructPos + 1) if (left.mergeWith(right)) {
structs.splice(pos, 1)
if (right instanceof AbstractItem && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
// @ts-ignore we already did a constructor check above
right.parent._map.set(right.parentSub, left)
}
}
}
} }
if (replacedStructPos > 0) { // on all affected store.clients props, try to merge
tryToMergeWithLeft(structs, replacedStructPos) for (const [client, clock] of transaction.afterState) {
const beforeClock = transaction.beforeState.get(client) || 0
if (beforeClock !== clock) {
/**
* @type {Array<AbstractStruct>}
*/
// @ts-ignore
const structs = store.clients.get(client)
// we iterate from right to left so we can safely remove entries
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
for (let i = structs.length - 1; i >= firstChangePos; i--) {
tryToMergeWithLeft(structs, i)
}
}
} }
// try to merge mergeStructs
for (const mid of transaction._mergeStructs) {
const client = mid.client
const clock = mid.clock
/**
* @type {Array<AbstractStruct>}
*/
// @ts-ignore
const structs = store.clients.get(client)
const replacedStructPos = findIndexSS(structs, clock)
if (replacedStructPos + 1 < structs.length) {
tryToMergeWithLeft(structs, replacedStructPos + 1)
}
if (replacedStructPos > 0) {
tryToMergeWithLeft(structs, replacedStructPos)
}
}
// @todo Merge all the transactions into one and provide send the data as a single update message
// @todo implement a dedicatet event that we can use to send updates to other peer
y.emit('afterTransactionCleanup', [transaction, y])
} }
y.emit('afterTransactionCleanup', [transaction, y]) y._transactionCleanups = []
} }
} }
} }

View File

@@ -39,6 +39,11 @@ export class Y extends Observable {
* @private * @private
*/ */
this._transaction = null this._transaction = null
/**
* @type {Array<Transaction>}
* @private
*/
this._transactionCleanups = []
} }
/** /**
* Changes that happen inside of a transaction are bundled. This means that * Changes that happen inside of a transaction are bundled. This means that
@@ -47,11 +52,12 @@ export class Y extends Observable {
* other peers. * other peers.
* *
* @param {function(Transaction):void} f The function that should be executed as a transaction * @param {function(Transaction):void} f The function that should be executed as a transaction
* @param {any} [origin] Origin of who started the transaction. Will be stored on transaction.origin
* *
* @public * @public
*/ */
transact (f) { transact (f, origin = null) {
transact(this, f) transact(this, f, origin)
} }
/** /**
* Define a shared data type. * Define a shared data type.

View File

@@ -12,6 +12,7 @@ import {
getState, getState,
findRootTypeKey, findRootTypeKey,
AbstractItem, AbstractItem,
ItemType,
ID, StructStore, Y, AbstractType // eslint-disable-line ID, StructStore, Y, AbstractType // eslint-disable-line
} from '../internals.js' } from '../internals.js'
@@ -240,7 +241,17 @@ export const createAbsolutePositionFromCursor = (cursor, y) => {
if (tname !== null) { if (tname !== null) {
type = y.get(tname) type = y.get(tname)
} else if (typeID !== null) { } else if (typeID !== null) {
type = getItemType(store, typeID).type if (getState(store, typeID.client) <= typeID.clock) {
// type does not exist yet
return null
}
const struct = getItemType(store, typeID)
if (struct instanceof ItemType) {
type = struct.type
} else {
// struct is garbage collected
return null
}
} else { } else {
throw error.unexpectedCase() throw error.unexpectedCase()
} }
@@ -259,8 +270,5 @@ export const createAbsolutePositionFromCursor = (cursor, y) => {
* @function * @function
*/ */
export const compareCursors = (a, b) => a === b || ( export const compareCursors = (a, b) => a === b || (
a !== null && b !== null && a.tname === b.tname && ( a !== null && b !== null && a.tname === b.tname && compareIDs(a.item, b.item) && compareIDs(a.type, b.type)
(a.item !== null && b.item !== null && compareIDs(a.item, b.item)) ||
(a.type !== null && b.type !== null && compareIDs(a.type, b.type))
)
) )

View File

@@ -9,24 +9,23 @@ import {
import * as t from 'lib0/testing.js' import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js' import * as prng from 'lib0/prng.js'
import { createMutex } from 'lib0/mutex.js'
import * as encoding from 'lib0/encoding.js' import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js' import * as decoding from 'lib0/decoding.js'
import * as syncProtocol from 'y-protocols/sync.js' import * as syncProtocol from 'y-protocols/sync.js'
/** /**
* @param {TestYInstance} y
* @param {Y.Transaction} transaction * @param {Y.Transaction} transaction
* @param {TestYInstance} y
*/ */
const afterTransaction = (y, transaction) => { const afterTransaction = (transaction, y) => {
y.mMux(() => { if (transaction.origin !== y.tc) {
const m = transaction.updateMessage const m = transaction.updateMessage
if (m !== null) { if (m !== null) {
const encoder = encoding.createEncoder() const encoder = encoding.createEncoder()
syncProtocol.writeUpdate(encoder, m) syncProtocol.writeUpdate(encoder, m)
broadcastMessage(y, encoding.toBuffer(encoder)) broadcastMessage(y, encoding.toBuffer(encoder))
} }
}) }
} }
/** /**
@@ -59,11 +58,6 @@ export class TestYInstance extends Y.Y {
* @type {Map<TestYInstance, Array<ArrayBuffer>>} * @type {Map<TestYInstance, Array<ArrayBuffer>>}
*/ */
this.receiving = new Map() this.receiving = new Map()
/**
* Message mutex
* @type {Function}
*/
this.mMux = createMutex()
testConnector.allConns.add(this) testConnector.allConns.add(this)
// set up observe on local model // set up observe on local model
this.on('afterTransactionCleanup', afterTransaction) this.on('afterTransactionCleanup', afterTransaction)
@@ -165,11 +159,9 @@ export class TestConnector {
return this.flushRandomMessage() return this.flushRandomMessage()
} }
const encoder = encoding.createEncoder() const encoder = encoding.createEncoder()
receiver.mMux(() => { // console.log('receive (' + sender.userID + '->' + receiver.userID + '):\n', syncProtocol.stringifySyncMessage(decoding.createDecoder(m), receiver))
// console.log('receive (' + sender.userID + '->' + receiver.userID + '):\n', syncProtocol.stringifySyncMessage(decoding.createDecoder(m), receiver)) // do not publish data created when this function is executed (could be ss2 or update message)
// do not publish data created when this function is executed (could be ss2 or update message) syncProtocol.readSyncMessage(decoding.createDecoder(m), encoder, receiver, receiver.tc)
syncProtocol.readSyncMessage(decoding.createDecoder(m), encoder, receiver)
})
if (encoding.length(encoder) > 0) { if (encoding.length(encoder) > 0) {
// send reply message // send reply message
sender._receive(encoding.toBuffer(encoder), receiver) sender._receive(encoding.toBuffer(encoder), receiver)
@@ -230,11 +222,13 @@ export class TestConnector {
} }
/** /**
* @template T
* @param {t.TestCase} tc * @param {t.TestCase} tc
* @param {{users?:number}} conf * @param {{users?:number}} conf
* @return {{testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.Array<any>,array1:Y.Array<any>,array2:Y.Array<any>,map0:Y.Map<any>,map1:Y.Map<any>,map2:Y.Map<any>,map3:Y.Map<any>,text0:Y.Text,text1:Y.Text,text2:Y.Text,xml0:Y.XmlElement,xml1:Y.XmlElement,xml2:Y.XmlElement}} * @param {InitTestObjectCallback<T>} [initTestObject]
* @return {{testObjects:Array<any>,testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.Array<any>,array1:Y.Array<any>,array2:Y.Array<any>,map0:Y.Map<any>,map1:Y.Map<any>,map2:Y.Map<any>,map3:Y.Map<any>,text0:Y.Text,text1:Y.Text,text2:Y.Text,xml0:Y.XmlElement,xml1:Y.XmlElement,xml2:Y.XmlElement}}
*/ */
export const init = (tc, { users = 5 } = {}) => { export const init = (tc, { users = 5 } = {}, initTestObject) => {
/** /**
* @type {Object<string,any>} * @type {Object<string,any>}
*/ */
@@ -254,6 +248,7 @@ export const init = (tc, { users = 5 } = {}) => {
result['text' + i] = y.get('text', Y.Text) result['text' + i] = y.get('text', Y.Text)
} }
testConnector.syncAll() testConnector.syncAll()
result.testObjects = result.users.map(initTestObject || (() => null))
// @ts-ignore // @ts-ignore
return result return result
} }
@@ -365,15 +360,24 @@ export const compareDS = (ds1, ds2) => {
} }
/** /**
* @param {t.TestCase} tc * @template T
* @param {Array<function(TestYInstance,prng.PRNG):void>} mods * @callback InitTestObjectCallback
* @param {number} iterations * @param {TestYInstance} y
* @return {T}
*/ */
export const applyRandomTests = (tc, mods, iterations) => {
/**
* @template T
* @param {t.TestCase} tc
* @param {Array<function(TestYInstance,prng.PRNG,T):void>} mods
* @param {number} iterations
* @param {InitTestObjectCallback<T>} [initTestObject]
*/
export const applyRandomTests = (tc, mods, iterations, initTestObject) => {
const gen = tc.prng const gen = tc.prng
const result = init(tc, { users: 5 }) const result = init(tc, { users: 5 }, initTestObject || (() => null))
const { testConnector, users } = result const { testConnector, users } = result
for (var i = 0; i < iterations; i++) { for (let i = 0; i < iterations; i++) {
if (prng.int31(gen, 0, 100) <= 2) { if (prng.int31(gen, 0, 100) <= 2) {
// 2% chance to disconnect/reconnect a random user // 2% chance to disconnect/reconnect a random user
if (prng.bool(gen)) { if (prng.bool(gen)) {
@@ -388,9 +392,9 @@ export const applyRandomTests = (tc, mods, iterations) => {
// 50% chance to flush a random message // 50% chance to flush a random message
testConnector.flushRandomMessage() testConnector.flushRandomMessage()
} }
let user = prng.oneOf(gen, users) const user = prng.int31(gen, 0, users.length - 1)
var test = prng.oneOf(gen, mods) const test = prng.oneOf(gen, mods)
test(user, gen) test(users[user], gen, result.testObjects[user])
} }
compare(users) compare(users)
return result return result

View File

@@ -144,6 +144,32 @@ export const testInsertAndDeleteEvents = tc => {
compare(users) compare(users)
} }
/**
* @param {t.TestCase} tc
*/
export const testNestedObserverEvents = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {Array<number>}
*/
const vals = []
array0.observe(e => {
if (array0.length === 1) {
// inserting, will call this observer again
// we expect that this observer is called after this event handler finishedn
array0.insert(1, [1])
vals.push(0)
} else {
// this should be called the second time an element is inserted (above case)
vals.push(1)
}
})
array0.insert(0, [0])
t.compareArrays(vals, [0, 1])
t.compareArrays(array0.toArray(), [0, 1])
compare(users)
}
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
@@ -309,8 +335,8 @@ const arrayTransactions = [
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
export const testRepeatGeneratingYarrayTests20 = tc => { export const testRepeatGeneratingYarrayTests4 = tc => {
applyRandomTests(tc, arrayTransactions, 3) applyRandomTests(tc, arrayTransactions, 4)
} }
/** /**

View File

@@ -54,9 +54,9 @@
/* Experimental Options */ /* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"maxNodeModuleJsDepth": 5, "maxNodeModuleJsDepth": 0,
// "types": ["./src/utils/typedefs.js"] // "types": ["./src/utils/typedefs.js"]
}, },
"include": ["./src/**/*", "./tests/**/*"], "include": ["./src/**/*", "./tests/**/*"],
"exclude": ["../lib0/**/*", "node_modules"] "exclude": ["../lib0/**/*", "node_modules/**/*", "dist", "dist/**/*.js"]
} }