snapshot of https://github.com/yjs/yjs.git @ 96c96e1fcb1ef6ce866d5264b3f97f7f77b11f64 (2026-09-15)
This commit is contained in:
702
src/utils/Renderer.js
Normal file
702
src/utils/Renderer.js
Normal file
@@ -0,0 +1,702 @@
|
||||
import { ObservableV2 } from 'lib0/observable'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
|
||||
import { getItemCleanStart } from './transaction-helpers.js'
|
||||
import { diffIdSet, createInsertSetFromStructStore, createDeleteSetFromStructStore, insertIntoIdSet, mergeIdSets, intersectSets, createIdSet, createIdSetFromIdMap, writeIdSet, createIdMapFromIdSet, insertIntoIdMap, diffIdMap, createIdMap, mergeIdMaps, intersectMaps, createMaybeAttrRange, createContentAttribute } from './ids.js'
|
||||
import { ContentDeleted, ContentFormat, ContentType } from '../structs/Item.js'
|
||||
import { createID } from './ID.js'
|
||||
import { writeStructsFromIdSet } from './encoding-helpers.js'
|
||||
import { applyUpdate, encodeStateAsUpdate } from './encoding.js'
|
||||
import { UpdateEncoderV1 } from './UpdateEncoder.js'
|
||||
import { transact } from './Transaction.js'
|
||||
import { UndoManager, StackItem } from './UndoManager.js'
|
||||
|
||||
import { $renderer, AttributedContent } 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<AttributedContent<any>>} 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<ContentAttribute<any>>|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 AttributionsRenderer extends ObservableV2 {
|
||||
/**
|
||||
* @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 (attributions, { renderedContent = null } = {}) {
|
||||
super()
|
||||
/**
|
||||
* The two attribution maps merged into one — `readContent` consults this for how to attribute
|
||||
* a piece.
|
||||
* @type {IdMap<any>}
|
||||
*/
|
||||
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 = 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 }
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasItem (item) {
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<AttributedContent<any>>} contents - where to write the result
|
||||
* @param {number} client
|
||||
* @param {number} clock
|
||||
* @param {boolean} deleted
|
||||
* @param {AbstractContent} content
|
||||
* @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 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<import('./ids.js').MaybeIdRange>?}
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @return {number}
|
||||
*/
|
||||
contentLength (item) {
|
||||
if (!item.content.isCountable()) {
|
||||
return 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
|
||||
* @param {number} clock
|
||||
* @param {number} len
|
||||
*/
|
||||
const getItemContent = (store, client, clock, len) => {
|
||||
// Retrieved item is never more fragmented than the newer item.
|
||||
const prevItem = store.getItem(createID(client, clock))
|
||||
const diffStart = clock - prevItem.id.clock
|
||||
let content = prevItem.length > 1 ? prevItem.content.copy() : prevItem.content
|
||||
// trim itemContent to the correct size.
|
||||
if (diffStart > 0) {
|
||||
content = content.splice(diffStart)
|
||||
}
|
||||
if (len < content.getLength()) {
|
||||
content.splice(len)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the whole subtree of an accepted pending node insert. A nested item can only exist in
|
||||
* the suggestion doc if its parent does, so every attribute entry and every child of an accepted
|
||||
* node belongs to the same suggestion - the attributes in particular: shipped without them, the
|
||||
* node that reaches the base doc is not the node that was suggested (an image without its `src`,
|
||||
* a paragraph without its text). Deleted nested items are transient (inserted and deleted within
|
||||
* the suggestion) and are collected like the top-level `collectAll` case; the receiving document
|
||||
* filters what it already holds.
|
||||
*
|
||||
* @param {import('../ynode.js').YNode} type
|
||||
* @param {IdSet} inserts
|
||||
* @param {IdSet} deletes
|
||||
*/
|
||||
const collectSubtree = (type, inserts, deletes) => {
|
||||
/**
|
||||
* @type {Array<Item|null>}
|
||||
*/
|
||||
const chains = [type._start]
|
||||
type._map.forEach(item => { chains.push(item) })
|
||||
for (const chain of chains) {
|
||||
// list items chain to the right, map entries to the left (their overwritten values)
|
||||
for (let item = chain; item != null; item = item.parentSub != null ? item.left : item.right) {
|
||||
inserts.add(item.id.client, item.id.clock, item.length)
|
||||
if (item.deleted) {
|
||||
deletes.add(item.id.client, item.id.clock, item.length)
|
||||
} else if (item.content instanceof ContentType) {
|
||||
collectSubtree(item.content.type, inserts, deletes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction?} tr - only specify this if you want to fill the content of deleted content
|
||||
* @param {DiffRenderer} renderer
|
||||
* @param {ID} start
|
||||
* @param {ID} end
|
||||
* @param {boolean} collectAll - collect as many items as possible. Accept adding redundant changes.
|
||||
* An accepted node insert then also collects its subtree (see {@link collectSubtree}).
|
||||
*/
|
||||
const collectSuggestedChanges = (tr, renderer, start, end, collectAll) => {
|
||||
const inserts = createIdSet()
|
||||
const deletes = createIdSet()
|
||||
const store = renderer._nextDoc.store
|
||||
/**
|
||||
* make sure to collect suggestions until all formats are closed
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
const openedCollectedFormats = new Set()
|
||||
/**
|
||||
* @type {Item?}
|
||||
*/
|
||||
let item = store.getItem(start)
|
||||
const endItem = start === end ? item : (end == null ? null : store.getItem(end))
|
||||
|
||||
// walk to the left and find first un-attributed change that is rendered
|
||||
while (item.left != null) {
|
||||
item = item.left
|
||||
if (item.content instanceof ContentFormat && item.content.value == null) {
|
||||
item = item.right
|
||||
break
|
||||
}
|
||||
if (!item.deleted) {
|
||||
const slice = renderer.inserts.slice(item.id.client, item.id.clock, item.length)
|
||||
if (slice.some(s => s.attrs === null)) {
|
||||
for (let i = slice.length - 1; i >= 0; i--) {
|
||||
const s = slice[i]
|
||||
if (s.attrs == null) break
|
||||
inserts.add(item.id.client, s.clock, s.len)
|
||||
if (collectAll && item.content instanceof ContentType) {
|
||||
collectSubtree(item.content.type, inserts, deletes)
|
||||
}
|
||||
}
|
||||
item = item.right
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
let foundEndItem = false
|
||||
// eslint-disable-next-line
|
||||
itemLoop: while (item != null) {
|
||||
const itemClient = item.id.client
|
||||
const slice = (item.deleted ? renderer.deletes : renderer.inserts).slice(itemClient, item.id.clock, item.length)
|
||||
foundEndItem ||= item === endItem
|
||||
if (item.deleted) {
|
||||
// item probably gc'd content. Need to split item and fill with content again
|
||||
for (let i = slice.length - 1; i >= 0; i--) {
|
||||
const s = slice[i]
|
||||
if (s.attrs != null || collectAll) {
|
||||
deletes.add(itemClient, s.clock, s.len)
|
||||
if (collectAll) {
|
||||
// in case item has been added and deleted this might be necessary. the forked document
|
||||
// will automatically filter this if it doesn't have it already.
|
||||
inserts.add(itemClient, s.clock, s.len)
|
||||
}
|
||||
}
|
||||
if (tr != null) {
|
||||
const splicedItem = getItemCleanStart(tr, createID(itemClient, s.clock))
|
||||
if (s.attrs != null) {
|
||||
splicedItem.content = getItemContent(renderer._prevDocStore, itemClient, s.clock, s.len)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (item.content instanceof ContentFormat) {
|
||||
const { key, value } = item.content
|
||||
if (value == null) {
|
||||
openedCollectedFormats.delete(key)
|
||||
} else {
|
||||
openedCollectedFormats.add(key)
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
const s = slice[i]
|
||||
if (s.attrs != null) {
|
||||
inserts.add(itemClient, s.clock, s.len)
|
||||
if (collectAll && item.content instanceof ContentType) {
|
||||
collectSubtree(item.content.type, inserts, deletes)
|
||||
}
|
||||
} else if (foundEndItem && openedCollectedFormats.size === 0) {
|
||||
// eslint-disable-next-line
|
||||
break itemLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
item = item.right
|
||||
}
|
||||
return { inserts, deletes }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IdMap<any>|undefined} attrs
|
||||
* @param {IdSet} slice
|
||||
*
|
||||
*/
|
||||
const extractAttributions = (attrs, slice) => attrs == null ? createIdMapFromIdSet(slice, []) : mergeIdMaps([intersectMaps(attrs, slice), createIdMapFromIdSet(slice, [])])
|
||||
|
||||
/**
|
||||
* @implements AbstractRenderer
|
||||
*
|
||||
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
|
||||
*/
|
||||
export class DiffRenderer extends ObservableV2 {
|
||||
/**
|
||||
* @param {Doc} prevDoc
|
||||
* @param {Doc} nextDoc
|
||||
* @param {Object} [options] - options for the renderer
|
||||
* @param {ContentMap?} [options.attributions] - the attributions to apply to the diff
|
||||
*/
|
||||
constructor (prevDoc, nextDoc, { attributions = null } = {}) {
|
||||
super()
|
||||
const _nextDocInserts = createInsertSetFromStructStore(nextDoc.store, false) // unmaintained
|
||||
const _prevDocInserts = createInsertSetFromStructStore(prevDoc.store, false) // unmaintained
|
||||
const nextDocDeletes = createDeleteSetFromStructStore(nextDoc.store) // maintained
|
||||
const prevDocDeletes = createDeleteSetFromStructStore(prevDoc.store) // maintained
|
||||
const insertDiff = diffIdSet(_nextDocInserts, _prevDocInserts)
|
||||
const deleteDiff = diffIdSet(nextDocDeletes, prevDocDeletes)
|
||||
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) —
|
||||
* `readContent` remains authoritative. See {@link AbstractRenderer#attributed}.
|
||||
* @type {IdSet}
|
||||
*/
|
||||
this.attributed = mergeIdSets([insertDiff, deleteDiff])
|
||||
this._prevDoc = prevDoc
|
||||
this._prevDocStore = prevDoc.store
|
||||
this._nextDoc = nextDoc
|
||||
// update before observer calls fired
|
||||
this._nextBOH = nextDoc.on('beforeObserverCalls', tr => {
|
||||
// update inserts
|
||||
const diffInserts = diffIdSet(tr.insertSet, _prevDocInserts)
|
||||
insertIntoIdMap(this.inserts, extractAttributions(attributions?.inserts, diffInserts))
|
||||
// update deletes
|
||||
const diffDeletes = diffIdSet(diffIdSet(tr.deleteSet, prevDocDeletes), this.inserts)
|
||||
insertIntoIdMap(this.deletes, extractAttributions(attributions?.deletes, diffDeletes))
|
||||
insertIntoIdSet(this.attributed, diffInserts)
|
||||
insertIntoIdSet(this.attributed, diffDeletes)
|
||||
// @todo fire update ranges on `diffInserts` and `diffDeletes`
|
||||
})
|
||||
this._prevBOH = prevDoc.on('beforeObserverCalls', tr => {
|
||||
insertIntoIdSet(_prevDocInserts, tr.insertSet)
|
||||
insertIntoIdSet(prevDocDeletes, tr.deleteSet)
|
||||
if (tr.insertSet.clients.size < 2) {
|
||||
tr.insertSet.forEach((attrRange, client) => {
|
||||
this.inserts.delete(client, attrRange.clock, attrRange.len)
|
||||
})
|
||||
} else {
|
||||
this.inserts = diffIdMap(this.inserts, tr.insertSet)
|
||||
}
|
||||
// insertIntoIdMap(this.deletes, createIdMapFromIdSet(intersectSets(tr.deleteSet, this.deletes), [createAttributionItem('acceptDelete', 'unknown')]))
|
||||
if (tr.deleteSet.clients.size < 2) {
|
||||
tr.deleteSet.forEach((attrRange, client) => {
|
||||
this.deletes.delete(client, attrRange.clock, attrRange.len)
|
||||
})
|
||||
} else {
|
||||
this.deletes = diffIdMap(this.deletes, tr.deleteSet)
|
||||
}
|
||||
// evict the accepted/rejected ranges from the coverage set, then re-add whatever the maps
|
||||
// still claim: `tr.insertSet` only evicts insert-attributions and `tr.deleteSet` only evicts
|
||||
// delete-attributions, but a single range can be covered by both maps (e.g. an accepted
|
||||
// insert with a still-pending delete suggestion on the same ids must stay attributed).
|
||||
const evicted = mergeIdSets([tr.insertSet, tr.deleteSet])
|
||||
this.attributed = diffIdSet(this.attributed, evicted)
|
||||
insertIntoIdSet(this.attributed, intersectSets(evicted, this.inserts))
|
||||
insertIntoIdSet(this.attributed, intersectSets(evicted, this.deletes))
|
||||
// fire event of "changed" attributions. exclude items that were added & deleted in the same
|
||||
// transaction
|
||||
this.emit('change', [diffIdSet(mergeIdSets([tr.insertSet, tr.deleteSet]), intersectSets(tr.insertSet, tr.deleteSet)), tr.origin, tr.local])
|
||||
})
|
||||
// changes from prevDoc should always flow into suggestionDoc
|
||||
// changes from suggestionDoc only flow into ydoc if suggestion-mode is disabled
|
||||
this._prevUpdateListener = prevDoc.on('update', (update, origin) => {
|
||||
origin !== this && applyUpdate(nextDoc, update)
|
||||
})
|
||||
this._ndUpdateListener = nextDoc.on('update', (update, origin, _doc, tr) => {
|
||||
// only if event is local and suggestion mode is enabled
|
||||
if (!this.suggestionMode && tr.local && (this.suggestionOrigins == null || this.suggestionOrigins.some(o => o === origin))) {
|
||||
applyUpdate(prevDoc, update, this)
|
||||
}
|
||||
})
|
||||
this._afterTrListener = nextDoc.on('afterTransaction', (tr) => {
|
||||
// apply deletes on attributed deletes (content that is already deleted, but is rendered by
|
||||
// the renderer)
|
||||
if (!this.suggestionMode && tr.local && (this.suggestionOrigins == null || this.suggestionOrigins.some(o => o === tr.origin))) {
|
||||
const attributedDeletes = tr.meta.get('attributedDeletes')
|
||||
if (attributedDeletes != null) {
|
||||
transact(prevDoc, () => {
|
||||
// apply attributed deletes if there are any
|
||||
const ds = new UpdateEncoderV1()
|
||||
encoding.writeVarUint(ds.restEncoder, 0) // encode 0 structs
|
||||
writeIdSet(ds, attributedDeletes)
|
||||
applyUpdate(prevDoc, ds.toUint8Array())
|
||||
}, this)
|
||||
}
|
||||
}
|
||||
})
|
||||
this.suggestionMode = true
|
||||
/**
|
||||
* Optionally limit origins that may sync changes to the main doc if suggestion-mode is
|
||||
* disabled.
|
||||
*
|
||||
* @type {Array<any>?}
|
||||
*/
|
||||
this.suggestionOrigins = null
|
||||
this._destroyHandler = nextDoc.on('destroy', this.destroy.bind(this))
|
||||
prevDoc.on('destroy', this._destroyHandler)
|
||||
}
|
||||
|
||||
get $type () { return $renderer }
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasItem (item) {
|
||||
return this.attributed.intersects(item.id.client, item.id.clock, item.length)
|
||||
}
|
||||
|
||||
destroy () {
|
||||
super.destroy()
|
||||
this._nextDoc.off('destroy', this._destroyHandler)
|
||||
this._prevDoc.off('destroy', this._destroyHandler)
|
||||
this._nextDoc.off('beforeObserverCalls', this._nextBOH)
|
||||
this._prevDoc.off('beforeObserverCalls', this._prevBOH)
|
||||
this._prevDoc.off('update', this._prevUpdateListener)
|
||||
this._nextDoc.off('update', this._ndUpdateListener)
|
||||
this._nextDoc.off('afterTransaction', this._afterTrListener)
|
||||
}
|
||||
|
||||
acceptAllChanges () {
|
||||
applyUpdate(this._prevDoc, encodeStateAsUpdate(this._nextDoc))
|
||||
}
|
||||
|
||||
rejectAllChanges () {
|
||||
this._prevDoc.transact(tr => {
|
||||
applyUpdate(this._prevDoc, encodeStateAsUpdate(this._nextDoc))
|
||||
const um = new UndoManager(this._prevDoc)
|
||||
um.undoStack.push(new StackItem(tr.insertSet, tr.deleteSet))
|
||||
um.undo()
|
||||
um.destroy()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} start
|
||||
* @param {ID} end
|
||||
*/
|
||||
acceptChanges (start, end = start) {
|
||||
const { inserts, deletes } = collectSuggestedChanges(null, this, start, end, true)
|
||||
const encoder = new UpdateEncoderV1()
|
||||
writeStructsFromIdSet(encoder, this._nextDoc.store, inserts)
|
||||
writeIdSet(encoder, deletes)
|
||||
applyUpdate(this._prevDoc, encoder.toUint8Array())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} start
|
||||
* @param {ID} end
|
||||
*/
|
||||
rejectChanges (start, end = start) {
|
||||
this._nextDoc.transact(tr => {
|
||||
const { inserts, deletes } = collectSuggestedChanges(tr, this, start, end, false)
|
||||
const encoder = new UpdateEncoderV1()
|
||||
writeStructsFromIdSet(encoder, this._nextDoc.store, inserts)
|
||||
writeIdSet(encoder, deletes)
|
||||
const um = new UndoManager(this._nextDoc)
|
||||
um.undoStack.push(new StackItem(inserts, deletes))
|
||||
um.undo()
|
||||
um.destroy()
|
||||
})
|
||||
this.acceptChanges(start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<AttributedContent<any>>} contents - where to write the result
|
||||
* @param {number} client
|
||||
* @param {number} clock
|
||||
* @param {boolean} deleted
|
||||
* @param {AbstractContent} _content
|
||||
* @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())
|
||||
/**
|
||||
* @type {AbstractContent?}
|
||||
*/
|
||||
let content = slice.length === 1 ? _content : _content.copy()
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
const s = slice[i]
|
||||
if (content == null || content instanceof ContentDeleted) {
|
||||
if ((!shouldRender && s.attrs == null) || this.inserts.has(client, s.clock)) {
|
||||
continue
|
||||
}
|
||||
// Retrieved item is never more fragmented than the newer item.
|
||||
const prevItem = this._prevDocStore.getItem(createID(client, s.clock))
|
||||
const diffStart = s.clock - prevItem.id.clock
|
||||
content = prevItem.length > 1 ? prevItem.content.copy() : prevItem.content
|
||||
// trim itemContent to the correct size.
|
||||
if (diffStart > 0) {
|
||||
content = content.splice(diffStart)
|
||||
}
|
||||
}
|
||||
const c = /** @type {AbstractContent} */ (content)
|
||||
const clen = c.getLength()
|
||||
if (clen < s.len) {
|
||||
slice.splice(i + 1, 0, createMaybeAttrRange(s.clock + clen, s.len - clen, s.attrs))
|
||||
s.len = clen
|
||||
}
|
||||
content = s.len < clen ? c.splice(s.len) : null
|
||||
// mode 3 (fresh content deleted in the same transaction) renders as an insert only where
|
||||
// the renderer attributes it — unattributed deleted content is invisible and renders as
|
||||
// *nothing* (there is nothing to insert, and a `delete` op would misapply: the consuming
|
||||
// state has never seen this content)
|
||||
if (!deleted || s.attrs != null || (shouldRender !== 0 && shouldRender !== 3)) {
|
||||
contents.push(new AttributedContent(c, s.clock, deleted, s.attrs, shouldRender))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @return {number}
|
||||
*/
|
||||
contentLength (item) {
|
||||
if (!item.deleted) {
|
||||
return item.content.isCountable() ? item.length : 0
|
||||
}
|
||||
/**
|
||||
* @type {Array<AttributedContent<any>>}
|
||||
*/
|
||||
const cs = []
|
||||
this.readContent(cs, item.id.client, item.id.clock, true, item.content, 0)
|
||||
return cs.reduce((cnt, c) => cnt + ((c.attrs != null && c.content.isCountable()) ? c.content.getLength() : 0), 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute changes from ydoc1 to ydoc2.
|
||||
*
|
||||
* @param {Doc} prevDoc
|
||||
* @param {Doc} nextDoc
|
||||
* @param {Object} [options] - options for the renderer
|
||||
* @param {ContentMap?} [options.attributions] - the attributions to apply to the diff
|
||||
*/
|
||||
export const createDiffRenderer = (prevDoc, nextDoc, options) => new DiffRenderer(prevDoc, nextDoc, options)
|
||||
|
||||
/**
|
||||
* Intended for projects that used the v13 snapshot feature. With this renderer you can
|
||||
* read content similar to the previous snapshot api. Requires that `ydoc.gc` is turned off.
|
||||
*
|
||||
* @implements AbstractRenderer
|
||||
*
|
||||
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
|
||||
*/
|
||||
export class SnapshotRenderer extends ObservableV2 {
|
||||
/**
|
||||
* @param {Snapshot} prevSnapshot
|
||||
* @param {Snapshot} nextSnapshot
|
||||
* @param {Object} [options] - options for the renderer
|
||||
* @param {Array<ContentAttribute>} [options.attrs] - the attributes to apply to the diff
|
||||
*/
|
||||
constructor (prevSnapshot, nextSnapshot) {
|
||||
super()
|
||||
this.prevSnapshot = prevSnapshot
|
||||
this.nextSnapshot = nextSnapshot
|
||||
const inserts = createIdMap()
|
||||
const deletes = createIdMapFromIdSet(diffIdSet(nextSnapshot.ds, prevSnapshot.ds), [createContentAttribute('change', '')])
|
||||
nextSnapshot.sv.forEach((clock, client) => {
|
||||
const prevClock = prevSnapshot.sv.get(client) || 0
|
||||
inserts.add(client, 0, prevClock, []) // content is included in prevSnapshot is rendered without attributes
|
||||
inserts.add(client, prevClock, clock - prevClock, [createContentAttribute('change', '')]) // content is rendered as "inserted"
|
||||
})
|
||||
this.attrs = mergeIdMaps([diffIdMap(inserts, prevSnapshot.ds), deletes])
|
||||
/**
|
||||
* Coverage of `attrs` (everything up to `nextSnapshot`). Content *after* the snapshot must be
|
||||
* hidden rather than rendered normally, which a finite set cannot express — `hasItem`
|
||||
* additionally claims all future content. See {@link AbstractRenderer#attributed}.
|
||||
* @type {IdSet}
|
||||
*/
|
||||
this.attributed = createIdSetFromIdMap(this.attrs)
|
||||
}
|
||||
|
||||
get $type () { return $renderer }
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasItem (item) {
|
||||
// claim future content (item ids at/after the snapshot's state vector, including clients the
|
||||
// snapshot has never seen) — `readContent` hides it, the generic path would render it
|
||||
return (this.nextSnapshot.sv.get(item.id.client) ?? 0) < item.id.clock + item.length ||
|
||||
this.attributed.intersects(item.id.client, item.id.clock, item.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<AttributedContent<any>>} contents - where to write the result
|
||||
* @param {number} client
|
||||
* @param {number} clock
|
||||
* @param {boolean} _deleted
|
||||
* @param {AbstractContent} content
|
||||
* @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) {
|
||||
if ((this.nextSnapshot.sv.get(client) ?? 0) <= clock) return // future item that should not be displayed
|
||||
const slice = this.attrs.slice(client, clock, content.getLength())
|
||||
content = slice.length === 1 ? content : content.copy()
|
||||
slice.forEach(s => {
|
||||
const deleted = this.nextSnapshot.ds.has(client, s.clock)
|
||||
const nonExistend = (this.nextSnapshot.sv.get(client) ?? 0) <= s.clock
|
||||
const c = content
|
||||
if (s.len < c.getLength()) {
|
||||
content = c.splice(s.len)
|
||||
}
|
||||
if (nonExistend) return
|
||||
if (shouldRender || !deleted || (s.attrs != null && s.attrs.length > 0)) {
|
||||
let attrsWithoutChange = s.attrs?.filter(attr => attr.name !== 'change') ?? null
|
||||
if (s.attrs?.length === 0) {
|
||||
attrsWithoutChange = null
|
||||
}
|
||||
contents.push(new AttributedContent(c, s.clock, deleted, attrsWithoutChange, shouldRender))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @return {number}
|
||||
*/
|
||||
contentLength (item) {
|
||||
return item.content.isCountable()
|
||||
? (item.deleted
|
||||
? this.attrs.sliceId(item.id, item.length).reduce((len, s) => s.attrs != null ? len + s.len : len, 0)
|
||||
: item.length
|
||||
)
|
||||
: 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Snapshot} prevSnapshot
|
||||
* @param {Snapshot} nextSnapshot
|
||||
*/
|
||||
export const createSnapshotRenderer = (prevSnapshot, nextSnapshot = prevSnapshot) => new SnapshotRenderer(prevSnapshot, nextSnapshot)
|
||||
Reference in New Issue
Block a user