diff --git a/src/index.js b/src/index.js index b3288e06..90598ba8 100644 --- a/src/index.js +++ b/src/index.js @@ -37,6 +37,8 @@ export { compareIDs, getState, Snapshot, + createSnapshot, + createSnapshotFromDoc, findRootTypeKey, typeListToArraySnapshot, typeMapGetSnapshot, diff --git a/src/types/YText.js b/src/types/YText.js index 63e48175..01f82ace 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -16,6 +16,7 @@ import { ContentEmbed, ContentFormat, ContentString, + splitSnapshotAffectedStructs, Doc, Item, Snapshot, StructStore, Transaction // eslint-disable-line } from '../internals.js' @@ -723,6 +724,7 @@ export class YText extends AbstractType { */ const ops = [] const currentAttributes = new Map() + const doc = /** @type {Doc} */ (this.doc) let str = '' let n = this._start function packStr () { @@ -748,42 +750,54 @@ export class YText extends AbstractType { str = '' } } - while (n !== null) { - if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) { - switch (n.content.constructor) { - case ContentString: - const cur = currentAttributes.get('ychange') - if (snapshot !== undefined && !isVisible(n, snapshot)) { - if (cur === undefined || cur.user !== n.id.client || cur.state !== 'removed') { - packStr() - currentAttributes.set('ychange', { user: n.id.client, state: 'removed' }) - } - } else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) { - if (cur === undefined || cur.user !== n.id.client || cur.state !== 'added') { - packStr() - currentAttributes.set('ychange', { user: n.id.client, state: 'added' }) - } - } else if (cur !== undefined) { - packStr() - currentAttributes.delete('ychange') - } - str += /** @type {ContentString} */ (n.content).str - break - case ContentEmbed: - packStr() - ops.push({ - insert: /** @type {ContentEmbed} */ (n.content).embed - }) - break - case ContentFormat: - packStr() - updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content)) - break - } + // snapshots are merged again after the transaction, so we need to keep the + // transalive until we are done + transact(doc, transaction => { + if (snapshot) { + splitSnapshotAffectedStructs(transaction, snapshot) } - n = n.right - } - packStr() + if (prevSnapshot) { + splitSnapshotAffectedStructs(transaction, prevSnapshot) + } + while (n !== null) { + if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) { + switch (n.content.constructor) { + case ContentString: + const cur = currentAttributes.get('ychange') + if (snapshot !== undefined && !isVisible(n, snapshot)) { + if (cur === undefined || cur.user !== n.id.client || cur.state !== 'removed') { + packStr() + currentAttributes.set('ychange', { user: n.id.client, state: 'removed' }) + } + } else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) { + if (cur === undefined || cur.user !== n.id.client || cur.state !== 'added') { + packStr() + currentAttributes.set('ychange', { user: n.id.client, state: 'added' }) + } + } else if (cur !== undefined) { + packStr() + currentAttributes.delete('ychange') + } + str += /** @type {ContentString} */ (n.content).str + break + case ContentEmbed: + packStr() + ops.push({ + insert: /** @type {ContentEmbed} */ (n.content).embed + }) + break + case ContentFormat: + if (isVisible(n, snapshot)) { + packStr() + updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content)) + } + break + } + } + n = n.right + } + packStr() + }, splitSnapshotAffectedStructs) return ops } diff --git a/src/utils/Snapshot.js b/src/utils/Snapshot.js index 2d33941f..3fb807d2 100644 --- a/src/utils/Snapshot.js +++ b/src/utils/Snapshot.js @@ -1,9 +1,17 @@ import { isDeleted, - DeleteSet, Item // eslint-disable-line + createDeleteSetFromStructStore, + getStateVector, + getItemCleanStart, + createID, + iterateDeletedStructs, + Transaction, Doc, DeleteSet, Item // eslint-disable-line } from '../internals.js' +import * as map from 'lib0/map.js' +import * as set from 'lib0/set.js' + export class Snapshot { /** * @param {DeleteSet} ds @@ -27,9 +35,16 @@ export class Snapshot { /** * @param {DeleteSet} ds * @param {Map} sm + * @return {Snapshot} */ export const createSnapshot = (ds, sm) => new Snapshot(ds, sm) +/** + * @param {Doc} doc + * @return {Snapshot} + */ +export const createSnapshotFromDoc = doc => createSnapshot(createDeleteSetFromStructStore(doc.store), getStateVector(doc.store)) + /** * @param {Item} item * @param {Snapshot|undefined} snapshot @@ -40,3 +55,20 @@ export const createSnapshot = (ds, sm) => new Snapshot(ds, sm) export const isVisible = (item, snapshot) => snapshot === undefined ? !item.deleted : ( snapshot.sm.has(item.id.client) && (snapshot.sm.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id) ) + +/** + * @param {Transaction} transaction + * @param {Snapshot} snapshot + */ +export const splitSnapshotAffectedStructs = (transaction, snapshot) => { + const meta = map.setIfUndefined(transaction.meta, splitSnapshotAffectedStructs, set.create) + const store = transaction.doc.store + // check if we already split for this snapshot + if (!meta.has(snapshot)) { + snapshot.sm.forEach((clock, client) => { + getItemCleanStart(transaction, store, createID(client, clock)) + }) + iterateDeletedStructs(transaction, snapshot.ds, store, item => {}) + meta.add(snapshot) + } +} diff --git a/src/utils/Transaction.js b/src/utils/Transaction.js index 2db80eeb..503bccbe 100644 --- a/src/utils/Transaction.js +++ b/src/utils/Transaction.js @@ -90,6 +90,11 @@ export class Transaction { * @type {any} */ this.origin = origin + /** + * Stores meta information on the transaction + * @type {Map} + */ + this.meta = new Map() } } diff --git a/tests/y-text.tests.js b/tests/y-text.tests.js index 2e860a79..1c9b0a85 100644 --- a/tests/y-text.tests.js +++ b/tests/y-text.tests.js @@ -1,6 +1,6 @@ -import { init, compare } from './testHelper.js' - +import * as Y from './testHelper.js' import * as t from 'lib0/testing.js' +const { init, compare } = Y /** * @param {t.TestCase} tc @@ -87,3 +87,65 @@ export const testGetDeltaWithEmbeds = tc => { insert: {linebreak: 's'} }]) } + +/** + * @param {t.TestCase} tc + */ +export const testSnapshot = tc => { + const { text0 } = init(tc, { users: 1 }) + const doc0 = /** @type {Y.Doc} */ (text0.doc) + doc0.gc = false + text0.applyDelta([{ + insert: 'abcd' + }]) + const snapshot1 = Y.createSnapshotFromDoc(doc0) + text0.applyDelta([{ + retain: 1 + }, { + insert: 'x' + }, { + delete: 1 + }]) + const snapshot2 = Y.createSnapshotFromDoc(doc0) + text0.applyDelta([{ + retain: 2 + }, { + delete: 3 + }, { + insert: 'x' + }, { + delete: 1 + }]) + const state1 = text0.toDelta(snapshot1) + t.compare(state1, [{ insert: 'abcd' }]) + const state2 = text0.toDelta(snapshot2) + t.compare(state2, [{ insert: 'axcd' }]) + const state2Diff = text0.toDelta(snapshot2, snapshot1) + // @ts-ignore Remove userid info + state2Diff.forEach(v => { + if (v.attributes && v.attributes.ychange) { + delete v.attributes.ychange.user + } + }) + t.compare(state2Diff, [{insert: 'a'}, {insert: 'x', attributes: {ychange: { state: 'added' }}}, {insert: 'b', attributes: {ychange: { state: 'removed' }}}, { insert: 'cd' }]) +} + +/** + * @param {t.TestCase} tc + */ +export const testSnapshotDeleteAfter = tc => { + const { text0 } = init(tc, { users: 1 }) + const doc0 = /** @type {Y.Doc} */ (text0.doc) + doc0.gc = false + text0.applyDelta([{ + insert: 'abcd' + }]) + const snapshot1 = Y.createSnapshotFromDoc(doc0) + text0.applyDelta([{ + retain: 4 + }, { + insert: 'e' + }]) + const state1 = text0.toDelta(snapshot1) + t.compare(state1, [{ insert: 'abcd' }]) +}