UNPKG

@y/y

Version:

Shared Editing Library

428 lines (416 loc) • 14.9 kB
import * as error from 'lib0/error' import * as map from 'lib0/map' import * as math from 'lib0/math' import * as logging from 'lib0/logging' import { callAll } from 'lib0/function' import { ContentFormat } from '../structs/Item.js' import { getStateVector } from './StructStore.js' import { callEventHandlerListeners } from './EventHandler.js' import { createIdSet, iterateStructsByIdSet } from './ids.js' import { GC } from '../structs/GC.js' import { YEvent } from './YEvent.js' import { writeUpdateMessageFromTransaction } from './encoding-helpers.js' import { UpdateEncoderV1, UpdateEncoderV2 } from './UpdateEncoder.js' import { findIndexSS, updateCurrentAttributes, cleanupFormattingGap, tryGcDeleteSet, tryMerge, tryToMergeWithLefts, cleanupContextlessFormattingGap } from './transaction-helpers.js' import * as random from 'lib0/random' export const generateNewClientId = random.uint53 /** * 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 ydoc = new Y.Doc() * const map = ydoc.getMap('map') * // 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: * ydoc.transact(() => { * map.set('a', 1) * map.set('b', 1) * }) // => "change triggered" * * @public */ export class Transaction { /** * @param {Doc} doc * @param {any} origin * @param {boolean} local */ constructor (doc, origin, local) { /** * The Yjs instance. * @type {Doc} */ this.doc = doc /** * Describes the set of deleted items by ids */ this.deleteSet = createIdSet() /** * Describes the set of items that are cleaned up / deleted by ids. It is a subset of * this.deleteSet */ this.cleanUps = createIdSet() /** * Describes the set of inserted items by ids */ this.insertSet = createIdSet() /** * Holds the state before the transaction started. * @type {Map<Number,Number>?} */ this._beforeState = null /** * Holds the state after the transaction. * @type {Map<Number,Number>?} */ this._afterState = null /** * 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<YType,Set<String|null>>} */ this.changed = new Map() /** * Stores the events for the types that observe also child elements. * It is mainly used by `observeDeep`. * @type {Map<YType,Array<YEvent<any>>>} */ this.changedParentTypes = new Map() /** * @type {Array<AbstractStruct>} */ this._mergeStructs = [] /** * @type {any} */ this.origin = origin /** * Stores meta information on the transaction * @type {Map<any,any>} */ this.meta = new Map() /** * Whether this change originates from this doc. * @type {boolean} */ this.local = local /** * @type {Set<Doc>} */ this.subdocsAdded = new Set() /** * @type {Set<Doc>} */ this.subdocsRemoved = new Set() /** * @type {Set<Doc>} */ this.subdocsLoaded = new Set() /** * @type {boolean} */ this._needFormattingCleanup = false this._done = false } /** * Holds the state before the transaction started. * * @deprecated * @type {Map<Number,Number>} */ get beforeState () { if (this._beforeState == null) { const sv = getStateVector(this.doc.store) this.insertSet.clients.forEach((ranges, client) => { sv.set(client, ranges.getIds()[0].clock) }) this._beforeState = sv } return this._beforeState } /** * Holds the state after the transaction. * * @deprecated * @type {Map<Number,Number>} */ get afterState () { if (!this._done) error.unexpectedCase() if (this._afterState == null) { const sv = getStateVector(this.doc.store) this.insertSet.clients.forEach((_ranges, client) => { const ranges = _ranges.getIds() const d = ranges[ranges.length - 1] sv.set(client, d.clock + d.len) }) this._afterState = sv } return this._afterState } } /** * This function is experimental and subject to change / be removed. * * Ideally, we don't need this function at all. Formatting attributes should be cleaned up * automatically after each change. This function iterates twice over the complete YText type * and removes unnecessary formatting attributes. This is also helpful for testing. * * This function won't be exported anymore as soon as there is confidence that the YText type works as intended. * * @param {YType} type * @return {number} How many formatting attributes have been cleaned up. */ export const cleanupYTextFormatting = type => { if (!type.doc?.cleanupFormatting) return 0 let res = 0 transact(/** @type {Doc} */ (type.doc), transaction => { let start = /** @type {Item} */ (type._start) let end = type._start let startAttributes = map.create() const currentAttributes = map.copy(startAttributes) while (end) { if (end.deleted === false) { switch (end.content.constructor) { case ContentFormat: updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (end.content)) break default: res += cleanupFormattingGap(transaction, start, end, startAttributes, currentAttributes) startAttributes = map.copy(currentAttributes) start = end break } } end = end.right } }) return res } /** * @param {Array<Transaction>} transactionCleanups * @param {number} i */ 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 mergeStructs = transaction._mergeStructs // insertIntoIdSet(store.ds, ds) try { doc.emit('beforeObserverCalls', [transaction, doc]) /** * An array of event callbacks. * * Each callback is called even if the other ones throw errors. * * @type {Array<function():void>} */ const fs = [] // observe events on changed types transaction.changed.forEach((subs, itemtype) => fs.push(() => { if (itemtype._item === null || !itemtype._item.deleted) { itemtype._callObserver(transaction, subs) } }) ) fs.push(() => { // deep observe events transaction.changedParentTypes.forEach((events, type) => { // We need to think about the possibility that the user transforms the // Y.Doc in the event. if (type._dEH.l.length > 0 && (type._item === null || !type._item.deleted)) { /** * @type {YEvent<any>} */ const deepEventHandler = events.find(event => event.target === type) || new YEvent(type, transaction, new Set(null)) callEventHandlerListeners(type._dEH, deepEventHandler, transaction) } }) }) fs.push(() => doc.emit('afterTransaction', [transaction, doc])) callAll(fs, []) if (transaction._needFormattingCleanup && doc.cleanupFormatting) { cleanupYTextAfterTransaction(transaction) } } finally { // Replace deleted items with ItemDeleted / GC. // This is where content is actually remove from the Yjs Doc. if (doc.gc) { tryGcDeleteSet(transaction, ds, doc.gcFilter) } tryMerge(ds, store) // on all affected store.clients props, try to merge transaction.insertSet.clients.forEach((ids, client) => { const firstClock = ids.getIds()[0].clock const structs = /** @type {Array<GC|Item>} */ (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 // @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left // but at the moment DS does not handle duplicates for (let i = mergeStructs.length - 1; i >= 0; i--) { const { client, clock } = mergeStructs[i].id const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client)) const replacedStructPos = findIndexSS(structs, clock) if (replacedStructPos + 1 < structs.length) { if (tryToMergeWithLefts(structs, replacedStructPos + 1) > 1) { continue // no need to perform next check, both are already merged } } if (replacedStructPos > 0) { tryToMergeWithLefts(structs, replacedStructPos) } } 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() } // @todo Merge all the transactions into one and provide send the data as a single update message doc.emit('afterTransactionCleanup', [transaction, doc]) if (doc._observers.has('update')) { const encoder = new UpdateEncoderV1() const hasContent = writeUpdateMessageFromTransaction(encoder, transaction) if (hasContent) { doc.emit('update', [encoder.toUint8Array(), transaction.origin, doc, transaction]) } } if (doc._observers.has('updateV2')) { const encoder = new UpdateEncoderV2() const hasContent = writeUpdateMessageFromTransaction(encoder, transaction) if (hasContent) { doc.emit('updateV2', [encoder.toUint8Array(), transaction.origin, doc, transaction]) } } const { subdocsAdded, subdocsLoaded, subdocsRemoved } = transaction if (subdocsAdded.size > 0 || subdocsRemoved.size > 0 || subdocsLoaded.size > 0) { subdocsAdded.forEach(subdoc => { subdoc.clientID = doc.clientID if (subdoc.collectionid == null) { subdoc.collectionid = doc.collectionid } doc.subdocs.add(subdoc) }) subdocsRemoved.forEach(subdoc => doc.subdocs.delete(subdoc)) doc.emit('subdocs', [{ loaded: subdocsLoaded, added: subdocsAdded, removed: subdocsRemoved }, doc, transaction]) subdocsRemoved.forEach(subdoc => subdoc.destroy()) } if (transactionCleanups.length <= i + 1) { doc._transactionCleanups = [] doc.emit('afterAllTransactions', [doc, transactionCleanups]) } else { cleanupTransactions(transactionCleanups, i + 1) } } } } /** * This will be called by the transaction once the event handlers are called to potentially cleanup * formatting attributes. * * @param {Transaction} transaction */ export const cleanupYTextAfterTransaction = transaction => { /** * @type {Set<YType>} */ const needFullCleanup = new Set() // check if another formatting item was inserted const doc = transaction.doc iterateStructsByIdSet(transaction, transaction.insertSet, (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) => { iterateStructsByIdSet(transaction, transaction.deleteSet, item => { if (item instanceof GC || !(/** @type {YType} */ (item.parent)._hasFormatting) || needFullCleanup.has(/** @type {YType} */ (item.parent))) { return } const parent = /** @type {YType} */ (item.parent) if (item.content.constructor === ContentFormat) { needFullCleanup.add(parent) } else { // If no formatting attribute was inserted or deleted, we can make due with contextless // formatting cleanups. // Contextless: it is not necessary to compute currentAttributes for the affected position. cleanupContextlessFormattingGap(t, item) } }) // If a formatting item was inserted, we simply clean the whole type. // We need to compute currentAttributes for the current position anyway. for (const yText of needFullCleanup) { cleanupYTextFormatting(yText) } }) } /** * Implements the functionality of `y.transact(()=>{..})` * * @template T * @param {Doc} doc * @param {function(Transaction):T} f * @param {any} [origin=true] * @param {boolean} [local=true] * @return {T} * * @function */ export const transact = (doc, f, origin = null, local = true) => { const transactionCleanups = doc._transactionCleanups let initialCall = false /** * @type {any} */ let result = null if (doc._transaction === null) { initialCall = true doc._transaction = new Transaction(doc, origin, local) transactionCleanups.push(doc._transaction) if (transactionCleanups.length === 1) { doc.emit('beforeAllTransactions', [doc]) } doc.emit('beforeTransaction', [doc._transaction, doc]) } try { result = f(doc._transaction) } finally { if (initialCall) { const finishCleanup = doc._transaction === transactionCleanups[0] doc._transaction = null if (finishCleanup) { // The first transaction ended, now process observer calls. // Observer call may create new transactions for which we need to call the observers and do cleanup. // We don't want to nest these calls, so we execute these calls one after // another. // Also we need to ensure that all cleanups are called, even if the // observes throw errors. // This file is full of hacky try {} finally {} blocks to ensure that an // event can throw errors and also that the cleanup is called. cleanupTransactions(transactionCleanups, 0) } } } return result }