@y/y 14.0.0-rc.20 → 14.0.0-rc.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ytype.js CHANGED
@@ -10,6 +10,7 @@ import * as math from 'lib0/math'
10
10
  import * as object from 'lib0/object'
11
11
  import * as s from 'lib0/schema'
12
12
  import * as traits from 'lib0/traits'
13
+ import { ObservableV2 } from 'lib0/observable'
13
14
  import {
14
15
  Item,
15
16
  ContentAny,
@@ -50,11 +51,12 @@ const maxSearchMarker = 80
50
51
  * @todo SHOULD NOT RETURN AN OBJECT!
51
52
  * @param {Array<ContentAttribute<any>>?} attrs
52
53
  * @param {boolean} deleted - whether the attributed item is deleted
53
- * @return {Attribution?}
54
+ * @return {Attribution|undefined} `undefined` when there is no attribution — under lib0's tri-state
55
+ * that means "skip / inherit the builder's attribution context" (NOT `null`, which would clear it).
54
56
  */
55
57
  export const createAttributionFromAttributionItems = (attrs, deleted) => {
56
58
  if (attrs == null) {
57
- return null
59
+ return undefined
58
60
  }
59
61
  /**
60
62
  * @type {Attribution}
@@ -98,14 +100,14 @@ export class ItemTextListPosition {
98
100
  * @param {Item|null} left
99
101
  * @param {Item|null} right
100
102
  * @param {number} index
101
- * @param {Map<string,any>} currentAttributes
103
+ * @param {Map<string,any>} currentFormats
102
104
  * @param {AbstractRenderer} renderer
103
105
  */
104
- constructor (left, right, index, currentAttributes, renderer) {
106
+ constructor (left, right, index, currentFormats, renderer) {
105
107
  this.left = left
106
108
  this.right = right
107
109
  this.index = index
108
- this.currentAttributes = currentAttributes
110
+ this.currentFormats = currentFormats
109
111
  this.renderer = renderer
110
112
  }
111
113
 
@@ -119,7 +121,7 @@ export class ItemTextListPosition {
119
121
  switch (this.right.content.constructor) {
120
122
  case ContentFormat:
121
123
  if (!this.right.deleted) {
122
- updateCurrentAttributes(this.currentAttributes, /** @type {ContentFormat} */ (this.right.content))
124
+ updateCurrentFormats(this.currentFormats, /** @type {ContentFormat} */ (this.right.content))
123
125
  }
124
126
  break
125
127
  default:
@@ -134,22 +136,22 @@ export class ItemTextListPosition {
134
136
  * @param {Transaction} transaction
135
137
  * @param {YType} parent
136
138
  * @param {number} length
137
- * @param {Object<string,any>} attributes
139
+ * @param {Object<string,any>} formats
138
140
  *
139
141
  * @function
140
142
  */
141
- formatText (transaction, parent, length, attributes) {
142
- minimizeAttributeChanges(this, attributes)
143
- const negatedAttributes = insertAttributes(transaction, parent, this, attributes)
143
+ formatText (transaction, parent, length, formats) {
144
+ minimizeFormatChanges(this, formats)
145
+ const negatedFormats = insertFormats(transaction, parent, this, formats)
144
146
  // iterate until first non-format or null is found
145
- // delete all formats with attributes[format.key] != null
146
- // also check the attributes after the first non-format as we do not want to insert redundant negated attributes there
147
+ // delete all formats with formats[format.key] != null
148
+ // also check the formats after the first non-format as we do not want to insert redundant negated formats there
147
149
  // eslint-disable-next-line no-labels
148
150
  iterationLoop: while (
149
151
  this.right !== null &&
150
152
  (length > 0 ||
151
153
  (
152
- negatedAttributes.size > 0 &&
154
+ negatedFormats.size > 0 &&
153
155
  ((this.right.deleted && this.renderer.contentLength(this.right) === 0) || this.right.content.constructor === ContentFormat)
154
156
  )
155
157
  )
@@ -158,21 +160,21 @@ export class ItemTextListPosition {
158
160
  case ContentFormat: {
159
161
  if (!this.right.deleted) {
160
162
  const { key, value } = /** @type {ContentFormat} */ (this.right.content)
161
- const attr = attributes[key]
163
+ const attr = formats[key]
162
164
  if (attr !== undefined) {
163
- if (equalAttrs(attr, value)) {
164
- negatedAttributes.delete(key)
165
+ if (equalFormats(attr, value)) {
166
+ negatedFormats.delete(key)
165
167
  } else {
166
168
  if (length === 0) {
167
- // no need to further extend negatedAttributes
169
+ // no need to further extend negatedFormats
168
170
  // eslint-disable-next-line no-labels
169
171
  break iterationLoop
170
172
  }
171
- negatedAttributes.set(key, value)
173
+ negatedFormats.set(key, value)
172
174
  }
173
175
  this.right.delete(transaction)
174
176
  } else {
175
- this.currentAttributes.set(key, value)
177
+ this.currentFormats.set(key, value)
176
178
  }
177
179
  }
178
180
  break
@@ -208,7 +210,7 @@ export class ItemTextListPosition {
208
210
  if (length > 0) {
209
211
  throw new Error('Exceeded content range')
210
212
  }
211
- insertNegatedAttributes(transaction, parent, this, negatedAttributes)
213
+ insertNegatedFormats(transaction, parent, this, negatedFormats)
212
214
  }
213
215
  }
214
216
 
@@ -218,29 +220,29 @@ export class ItemTextListPosition {
218
220
  * @param {Transaction} transaction
219
221
  * @param {YType} parent
220
222
  * @param {ItemTextListPosition} currPos
221
- * @param {Map<string,any>} negatedAttributes
223
+ * @param {Map<string,any>} negatedFormats
222
224
  *
223
225
  * @private
224
226
  * @function
225
227
  */
226
- const insertNegatedAttributes = (transaction, parent, currPos, negatedAttributes) => {
227
- // check if we really need to remove attributes
228
+ const insertNegatedFormats = (transaction, parent, currPos, negatedFormats) => {
229
+ // check if we really need to remove formats
228
230
  while (
229
231
  currPos.right !== null && (
230
232
  (currPos.right.deleted && (currPos.renderer === baseRenderer || currPos.renderer.contentLength(currPos.right) === 0)) || (
231
233
  currPos.right.content.constructor === ContentFormat &&
232
- equalAttrs(negatedAttributes.get(/** @type {ContentFormat} */ (currPos.right.content).key), /** @type {ContentFormat} */ (currPos.right.content).value)
234
+ equalFormats(negatedFormats.get(/** @type {ContentFormat} */ (currPos.right.content).key), /** @type {ContentFormat} */ (currPos.right.content).value)
233
235
  )
234
236
  )
235
237
  ) {
236
238
  if (!currPos.right.deleted) {
237
- negatedAttributes.delete(/** @type {ContentFormat} */ (currPos.right.content).key)
239
+ negatedFormats.delete(/** @type {ContentFormat} */ (currPos.right.content).key)
238
240
  }
239
241
  currPos.forward()
240
242
  }
241
243
  const doc = transaction.doc
242
244
  const ownClientId = doc.clientID
243
- negatedAttributes.forEach((val, key) => {
245
+ negatedFormats.forEach((val, key) => {
244
246
  const left = currPos.left
245
247
  const right = currPos.right
246
248
  const nextFormat = new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
@@ -251,34 +253,34 @@ const insertNegatedAttributes = (transaction, parent, currPos, negatedAttributes
251
253
  }
252
254
 
253
255
  /**
254
- * @param {Map<string,any>} currentAttributes
256
+ * @param {Map<string,any>} currentFormats
255
257
  * @param {ContentFormat} format
256
258
  *
257
259
  * @private
258
260
  * @function
259
261
  */
260
- const updateCurrentAttributes = (currentAttributes, format) => {
262
+ const updateCurrentFormats = (currentFormats, format) => {
261
263
  const { key, value } = format
262
264
  if (value === null) {
263
- currentAttributes.delete(key)
265
+ currentFormats.delete(key)
264
266
  } else {
265
- currentAttributes.set(key, value)
267
+ currentFormats.set(key, value)
266
268
  }
267
269
  }
268
270
 
269
271
  /**
270
272
  * @param {ItemTextListPosition} currPos
271
- * @param {Object<string,any>} attributes
273
+ * @param {Object<string,any>} formats
272
274
  *
273
275
  * @private
274
276
  * @function
275
277
  */
276
- const minimizeAttributeChanges = (currPos, attributes) => {
277
- // go right while attributes[right.key] === right.value (or right is deleted)
278
+ const minimizeFormatChanges = (currPos, formats) => {
279
+ // go right while formats[right.key] === right.value (or right is deleted)
278
280
  while (true) {
279
281
  if (currPos.right === null) {
280
282
  break
281
- } else if (currPos.right.deleted ? (currPos.renderer.contentLength(currPos.right) === 0) : (!currPos.right.deleted && currPos.right.content.constructor === ContentFormat && equalAttrs(attributes[(/** @type {ContentFormat} */ (currPos.right.content)).key] ?? null, /** @type {ContentFormat} */ (currPos.right.content).value))) {
283
+ } else if (currPos.right.deleted ? (currPos.renderer.contentLength(currPos.right) === 0) : (!currPos.right.deleted && currPos.right.content.constructor === ContentFormat && equalFormats(formats[(/** @type {ContentFormat} */ (currPos.right.content)).key] ?? null, /** @type {ContentFormat} */ (currPos.right.content).value))) {
282
284
  //
283
285
  } else {
284
286
  break
@@ -291,30 +293,30 @@ const minimizeAttributeChanges = (currPos, attributes) => {
291
293
  * @param {Transaction} transaction
292
294
  * @param {YType} parent
293
295
  * @param {ItemTextListPosition} currPos
294
- * @param {Object<string,any>} attributes
296
+ * @param {Object<string,any>} formats
295
297
  * @return {Map<string,any>}
296
298
  *
297
299
  * @private
298
300
  * @function
299
301
  **/
300
- const insertAttributes = (transaction, parent, currPos, attributes) => {
302
+ const insertFormats = (transaction, parent, currPos, formats) => {
301
303
  const doc = transaction.doc
302
304
  const ownClientId = doc.clientID
303
- const negatedAttributes = new Map()
305
+ const negatedFormats = new Map()
304
306
  // insert format-start items
305
- for (const key in attributes) {
306
- const val = attributes[key]
307
- const currentVal = currPos.currentAttributes.get(key) ?? null
308
- if (!equalAttrs(currentVal, val)) {
309
- // save negated attribute (set null if currentVal undefined)
310
- negatedAttributes.set(key, currentVal)
307
+ for (const key in formats) {
308
+ const val = formats[key]
309
+ const currentVal = currPos.currentFormats.get(key) ?? null
310
+ if (!equalFormats(currentVal, val)) {
311
+ // save negated format (set null if currentVal undefined)
312
+ negatedFormats.set(key, currentVal)
311
313
  const { left, right } = currPos
312
314
  currPos.right = new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
313
315
  currPos.right.integrate(transaction, 0)
314
316
  currPos.forward()
315
317
  }
316
318
  }
317
- return negatedAttributes
319
+ return negatedFormats
318
320
  }
319
321
 
320
322
  /**
@@ -322,21 +324,21 @@ const insertAttributes = (transaction, parent, currPos, attributes) => {
322
324
  * @param {YType} parent
323
325
  * @param {ItemTextListPosition} currPos
324
326
  * @param {import('./structs/Item.js').AbstractContent} content
325
- * @param {Object<string,any>} attributes
327
+ * @param {Object<string,any>} formats
326
328
  *
327
329
  * @private
328
330
  * @function
329
331
  **/
330
- export const insertContent = (transaction, parent, currPos, content, attributes) => {
331
- currPos.currentAttributes.forEach((_val, key) => {
332
- if (attributes[key] === undefined) {
333
- attributes[key] = null
332
+ export const insertContent = (transaction, parent, currPos, content, formats) => {
333
+ currPos.currentFormats.forEach((_val, key) => {
334
+ if (formats[key] === undefined) {
335
+ formats[key] = null
334
336
  }
335
337
  })
336
338
  const doc = transaction.doc
337
339
  const ownClientId = doc.clientID
338
- minimizeAttributeChanges(currPos, attributes)
339
- const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes)
340
+ minimizeFormatChanges(currPos, formats)
341
+ const negatedFormats = insertFormats(transaction, parent, currPos, formats)
340
342
  let { left, right, index } = currPos
341
343
  if (parent._searchMarker) {
342
344
  updateMarkerChanges(parent._searchMarker, currPos.index, content.getLength())
@@ -346,7 +348,7 @@ export const insertContent = (transaction, parent, currPos, content, attributes)
346
348
  currPos.right = right
347
349
  currPos.index = index
348
350
  currPos.forward()
349
- insertNegatedAttributes(transaction, parent, currPos, negatedAttributes)
351
+ insertNegatedFormats(transaction, parent, currPos, negatedFormats)
350
352
  }
351
353
 
352
354
  /**
@@ -354,27 +356,27 @@ export const insertContent = (transaction, parent, currPos, content, attributes)
354
356
  * @param {YType} parent
355
357
  * @param {ItemTextListPosition} currPos
356
358
  * @param {Array<any>|string} insert
357
- * @param {Object<string,any>} attributes
359
+ * @param {Object<string,any>} formats
358
360
  */
359
- export const insertContentHelper = (transaction, parent, currPos, insert, attributes) => {
361
+ export const insertContentHelper = (transaction, parent, currPos, insert, formats) => {
360
362
  if (s.$string.check(insert)) {
361
- insertContent(transaction, parent, currPos, new ContentString(insert), attributes)
363
+ insertContent(transaction, parent, currPos, new ContentString(insert), formats)
362
364
  } else {
363
365
  insert = insert.map(ins => delta.$deltaAny.check(ins) ? YType.from(ins) : ins)
364
366
  for (let i = 0; i < insert.length;) {
365
367
  const first = insert[i]
366
368
  if (first instanceof YType) {
367
- insertContent(transaction, parent, currPos, new ContentType(first), attributes)
369
+ insertContent(transaction, parent, currPos, new ContentType(first), formats)
368
370
  i++
369
371
  } else if ($ydoc.check(first)) {
370
- insertContent(transaction, parent, currPos, createContentDocFromDoc(first), attributes)
372
+ insertContent(transaction, parent, currPos, createContentDocFromDoc(first), formats)
371
373
  i++
372
374
  } else {
373
375
  // insert "any" content
374
376
  // compute slice len
375
377
  let j = i + 1
376
378
  for (; j < insert.length && !(insert[j] instanceof YType || $ydoc.check(insert[j])); j++) { /* nop */ }
377
- insertContent(transaction, parent, currPos, new ContentAny((i === 0 && j === insert.length) ? insert : insert.slice(i, j)), attributes)
379
+ insertContent(transaction, parent, currPos, new ContentAny((i === 0 && j === insert.length) ? insert : insert.slice(i, j)), formats)
378
380
  i = j
379
381
  }
380
382
  }
@@ -392,7 +394,7 @@ export const insertContentHelper = (transaction, parent, currPos, insert, attrib
392
394
  */
393
395
  export const deleteText = (transaction, currPos, length) => {
394
396
  const startLength = length
395
- const startAttrs = map.copy(currPos.currentAttributes)
397
+ const startFormats = map.copy(currPos.currentFormats)
396
398
  const start = currPos.right
397
399
  while (length > 0 && currPos.right !== null) {
398
400
  const item = currPos.right
@@ -428,7 +430,7 @@ export const deleteText = (transaction, currPos, length) => {
428
430
  currPos.forward()
429
431
  }
430
432
  if (start) {
431
- cleanupFormattingGap(transaction, start, currPos.right, startAttrs, currPos.currentAttributes)
433
+ cleanupFormattingGap(transaction, start, currPos.right, startFormats, currPos.currentFormats)
432
434
  }
433
435
  const parent = /** @type {YType<any>} */ (/** @type {Item} */ (currPos.left || currPos.right).parent)
434
436
  if (parent._searchMarker) {
@@ -631,14 +633,26 @@ export const callTypeObservers = (type, transaction, event) => {
631
633
  }
632
634
 
633
635
  /**
634
- * Abstract Yjs Type class
636
+ * Abstract Yjs Type class.
637
+ *
638
+ * A `YType` is a {@link https://github.com/dmonad/lib0 lib0} `RDT` ("replicated data type", see
639
+ * `lib0/delta/rdt.js`): it emits a `'delta'` event whenever its state changes (carrying the change
640
+ * and the origin of the transaction that caused it), accepts foreign
641
+ * changes via {@link YType#applyDelta}, exposes its delta {@link YType#$delta schema}, and can be
642
+ * torn down via {@link YType#destroy}. This lets a `YType` be `bind()`-ed to any other RDT (another
643
+ * `YType`, an in-memory delta, a DOM subtree, …). The legacy {@link YType#observe `observe`} /
644
+ * {@link YType#observeDeep `observeDeep`} `YEvent` API continues to work alongside the `'delta'`
645
+ * channel.
646
+ *
635
647
  * @template {delta.DeltaConf} [DConf=any]
648
+ * @extends {ObservableV2<{ delta: (delta: delta.Delta<DConf>, origin: any) => void, destroy: (type: YType<DConf>) => void }>}
636
649
  */
637
- export class YType {
650
+ export class YType extends ObservableV2 {
638
651
  /**
639
652
  * @param {delta.DeltaConfGetName<DConf>?} name
640
653
  */
641
654
  constructor (name = null) {
655
+ super()
642
656
  /**
643
657
  * @type {delta.DeltaConfGetName<DConf>}
644
658
  */
@@ -675,20 +689,127 @@ export class YType {
675
689
  */
676
690
  this._searchMarker = null
677
691
  /**
678
- * @type {delta.DeltaBuilder<DConf>}
679
- * @private
692
+ * Maintained deep-delta cache backing {@link YType#delta}. `null` until `delta` is first
693
+ * accessed; thereafter kept current on every event of this type (incrementally, by applying the
694
+ * deep change) and re-diffed by {@link YType#useRenderer}. Cleared by {@link YType#clearCache}.
695
+ * @type {delta.DeltaBuilderAny | null}
680
696
  */
681
- this._content = /** @type {delta.DeltaBuilderAny} */ (delta.create())
697
+ this._delta = null
682
698
  this._legacyTypeRef = this.name == null ? YXmlFragmentRefID : YXmlElementRefID
683
699
  /**
684
700
  * @type {Array<ArraySearchMarker>|null}
685
701
  */
686
702
  this._searchMarker = []
687
703
  /**
688
- * Whether this YText contains formatting attributes.
704
+ * Whether this YText contains formats.
689
705
  * This flag is updated when a formatting item is integrated (see ContentFormat.integrate)
690
706
  */
691
707
  this._hasFormatting = false
708
+ /**
709
+ * The active default renderer. Used by `toDelta`, `applyDelta`, and the events whenever no
710
+ * explicit renderer is passed. Change it via {@link YType#useRenderer}.
711
+ * @type {AbstractRenderer}
712
+ */
713
+ this._renderer = baseRenderer
714
+ }
715
+
716
+ /**
717
+ * Schema of the deltas this type produces — part of the lib0 `RDT` interface.
718
+ *
719
+ * @type {s.Schema<delta.Delta<DConf>>}
720
+ */
721
+ get $delta () {
722
+ return /** @type {any} */ (delta.$deltaAny)
723
+ }
724
+
725
+ /**
726
+ * The deep delta of this type (the full nested content tree, children rendered as their own
727
+ * deltas).
728
+ *
729
+ * The returned value is the type's **live** maintained cache: it is materialized on first access
730
+ * and then kept current on every event fired on this type (and re-diffed by
731
+ * {@link YType#useRenderer}), so a reference held across edits keeps updating in place. Clone it
732
+ * (e.g. `type.delta.clone()`) if you need a stable snapshot, and call {@link YType#clearCache} to
733
+ * drop the cache.
734
+ *
735
+ * Consider the returned delta **done** — it must not be edited from the outside. It is
736
+ * deliberately typed as a `Delta` (not a `DeltaBuilder`) so the mutating builder API is not
737
+ * reachable; editing it anyway would corrupt the cache without changing the CRDT. The proper way
738
+ * to change this type is {@link YType#applyDelta}.
739
+ *
740
+ * @type {delta.Delta<DConf>}
741
+ */
742
+ get delta () {
743
+ if (this._delta === null) {
744
+ this._delta = this._renderDelta()
745
+ }
746
+ return /** @type {any} */ (this._delta)
747
+ }
748
+
749
+ /**
750
+ * Render the full deep current state into a fresh `isFinal` builder (so subsequent `.apply`s of
751
+ * deep changes update content in place). Uses this type's active renderer.
752
+ *
753
+ * @return {delta.DeltaBuilderAny}
754
+ */
755
+ _renderDelta () {
756
+ const state = /** @type {delta.DeltaBuilderAny} */ (delta.create(this.name))
757
+ state.isFinal = true
758
+ state.apply(this.toDelta({ deep: true }))
759
+ return state
760
+ }
761
+
762
+ /**
763
+ * Discard the cached deep delta backing {@link YType#delta}.
764
+ *
765
+ * After `delta` is first accessed, the cache is updated on every event fired on this type (and
766
+ * re-diffed by {@link YType#useRenderer}). Call this to drop it — e.g. to reclaim memory, or to
767
+ * force an exact recomputation after editing while a non-base renderer is active (the incremental
768
+ * updates can drift from a fresh deep render in that case).
769
+ */
770
+ clearCache () {
771
+ this._delta = null
772
+ }
773
+
774
+ /**
775
+ * Change the default renderer used by this type. After calling `useRenderer(renderer)`, the
776
+ * `toDelta`, `applyDelta`, and event methods all use `renderer` whenever no explicit renderer is
777
+ * passed (an explicit `{ renderer }` argument still overrides it per call).
778
+ *
779
+ * If the deep-delta cache ({@link YType#delta}) is being maintained, or a `'delta'` listener is
780
+ * attached, the content is re-rendered with the new renderer and the difference is emitted on the
781
+ * `'delta'` channel only (a renderer switch is not a CRDT change, so no `YEvent` is produced, and
782
+ * the emitted origin is `null` as no transaction is involved).
783
+ *
784
+ * @param {AbstractRenderer} renderer
785
+ * @return {this}
786
+ */
787
+ useRenderer (renderer) {
788
+ const prev = this._renderer
789
+ const hasDeltaListeners = (this._observers.get('delta')?.size ?? 0) > 0
790
+ if (renderer !== prev && (this._delta !== null || hasDeltaListeners)) {
791
+ const oldState = this._delta ?? this._renderDelta()
792
+ this._renderer = renderer
793
+ const newState = this._renderDelta()
794
+ if (this._delta !== null) this._delta = newState
795
+ if (hasDeltaListeners) {
796
+ const d = /** @type {any} */ (delta.diff(/** @type {any} */ (oldState), /** @type {any} */ (newState)))
797
+ if (!d.isEmpty()) this.emit('delta', [d, null])
798
+ }
799
+ } else {
800
+ this._renderer = renderer
801
+ }
802
+ return this
803
+ }
804
+
805
+ /**
806
+ * Tear down this type as an `RDT`: emit the `'destroy'` event and unregister all `'delta'` /
807
+ * `'destroy'` listeners. The CRDT content and the `observe`/`observeDeep` handlers are left
808
+ * untouched — this only releases the RDT/binding observers.
809
+ */
810
+ destroy () {
811
+ this.emit('destroy', [this])
812
+ super.destroy()
692
813
  }
693
814
 
694
815
  /**
@@ -760,6 +881,9 @@ export class YType {
760
881
  _callObserver (transaction, parentSubs) {
761
882
  const event = new YEvent(/** @type {any} */ (this), transaction, parentSubs)
762
883
  callTypeObservers(/** @type {any} */ (this), transaction, event)
884
+ // Note: the RDT `'delta'` channel (and the deep-delta cache) is driven in the transaction
885
+ // cleanup's `changedParentTypes` loop (see Transaction.js) so it bubbles to ancestors like
886
+ // `observeDeep`, not here where only the directly-changed type is visible.
763
887
  if (!transaction.local && this._searchMarker) {
764
888
  this._searchMarker.length = 0
765
889
  }
@@ -821,7 +945,7 @@ export class YType {
821
945
  * @template {boolean} [Deep=false]
822
946
  *
823
947
  * @param {Object} [opts]
824
- * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to `baseRenderer`
948
+ * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YType#useRenderer}), i.e. `baseRenderer` unless changed
825
949
  * @param {IdSet?} [opts.itemsToRender]
826
950
  * @param {boolean} [opts.retainInserts] - if true, retain rendered inserts with attributions
827
951
  * @param {boolean} [opts.retainDeletes] - if true, retain rendered+attributed deletes only
@@ -833,7 +957,7 @@ export class YType {
833
957
  * @public
834
958
  */
835
959
  toDelta (opts = {}) {
836
- const { renderer = baseRenderer, itemsToRender = null, retainInserts = false, retainDeletes = false, deletedItems = null, deep = false } = opts
960
+ const { renderer = this._renderer, itemsToRender = null, retainInserts = false, retainDeletes = false, deletedItems = null, deep = false } = opts
837
961
  const { modified = (deep && itemsToRender) ? computeModifiedFromItems(/** @type {Doc} */ (this.doc).store, itemsToRender) : null } = opts
838
962
  const renderAttrs = modified?.get(this) || null
839
963
  const renderChildren = modified == null || !modified.has(this) || /** @type {Set<string|null>} */ (modified.get(this)).has(null)
@@ -846,29 +970,29 @@ export class YType {
846
970
  typeMapGetDelta(d, /** @type {any} */ (this), renderAttrs, renderer, deep, modified, deletedItems, itemsToRender, optsAll, optsAll)
847
971
  if (renderChildren) {
848
972
  /**
849
- * @type {delta.FormattingAttributes}
973
+ * @type {delta.Formats}
850
974
  */
851
- let currentAttributes = {} // saves all current attributes for insert
852
- let usingCurrentAttributes = false
975
+ let currentFormats = {} // saves all current formats for insert
976
+ let usingCurrentFormats = false
853
977
  /**
854
- * @type {delta.FormattingAttributes}
978
+ * @type {delta.Formats}
855
979
  */
856
- let changedAttributes = {} // saves changed attributes for retain
857
- let usingChangedAttributes = false
980
+ let changedFormats = {} // saves changed formats for retain
981
+ let usingChangedFormats = false
858
982
  /**
859
- * Logic for formatting attribute attribution
860
- * Everything that comes after an formatting attribute is formatted by the user that created it.
983
+ * Logic for format attribution
984
+ * Everything that comes after a format is formatted by the user that created it.
861
985
  * Two exceptions:
862
986
  * - the user resets formatting to the previously known formatting that is not attributed
863
- * - the user deletes a formatting attribute and hence restores the previously known formatting
987
+ * - the user deletes a format and hence restores the previously known formatting
864
988
  * that is not attributed.
865
- * @type {delta.FormattingAttributes}
989
+ * @type {delta.Formats}
866
990
  */
867
- const previousUnattributedAttributes = {} // contains previously known unattributed formatting
991
+ const previousUnattributedFormats = {} // contains previously known unattributed formatting
868
992
  /**
869
- * @type {delta.FormattingAttributes}
993
+ * @type {delta.Formats}
870
994
  */
871
- const previousAttributes = {} // The value before changes
995
+ const previousFormats = {} // The value before changes
872
996
  /**
873
997
  * @type {Array<AttributedContent<any>>}
874
998
  */
@@ -897,11 +1021,11 @@ export class YType {
897
1021
  // render (attributed) content even if it was deleted
898
1022
  const renderContent = c.render && (!c.deleted || c.attrs != null)
899
1023
  // content that was just deleted. It is not rendered as an insertion, because it doesn't
900
- // have any attributes.
1024
+ // have any formats.
901
1025
  const renderDelete = c.render && c.deleted
902
- // existing content that should be retained, only adding changed attributes
1026
+ // existing content that should be retained, only adding changed formats
903
1027
  const retainContent = !c.render && (!c.deleted || c.attrs != null)
904
- const attribution = (renderContent || c.content.constructor === ContentFormat) ? createAttributionFromAttributionItems(c.attrs, c.deleted) : null
1028
+ const attribution = (renderContent || c.content.constructor === ContentFormat) ? createAttributionFromAttributionItems(c.attrs, c.deleted) : undefined
905
1029
  switch (c.content.constructor) {
906
1030
  case ContentDeleted: {
907
1031
  if (renderDelete) d.delete(c.content.getLength())
@@ -909,18 +1033,26 @@ export class YType {
909
1033
  }
910
1034
  case ContentString:
911
1035
  if (renderContent) {
912
- d.usedAttributes = currentAttributes
913
- usingCurrentAttributes = true
914
1036
  if (c.deleted ? retainDeletes : retainInserts) {
915
- d.retain(/** @type {ContentString} */ (c.content).str.length, null, attribution ?? {})
1037
+ // a retain expresses the format *diff* against existing (cached) content, so use
1038
+ // `changedFormats`: a format removed this change (e.g. its marker was deleted)
1039
+ // is present there as a `null` clear, whereas `currentFormats` (absolute) can
1040
+ // only re-assert present formats and would silently keep a stale one.
1041
+ d.usedFormats = changedFormats
1042
+ usingChangedFormats = true
1043
+ // change render: a retained item with no attribution means its attribution was
1044
+ // removed → emit `null` (clear) rather than `{}` (skip). Present attribution merges.
1045
+ d.retain(/** @type {ContentString} */ (c.content).str.length, undefined, attribution ?? null)
916
1046
  } else {
917
- d.insert(/** @type {ContentString} */ (c.content).str, null, attribution)
1047
+ d.usedFormats = currentFormats
1048
+ usingCurrentFormats = true
1049
+ d.insert(/** @type {ContentString} */ (c.content).str, undefined, attribution)
918
1050
  }
919
1051
  } else if (renderDelete) {
920
1052
  d.delete(c.content.getLength())
921
1053
  } else if (retainContent) {
922
- d.usedAttributes = changedAttributes
923
- usingChangedAttributes = true
1054
+ d.usedFormats = changedFormats
1055
+ usingChangedFormats = true
924
1056
  d.retain(c.content.getLength())
925
1057
  }
926
1058
  break
@@ -930,19 +1062,24 @@ export class YType {
930
1062
  case ContentType:
931
1063
  case ContentBinary:
932
1064
  if (renderContent) {
933
- d.usedAttributes = currentAttributes
934
- usingCurrentAttributes = true
935
1065
  if (c.deleted ? retainDeletes : retainInserts) {
1066
+ // a retain expresses the format *diff* → use `changedFormats` (see ContentString)
1067
+ d.usedFormats = changedFormats
1068
+ usingChangedFormats = true
936
1069
  if (c.deleted && c.content.constructor === ContentType) {
937
1070
  // @todo use current transaction instead
938
- d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll), null, attribution ?? {})
1071
+ d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll), undefined, attribution ?? null)
939
1072
  } else {
940
- d.retain(c.content.getLength(), null, attribution ?? {})
1073
+ d.retain(c.content.getLength(), undefined, attribution ?? null)
941
1074
  }
942
1075
  } else if (deep && c.content.constructor === ContentType) {
943
- d.insert([/** @type {any} */(c.content).type.toDelta(optsAll)], null, attribution)
1076
+ d.usedFormats = currentFormats
1077
+ usingCurrentFormats = true
1078
+ d.insert([/** @type {any} */(c.content).type.toDelta(optsAll)], undefined, attribution)
944
1079
  } else {
945
- d.insert(c.content.getContent(), null, attribution)
1080
+ d.usedFormats = currentFormats
1081
+ usingCurrentFormats = true
1082
+ d.insert(c.content.getContent(), undefined, attribution)
946
1083
  }
947
1084
  } else if (renderDelete) {
948
1085
  d.delete(1)
@@ -951,98 +1088,127 @@ export class YType {
951
1088
  // @todo use current transaction instead
952
1089
  d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll))
953
1090
  } else {
954
- d.usedAttributes = changedAttributes
955
- usingChangedAttributes = true
1091
+ d.usedFormats = changedFormats
1092
+ usingChangedFormats = true
956
1093
  d.retain(1)
957
1094
  }
958
1095
  }
959
1096
  break
960
1097
  case ContentFormat: {
961
1098
  const { key, value } = /** @type {ContentFormat} */ (c.content)
962
- const currAttrVal = currentAttributes[key] ?? null
963
- if (attribution != null && (c.deleted || !object.hasProperty(previousUnattributedAttributes, key))) {
964
- previousUnattributedAttributes[key] = c.deleted ? value : currAttrVal
1099
+ const currFormatVal = currentFormats[key] ?? null
1100
+ if (attribution != null && (c.deleted || !object.hasProperty(previousUnattributedFormats, key))) {
1101
+ previousUnattributedFormats[key] = c.deleted ? value : currFormatVal
965
1102
  }
966
- // @todo write a function "updateCurrentAttributes" and "updateChangedAttributes"
967
- // # Update Attributes
1103
+ // @todo write a function "updateCurrentFormats" and "updateChangedFormats"
1104
+ // # Update Formats
968
1105
  if (renderContent || renderDelete) {
969
1106
  // create fresh references
970
- if (usingCurrentAttributes) {
971
- currentAttributes = object.assign({}, currentAttributes)
972
- usingCurrentAttributes = false
1107
+ if (usingCurrentFormats) {
1108
+ currentFormats = object.assign({}, currentFormats)
1109
+ usingCurrentFormats = false
973
1110
  }
974
- if (usingChangedAttributes) {
975
- usingChangedAttributes = false
976
- changedAttributes = object.assign({}, changedAttributes)
1111
+ if (usingChangedFormats) {
1112
+ usingChangedFormats = false
1113
+ changedFormats = object.assign({}, changedFormats)
977
1114
  }
978
1115
  }
979
1116
  if (renderContent || renderDelete) {
980
1117
  if (c.deleted) {
981
1118
  // content was deleted, but is possibly attributed
982
- if (!equalAttrs(value, currAttrVal)) { // do nothing if nothing changed
983
- if (equalAttrs(currAttrVal, previousAttributes[key] ?? null) && changedAttributes[key] !== undefined) {
984
- delete changedAttributes[key]
1119
+ if (!equalFormats(value, currFormatVal)) { // do nothing if nothing changed
1120
+ if (equalFormats(currFormatVal, previousFormats[key] ?? null) && changedFormats[key] !== undefined) {
1121
+ delete changedFormats[key]
985
1122
  } else {
986
- changedAttributes[key] = currAttrVal
1123
+ changedFormats[key] = currFormatVal
987
1124
  }
988
- // current attributes doesn't change
989
- previousAttributes[key] = value
1125
+ // current formats doesn't change
1126
+ previousFormats[key] = value
990
1127
  }
991
1128
  } else { // !c.deleted
992
1129
  // content was inserted, and is possibly attributed
993
- if (equalAttrs(value, currAttrVal)) {
1130
+ if (equalFormats(value, currFormatVal)) {
994
1131
  // item.delete(transaction)
995
- } else if (equalAttrs(value, previousAttributes[key] ?? null)) {
996
- delete changedAttributes[key]
1132
+ } else if (equalFormats(value, previousFormats[key] ?? null)) {
1133
+ delete changedFormats[key]
997
1134
  } else {
998
- changedAttributes[key] = value
1135
+ changedFormats[key] = value
999
1136
  }
1000
1137
  if (value == null) {
1001
- delete currentAttributes[key]
1138
+ delete currentFormats[key]
1002
1139
  } else {
1003
- currentAttributes[key] = value
1140
+ currentFormats[key] = value
1004
1141
  }
1005
1142
  }
1006
1143
  } else if (retainContent && !c.deleted) {
1007
- // fresh reference to currentAttributes only
1008
- if (usingCurrentAttributes) {
1009
- currentAttributes = object.assign({}, currentAttributes)
1010
- usingCurrentAttributes = false
1144
+ // fresh reference to currentFormats only
1145
+ if (usingCurrentFormats) {
1146
+ currentFormats = object.assign({}, currentFormats)
1147
+ usingCurrentFormats = false
1011
1148
  }
1012
- if (usingChangedAttributes && changedAttributes[key] !== undefined) {
1013
- usingChangedAttributes = false
1014
- changedAttributes = object.assign({}, changedAttributes)
1149
+ if (usingChangedFormats && changedFormats[key] !== undefined) {
1150
+ usingChangedFormats = false
1151
+ changedFormats = object.assign({}, changedFormats)
1015
1152
  }
1016
1153
  if (value == null) {
1017
- delete currentAttributes[key]
1154
+ delete currentFormats[key]
1018
1155
  } else {
1019
- currentAttributes[key] = value
1156
+ currentFormats[key] = value
1020
1157
  }
1021
- delete changedAttributes[key]
1022
- previousAttributes[key] = value
1158
+ delete changedFormats[key]
1159
+ previousFormats[key] = value
1023
1160
  }
1024
1161
  // # Update Attributions
1025
- if (attribution != null || object.hasProperty(previousUnattributedAttributes, key)) {
1162
+ // A format marker deleted in a change render under an *attributing* renderer nets to no
1163
+ // attribution (its insert+delete suggestion cancels → `attribution == null`), yet it ends
1164
+ // the attributed range it opened: the following retained content must drop the stale
1165
+ // `{ format: { [key]: [] } }` the marker's insertion wrote to the cache. Emit an explicit
1166
+ // `null` leaf for the key (a context-wide `useAttribution(null)` cannot carry a per-key
1167
+ // clear). Conditions: only an attributing render (`renderer !== baseRenderer`; the base
1168
+ // renderer has no attributions to clear), and only when the deletion actually *removes*
1169
+ // the format — i.e. it reverts to no value (`currFormatVal == null`). If it reverts to a
1170
+ // still-present surrounding value (e.g. deleting a `bold:null` marker re-exposes an
1171
+ // enclosing attributed `bold:true`, as when re-bolding), the attribution is preserved.
1172
+ const isDeletedFormatClear = attribution == null && renderer !== baseRenderer && renderDelete && c.deleted && itemsToRender != null && currFormatVal == null && !equalFormats(value, currFormatVal)
1173
+ if (attribution != null || isDeletedFormatClear || object.hasProperty(previousUnattributedFormats, key)) {
1026
1174
  /**
1027
1175
  * @type {Attribution}
1028
1176
  */
1029
1177
  const formattingAttribution = object.assign({}, d.usedAttribution)
1030
- const changedAttributedAttributes = /** @type {{ [key: string]: Array<any> }} */ (formattingAttribution.format = object.assign({}, formattingAttribution.format ?? {}))
1031
- if (attribution == null || equalAttrs(previousUnattributedAttributes[key], currentAttributes[key] ?? null)) {
1032
- // an unattributed formatting attribute was found or an attributed formatting
1033
- // attribute was found that resets to the previous status
1034
- delete changedAttributedAttributes[key]
1035
- delete previousUnattributedAttributes[key]
1178
+ const changedAttributedFormats = /** @type {{ [key: string]: Array<any>|null }} */ (formattingAttribution.format = object.assign({}, formattingAttribution.format ?? {}))
1179
+ const sameAsPreviousAttributions = equalFormats(previousUnattributedFormats[key], currentFormats[key] ?? null)
1180
+ if (isDeletedFormatClear) {
1181
+ changedAttributedFormats[key] = null
1182
+ delete previousUnattributedFormats[key]
1183
+ } else if (attribution == null && !sameAsPreviousAttributions) {
1184
+ // skip
1185
+ } else if (attribution == null || sameAsPreviousAttributions) {
1186
+ // an unattributed format was found or an attributed format
1187
+ // was found that resets to the previous status. When this format item is
1188
+ // itself rendered this transaction (`renderContent || renderDelete`) in a change/diff
1189
+ // render (`itemsToRender != null`), it is the END of an attributed format range:
1190
+ // emit an explicit clear (a `null` leaf) so the retained content drops any stale
1191
+ // `{ format: { [key]: [] } }` from the maintained `delta` cache — a bare context-skip
1192
+ // (`delete`) would leave it in place. For a merely-retained (unchanged) boundary, or a
1193
+ // full insert render (removal is already modeled as absence in `currentFormats`),
1194
+ // just drop the key: a change render must not emit ops for unchanged ranges, and
1195
+ // inserts must stay free of a spurious `{ format: { [key]: null } }`.
1196
+ if (attribution != null && itemsToRender != null && (renderContent || renderDelete)) {
1197
+ changedAttributedFormats[key] = null
1198
+ } else {
1199
+ delete changedAttributedFormats[key]
1200
+ }
1201
+ delete previousUnattributedFormats[key]
1036
1202
  } else {
1037
- const by = changedAttributedAttributes[key] = (changedAttributedAttributes[key]?.slice() ?? [])
1203
+ const by = changedAttributedFormats[key] = (changedAttributedFormats[key]?.slice() ?? [])
1038
1204
  by.push(...((c.deleted ? attribution.delete : attribution.insert) ?? []))
1039
1205
  const attributedAt = (c.deleted ? attribution.deleteAt : attribution.insertAt)
1040
1206
  if (attributedAt) formattingAttribution.formatAt = attributedAt
1041
1207
  }
1042
- if (object.isEmpty(changedAttributedAttributes)) {
1208
+ if (object.isEmpty(changedAttributedFormats)) {
1043
1209
  d.useAttribution(null)
1044
- } else if (attribution != null) {
1045
- const attributedAt = (c.deleted ? attribution.deleteAt : attribution.insertAt)
1210
+ } else if (attribution != null || isDeletedFormatClear) {
1211
+ const attributedAt = (c.deleted ? attribution?.deleteAt : attribution?.insertAt)
1046
1212
  if (attributedAt != null) formattingAttribution.formatAt = attributedAt
1047
1213
  d.useAttribution(formattingAttribution)
1048
1214
  }
@@ -1061,7 +1227,7 @@ export class YType {
1061
1227
  * attributions.
1062
1228
  *
1063
1229
  * @param {Object} [opts]
1064
- * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to `baseRenderer`
1230
+ * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YType#useRenderer}), i.e. `baseRenderer` unless changed
1065
1231
  * @return {delta.Delta<DConf>}
1066
1232
  */
1067
1233
  toDeltaDeep (opts = {}) {
@@ -1073,11 +1239,14 @@ export class YType {
1073
1239
  *
1074
1240
  * @param {delta.DeltaAny} d The changes to apply on this element.
1075
1241
  * @param {Object} [opts]
1076
- * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to `baseRenderer`
1242
+ * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YType#useRenderer}), i.e. `baseRenderer` unless changed
1243
+ * @return {null} The lib0 `RDT` "fix" of this apply — always `null`: a `YType` accepts every valid
1244
+ * delta as-is and never needs to self-correct.
1077
1245
  *
1078
1246
  * @public
1079
1247
  */
1080
- applyDelta (d, { renderer = baseRenderer } = {}) {
1248
+ applyDelta (d, { renderer = this._renderer } = {}) {
1249
+ if (d.isEmpty()) return null
1081
1250
  if (this.doc == null) {
1082
1251
  (this._prelim || (this._prelim = /** @type {any} */ (delta.create()))).apply(d)
1083
1252
  } else if (this._item?.deleted !== true) {
@@ -1118,7 +1287,7 @@ export class YType {
1118
1287
  }
1119
1288
  })
1120
1289
  }
1121
- return this
1290
+ return null
1122
1291
  }
1123
1292
 
1124
1293
  /**
@@ -1224,7 +1393,7 @@ export class YType {
1224
1393
  *
1225
1394
  * @param {number} index The index to insert content at.
1226
1395
  * @param {Array<delta.DeltaConfGetChildren<DConf>>|delta.DeltaConfGetText<DConf>} content Array of content to append.
1227
- * @param {delta.FormattingAttributes} [format]
1396
+ * @param {delta.Formats} [format]
1228
1397
  */
1229
1398
  insert (index, content, format) {
1230
1399
  this.applyDelta(delta.create().retain(index).insert(/** @type {any} */ (content), format).done())
@@ -1245,7 +1414,7 @@ export class YType {
1245
1414
  *
1246
1415
  * @param {number} index The index to insert content at.
1247
1416
  * @param {number} length The index to insert content at.
1248
- * @param {delta.FormattingAttributes} formats
1417
+ * @param {delta.Formats} formats
1249
1418
  *
1250
1419
  */
1251
1420
  format (index, length, formats) {
@@ -1519,7 +1688,7 @@ export const computeModifiedFromItems = (store, items) => {
1519
1688
  * @param {any} b
1520
1689
  * @return {boolean}
1521
1690
  */
1522
- export const equalAttrs = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b))
1691
+ export const equalFormats = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b))
1523
1692
 
1524
1693
  /**
1525
1694
  * @template {delta.DeltaConf} DConf
@@ -1922,25 +2091,26 @@ export const typeMapGetDelta = (d, parent, attrsToRender, renderer, deep, modifi
1922
2091
  */
1923
2092
  const cs = []
1924
2093
  renderer.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, 1)
1925
- const { deleted, attrs, content, render } = cs[cs.length - 1]
1926
- if (!render) return
2094
+ if (cs.length === 0) return // the renderer surfaces nothing for this attribute (e.g. a diff renderer hiding an unchanged delete)
2095
+ const { deleted, attrs, content } = cs[cs.length - 1]
1927
2096
  const attribution = createAttributionFromAttributionItems(attrs, deleted)
1928
2097
  let c = array.last(content.getContent())
1929
2098
  if (deleted) {
1930
- if (itemsToRender == null || itemsToRender.hasId(item.lastId)) {
1931
- if (attribution != null) {
1932
- // Item surfaced under attribution (suggestion view / diff AM,
1933
- // either in snapshot mode or in an event-driven render). The
1934
- // attribute is still observable in the rendered state, so emit
1935
- // a positive `SetAttrOp` carrying the attribution metadata -
1936
- // matching how content children are rendered for the same case
1937
- // (positive `InsertOp` with attribution, never `DeleteOp`).
2099
+ if (attribution != null) {
2100
+ // Item surfaced under attribution (suggestion view / diff renderer, either in snapshot mode
2101
+ // or in an event-driven render). The attribute is still observable in the rendered state, so
2102
+ // emit a positive `SetAttrOp` carrying the attribution metadata - matching how content
2103
+ // children are rendered for the same case (positive `InsertOp` with attribution, never
2104
+ // `DeleteOp`).
2105
+ if (itemsToRender == null || itemsToRender.hasId(item.lastId)) {
1938
2106
  d.setAttr(key, c, attribution)
1939
- } else {
1940
- // Hard-deleted attribute (no AM-surfaced attribution): emit the
1941
- // change op so event consumers can apply it.
1942
- d.deleteAttr(key, attribution, c)
1943
2107
  }
2108
+ } else if (itemsToRender != null && itemsToRender.hasId(item.lastId)) {
2109
+ // Hard-deleted attribute within a change render: emit the `deleteAttr` op so consumers (the
2110
+ // `YEvent` delta, RDT bindings, the maintained `delta` cache) can apply the removal. In
2111
+ // full-state mode (`itemsToRender == null`) the attribute is simply omitted (above renders
2112
+ // run with `render === false` for such items, so nothing was emitted before either).
2113
+ d.deleteAttr(key, attribution, c)
1944
2114
  }
1945
2115
  } else if (deep && c instanceof YType && modified?.has(c)) {
1946
2116
  d.modifyAttr(key, c.toDelta(opts))