diff --git a/src/index.js b/src/index.js index 0004db50..81024bda 100644 --- a/src/index.js +++ b/src/index.js @@ -18,7 +18,7 @@ export { Transaction, transact, cleanupYTextFormatting } from './utils/Transacti export { UndoManager, undoContentIds } from './utils/UndoManager.js' export { logUpdate, logUpdateV2, decodeUpdate, decodeUpdateV2, encodeStateVectorFromUpdate, encodeStateVectorFromUpdateV2, convertUpdateFormatV1ToV2, convertUpdateFormatV2ToV1, obfuscateUpdate, obfuscateUpdateV2, createContentIdsFromUpdate, createContentIdsFromUpdateV2, intersectUpdateWithContentIds, intersectUpdateWithContentIdsV2 } from './utils/updates.js' export { YEvent, getPathTo } from './utils/YEvent.js' -export { TwosetRenderer, baseRenderer, AbstractRenderer, createDiffRenderer, DiffRenderer, createSnapshotRenderer, SnapshotRenderer, Attributions, $renderer } from './utils/Renderer.js' +export { AttributionsRenderer, createAttributionsRenderer, AbstractRenderer, createDiffRenderer, DiffRenderer, createSnapshotRenderer, SnapshotRenderer, $renderer } from './utils/Renderer.js' export { diffDocsToDelta } from './utils/delta-helpers.js' export { YType as Type, getTypeChildren, typeMapGetSnapshot, typeMapGetAllSnapshot, $ytype, $ytypeAny } from './ytype.js' export { AbstractStruct } from './structs/AbstractStruct.js' diff --git a/src/utils/Renderer.js b/src/utils/Renderer.js index 1760bc82..c089ee13 100644 --- a/src/utils/Renderer.js +++ b/src/utils/Renderer.js @@ -13,28 +13,87 @@ import { UndoManager, StackItem } from './UndoManager.js' import { $renderer, AttributedContent } from './renderer-helpers.js' -export { baseRenderer, AbstractRenderer, rendererContentLength, $renderer } from './renderer-helpers.js' +export { AbstractRenderer, rendererContentLength, $renderer } from './renderer-helpers.js' /** + * Emit a single content piece into `contents`, applying the rendering rules of + * {@link AttributionsRenderer}. `inRC` is whether the piece is in `renderedContent` (renders normally, + * even when the item is deleted — a "restore"); `attrs` is its attribution (or `null`). + * + * @param {Array>} contents - where to write the result + * @param {AbstractContent} c + * @param {number} clock + * @param {boolean} deleted - the item's doc deleted flag + * @param {boolean} inRC - whether this piece is in `renderedContent` + * @param {Array>|null} attrs + * @param {0|1|2|3} shouldRender - see {@link AbstractRenderer#readContent} + */ +const pushAttributedPiece = (contents, c, clock, deleted, inRC, attrs, shouldRender) => { + if (inRC || attrs != null) { + // Visible: rendered normally (in `renderedContent`) and/or attributed. A restored deletion + // (`inRC`) renders as alive, so its effective `deleted` flag is cleared. + contents.push(new AttributedContent(c, clock, deleted && !inRC, attrs, shouldRender)) + } else if (deleted && shouldRender !== 0 && shouldRender !== 3) { + // Genuinely deleted, unattributed, not restored: preserve the legacy retain/delete-op + // behavior (mode 3 renders unattributed deleted content as nothing — see DiffRenderer). + contents.push(new AttributedContent(c, clock, true, null, shouldRender)) + } + // else: hidden alive content (or invisible deleted content under mode 0/3) — emit nothing. +} + +/** + * Renders content with attributions, given a single `attributions` {@link ContentMap} of how to + * attribute content and an optional `renderedContent` {@link IdSet} of what renders. + * + * - `attributions` (inserts ∪ deletes) is merged into a single `renderAs` map; content it covers + * always renders, carrying its attribution. + * - `renderedContent` defines the content that renders *normally* — even if the item is marked + * deleted in the doc (a "restore"). It defaults to the doc's alive content (`inserts − deletes`), + * applied implicitly (a piece is in the default set ⟺ its item is not `deleted`), so the common + * "pure attribution overlay" case does no extra work. When a custom set is supplied it is + * authoritative: alive content omitted from it (and unattributed) is hidden. + * + * Restoring deleted content requires `gc: false` on the doc (the renderer does not rehydrate + * garbage-collected content, unlike {@link DiffRenderer}). + * * @implements AbstractRenderer * * @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>} */ -export class TwosetRenderer extends ObservableV2 { +export class AttributionsRenderer extends ObservableV2 { /** - * @param {IdMap} inserts - * @param {IdMap} deletes + * @param {ContentMap} attributions - how to attribute content (`{ inserts, deletes }` IdMaps) + * @param {Object} [options] + * @param {IdSet?} [options.renderedContent] - content that renders normally; defaults to the + * doc's alive content (`inserts − deletes`), applied implicitly. */ - constructor (inserts, deletes) { + constructor (attributions, { renderedContent = null } = {}) { super() - this.inserts = inserts - this.deletes = deletes /** - * Raw coverage of the two maps — `readContent` remains authoritative for what actually - * renders. See {@link AbstractRenderer#attributed}. + * The two attribution maps merged into one — `readContent` consults this for how to attribute + * a piece. + * @type {IdMap} + */ + this.renderAs = mergeIdMaps([attributions.inserts, attributions.deletes]) + /** + * Coverage of `renderAs` (ids that carry attributions). The small set `hasItem` checks. May + * over-approximate what actually renders — `readContent` remains authoritative. See + * {@link AbstractRenderer#attributed}. * @type {IdSet} */ - this.attributed = mergeIdSets([createIdSetFromIdMap(inserts), createIdSetFromIdMap(deletes)]) + this.attributed = createIdSetFromIdMap(this.renderAs) + /** + * Custom "content that renders normally" set, or `null` to use the implicit default + * (alive ⟺ `!item.deleted`). + * @type {IdSet?} + */ + this.renderedContent = renderedContent + /** + * Union of `renderedContent` and `attributed` — the single cheap "does this produce visible + * output?" set. Only materialized when a custom `renderedContent` is supplied. + * @type {IdSet?} + */ + this.rendered = renderedContent === null ? null : mergeIdSets([renderedContent, this.attributed]) } get $type () { return $renderer } @@ -44,7 +103,14 @@ export class TwosetRenderer extends ObservableV2 { * @return {boolean} */ hasItem (item) { - return this.attributed.intersects(item.id.client, item.id.clock, item.length) + const { client, clock } = item.id + if (this.attributed.intersects(client, clock, item.length)) return true + if (this.renderedContent === null) return false + // Custom renderedContent differs from the generic fast path for: deleted content that must be + // restored, and alive content that must be hidden (not fully covered). + return item.deleted + ? this.renderedContent.intersects(client, clock, item.length) + : !this.renderedContent.covers(client, clock, item.length) } /** @@ -56,18 +122,49 @@ export class TwosetRenderer extends ObservableV2 { * @param {0|1|2|3} shouldRender - whether this should render or just result in a `retain` operation (see AbstractRenderer#readContent) */ readContent (contents, client, clock, deleted, content, shouldRender) { - const slice = (deleted ? this.deletes : this.inserts).slice(client, clock, content.getLength()) - content = slice.length === 1 ? content : content.copy() - slice.forEach(s => { - const c = content - if (s.len < c.getLength()) { - content = c.splice(s.len) + const total = content.getLength() + /** + * Segments of the item partitioned by `renderedContent`, or `null` when `inRC` is uniform over + * the whole item (the common case: default set, or a custom set that fully contains/excludes + * the item). `null` lets us slice `renderAs` once. + * @type {Array?} + */ + let outer = null + let inRC = !deleted // implicit default: alive ⟺ rendered normally + if (this.renderedContent !== null) { + outer = this.renderedContent.slice(client, clock, total) + if (outer.length === 1) { + inRC = outer[0].exists + outer = null } - // see DiffRenderer#readContent: mode 3 renders unattributed deleted content as nothing - if (!deleted || s.attrs != null || (shouldRender !== 0 && shouldRender !== 3)) { - contents.push(new AttributedContent(c, s.clock, deleted, s.attrs, shouldRender)) + } + if (outer === null) { + // Uniform `inRC` — a single `renderAs` slice fixes the attribution boundaries. + const slice = this.renderAs.slice(client, clock, total) + let rest = slice.length === 1 ? content : content.copy() + for (let i = 0; i < slice.length; i++) { + const s = slice[i] + const c = rest + if (i < slice.length - 1) rest = c.splice(s.len) + pushAttributedPiece(contents, c, s.clock, deleted, inRC, s.attrs, shouldRender) } - }) + return + } + // General case: partition by `renderedContent` (outer, fixes `inRC`/effectiveDeleted), then by + // `renderAs` (inner, fixes attrs). Multiple outer segments ⇒ multi-id (string-like) content, so + // copying to avoid mutating the caller's content is safe. + let rest = content.copy() + for (let oi = 0; oi < outer.length; oi++) { + const rcSeg = outer[oi] + const slice = this.renderAs.slice(client, rcSeg.clock, rcSeg.len) + for (let ii = 0; ii < slice.length; ii++) { + const s = slice[ii] + const last = oi === outer.length - 1 && ii === slice.length - 1 + const c = rest + if (!last) rest = c.splice(s.len) + pushAttributedPiece(contents, c, s.clock, deleted, rcSeg.exists, s.attrs, shouldRender) + } + } } /** @@ -77,14 +174,25 @@ export class TwosetRenderer extends ObservableV2 { contentLength (item) { if (!item.content.isCountable()) { return 0 - } else if (!item.deleted) { - return item.length - } else { - return this.deletes.sliceId(item.id, item.length).reduce((len, s) => s.attrs != null ? len + s.len : len, 0) } + const { client, clock } = item.id + if (this.renderedContent === null) { + // Default: alive content renders in full; deleted content only where attributed. + return item.deleted ? this.renderAs.coveredLength(client, clock, item.length) : item.length + } + // Custom: rendered length is |item ∩ rendered| (renderedContent ∪ attributed). + return /** @type {IdSet} */ (this.rendered).coveredLength(client, clock, item.length) } } +/** + * @param {ContentMap} attributions - how to attribute content (`{ inserts, deletes }` IdMaps) + * @param {Object} [options] + * @param {IdSet?} [options.renderedContent] - content that renders normally; defaults to the doc's + * alive content (`inserts − deletes`). + */ +export const createAttributionsRenderer = (attributions, options) => new AttributionsRenderer(attributions, options) + /** * @param {StructStore} store * @param {number} client @@ -197,13 +305,6 @@ const collectSuggestedChanges = (tr, renderer, start, end, collectAll) => { return { inserts, deletes } } -export class Attributions { - constructor () { - this.inserts = createIdMap() - this.deletes = createIdMap() - } -} - /** * @param {IdMap|undefined} attrs * @param {IdSet} slice @@ -221,9 +322,9 @@ export class DiffRenderer extends ObservableV2 { * @param {Doc} prevDoc * @param {Doc} nextDoc * @param {Object} [options] - options for the renderer - * @param {Attributions?} [options.attrs] - the attributes to apply to the diff + * @param {ContentMap?} [options.attributions] - the attributions to apply to the diff */ - constructor (prevDoc, nextDoc, { attrs = null } = {}) { + constructor (prevDoc, nextDoc, { attributions = null } = {}) { super() const _nextDocInserts = createInsertSetFromStructStore(nextDoc.store, false) // unmaintained const _prevDocInserts = createInsertSetFromStructStore(prevDoc.store, false) // unmaintained @@ -231,8 +332,8 @@ export class DiffRenderer extends ObservableV2 { const prevDocDeletes = createDeleteSetFromStructStore(prevDoc.store) // maintained const insertDiff = diffIdSet(_nextDocInserts, _prevDocInserts) const deleteDiff = diffIdSet(nextDocDeletes, prevDocDeletes) - this.inserts = extractAttributions(attrs?.inserts, insertDiff) - this.deletes = extractAttributions(attrs?.deletes, deleteDiff) + this.inserts = extractAttributions(attributions?.inserts, insertDiff) + this.deletes = extractAttributions(attributions?.deletes, deleteDiff) /** * Raw coverage of `inserts` ∪ `deletes`, maintained alongside them. Over-approximates the * actually-rendered set (e.g. it keeps suggested-inserts that were deleted later) — @@ -247,10 +348,10 @@ export class DiffRenderer extends ObservableV2 { this._nextBOH = nextDoc.on('beforeObserverCalls', tr => { // update inserts const diffInserts = diffIdSet(tr.insertSet, _prevDocInserts) - insertIntoIdMap(this.inserts, extractAttributions(attrs?.inserts, diffInserts)) + insertIntoIdMap(this.inserts, extractAttributions(attributions?.inserts, diffInserts)) // update deletes const diffDeletes = diffIdSet(diffIdSet(tr.deleteSet, prevDocDeletes), this.inserts) - insertIntoIdMap(this.deletes, extractAttributions(attrs?.deletes, diffDeletes)) + insertIntoIdMap(this.deletes, extractAttributions(attributions?.deletes, diffDeletes)) insertIntoIdSet(this.attributed, diffInserts) insertIntoIdSet(this.attributed, diffDeletes) // @todo fire update ranges on `diffInserts` and `diffDeletes` @@ -458,7 +559,7 @@ export class DiffRenderer extends ObservableV2 { * @param {Doc} prevDoc * @param {Doc} nextDoc * @param {Object} [options] - options for the renderer - * @param {ContentMap?} [options.attrs] - the attributes to apply to the diff + * @param {ContentMap?} [options.attributions] - the attributions to apply to the diff */ export const createDiffRenderer = (prevDoc, nextDoc, options) => new DiffRenderer(prevDoc, nextDoc, options) diff --git a/src/utils/ids.js b/src/utils/ids.js index dd5ac7b3..5ac29ce6 100644 --- a/src/utils/ids.js +++ b/src/utils/ids.js @@ -229,6 +229,60 @@ export class IdSet { return false } + /** + * Whether the entire range `[clock, clock+len)` of `client` is contained in this set. + * Allocation-free (binary search). Because `getIds()` returns maximal, merged ranges, full + * coverage of a range implies it lies within a single range. + * + * @param {number} client + * @param {number} clock + * @param {number} len + * @return {boolean} + */ + covers (client, clock, len) { + const dr = this.clients.get(client) + if (dr) { + const ranges = dr.getIds() + const index = findIndexInIdRanges(ranges, clock) + if (index !== null) { + const r = ranges[index] + return clock + len <= r.clock + r.len + } + } + return false + } + + /** + * Total number of ids in `[clock, clock+len)` of `client` contained in this set. + * Allocation-free (binary search + linear scan of the overlapping ranges). Equivalent to + * `slice(client, clock, len).reduce((s, r) => r.exists ? s + r.len : s, 0)` without allocating. + * + * @param {number} client + * @param {number} clock + * @param {number} len + * @return {number} + */ + coveredLength (client, clock, len) { + const dr = this.clients.get(client) + let covered = 0 + if (dr) { + const ranges = dr.getIds() + const end = clock + len + let index = findRangeStartInIdRanges(ranges, clock) + if (index !== null) { + for (; index < ranges.length; index++) { + const r = ranges[index] + if (r.clock >= end) break + const from = r.clock < clock ? clock : r.clock + const rEnd = r.clock + r.len + const to = rEnd > end ? end : rEnd + covered += to - from + } + } + } + return covered + } + /** * Return slices of ids that exist in this idset. * @@ -1148,6 +1202,38 @@ export class IdMap { return false } + /** + * Total number of ids in `[clock, clock+len)` of `client` contained in this map (i.e. carrying + * an attribution, incl. an empty `[]`). Allocation-free (binary search + linear scan). + * Equivalent to `slice(client, clock, len).reduce((s, r) => r.attrs != null ? s + r.len : s, 0)` + * without allocating. + * + * @param {number} client + * @param {number} clock + * @param {number} len + * @return {number} + */ + coveredLength (client, clock, len) { + const dr = this.clients.get(client) + let covered = 0 + if (dr) { + const ranges = dr.getIds() + const end = clock + len + let index = findRangeStartInIdRanges(ranges, clock) + if (index !== null) { + for (; index < ranges.length; index++) { + const r = ranges[index] + if (r.clock >= end) break + const from = r.clock < clock ? clock : r.clock + const rEnd = r.clock + r.len + const to = rEnd > end ? end : rEnd + covered += to - from + } + } + } + return covered + } + /** * Return attributions for a slice of ids. * diff --git a/src/utils/meta.js b/src/utils/meta.js index dd9a3b5d..2d6b0d4f 100644 --- a/src/utils/meta.js +++ b/src/utils/meta.js @@ -11,6 +11,7 @@ import { diffIdMap, mergeIdMaps, createIdSet, + createIdMap, mergeIdSets, diffIdSet, createIdMapFromIdSet, @@ -88,11 +89,11 @@ export const mergeContentIds = contents => createContentIds( ) /** - * @param {IdMap} inserts - * @param {IdMap} deletes + * @param {IdMap} [inserts] + * @param {IdMap} [deletes] * @return {ContentMap} */ -export const createContentMap = (inserts, deletes) => ({ inserts, deletes }) +export const createContentMap = (inserts = createIdMap(), deletes = createIdMap()) => ({ inserts, deletes }) /** * @param {ContentIds} contentIds diff --git a/src/utils/renderer-helpers.js b/src/utils/renderer-helpers.js index ee4f8573..6ab93e2a 100644 --- a/src/utils/renderer-helpers.js +++ b/src/utils/renderer-helpers.js @@ -109,16 +109,6 @@ export class AbstractRenderer extends ObservableV2 { export const $renderer = AbstractRenderer.prototype.$type = s.$type('y:r', AbstractRenderer) -/** - * The absence of a renderer: content renders as-is via the generic fast path, without any - * attribution lookups. - * - * @deprecated pass `null` (or omit the renderer option) instead — kept as an alias for downstream - * code that referenced the former base-renderer object. - * @type {null} - */ -export const baseRenderer = null - /** * Rendered length of `item` under `renderer`: the generic rule — alive countable content renders * at full length, everything else at length `0` — unless the renderer claims the item. diff --git a/tests/IdSet.tests.js b/tests/IdSet.tests.js index 6146baf4..802b1090 100644 --- a/tests/IdSet.tests.js +++ b/tests/IdSet.tests.js @@ -242,6 +242,35 @@ export const testRepeatRandomIntersects = tc => { compareIdSets(diffed1, altDiffed1) } +/** + * The allocation-free `covers` / `coveredLength` must agree with per-id `has` and with the + * allocating `slice(...)` reduction. + * + * @param {t.TestCase} tc + */ +export const testRepeatRandomCoversAndCoveredLength = tc => { + const clients = 4 + const clockRange = 100 + const ids = createRandomIdSet(tc.prng, clients, clockRange) + for (let iter = 0; iter < 300; iter++) { + const client = prng.int31(tc.prng, 0, clients - 1) + const clock = prng.int31(tc.prng, 0, clockRange) + const len = prng.int31(tc.prng, 1, clockRange - clock + 5) // may exceed the range to cover edges + // reference by scanning every id in the range + let refCovered = 0 + let refCovers = true + for (let c = clock; c < clock + len; c++) { + if (ids.has(client, c)) refCovered++ + else refCovers = false + } + t.assert(ids.covers(client, clock, len) === refCovers) + t.assert(ids.coveredLength(client, clock, len) === refCovered) + // cross-check against the allocating slice + const sliceCovered = ids.slice(client, clock, len).reduce((s, r) => r.exists ? s + r.len : s, 0) + t.assert(ids.coveredLength(client, clock, len) === sliceCovered) + } +} + /** * The struct that covers `clock` for the document's local client. * diff --git a/tests/attribution.tests.js b/tests/attribution.tests.js index 0645cb53..fc2964af 100644 --- a/tests/attribution.tests.js +++ b/tests/attribution.tests.js @@ -142,7 +142,7 @@ export const testChildListContent = () => { export const testAttributionSession1 = tc => { const { testConnector, users, text0, text1 } = init(tc, { users: 3 }) users[0].gc = false - const globalAttributions = new Y.Attributions() + const globalAttributions = Y.createContentMap() const v1 = Y.cloneDoc(users[0]) users.forEach(user => user.on('update', (update, _, ydoc, tr) => { if (!tr.local) return @@ -154,20 +154,20 @@ export const testAttributionSession1 = tc => { text0.insert(0, 'a') text1.insert(0, 'b') testConnector.flushAllMessages() - const d1 = text0.toDelta({ renderer: Y.createDiffRenderer(v1, users[0], { attrs: globalAttributions }) }) + const d1 = text0.toDelta({ renderer: Y.createDiffRenderer(v1, users[0], { attributions: globalAttributions }) }) t.compare(d1, delta.create().insert('a', null, { insert: ['0'] }).insert('b', null, { insert: ['1'] }).done()) const v2 = Y.cloneDoc(users[0]) text0.delete(1, 1) text1.insert(2, 'c') testConnector.flushAllMessages() - const d2 = text0.toDelta({ renderer: Y.createDiffRenderer(v2, users[0], { attrs: globalAttributions }) }) + const d2 = text0.toDelta({ renderer: Y.createDiffRenderer(v2, users[0], { attributions: globalAttributions }) }) t.compare(d2, delta.create().insert('a').insert('b', null, { delete: ['0'] }).insert('c', null, { insert: ['1'] }).done()) - const onlyUser0ChangesAttributed = { - inserts: Y.filterIdMap(globalAttributions.inserts, attrs => attrs.some(attr => attr.name === 'insert' && attr.val === '0')), - deletes: Y.filterIdMap(globalAttributions.deletes, attrs => attrs.some(attr => attr.name === 'delete' && attr.val === '0')) - } - const rendererUser0 = new Y.TwosetRenderer(onlyUser0ChangesAttributed.inserts, onlyUser0ChangesAttributed.deletes) + const onlyUser0ChangesAttributed = Y.createContentMap( + Y.filterIdMap(globalAttributions.inserts, attrs => attrs.some(attr => attr.name === 'insert' && attr.val === '0')), + Y.filterIdMap(globalAttributions.deletes, attrs => attrs.some(attr => attr.name === 'delete' && attr.val === '0')) + ) + const rendererUser0 = new Y.AttributionsRenderer(onlyUser0ChangesAttributed) const d3 = text0.toDelta({ renderer: rendererUser0 }) t.compare(d3, delta.create().insert('a', null, { insert: ['0'] }).insert('b', null, { delete: ['0'] }).insert('c').done()) Y.undoContentIds(users[0], Y.createContentIdsFromContentMap(onlyUser0ChangesAttributed)) @@ -701,17 +701,17 @@ export const testRdtDeltaAttributionSanity = () => { const ydoc = new Y.Doc() const root = ydoc.get('root') const v1 = Y.cloneDoc(ydoc) - const attrs = new Y.Attributions() + const attributions = Y.createContentMap() ydoc.on('update', (update, _origin, doc, tr) => { if (!tr.local) return const uid = doc.clientID.toString() const cids = Y.createContentIdsFromUpdate(update) - Y.insertIntoIdMap(attrs.inserts, Y.createIdMapFromIdSet(cids.inserts, [Y.createContentAttribute('insert', uid)])) - Y.insertIntoIdMap(attrs.deletes, Y.createIdMapFromIdSet(cids.deletes, [Y.createContentAttribute('delete', uid)])) + Y.insertIntoIdMap(attributions.inserts, Y.createIdMapFromIdSet(cids.inserts, [Y.createContentAttribute('insert', uid)])) + Y.insertIntoIdMap(attributions.deletes, Y.createIdMapFromIdSet(cids.deletes, [Y.createContentAttribute('delete', uid)])) }) root.insert(0, 'hello') // a suggestion relative to v1 const uid = ydoc.clientID.toString() - root.useRenderer(Y.createDiffRenderer(v1, ydoc, { attrs })) + root.useRenderer(Y.createDiffRenderer(v1, ydoc, { attributions })) t.assert(root.delta.equals(delta.create().insert('hello', null, { insert: [uid] }).done())) t.assert(root.delta.equals(root.toDelta({ deep: true }))) } @@ -729,7 +729,7 @@ export const testRdtDeltaAttributionSanity = () => { export const testRdtFormatAcrossSuggestionDeletedDrift = () => { const doc = new Y.Doc({ gc: false }) const suggestionDoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) - const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attributions: Y.createContentMap() }) doc.get('prosemirror').applyDelta( delta.create().insert([delta.create('paragraph', {}, 'hello world')]).done() ) @@ -767,7 +767,7 @@ const createSuggestionPair = (baseClientID, sdocClientID, useRootRenderer = true doc.clientID = baseClientID const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) sdoc.clientID = sdocClientID - const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) doc.get('prosemirror').applyDelta( delta.create().insert([delta.create('paragraph', {}, 'hello world')]).done() ) @@ -885,7 +885,7 @@ export const testRdtDeltaFreshRangeAfterItemMerge = () => { doc.clientID = 1 const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) sdoc.clientID = 2 - const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) doc.get('t').applyDelta(delta.create().insert('XY').done()) const ytype = sdoc.get('t') ytype.useRenderer(renderer) @@ -1009,7 +1009,7 @@ export const testRdtApplyDeltaNodeFormatOnTombstoneInverse = () => { doc.clientID = baseClientID const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) sdoc.clientID = sdocClientID - const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) doc.get('prosemirror').applyDelta( delta.create().insert([delta.create('paragraph', {}, 'aa'), delta.create('paragraph', {}, 'bb')]).done() ) @@ -1040,7 +1040,7 @@ export const testRdtApplyDeltaMixedFixCoordinates = () => { doc.clientID = baseClientID const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) sdoc.clientID = sdocClientID - const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) doc.get('prosemirror').applyDelta( delta.create().insert([ delta.create('paragraph', {}, 'aa'), delta.create('paragraph', {}, 'hello world'), delta.create('paragraph', {}, 'cc') @@ -1079,7 +1079,7 @@ export const testRdtApplyDeltaModifyAttrOnDeletedMapValue = () => { doc.clientID = baseClientID const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) sdoc.clientID = sdocClientID - const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) const title = doc.get('m').setAttr('title', new Y.Type()) title.insert(0, 'hi') const m = sdoc.get('m') @@ -1152,7 +1152,7 @@ export const testRdtApplyDeltaNestedTombstoneFixBubbles = () => { doc.clientID = baseClientID const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) sdoc.clientID = sdocClientID - const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) doc.get('prosemirror').applyDelta( delta.create().insert([delta.create('paragraph', {}, [delta.create('nested', {}, 'ww')])]).done() ) @@ -1186,7 +1186,7 @@ export const testRdtApplyDeltaPureDeleteOverTombstoneNoFix = () => { doc.clientID = baseClientID const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) sdoc.clientID = sdocClientID - const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) doc.get('prosemirror').applyDelta( delta.create().insert([ delta.create('paragraph', {}, 'aa'), delta.create('paragraph', {}, 'hello world'), delta.create('paragraph', {}, 'cc') @@ -1215,7 +1215,7 @@ export const testRdtApplyDeltaDeleteMidStruckChunkKeepsCursorSync = () => { doc.clientID = baseClientID const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) sdoc.clientID = sdocClientID - const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) doc.get('t').applyDelta(delta.create().insert('abc').done()) doc.get('t').applyDelta(delta.create().retain(3).insert([delta.create('nA', {}, 'kk'), delta.create('nB', {}, 'qqq')]).done()) const ytype = sdoc.get('t') @@ -1250,7 +1250,7 @@ export const testRdtDeltaThroughDeletedAttrValue = () => { doc.clientID = baseClientID const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) sdoc.clientID = sdocClientID - const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) const title = doc.get('m').setAttr('title', new Y.Type()) title.insert(0, 'hi') const m = sdoc.get('m') @@ -1404,7 +1404,7 @@ export const testRdtDeletedSubtreeUndoScope = () => { export const testRdtAcceptingNodeInsertCacheDrift = () => { const doc = new Y.Doc({ gc: false }) const suggestionDoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) - const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attributions: Y.createContentMap() }) renderer.suggestionMode = false doc.get('prosemirror').applyDelta( delta.create().insert([delta.create('paragraph', {}, 'base para')]).done() @@ -1429,7 +1429,7 @@ export const testRdtAcceptingNodeInsertCacheDrift = () => { { const doc2 = new Y.Doc({ gc: false }) const suggestionDoc2 = new Y.Doc({ isSuggestionDoc: true, gc: false }) - const renderer2 = Y.createDiffRenderer(doc2, suggestionDoc2, { attrs: new Y.Attributions() }) + const renderer2 = Y.createDiffRenderer(doc2, suggestionDoc2, { attributions: Y.createContentMap() }) doc2.get('prosemirror').applyDelta(delta.create().insert([delta.create('paragraph', {}, 'base para')]).done()) const ytype2 = suggestionDoc2.get('prosemirror') ytype2.useRenderer(renderer2) @@ -1458,7 +1458,7 @@ export const testRdtAcceptingNodeInsertCacheDrift = () => { export const testRdtAcceptingNodeInsertRenderedAsSuggestion = () => { const doc = new Y.Doc({ gc: false }) const suggestionDoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) - const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attributions: Y.createContentMap() }) renderer.suggestionMode = false doc.get('prosemirror').applyDelta( delta.create().insert([delta.create('paragraph', {}, 'base para')]).done() @@ -1499,7 +1499,7 @@ export const testRdtAcceptingNodeInsertRenderedAsSuggestion = () => { export const testRdtPartialAcceptKeepsPendingChildSuggestions = () => { const doc = new Y.Doc({ gc: false }) const suggestionDoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) - const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attributions: Y.createContentMap() }) doc.get('prosemirror').applyDelta(delta.create().insert([delta.create('paragraph', {}, 'base para')]).done()) const ytype = suggestionDoc.get('prosemirror') ytype.useRenderer(renderer) @@ -1553,7 +1553,7 @@ export const testRdtBaseInsertIntoOverlappingFormatRangeCacheDrift = () => { base.clientID = 0 const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) sugg.clientID = 1 - const renderer = Y.createDiffRenderer(base, sugg, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) renderer.suggestionMode = true const bt = base.get('t') const st = sugg.get('t') @@ -1599,7 +1599,7 @@ export const testRdtSuggestedInsertUnderTwoKeySuggestedFormatsCacheDrift = () => base.clientID = 1 const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) sugg.clientID = 0 - const renderer = Y.createDiffRenderer(base, sugg, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) renderer.suggestionMode = true const bt = base.get('t') const st = sugg.get('t') @@ -1646,7 +1646,7 @@ export const testRdtFormattedBaseInsertUnderTwoKeySuggestedFormatsCacheDrift = ( base.clientID = 1 const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) sugg.clientID = 0 - const renderer = Y.createDiffRenderer(base, sugg, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) renderer.suggestionMode = true const bt = base.get('t') const st = sugg.get('t') @@ -1698,7 +1698,7 @@ export const testRdtBaseFormatEndingInsideSuggestedFormatRangeFormatValueCacheDr base.clientID = 0 const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) sugg.clientID = 1 - const renderer = Y.createDiffRenderer(base, sugg, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) renderer.suggestionMode = true const bt = base.get('t') const st = sugg.get('t') @@ -1748,7 +1748,7 @@ export const testRdtSuggestedRemovalOfOverriddenBaseFormatCacheDrift = () => { base.clientID = 0 const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) sugg.clientID = 1 - const renderer = Y.createDiffRenderer(base, sugg, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) renderer.suggestionMode = true const bt = base.get('t') const st = sugg.get('t') @@ -1797,7 +1797,7 @@ export const testRdtStagedSuggestedUnformatOfBaseFormatCacheDrift = () => { base.clientID = 0 const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) sugg.clientID = 1 - const renderer = Y.createDiffRenderer(base, sugg, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) renderer.suggestionMode = true const bt = base.get('t') const st = sugg.get('t') @@ -1850,7 +1850,7 @@ export const testRdtAcceptAllOfSameValueSuggestedFormatCacheDrift = () => { base.clientID = 0 const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) sugg.clientID = 1 - const renderer = Y.createDiffRenderer(base, sugg, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) renderer.suggestionMode = true const bt = base.get('t') const st = sugg.get('t') @@ -1901,7 +1901,7 @@ export const testRdtRejectAllOverlappingSameKeyFormatProvenanceResidual = () => base.clientID = 0 const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) sugg.clientID = 1 - const renderer = Y.createDiffRenderer(base, sugg, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) renderer.suggestionMode = true const bt = base.get('t') const st = sugg.get('t') @@ -1963,7 +1963,7 @@ export const testRdtSplitFormatMarkerPairPartialAcceptThenUnformatCacheDrift = ( base.clientID = 0 const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) sugg.clientID = 1 - const renderer = Y.createDiffRenderer(base, sugg, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) renderer.suggestionMode = true const bt = base.get('t') const st = sugg.get('t') @@ -2025,7 +2025,7 @@ export const testRdtPartialAcceptSkippingUnformatOpenMarkerCacheDrift = () => { base.clientID = 0 const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) sugg.clientID = 1 - const renderer = Y.createDiffRenderer(base, sugg, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) renderer.suggestionMode = true const bt = base.get('t') const st = sugg.get('t') @@ -2093,7 +2093,7 @@ export const testRdtBaseUnformatInsideBlockDeletedParagraphCacheDrift = () => { base.clientID = 0 const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) sugg.clientID = 1 - const renderer = Y.createDiffRenderer(base, sugg, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) renderer.suggestionMode = true const bt = base.get('t') const st = sugg.get('t') @@ -2149,7 +2149,7 @@ export const testRdtBaseFormatClearInsideSuggestionDeletedParagraphCacheDrift = base.clientID = 0 const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) sugg.clientID = 1 - const renderer = Y.createDiffRenderer(base, sugg, { attrs: new Y.Attributions() }) + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) renderer.suggestionMode = true const bt = base.get('t') const st = sugg.get('t') diff --git a/tests/y-array.tests.js b/tests/y-array.tests.js index 4b044387..8a5049ef 100644 --- a/tests/y-array.tests.js +++ b/tests/y-array.tests.js @@ -467,11 +467,11 @@ export const testAttributedContent = _tc => { */ const yarray = ydoc.get() yarray.insert(0, [1, 2]) - let renderer = /** @type {AbstractRenderer?} */ (Y.baseRenderer) + let renderer = /** @type {AbstractRenderer?} */ (null) ydoc.on('afterTransaction', tr => { - // renderer = new TwosetRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) - renderer = new Y.TwosetRenderer(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, [])) + // renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) + renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, []))) }) t.group('insert / delete', () => { ydoc.transact(() => { diff --git a/tests/y-map.tests.js b/tests/y-map.tests.js index d084d45e..7fb03299 100644 --- a/tests/y-map.tests.js +++ b/tests/y-map.tests.js @@ -1,6 +1,6 @@ import * as Y from '../src/index.js' import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line -import { baseRenderer, TwosetRenderer } from '../src/utils/Renderer.js' +import { AttributionsRenderer } from '../src/utils/Renderer.js' import { createIdMapFromIdSet } from '../src/utils/ids.js' import * as t from 'lib0/testing' import * as prng from 'lib0/prng' @@ -552,11 +552,11 @@ export const testYmapEventHasCorrectValueWhenSettingAPrimitiveFromOtherUser = tc export const testAttributedContent = _tc => { const ydoc = new Y.Doc({ gc: false }) const ymap = ydoc.get() - let renderer = /** @type {AbstractRenderer?} */ (baseRenderer) + let renderer = /** @type {AbstractRenderer?} */ (null) ydoc.on('afterTransaction', tr => { - // renderer = new TwosetRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) - renderer = new TwosetRenderer(createIdMapFromIdSet(tr.insertSet, []), createIdMapFromIdSet(tr.deleteSet, [])) + // renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) + renderer = new AttributionsRenderer(Y.createContentMap(createIdMapFromIdSet(tr.insertSet, []), createIdMapFromIdSet(tr.deleteSet, []))) }) t.group('initial value', () => { ymap.setAttr('test', 42) diff --git a/tests/y-text.tests.js b/tests/y-text.tests.js index 88b9e7f8..4f330a27 100644 --- a/tests/y-text.tests.js +++ b/tests/y-text.tests.js @@ -4,7 +4,7 @@ import * as prng from 'lib0/prng' import * as math from 'lib0/math' import * as delta from 'lib0/delta' import { createIdMapFromIdSet } from '../src/utils/ids.js' -import { baseRenderer, TwosetRenderer, createSnapshotRenderer } from '../src/utils/Renderer.js' +import { AttributionsRenderer, createSnapshotRenderer } from '../src/utils/Renderer.js' const { init, compare } = Y @@ -1900,11 +1900,11 @@ export const testAttributedContent = _tc => { const ydoc = new Y.Doc({ gc: false }) const ytext = ydoc.get() ytext.insert(0, 'Hello World!') - let renderer = /** @type {AbstractRenderer?} */ (baseRenderer) + let renderer = /** @type {AbstractRenderer?} */ (null) ydoc.on('afterTransaction', tr => { - // renderer = new TwosetRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) - renderer = new TwosetRenderer(createIdMapFromIdSet(tr.insertSet, []), createIdMapFromIdSet(tr.deleteSet, [])) + // renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) + renderer = new AttributionsRenderer(Y.createContentMap(createIdMapFromIdSet(tr.insertSet, []), createIdMapFromIdSet(tr.deleteSet, []))) }) t.group('insert / delete / format', () => { ytext.applyDelta(delta.create().retain(4, { italic: true }).retain(2).delete(5).insert('attributions').done()) @@ -1944,8 +1944,8 @@ export const testAttributedDiffing = _tc => { const attributedInsertions = createIdMapFromIdSet(insertionSetDiff, [Y.createContentAttribute('insert', 'Bob')]) const attributedDeletions = createIdMapFromIdSet(deleteSetDiff, [Y.createContentAttribute('delete', 'Bob')]) // now we can define an attribution manager that maps these changes to output. One of the - // implementations is the TwosetRenderer - const renderer = new TwosetRenderer(attributedInsertions, attributedDeletions) + // implementations is the AttributionsRenderer + const renderer = new AttributionsRenderer(Y.createContentMap(attributedInsertions, attributedDeletions)) // we render the attributed content with the renderer const attributedContent = ytext.toDelta({ renderer }) console.log(JSON.stringify(attributedContent.toJSON(), null, 2)) @@ -1954,6 +1954,52 @@ export const testAttributedDiffing = _tc => { console.log(Y.encodeIdMap(attributedInsertions).length) } +/** + * The `renderedContent` option decouples "what is visible" from the doc's deleted state and from + * attributions: content in `renderedContent` renders normally (even if deleted → "restore"), alive + * content omitted from it is hidden, and attributions always render (even outside it). + * + * @param {t.TestCase} _tc + */ +export const testAttributionsRendererRenderedContent = _tc => { + const ydoc = new Y.Doc({ gc: false }) // restore requires gc:false (no rehydration of gc'd content) + ydoc.clientID = 1 + const ytext = ydoc.get() + ytext.insert(0, 'Hello World!') + ytext.delete(5, 6) // delete ' World' (clock 5..10) → the visible doc is 'Hello!' + t.compare(ytext.toDelta().toJSON(), delta.create().insert('Hello!').done().toJSON()) // sanity: default hides the deletion + + const emptyAttributions = Y.createContentMap() + const aliveSet = Y.createInsertSetFromStructStore(ydoc.store, true) // {0..4, 11} + + t.group('restore: deleted content in renderedContent renders normally', () => { + const renderedContent = Y.createInsertSetFromStructStore(ydoc.store, false) // all content incl. deleted + const renderer = new AttributionsRenderer(emptyAttributions, { renderedContent }) + const expected = delta.create().insert('Hello World!') + const rendered = ytext.toDelta({ renderer }) + t.assert(rendered.equals(expected)) + }) + + t.group('hide: alive content omitted from renderedContent renders as nothing', () => { + const hidden = Y.createIdSet() + hidden.add(1, 1, 4) // hide 'ello' (clock 1..4) + const renderer = new AttributionsRenderer(emptyAttributions, { renderedContent: Y.diffIdSet(aliveSet, hidden) }) + const expected = delta.create().insert('H!') + const rendered = ytext.toDelta({ renderer }) + t.assert(rendered.equals(expected)) + }) + + t.group('attributions always render, even outside renderedContent', () => { + const deletedRange = Y.createIdSet() + deletedRange.add(1, 5, 6) // ' World' + const attributions = Y.createContentMap(Y.createIdMap(), createIdMapFromIdSet(deletedRange, [Y.createContentAttribute('delete', 'bob')])) + const renderer = new AttributionsRenderer(attributions, { renderedContent: aliveSet }) + const expected = delta.create().insert('Hello').insert(' World', {}, { delete: ['bob'] }).insert('!') + const rendered = ytext.toDelta({ renderer }) + t.assert(rendered.equals(expected)) + }) +} + // RANDOM TESTS let charCounter = 0 @@ -2219,6 +2265,54 @@ export const testRendererDefaultPerformance = tc => { }) } +/** + * Benchmark the generic fast path (default / no renderer) against an {@link AttributionsRenderer} + * configured with *no* attributions (an empty {@link ContentMap}) and a custom `renderedContent` + * set to the doc's full insert set. Since the insert set also contains the ids of deleted content, + * the renderer *restores* every deletion — rendering the whole document normally, without + * attributions — while alive content (fully covered by `renderedContent`) still takes the generic + * fast path. This measures the renderer's restore path (`hasItem`'s restore branch, `readContent`'s + * custom-`renderedContent` path, and `contentLength` via `coveredLength`) against the default. + * + * @param {t.TestCase} tc + */ +export const testRendererAttributionsPerformance = tc => { + const N = 10000 + const MaxDeletionLength = 5 // 25% chance of deletion + const MaxInsertionLength = 5 + const ydoc = new Y.Doc({ gc: false }) // keep deleted content so the delete set renders real content + const ytext = ydoc.get() + for (let i = 0; i < N; i++) { + if (prng.bool(tc.prng) && prng.bool(tc.prng) && ytext.length > 0) { + const index = prng.int31(tc.prng, 0, ytext.length - 1) + const len = prng.int31(tc.prng, 0, math.min(ytext.length - index, MaxDeletionLength)) + ytext.delete(index, len) + } else { + const index = prng.int31(tc.prng, 0, ytext.length) + const content = prng.utf16String(tc.prng, MaxInsertionLength) + ytext.insert(index, content) + } + } + // no attributions; renderedContent = the full insert set, which also covers deleted ids, so every + // deletion is restored and rendered normally + const insertSet = Y.createInsertSetFromStructStore(ydoc.store, false) + // const deleteSet = Y.createDeleteSetFromStructStore(ydoc.store) + const renderer = new AttributionsRenderer(Y.createContentMap(), { renderedContent: insertSet }) + t.info(`number of changes: ${N / 1000}k`) + t.info(`length of visible text: ${ytext.length}`) + const M = 10 + t.measureTime(`default renderer (fast path): toDelta() `, () => { + for (let i = 0; i < M; i++) { + ytext.toDelta() + } + }) + t.measureTime(`AttributionsRenderer (no attributions, renderedContent = insert set): toDelta({ renderer }) `, () => { + for (let i = 0; i < M; i++) { + ytext.toDelta({ renderer }) + } + }) +} + /** * @param {t.TestCase} tc */ diff --git a/tests/y-xml.tests.js b/tests/y-xml.tests.js index 2bd3efc2..37a8f088 100644 --- a/tests/y-xml.tests.js +++ b/tests/y-xml.tests.js @@ -139,10 +139,10 @@ export const testFragmentAttributedContent = _tc => { const elem3 = Y.Type.from(delta.create().insert('world')) yfragment.insert(0, [elem1, elem2]) ydoc.get().insert(0, [yfragment]) - let renderer = /** @type {AbstractRenderer?} */ (Y.baseRenderer) + let renderer = /** @type {AbstractRenderer?} */ (null) ydoc.on('afterTransaction', tr => { - // renderer = new TwosetRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) - renderer = new Y.TwosetRenderer(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, [])) + // renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) + renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, []))) }) t.group('insert / delete', () => { ydoc.transact(() => { @@ -167,10 +167,10 @@ export const testElementAttributedContent = _tc => { const elem2 = delta.create('span').done() const elem3 = delta.create().insert('world').done() yelement.insert(0, [elem1, elem2]) - let renderer = /** @type {AbstractRenderer?} */ (Y.baseRenderer) + let renderer = /** @type {AbstractRenderer?} */ (null) ydoc.on('afterTransaction', tr => { - // renderer = new TwosetRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) - renderer = new Y.TwosetRenderer(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, [])) + // renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) + renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, []))) }) t.group('insert / delete', () => { ydoc.transact(() => {