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

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,25 @@ 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, accepts foreign
640
+ * changes via {@link YType#applyDelta}, exposes its delta {@link YType#$delta schema}, and can be
641
+ * torn down via {@link YType#destroy}. This lets a `YType` be `bind()`-ed to any other RDT (another
642
+ * `YType`, an in-memory delta, a DOM subtree, …). The legacy {@link YType#observe `observe`} /
643
+ * {@link YType#observeDeep `observeDeep`} `YEvent` API continues to work alongside the `'delta'`
644
+ * channel.
645
+ *
635
646
  * @template {delta.DeltaConf} [DConf=any]
647
+ * @extends {ObservableV2<{ delta: (delta: delta.Delta<DConf>) => void, destroy: (type: YType<DConf>) => void }>}
636
648
  */
637
- export class YType {
649
+ export class YType extends ObservableV2 {
638
650
  /**
639
651
  * @param {delta.DeltaConfGetName<DConf>?} name
640
652
  */
641
653
  constructor (name = null) {
654
+ super()
642
655
  /**
643
656
  * @type {delta.DeltaConfGetName<DConf>}
644
657
  */
@@ -675,20 +688,121 @@ export class YType {
675
688
  */
676
689
  this._searchMarker = null
677
690
  /**
678
- * @type {delta.DeltaBuilder<DConf>}
679
- * @private
691
+ * Maintained deep-delta cache backing {@link YType#delta}. `null` until `delta` is first
692
+ * accessed; thereafter kept current on every event of this type (incrementally, by applying the
693
+ * deep change) and re-diffed by {@link YType#useRenderer}. Cleared by {@link YType#clearCache}.
694
+ * @type {delta.DeltaBuilderAny | null}
680
695
  */
681
- this._content = /** @type {delta.DeltaBuilderAny} */ (delta.create())
696
+ this._delta = null
682
697
  this._legacyTypeRef = this.name == null ? YXmlFragmentRefID : YXmlElementRefID
683
698
  /**
684
699
  * @type {Array<ArraySearchMarker>|null}
685
700
  */
686
701
  this._searchMarker = []
687
702
  /**
688
- * Whether this YText contains formatting attributes.
703
+ * Whether this YText contains formats.
689
704
  * This flag is updated when a formatting item is integrated (see ContentFormat.integrate)
690
705
  */
691
706
  this._hasFormatting = false
707
+ /**
708
+ * The active default renderer. Used by `toDelta`, `applyDelta`, and the events whenever no
709
+ * explicit renderer is passed. Change it via {@link YType#useRenderer}.
710
+ * @type {AbstractRenderer}
711
+ */
712
+ this._renderer = baseRenderer
713
+ }
714
+
715
+ /**
716
+ * Schema of the deltas this type produces — part of the lib0 `RDT` interface.
717
+ *
718
+ * @type {s.Schema<delta.Delta<DConf>>}
719
+ */
720
+ get $delta () {
721
+ return /** @type {any} */ (delta.$deltaAny)
722
+ }
723
+
724
+ /**
725
+ * The deep delta of this type (the full nested content tree, children rendered as their own
726
+ * deltas).
727
+ *
728
+ * The returned value is the type's **live** maintained cache: it is materialized on first access
729
+ * and then kept current on every event fired on this type (and re-diffed by
730
+ * {@link YType#useRenderer}), so a reference held across edits keeps updating in place. Clone it
731
+ * (e.g. `type.delta.clone()`) if you need a stable snapshot, and call {@link YType#clearCache} to
732
+ * drop the cache.
733
+ *
734
+ * @type {delta.Delta<DConf>}
735
+ */
736
+ get delta () {
737
+ if (this._delta === null) {
738
+ this._delta = this._renderDelta()
739
+ }
740
+ return /** @type {any} */ (this._delta)
741
+ }
742
+
743
+ /**
744
+ * Render the full deep current state into a fresh `isFinal` builder (so subsequent `.apply`s of
745
+ * deep changes update content in place). Uses this type's active renderer.
746
+ *
747
+ * @return {delta.DeltaBuilderAny}
748
+ */
749
+ _renderDelta () {
750
+ const state = /** @type {delta.DeltaBuilderAny} */ (delta.create(this.name))
751
+ state.isFinal = true
752
+ state.apply(this.toDelta({ deep: true }))
753
+ return state
754
+ }
755
+
756
+ /**
757
+ * Discard the cached deep delta backing {@link YType#delta}.
758
+ *
759
+ * After `delta` is first accessed, the cache is updated on every event fired on this type (and
760
+ * re-diffed by {@link YType#useRenderer}). Call this to drop it — e.g. to reclaim memory, or to
761
+ * force an exact recomputation after editing while a non-base renderer is active (the incremental
762
+ * updates can drift from a fresh deep render in that case).
763
+ */
764
+ clearCache () {
765
+ this._delta = null
766
+ }
767
+
768
+ /**
769
+ * Change the default renderer used by this type. After calling `useRenderer(renderer)`, the
770
+ * `toDelta`, `applyDelta`, and event methods all use `renderer` whenever no explicit renderer is
771
+ * passed (an explicit `{ renderer }` argument still overrides it per call).
772
+ *
773
+ * If the deep-delta cache ({@link YType#delta}) is being maintained, or a `'delta'` listener is
774
+ * attached, the content is re-rendered with the new renderer and the difference is emitted on the
775
+ * `'delta'` channel only (a renderer switch is not a CRDT change, so no `YEvent` is produced).
776
+ *
777
+ * @param {AbstractRenderer} renderer
778
+ * @return {this}
779
+ */
780
+ useRenderer (renderer) {
781
+ const prev = this._renderer
782
+ const hasDeltaListeners = (this._observers.get('delta')?.size ?? 0) > 0
783
+ if (renderer !== prev && (this._delta !== null || hasDeltaListeners)) {
784
+ const oldState = this._delta ?? this._renderDelta()
785
+ this._renderer = renderer
786
+ const newState = this._renderDelta()
787
+ if (this._delta !== null) this._delta = newState
788
+ if (hasDeltaListeners) {
789
+ const d = /** @type {any} */ (delta.diff(/** @type {any} */ (oldState), /** @type {any} */ (newState)))
790
+ if (!d.isEmpty()) this.emit('delta', [d])
791
+ }
792
+ } else {
793
+ this._renderer = renderer
794
+ }
795
+ return this
796
+ }
797
+
798
+ /**
799
+ * Tear down this type as an `RDT`: emit the `'destroy'` event and unregister all `'delta'` /
800
+ * `'destroy'` listeners. The CRDT content and the `observe`/`observeDeep` handlers are left
801
+ * untouched — this only releases the RDT/binding observers.
802
+ */
803
+ destroy () {
804
+ this.emit('destroy', [this])
805
+ super.destroy()
692
806
  }
693
807
 
694
808
  /**
@@ -760,6 +874,9 @@ export class YType {
760
874
  _callObserver (transaction, parentSubs) {
761
875
  const event = new YEvent(/** @type {any} */ (this), transaction, parentSubs)
762
876
  callTypeObservers(/** @type {any} */ (this), transaction, event)
877
+ // Note: the RDT `'delta'` channel (and the deep-delta cache) is driven in the transaction
878
+ // cleanup's `changedParentTypes` loop (see Transaction.js) so it bubbles to ancestors like
879
+ // `observeDeep`, not here where only the directly-changed type is visible.
763
880
  if (!transaction.local && this._searchMarker) {
764
881
  this._searchMarker.length = 0
765
882
  }
@@ -821,7 +938,7 @@ export class YType {
821
938
  * @template {boolean} [Deep=false]
822
939
  *
823
940
  * @param {Object} [opts]
824
- * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to `baseRenderer`
941
+ * @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
942
  * @param {IdSet?} [opts.itemsToRender]
826
943
  * @param {boolean} [opts.retainInserts] - if true, retain rendered inserts with attributions
827
944
  * @param {boolean} [opts.retainDeletes] - if true, retain rendered+attributed deletes only
@@ -833,7 +950,7 @@ export class YType {
833
950
  * @public
834
951
  */
835
952
  toDelta (opts = {}) {
836
- const { renderer = baseRenderer, itemsToRender = null, retainInserts = false, retainDeletes = false, deletedItems = null, deep = false } = opts
953
+ const { renderer = this._renderer, itemsToRender = null, retainInserts = false, retainDeletes = false, deletedItems = null, deep = false } = opts
837
954
  const { modified = (deep && itemsToRender) ? computeModifiedFromItems(/** @type {Doc} */ (this.doc).store, itemsToRender) : null } = opts
838
955
  const renderAttrs = modified?.get(this) || null
839
956
  const renderChildren = modified == null || !modified.has(this) || /** @type {Set<string|null>} */ (modified.get(this)).has(null)
@@ -846,29 +963,29 @@ export class YType {
846
963
  typeMapGetDelta(d, /** @type {any} */ (this), renderAttrs, renderer, deep, modified, deletedItems, itemsToRender, optsAll, optsAll)
847
964
  if (renderChildren) {
848
965
  /**
849
- * @type {delta.FormattingAttributes}
966
+ * @type {delta.Formats}
850
967
  */
851
- let currentAttributes = {} // saves all current attributes for insert
852
- let usingCurrentAttributes = false
968
+ let currentFormats = {} // saves all current formats for insert
969
+ let usingCurrentFormats = false
853
970
  /**
854
- * @type {delta.FormattingAttributes}
971
+ * @type {delta.Formats}
855
972
  */
856
- let changedAttributes = {} // saves changed attributes for retain
857
- let usingChangedAttributes = false
973
+ let changedFormats = {} // saves changed formats for retain
974
+ let usingChangedFormats = false
858
975
  /**
859
- * Logic for formatting attribute attribution
860
- * Everything that comes after an formatting attribute is formatted by the user that created it.
976
+ * Logic for format attribution
977
+ * Everything that comes after a format is formatted by the user that created it.
861
978
  * Two exceptions:
862
979
  * - 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
980
+ * - the user deletes a format and hence restores the previously known formatting
864
981
  * that is not attributed.
865
- * @type {delta.FormattingAttributes}
982
+ * @type {delta.Formats}
866
983
  */
867
- const previousUnattributedAttributes = {} // contains previously known unattributed formatting
984
+ const previousUnattributedFormats = {} // contains previously known unattributed formatting
868
985
  /**
869
- * @type {delta.FormattingAttributes}
986
+ * @type {delta.Formats}
870
987
  */
871
- const previousAttributes = {} // The value before changes
988
+ const previousFormats = {} // The value before changes
872
989
  /**
873
990
  * @type {Array<AttributedContent<any>>}
874
991
  */
@@ -897,11 +1014,11 @@ export class YType {
897
1014
  // render (attributed) content even if it was deleted
898
1015
  const renderContent = c.render && (!c.deleted || c.attrs != null)
899
1016
  // content that was just deleted. It is not rendered as an insertion, because it doesn't
900
- // have any attributes.
1017
+ // have any formats.
901
1018
  const renderDelete = c.render && c.deleted
902
- // existing content that should be retained, only adding changed attributes
1019
+ // existing content that should be retained, only adding changed formats
903
1020
  const retainContent = !c.render && (!c.deleted || c.attrs != null)
904
- const attribution = (renderContent || c.content.constructor === ContentFormat) ? createAttributionFromAttributionItems(c.attrs, c.deleted) : null
1021
+ const attribution = (renderContent || c.content.constructor === ContentFormat) ? createAttributionFromAttributionItems(c.attrs, c.deleted) : undefined
905
1022
  switch (c.content.constructor) {
906
1023
  case ContentDeleted: {
907
1024
  if (renderDelete) d.delete(c.content.getLength())
@@ -909,18 +1026,26 @@ export class YType {
909
1026
  }
910
1027
  case ContentString:
911
1028
  if (renderContent) {
912
- d.usedAttributes = currentAttributes
913
- usingCurrentAttributes = true
914
1029
  if (c.deleted ? retainDeletes : retainInserts) {
915
- d.retain(/** @type {ContentString} */ (c.content).str.length, null, attribution ?? {})
1030
+ // a retain expresses the format *diff* against existing (cached) content, so use
1031
+ // `changedFormats`: a format removed this change (e.g. its marker was deleted)
1032
+ // is present there as a `null` clear, whereas `currentFormats` (absolute) can
1033
+ // only re-assert present formats and would silently keep a stale one.
1034
+ d.usedFormats = changedFormats
1035
+ usingChangedFormats = true
1036
+ // change render: a retained item with no attribution means its attribution was
1037
+ // removed → emit `null` (clear) rather than `{}` (skip). Present attribution merges.
1038
+ d.retain(/** @type {ContentString} */ (c.content).str.length, undefined, attribution ?? null)
916
1039
  } else {
917
- d.insert(/** @type {ContentString} */ (c.content).str, null, attribution)
1040
+ d.usedFormats = currentFormats
1041
+ usingCurrentFormats = true
1042
+ d.insert(/** @type {ContentString} */ (c.content).str, undefined, attribution)
918
1043
  }
919
1044
  } else if (renderDelete) {
920
1045
  d.delete(c.content.getLength())
921
1046
  } else if (retainContent) {
922
- d.usedAttributes = changedAttributes
923
- usingChangedAttributes = true
1047
+ d.usedFormats = changedFormats
1048
+ usingChangedFormats = true
924
1049
  d.retain(c.content.getLength())
925
1050
  }
926
1051
  break
@@ -930,19 +1055,24 @@ export class YType {
930
1055
  case ContentType:
931
1056
  case ContentBinary:
932
1057
  if (renderContent) {
933
- d.usedAttributes = currentAttributes
934
- usingCurrentAttributes = true
935
1058
  if (c.deleted ? retainDeletes : retainInserts) {
1059
+ // a retain expresses the format *diff* → use `changedFormats` (see ContentString)
1060
+ d.usedFormats = changedFormats
1061
+ usingChangedFormats = true
936
1062
  if (c.deleted && c.content.constructor === ContentType) {
937
1063
  // @todo use current transaction instead
938
- d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll), null, attribution ?? {})
1064
+ d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll), undefined, attribution ?? null)
939
1065
  } else {
940
- d.retain(c.content.getLength(), null, attribution ?? {})
1066
+ d.retain(c.content.getLength(), undefined, attribution ?? null)
941
1067
  }
942
1068
  } else if (deep && c.content.constructor === ContentType) {
943
- d.insert([/** @type {any} */(c.content).type.toDelta(optsAll)], null, attribution)
1069
+ d.usedFormats = currentFormats
1070
+ usingCurrentFormats = true
1071
+ d.insert([/** @type {any} */(c.content).type.toDelta(optsAll)], undefined, attribution)
944
1072
  } else {
945
- d.insert(c.content.getContent(), null, attribution)
1073
+ d.usedFormats = currentFormats
1074
+ usingCurrentFormats = true
1075
+ d.insert(c.content.getContent(), undefined, attribution)
946
1076
  }
947
1077
  } else if (renderDelete) {
948
1078
  d.delete(1)
@@ -951,98 +1081,127 @@ export class YType {
951
1081
  // @todo use current transaction instead
952
1082
  d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll))
953
1083
  } else {
954
- d.usedAttributes = changedAttributes
955
- usingChangedAttributes = true
1084
+ d.usedFormats = changedFormats
1085
+ usingChangedFormats = true
956
1086
  d.retain(1)
957
1087
  }
958
1088
  }
959
1089
  break
960
1090
  case ContentFormat: {
961
1091
  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
1092
+ const currFormatVal = currentFormats[key] ?? null
1093
+ if (attribution != null && (c.deleted || !object.hasProperty(previousUnattributedFormats, key))) {
1094
+ previousUnattributedFormats[key] = c.deleted ? value : currFormatVal
965
1095
  }
966
- // @todo write a function "updateCurrentAttributes" and "updateChangedAttributes"
967
- // # Update Attributes
1096
+ // @todo write a function "updateCurrentFormats" and "updateChangedFormats"
1097
+ // # Update Formats
968
1098
  if (renderContent || renderDelete) {
969
1099
  // create fresh references
970
- if (usingCurrentAttributes) {
971
- currentAttributes = object.assign({}, currentAttributes)
972
- usingCurrentAttributes = false
1100
+ if (usingCurrentFormats) {
1101
+ currentFormats = object.assign({}, currentFormats)
1102
+ usingCurrentFormats = false
973
1103
  }
974
- if (usingChangedAttributes) {
975
- usingChangedAttributes = false
976
- changedAttributes = object.assign({}, changedAttributes)
1104
+ if (usingChangedFormats) {
1105
+ usingChangedFormats = false
1106
+ changedFormats = object.assign({}, changedFormats)
977
1107
  }
978
1108
  }
979
1109
  if (renderContent || renderDelete) {
980
1110
  if (c.deleted) {
981
1111
  // 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]
1112
+ if (!equalFormats(value, currFormatVal)) { // do nothing if nothing changed
1113
+ if (equalFormats(currFormatVal, previousFormats[key] ?? null) && changedFormats[key] !== undefined) {
1114
+ delete changedFormats[key]
985
1115
  } else {
986
- changedAttributes[key] = currAttrVal
1116
+ changedFormats[key] = currFormatVal
987
1117
  }
988
- // current attributes doesn't change
989
- previousAttributes[key] = value
1118
+ // current formats doesn't change
1119
+ previousFormats[key] = value
990
1120
  }
991
1121
  } else { // !c.deleted
992
1122
  // content was inserted, and is possibly attributed
993
- if (equalAttrs(value, currAttrVal)) {
1123
+ if (equalFormats(value, currFormatVal)) {
994
1124
  // item.delete(transaction)
995
- } else if (equalAttrs(value, previousAttributes[key] ?? null)) {
996
- delete changedAttributes[key]
1125
+ } else if (equalFormats(value, previousFormats[key] ?? null)) {
1126
+ delete changedFormats[key]
997
1127
  } else {
998
- changedAttributes[key] = value
1128
+ changedFormats[key] = value
999
1129
  }
1000
1130
  if (value == null) {
1001
- delete currentAttributes[key]
1131
+ delete currentFormats[key]
1002
1132
  } else {
1003
- currentAttributes[key] = value
1133
+ currentFormats[key] = value
1004
1134
  }
1005
1135
  }
1006
1136
  } else if (retainContent && !c.deleted) {
1007
- // fresh reference to currentAttributes only
1008
- if (usingCurrentAttributes) {
1009
- currentAttributes = object.assign({}, currentAttributes)
1010
- usingCurrentAttributes = false
1137
+ // fresh reference to currentFormats only
1138
+ if (usingCurrentFormats) {
1139
+ currentFormats = object.assign({}, currentFormats)
1140
+ usingCurrentFormats = false
1011
1141
  }
1012
- if (usingChangedAttributes && changedAttributes[key] !== undefined) {
1013
- usingChangedAttributes = false
1014
- changedAttributes = object.assign({}, changedAttributes)
1142
+ if (usingChangedFormats && changedFormats[key] !== undefined) {
1143
+ usingChangedFormats = false
1144
+ changedFormats = object.assign({}, changedFormats)
1015
1145
  }
1016
1146
  if (value == null) {
1017
- delete currentAttributes[key]
1147
+ delete currentFormats[key]
1018
1148
  } else {
1019
- currentAttributes[key] = value
1149
+ currentFormats[key] = value
1020
1150
  }
1021
- delete changedAttributes[key]
1022
- previousAttributes[key] = value
1151
+ delete changedFormats[key]
1152
+ previousFormats[key] = value
1023
1153
  }
1024
1154
  // # Update Attributions
1025
- if (attribution != null || object.hasProperty(previousUnattributedAttributes, key)) {
1155
+ // A format marker deleted in a change render under an *attributing* renderer nets to no
1156
+ // attribution (its insert+delete suggestion cancels → `attribution == null`), yet it ends
1157
+ // the attributed range it opened: the following retained content must drop the stale
1158
+ // `{ format: { [key]: [] } }` the marker's insertion wrote to the cache. Emit an explicit
1159
+ // `null` leaf for the key (a context-wide `useAttribution(null)` cannot carry a per-key
1160
+ // clear). Conditions: only an attributing render (`renderer !== baseRenderer`; the base
1161
+ // renderer has no attributions to clear), and only when the deletion actually *removes*
1162
+ // the format — i.e. it reverts to no value (`currFormatVal == null`). If it reverts to a
1163
+ // still-present surrounding value (e.g. deleting a `bold:null` marker re-exposes an
1164
+ // enclosing attributed `bold:true`, as when re-bolding), the attribution is preserved.
1165
+ const isDeletedFormatClear = attribution == null && renderer !== baseRenderer && renderDelete && c.deleted && itemsToRender != null && currFormatVal == null && !equalFormats(value, currFormatVal)
1166
+ if (attribution != null || isDeletedFormatClear || object.hasProperty(previousUnattributedFormats, key)) {
1026
1167
  /**
1027
1168
  * @type {Attribution}
1028
1169
  */
1029
1170
  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]
1171
+ const changedAttributedFormats = /** @type {{ [key: string]: Array<any>|null }} */ (formattingAttribution.format = object.assign({}, formattingAttribution.format ?? {}))
1172
+ const sameAsPreviousAttributions = equalFormats(previousUnattributedFormats[key], currentFormats[key] ?? null)
1173
+ if (isDeletedFormatClear) {
1174
+ changedAttributedFormats[key] = null
1175
+ delete previousUnattributedFormats[key]
1176
+ } else if (attribution == null && !sameAsPreviousAttributions) {
1177
+ // skip
1178
+ } else if (attribution == null || sameAsPreviousAttributions) {
1179
+ // an unattributed format was found or an attributed format
1180
+ // was found that resets to the previous status. When this format item is
1181
+ // itself rendered this transaction (`renderContent || renderDelete`) in a change/diff
1182
+ // render (`itemsToRender != null`), it is the END of an attributed format range:
1183
+ // emit an explicit clear (a `null` leaf) so the retained content drops any stale
1184
+ // `{ format: { [key]: [] } }` from the maintained `delta` cache — a bare context-skip
1185
+ // (`delete`) would leave it in place. For a merely-retained (unchanged) boundary, or a
1186
+ // full insert render (removal is already modeled as absence in `currentFormats`),
1187
+ // just drop the key: a change render must not emit ops for unchanged ranges, and
1188
+ // inserts must stay free of a spurious `{ format: { [key]: null } }`.
1189
+ if (attribution != null && itemsToRender != null && (renderContent || renderDelete)) {
1190
+ changedAttributedFormats[key] = null
1191
+ } else {
1192
+ delete changedAttributedFormats[key]
1193
+ }
1194
+ delete previousUnattributedFormats[key]
1036
1195
  } else {
1037
- const by = changedAttributedAttributes[key] = (changedAttributedAttributes[key]?.slice() ?? [])
1196
+ const by = changedAttributedFormats[key] = (changedAttributedFormats[key]?.slice() ?? [])
1038
1197
  by.push(...((c.deleted ? attribution.delete : attribution.insert) ?? []))
1039
1198
  const attributedAt = (c.deleted ? attribution.deleteAt : attribution.insertAt)
1040
1199
  if (attributedAt) formattingAttribution.formatAt = attributedAt
1041
1200
  }
1042
- if (object.isEmpty(changedAttributedAttributes)) {
1201
+ if (object.isEmpty(changedAttributedFormats)) {
1043
1202
  d.useAttribution(null)
1044
- } else if (attribution != null) {
1045
- const attributedAt = (c.deleted ? attribution.deleteAt : attribution.insertAt)
1203
+ } else if (attribution != null || isDeletedFormatClear) {
1204
+ const attributedAt = (c.deleted ? attribution?.deleteAt : attribution?.insertAt)
1046
1205
  if (attributedAt != null) formattingAttribution.formatAt = attributedAt
1047
1206
  d.useAttribution(formattingAttribution)
1048
1207
  }
@@ -1061,7 +1220,7 @@ export class YType {
1061
1220
  * attributions.
1062
1221
  *
1063
1222
  * @param {Object} [opts]
1064
- * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to `baseRenderer`
1223
+ * @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
1224
  * @return {delta.Delta<DConf>}
1066
1225
  */
1067
1226
  toDeltaDeep (opts = {}) {
@@ -1073,11 +1232,14 @@ export class YType {
1073
1232
  *
1074
1233
  * @param {delta.DeltaAny} d The changes to apply on this element.
1075
1234
  * @param {Object} [opts]
1076
- * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to `baseRenderer`
1235
+ * @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
1236
+ * @return {null} The lib0 `RDT` "fix" of this apply — always `null`: a `YType` accepts every valid
1237
+ * delta as-is and never needs to self-correct.
1077
1238
  *
1078
1239
  * @public
1079
1240
  */
1080
- applyDelta (d, { renderer = baseRenderer } = {}) {
1241
+ applyDelta (d, { renderer = this._renderer } = {}) {
1242
+ if (d.isEmpty()) return null
1081
1243
  if (this.doc == null) {
1082
1244
  (this._prelim || (this._prelim = /** @type {any} */ (delta.create()))).apply(d)
1083
1245
  } else if (this._item?.deleted !== true) {
@@ -1118,7 +1280,7 @@ export class YType {
1118
1280
  }
1119
1281
  })
1120
1282
  }
1121
- return this
1283
+ return null
1122
1284
  }
1123
1285
 
1124
1286
  /**
@@ -1224,7 +1386,7 @@ export class YType {
1224
1386
  *
1225
1387
  * @param {number} index The index to insert content at.
1226
1388
  * @param {Array<delta.DeltaConfGetChildren<DConf>>|delta.DeltaConfGetText<DConf>} content Array of content to append.
1227
- * @param {delta.FormattingAttributes} [format]
1389
+ * @param {delta.Formats} [format]
1228
1390
  */
1229
1391
  insert (index, content, format) {
1230
1392
  this.applyDelta(delta.create().retain(index).insert(/** @type {any} */ (content), format).done())
@@ -1245,7 +1407,7 @@ export class YType {
1245
1407
  *
1246
1408
  * @param {number} index The index to insert content at.
1247
1409
  * @param {number} length The index to insert content at.
1248
- * @param {delta.FormattingAttributes} formats
1410
+ * @param {delta.Formats} formats
1249
1411
  *
1250
1412
  */
1251
1413
  format (index, length, formats) {
@@ -1519,7 +1681,7 @@ export const computeModifiedFromItems = (store, items) => {
1519
1681
  * @param {any} b
1520
1682
  * @return {boolean}
1521
1683
  */
1522
- export const equalAttrs = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b))
1684
+ export const equalFormats = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b))
1523
1685
 
1524
1686
  /**
1525
1687
  * @template {delta.DeltaConf} DConf
@@ -1922,25 +2084,26 @@ export const typeMapGetDelta = (d, parent, attrsToRender, renderer, deep, modifi
1922
2084
  */
1923
2085
  const cs = []
1924
2086
  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
2087
+ if (cs.length === 0) return // the renderer surfaces nothing for this attribute (e.g. a diff renderer hiding an unchanged delete)
2088
+ const { deleted, attrs, content } = cs[cs.length - 1]
1927
2089
  const attribution = createAttributionFromAttributionItems(attrs, deleted)
1928
2090
  let c = array.last(content.getContent())
1929
2091
  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`).
2092
+ if (attribution != null) {
2093
+ // Item surfaced under attribution (suggestion view / diff renderer, either in snapshot mode
2094
+ // or in an event-driven render). The attribute is still observable in the rendered state, so
2095
+ // emit a positive `SetAttrOp` carrying the attribution metadata - matching how content
2096
+ // children are rendered for the same case (positive `InsertOp` with attribution, never
2097
+ // `DeleteOp`).
2098
+ if (itemsToRender == null || itemsToRender.hasId(item.lastId)) {
1938
2099
  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
2100
  }
2101
+ } else if (itemsToRender != null && itemsToRender.hasId(item.lastId)) {
2102
+ // Hard-deleted attribute within a change render: emit the `deleteAttr` op so consumers (the
2103
+ // `YEvent` delta, RDT bindings, the maintained `delta` cache) can apply the removal. In
2104
+ // full-state mode (`itemsToRender == null`) the attribute is simply omitted (above renders
2105
+ // run with `render === false` for such items, so nothing was emitted before either).
2106
+ d.deleteAttr(key, attribution, c)
1944
2107
  }
1945
2108
  } else if (deep && c instanceof YType && modified?.has(c)) {
1946
2109
  d.modifyAttr(key, c.toDelta(opts))