diff --git a/src/structs/ContentType.js b/src/structs/ContentType.js index 630efeb3..9785d674 100644 --- a/src/structs/ContentType.js +++ b/src/structs/ContentType.js @@ -6,6 +6,7 @@ import { readYXmlFragment, readYXmlHook, readYXmlText, + isDeleted, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line } from '../internals.js' @@ -107,7 +108,7 @@ export class ContentType { while (item !== null) { if (!item.deleted) { item.delete(transaction) - } else if (item.id.clock < (transaction.beforeState.get(item.id.client) || 0)) { + } else if (!isDeleted(transaction.insertSet, item.id)) { // This will be gc'd later and we want to merge it if possible // We try to merge all deleted items after each transaction, // but we have no knowledge about that this needs to be merged @@ -119,7 +120,7 @@ export class ContentType { this.type._map.forEach(item => { if (!item.deleted) { item.delete(transaction) - } else if (item.id.clock < (transaction.beforeState.get(item.id.client) || 0)) { + } else if (!isDeleted(transaction.insertSet, item.id)) { // same as above transaction._mergeStructs.push(item) } diff --git a/src/structs/GC.js b/src/structs/GC.js index 3c7cec0c..cdda4715 100644 --- a/src/structs/GC.js +++ b/src/structs/GC.js @@ -1,7 +1,8 @@ import { AbstractStruct, addStruct, - UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, ID // eslint-disable-line + UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, ID, // eslint-disable-line + addItemToInsertSet } from '../internals.js' export const structGCRefNumber = 0 @@ -37,6 +38,7 @@ export class GC extends AbstractStruct { this.id.clock += offset this.length -= offset } + addItemToInsertSet(transaction, this) addStruct(transaction.doc.store, this) } diff --git a/src/structs/Item.js b/src/structs/Item.js index 2d2b1bb9..923db3aa 100644 --- a/src/structs/Item.js +++ b/src/structs/Item.js @@ -22,7 +22,8 @@ import { readContentType, addChangedTypeToTransaction, isDeleted, - StackItem, DeleteSet, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line + StackItem, DeleteSet, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction, // eslint-disable-line + addItemToInsertSet } from '../internals.js' import * as error from 'lib0/error' @@ -514,6 +515,7 @@ export class Item extends AbstractStruct { if (this.parentSub === null && this.countable && !this.deleted) { /** @type {AbstractType} */ (this.parent)._length += this.length } + addItemToInsertSet(transaction, this) addStruct(transaction.doc.store, this) this.content.integrate(transaction, this) // add parent to transaction.changed diff --git a/src/types/YText.js b/src/types/YText.js index 56aec77f..f902cc85 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -493,19 +493,13 @@ export const cleanupYTextAfterTransaction = transaction => { const needFullCleanup = new Set() // check if another formatting item was inserted const doc = transaction.doc - for (const [client, afterClock] of transaction.afterState.entries()) { - const clock = transaction.beforeState.get(client) || 0 - if (afterClock === clock) { - continue + iterateDeletedStructs(transaction, transaction.insertSet, (item) => { + if ( + !item.deleted && /** @type {Item} */ (item).content.constructor === ContentFormat && item.constructor !== GC + ) { + needFullCleanup.add(/** @type {any} */ (item).parent) } - iterateStructs(transaction, /** @type {Array} */ (doc.store.clients.get(client)), clock, afterClock, item => { - if ( - !item.deleted && /** @type {Item} */ (item).content.constructor === ContentFormat && item.constructor !== GC - ) { - needFullCleanup.add(/** @type {any} */ (item).parent) - } - }) - } + }) // cleanup in a new transaction transact(doc, (t) => { iterateDeletedStructs(transaction, transaction.deleteSet, item => { diff --git a/src/utils/Transaction.js b/src/utils/Transaction.js index 9792d32e..e821683a 100644 --- a/src/utils/Transaction.js +++ b/src/utils/Transaction.js @@ -11,9 +11,12 @@ import { generateNewClientId, createID, cleanupYTextAfterTransaction, - UpdateEncoderV1, UpdateEncoderV2, GC, StructStore, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line + isDeleted, + UpdateEncoderV1, UpdateEncoderV2, GC, StructStore, AbstractType, AbstractStruct, YEvent, Doc, // eslint-disable-line + DeleteItem } from '../internals.js' +import * as error from 'lib0/error' import * as map from 'lib0/map' import * as math from 'lib0/math' import * as set from 'lib0/set' @@ -63,15 +66,20 @@ export class Transaction { */ this.deleteSet = new DeleteSet() /** - * Holds the state before the transaction started. - * @type {Map} + * Describes the set of inserted items by ids + * @type {DeleteSet} */ - this.beforeState = getStateVector(doc.store) + this.insertSet = new DeleteSet() + /** + * Holds the state before the transaction started. + * @type {Map?} + */ + this._beforeState = null /** * Holds the state after the transaction. - * @type {Map} + * @type {Map?} */ - this.afterState = new Map() + this._afterState = null /** * All types that were directly modified (property added or child * inserted/deleted). New types are not included in this Set. @@ -119,6 +127,43 @@ export class Transaction { * @type {boolean} */ this._needFormattingCleanup = false + this._done = false + } + + /** + * Holds the state before the transaction started. + * + * @deprecated + * @type {Map} + */ + get beforeState () { + if (this._beforeState == null) { + const sv = getStateVector(this.doc.store) + this.insertSet.clients.forEach((ranges, client) => { + sv.set(client, ranges[0].clock) + }) + this._beforeState = sv + } + return this._beforeState + } + + /** + * Holds the state after the transaction. + * + * @deprecated + * @type {Map} + */ + get afterState () { + if (!this._done) error.unexpectedCase() + if (this._afterState == null) { + const sv = getStateVector(this.doc.store) + this.insertSet.clients.forEach((ranges, client) => { + const d = ranges[ranges.length - 1] + sv.set(client, d.clock + d.len) + }) + this._afterState = sv + } + return this._afterState } } @@ -128,7 +173,7 @@ export class Transaction { * @return {boolean} Whether data was written. */ export const writeUpdateMessageFromTransaction = (encoder, transaction) => { - if (transaction.deleteSet.clients.size === 0 && !map.any(transaction.afterState, (clock, client) => transaction.beforeState.get(client) !== clock)) { + if (transaction.deleteSet.clients.size === 0 && transaction.insertSet.clients.size === 0) { return false } sortAndMergeDeleteSet(transaction.deleteSet) @@ -158,11 +203,28 @@ export const nextID = transaction => { */ export const addChangedTypeToTransaction = (transaction, type, parentSub) => { const item = type._item - if (item === null || (item.id.clock < (transaction.beforeState.get(item.id.client) || 0) && !item.deleted)) { + if (item === null || (!item.deleted && !isDeleted(transaction.insertSet, item.id))) { map.setIfUndefined(transaction.changed, type, set.create).add(parentSub) } } +/** + * @param {Transaction} tr + * @param {AbstractStruct} item + */ +export const addItemToInsertSet = (tr, item) => { + const ranges = map.setIfUndefined(tr.insertSet.clients, item.id.client, () => /** @type {Array} */ ([])) + if (ranges.length > 0) { + const r = ranges[ranges.length - 1] + if (r.clock + r.len === item.id.clock) { + // @ts-ignore + r.len += item.length + return + } + } + ranges.push(new DeleteItem(item.id.clock, item.length)) +} + /** * @param {Array} structs * @param {number} pos @@ -260,13 +322,15 @@ export const tryGc = (ds, store, gcFilter) => { const cleanupTransactions = (transactionCleanups, i) => { if (i < transactionCleanups.length) { const transaction = transactionCleanups[i] + transaction._done = true const doc = transaction.doc const store = doc.store const ds = transaction.deleteSet + const insertSet = transaction.insertSet const mergeStructs = transaction._mergeStructs try { sortAndMergeDeleteSet(ds) - transaction.afterState = getStateVector(transaction.doc.store) + sortAndMergeDeleteSet(insertSet) doc.emit('beforeObserverCalls', [transaction, doc]) /** * An array of event callbacks. @@ -323,15 +387,13 @@ const cleanupTransactions = (transactionCleanups, i) => { tryMergeDeleteSet(ds, store) // on all affected store.clients props, try to merge - transaction.afterState.forEach((clock, client) => { - const beforeClock = transaction.beforeState.get(client) || 0 - if (beforeClock !== clock) { - const structs = /** @type {Array} */ (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 -= 1 + tryToMergeWithLefts(structs, i) - } + transaction.insertSet.clients.forEach((ids, client) => { + const firstClock = ids[0].clock + const structs = /** @type {Array} */ (store.clients.get(client)) + // we iterate from right to left so we can safely remove entries + const firstChangePos = math.max(findIndexSS(structs, firstClock), 1) + for (let i = structs.length - 1; i >= firstChangePos;) { + i -= 1 + tryToMergeWithLefts(structs, i) } }) // try to merge mergeStructs @@ -350,7 +412,7 @@ const cleanupTransactions = (transactionCleanups, i) => { tryToMergeWithLefts(structs, replacedStructPos) } } - if (!transaction.local && transaction.afterState.get(doc.clientID) !== transaction.beforeState.get(doc.clientID)) { + if (!transaction.local && transaction.insertSet.clients.has(doc.clientID)) { logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.') doc.clientID = generateNewClientId() } diff --git a/src/utils/UndoManager.js b/src/utils/UndoManager.js index 98410e4d..9c2d7f00 100644 --- a/src/utils/UndoManager.js +++ b/src/utils/UndoManager.js @@ -226,14 +226,7 @@ export class UndoManager extends ObservableV2 { // neither undoing nor redoing: delete redoStack this.clear(false, true) } - const insertions = new DeleteSet() - transaction.afterState.forEach((endClock, client) => { - const startClock = transaction.beforeState.get(client) || 0 - const len = endClock - startClock - if (len > 0) { - addToDeleteSet(insertions, client, startClock, len) - } - }) + const insertions = transaction.insertSet const now = time.getUnixTime() let didAdd = false if (this.lastChange > 0 && now - this.lastChange < this.captureTimeout && stack.length > 0 && !undoing && !redoing) { diff --git a/src/utils/YEvent.js b/src/utils/YEvent.js index 61bc2840..cf47a77f 100644 --- a/src/utils/YEvent.js +++ b/src/utils/YEvent.js @@ -158,7 +158,7 @@ export class YEvent { * @return {boolean} */ adds (struct) { - return struct.id.clock >= (this.transaction.beforeState.get(struct.id.client) || 0) + return isDeleted(this.transaction.insertSet, struct.id) } /** diff --git a/src/utils/encoding.js b/src/utils/encoding.js index b195ccc3..9ba8aa27 100644 --- a/src/utils/encoding.js +++ b/src/utils/encoding.js @@ -36,7 +36,8 @@ import { Skip, diffUpdateV2, convertUpdateFormatV2ToV1, - DSDecoderV2, Doc, Transaction, GC, Item, StructStore // eslint-disable-line + DeleteSet, DSDecoderV2, Doc, Transaction, GC, Item, StructStore, // eslint-disable-line + iterateDeletedStructs } from '../internals.js' import * as encoding from 'lib0/encoding' @@ -101,6 +102,26 @@ export const writeClientsStructs = (encoder, store, _sm) => { }) } +/** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {StructStore} store + * @param {DeleteSet} idset + * + * @todo at the moment this writes the full deleteset range + * + * @private + * @function + */ +export const writeStructsFromIdSet = (encoder, store, idset) => { + // write # states that were updated + encoding.writeVarUint(encoder.restEncoder, idset.clients.size) + // Write items with higher client ids first + // This heavily improves the conflict algorithm. + array.from(idset.clients.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, ids]) => { + writeStructs(encoder, /** @type {Array} */ (store.clients.get(client)), client, ids[0].clock) + }) +} + /** * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder The decoder object to read data from. * @param {Doc} doc @@ -365,7 +386,7 @@ const integrateStructs = (transaction, store, clientsStructRefs) => { * @private * @function */ -export const writeStructsFromTransaction = (encoder, transaction) => writeClientsStructs(encoder, transaction.doc.store, transaction.beforeState) +export const writeStructsFromTransaction = (encoder, transaction) => writeStructsFromIdSet(encoder, transaction.doc.store, transaction.insertSet) /** * Read and apply a document update.