lib0
Version:
isomorphic utility functions
4,837 lines • 204 kB
JavaScript
/**
* @beta this API is about to change
*
* ## Consumer API
*
* This module exports a large surface, but a consumer only needs a handful of it:
* - **build / apply / inspect:** {@link create} (the constructor) and the {@link DeltaBuilder} methods
* it returns (`insert`/`delete`/`retain`/`modify`/`setAttr`/`addMark`/…, `apply`, `rebase`, `done`);
* {@link clone}, {@link slice}, {@link diff}, {@link inverse}, and `toJSON`/`equals`/`isEmpty` on the result.
* The content/attr builder methods are also exported standalone as shorthands for a `create()`-headed
* chain — `delta.insert(..)` ≡ `delta.create().insert(..)`, see {@link insert}. The `delete`
* shorthand is spelled {@link delete_} (`delete` is a reserved word).
* - **schemas:** {@link $delta} (define a typed delta schema) and {@link $deltaAny} (the catch-all).
* - **types:** `Delta`, `DeltaBuilder`, `DeltaAny`, `DeltaConf` for annotations.
*
* Everything else is `@internal` plumbing: the `*Op` classes and `$*Op` schema sentinels exist for the
* transformer layer's V8-stable dispatch (do not construct them); the `*RootMark*` helpers,
* {@link cloneShallow}, {@link random}, the merge helpers, and the remaining `$*` schemas are for
* cross-module / custom-transformer use, not application code. The other `DeltaConf*` typedefs are the
* inference machinery behind the fluent builder's typed chain — never named directly.
*
* ## Mutability
*
* Deltas are mutable by default. But references are often shared, by marking a Delta as "done". You
* may only modify deltas by applying other deltas to them. Casting a Delta to a DeltaBuilder
* manually, will likely modify "shared" state.
*/
import * as list from '../list.js'
import * as object from '../object.js'
import * as equalityTrait from '../trait/equality.js'
import * as fingerprintTrait from '../trait/fingerprint.js'
import * as arr from '../array.js'
import * as fun from '../function.js'
import * as s from '../schema.js'
import * as error from '../error.js'
import * as math from '../math.js'
import * as rabin from '../hash/rabin.js'
import * as encoding from '../encoding.js'
import * as buffer from '../buffer.js'
import * as patience from '../diff/patience.js'
import * as prng from '../prng.js'
import * as rand from '../random.js'
/**
* Provenance metadata on a content/attr op: *who/what* inserted, deleted, or formatted it. The canonical
* shape below is a convention — apply/diff/equality treat attribution as an **opaque** object (never branching
* on these field names), so custom keys flow through — with ONE exception: the nested `format` key merges per
* inner key (one level), so applying `{format:{italic:[…]}}` *adds* italic while keeping a pre-existing
* `{format:{bold:[…]}}`, and `{format:{bold:null}}` removes just that inner key (an emptied `format` is
* dropped). Every other key is flat/wholesale. The `format`/`formatAt` part exists only on
* children (content ops), never on node attribute ops. As an *update value* on `retain`/`modify`/`*Attr`,
* attribution uses the same unified tri-state as {@link Formats} (`undefined` skip / `null`
* clear / `{k:v}` set / `{k:null}` remove) — see the delta `readme.md` "Formats & Attributions". The blanket
* `null`-clear channel caveat on {@link Formats} applies here too; note `rebase` does not
* reconcile attribution at all (concurrent attribution edits don't converge), so prefer deterministic
* attribution assignment outside of concurrent editing.
*
* @typedef {{
* insert?: string[]
* insertAt?: number
* delete?: string[]
* deleteAt?: number
* format?: Record<string,string[]>
* formatAt?: number
* }} Attribution
*/
/**
* @type {s.Schema<Attribution>}
*/
export const $attribution = /* @__PURE__ */(() => s.$object({
insert: s.$array(s.$string).optional,
insertAt: s.$number.optional,
delete: s.$array(s.$string).optional,
deleteAt: s.$number.optional,
format: s.$record(s.$string, s.$array(s.$string)).optional,
formatAt: s.$number.optional
}))()
/**
* Rich-text formatting attributes (`{ bold: true, … }`). As an *update value* on `retain`/`modify` it is a
* unified tri-state (identical to {@link Attribution}): `undefined`/omitted = skip, `null` = clear all,
* `{k:v}` = set, `{k:null}` = remove key — see the delta `readme.md` "Formats & Attributions".
*
* ⚠️ **Blanket `null` clear is a local-only utility — never send it over a channel.** A `null` clear carries
* no key information, so it cannot be reconciled key-by-key under `rebase`: a clear *always wins* a concurrent
* edit (priority-independent — that is what makes it converge), which silently DROPS the other side's set. For
* collaborative / transmitted data, clear keys individually with `{k:null}` removals instead — those reconcile
* by priority and converge without data loss (this is what {@link diff} emits and what the rebase fuzz uses).
*
* @typedef {{ [key: string]: any }} Formats
*/
/**
* @typedef {{ id: string, key: number|string, assoc: 1|-1, attrs?: object, transient?: boolean }} MarkJSON
*/
/**
* @typedef {{
* type: 'delta',
* name?: string,
* attrs?: { [Key in string|number]: DeltaAttrOpJSON },
* children?: Array<DeltaListOpJSON>,
* marks?: Array<MarkJSON>,
* deleteMarks?: Array<string>
* }} DeltaJSON
*/
/**
* @typedef {{ type: 'insert', insert: string|Array<any>, format?: { [key: string]: any }, attribution?: Attribution } | { delete: number } | { type: 'retain', retain: number, format?: { [key:string]: any }|null, attribution?: Attribution|null } | { type: 'modify', value: object, format?: { [key:string]: any }|null, attribution?: Attribution|null }} DeltaListOpJSON
*/
/**
* @typedef {{ type: 'insert', value: any, attribution?: Attribution } | { type: 'delete', attribution?: Attribution } | { type: 'modify', value: DeltaJSON, attribution?: Attribution|null }} DeltaAttrOpJSON
*/
/**
* @typedef {TextOp|InsertOp<any>|DeleteOp<any>|RetainOp|ModifyOp<any>} ChildrenOpAny
*/
/**
* @typedef {SetAttrOp<any>|DeleteAttrOp<any>|ModifyAttrOp} AttrOpAny
*/
/**
* @typedef {ChildrenOpAny|AttrOpAny} _OpAny
*/
/**
* @type {s.Schema<DeltaAttrOpJSON>}
*/
export const $deltaMapChangeJson = /* @__PURE__ */(() => s.$union(
s.$object({ type: s.$literal('insert'), value: s.$any, attribution: $attribution.optional }),
s.$object({ type: s.$literal('modify'), value: s.$any, attribution: $attribution.optional }),
s.$object({ type: s.$literal('delete'), attribution: $attribution.optional })
))()
/**
* @template {{[key:string]: any} | null | undefined} Attrs
* @param {Attrs} attrs
* @return {Attrs}
*/
const _cloneAttrs = attrs => attrs == null ? attrs : { ...attrs }
/**
* A retain with neither format nor attribution — positional only, not content. `done()` trims a
* trailing run of them; fingerprint, equality and `isEmpty` ignore that run. (`format: null` is a
* clear instruction and therefore content — the same rule `done()` applies.)
*
* @param {ChildrenOpAny} op
*/
const _isPlainRetain = op => $retainOp.check(op) && op.format === undefined && op.attribution === undefined
/**
* The number of content ops in a children list: its length minus the trailing plain-retain run (found
* by walking from the right). The first `n` ops from `cs.start` are the content.
*
* @param {list.List<ChildrenOpAny>} cs
* @return {number}
*/
const _contentLen = cs => {
let n = cs.len
for (let end = cs.end; end !== null && _isPlainRetain(end); end = end.prev) n--
return n
}
/**
* Shallow per-key tri-state merge of `update` into `base` (the usual `format`-dimension semantics, also reused
* as the inner step of {@link mergeAttr}). Per key: `undefined` skips, `null` removes, anything else sets.
* `resolve` true (data semantics) applies a `null` removal by deleting the key; false (instruction semantics)
* keeps the `null` verbatim so it still removes when the instruction is later applied. When `resolve` is true
* the `base` is canonicalised too — a `{k:null}` already present in `base` has nothing to clear on settled
* data, so it is dropped rather than copied through. (The used-context call sites pass pre-resolved
* companions — see {@link resolveUsedData} — so for them this is a no-op safety net; it matters for the
* apply/diff paths that merge instructions onto stored values.)
*
* @param {{[k:string]:any}|null|undefined} base
* @param {{[k:string]:any}} update
* @param {boolean} resolve
* @return {{[k:string]:any}}
*/
const mergeShallow = (base, update, resolve) => {
const r = /** @type {{[k:string]:any}} */ ({})
// copy base; when resolving, drop any `{k:null}` removal already in base (settled data carries no `null` leaf)
if (s.$objectAny.check(base)) for (const k in base) { if (!resolve || base[k] !== null) r[k] = base[k] }
for (const k in update) {
const v = update[k]
if (v === undefined) continue // skip this key
else if (v !== null) r[k] = v // set this key
else if (resolve) delete r[k] // data: apply the removal
else r[k] = null // instruction: keep the `null` removal verbatim for later
}
return r
}
/**
* Merge for the `attribution` dimension: the usual shallow tri-state ({@link mergeShallow}) **except** the
* `format` key — attribution's one structured sub-field — is itself merged per inner key (one level only, same
* tri-state). This realises "first merge the formats, then merge the whole attribution". The nesting applies to
* the `format` key alone; every other attribution key (`insert`/`delete` arrays, the `*At` numbers, …) stays a
* leaf. An emptied `format` is dropped (a settled attribution never stores `format: {}`). When `resolve` is
* true the `base` is canonicalised too (see {@link mergeShallow}): a `{k:null}` leaf in `base`, and a
* `{innerK:null}` inside `base.format`, have nothing to clear on settled data, so they are dropped — an
* emptied `base.format` is removed. This collapses a `useAttribution({format:{bold:null}})`-style context on
* a data op instead of storing the removal.
*
* @param {{[k:string]:any}|null|undefined} base
* @param {{[k:string]:any}} update
* @param {boolean} resolve see {@link mergeShallow}
* @return {{[k:string]:any}}
*/
const mergeAttr = (base, update, resolve) => {
// copy base; when resolving, canonicalise it — drop a `{k:null}` leaf and resolve `base.format`'s own
// inner removals (dropping an emptied `format`). On an instruction merge (`!resolve`) base is copied verbatim.
const r = /** @type {{[k:string]:any}} */ ({})
if (s.$objectAny.check(base)) {
for (const k in base) {
const bv = base[k]
if (resolve && bv === null) continue // drop a base leaf removal
if (resolve && k === 'format' && s.$objectAny.check(bv)) {
const f = mergeShallow(bv, {}, true) // resolve base.format's own `{innerK:null}` removals
if (!object.isEmpty(f)) r[k] = f // drop an emptied format
} else r[k] = bv
}
}
for (const k in update) {
const v = update[k]
if (v === undefined) continue // skip this key
else if (k === 'format' && s.$objectAny.check(v)) { // the one nested key: merge per inner key (one level)
const sub = mergeShallow(r[k], v, resolve)
if (object.isEmpty(sub)) delete r[k] // emptied format ⇒ drop the key
else r[k] = sub
} else if (v !== null) r[k] = v // set leaf
else if (resolve) delete r[k] // data: apply the removal
else r[k] = null // instruction: keep the `null` removal verbatim for later
}
return r
}
/**
* Resolve a retain's tri-state `format`/`attribution` instruction against an EMPTY base — used by a
* `final` (materializing) apply for a retain that extends beyond existing content: a `{k:null}` removal or
* a `null` clear has nothing to act on, so it collapses to `undefined` (skip); a set survives. `deep`
* selects the attribution merge (its nested `format` key merges one level, {@link mergeAttr}) vs. the
* shallow `format` merge ({@link mergeShallow}).
*
* @param {{[k:string]:any}|null|undefined} arg
* @param {boolean} deep
* @return {{[k:string]:any}|undefined}
*/
const resolveBeyond = (arg, deep) => {
if (arg == null) return undefined // undefined = skip; null = clear-all with nothing to clear = no-op
const m = deep ? mergeAttr(undefined, arg, true) : mergeShallow(undefined, arg, true)
return object.isEmpty(m) ? undefined : m
}
/**
* Resolve a `used*` context (set via {@link DeltaBuilder#useFormats}/`useAttribution`) for **data**
* consumption: settled data stores canonical values only
* (no `null` leaves — see {@link combineData}), while the slot itself keeps removal instructions
* verbatim for the instruction ops it governs. Returns the SAME object when it is already canonical
* (the common case — provenance contexts carry no removals), so every data op under one context shares
* one interned object; otherwise strips the removals ONCE into a shared copy (`{}` collapses to `null`).
* Never called per op — {@link DeltaBuilder#_getUsedFormatsData} caches the result per context object.
*
* @param {{[k:string]:any}|null} used
* @param {boolean} deep `true` for the `attribution` dimension (its `format` key resolves one level
* deeper via {@link mergeAttr}); `false` for `format` (shallow, {@link mergeShallow})
* @return {{[k:string]:any}|null}
*/
const resolveUsedData = (used, deep) => {
if (used === null) return null
let empty = true
let canonical = true
for (const k in used) {
empty = false
const v = used[k]
if (v === null) { canonical = false; break } // a top-level removal (incl. `format: null`)
if (deep && k === 'format' && s.$objectAny.check(v)) {
// mirror mergeAttr's resolve-mode base handling: an emptied `format` is dropped on data, and an
// inner `{k:null}` removal is stripped — either makes the context non-canonical for data ops
let formatCanonical = false // empty `format: {}` ⇒ non-canonical
for (const ik in v) {
if (v[ik] === null) { formatCanonical = false; break }
formatCanonical = true
}
if (!formatCanonical) { canonical = false; break }
}
}
if (empty) return null
if (canonical) return used
const m = deep ? mergeAttr(used, {}, true) : mergeShallow(used, {}, true)
return object.isEmpty(m) ? null : m
}
/**
* Combine a builder's `used*` context with a per-call format/attribution argument for an
* **instruction** op (`retain`/`modify`/`modifyAttr`) under the unified tri-state: `undefined` — or an
* empty object, an empty update — inherits the context BY IDENTITY (no traversal, no copy; contexts
* are interned); `null` clears (ignoring the context); a non-empty object merges over the context,
* keeping `{k:null}` removals verbatim so they still remove when the instruction is later applied.
* An empty result is a no-op → `undefined` (skip).
*
* @param {{[k:string]:any}|null} used the raw context slot (removal instructions intact)
* @param {{[k:string]:any}|null|undefined} arg
* @param {boolean} deep `true` for the `attribution` dimension (its `format` key merges per inner key via
* {@link mergeAttr}); `false` for `format` (shallow, {@link mergeShallow}).
* @return {{[k:string]:any}|null|undefined}
*/
const combineInstr = (used, arg, deep) => {
if (arg === null) return null // explicit clear — must precede the isEmpty check (object.isEmpty(null) is true)
if (arg === undefined || object.isEmpty(arg)) return used === null || object.isEmpty(used) ? undefined : used
const r = deep ? mergeAttr(used, arg, false) : mergeShallow(used, arg, false)
return object.isEmpty(r) ? undefined : r
}
/**
* Combine for a **data** op (`insert`/`text`/`setAttr`/`deleteAttr`): the stored value is a canonical
* object or `null` ("none") — never a `{k:null}` removal, there is nothing to remove on settled data.
* `usedData` is the builder's RESOLVED context companion ({@link resolveUsedData}, cached by
* {@link DeltaBuilder#_getUsedFormatsData}) and thus already canonical (`null` or a non-empty
* no-`null` object), so `undefined` — or an empty object, an empty update — inherits it BY IDENTITY
* (no traversal, no copy); `null` clears; a non-empty object merges over it, resolving the argument's
* own `{k:null}` removals. `{}` results collapse to `null`.
*
* @param {{[k:string]:any}|null} usedData
* @param {{[k:string]:any}|null|undefined} arg
* @param {boolean} deep see {@link combineInstr}
* @return {{[k:string]:any}|null}
*/
const combineData = (usedData, arg, deep) => {
if (arg === null) return null // explicit clear — must precede the isEmpty check (object.isEmpty(null) is true)
if (arg === undefined || object.isEmpty(arg)) return usedData
const r = deep ? mergeAttr(usedData, arg, true) : mergeShallow(usedData, arg, true)
return object.isEmpty(r) ? null : /** @type {{[k:string]:any}} */ (r)
}
/**
* Freeze a maybe-delta for a frozen clone (a scalar passes through untouched). `done()` is
* fingerprint-neutral (it only trims trailing plain retains, which are not fingerprinted), so the
* clone may carry the op's cached fingerprint over.
*
* @template {any} MaybeDelta
* @param {MaybeDelta} maybeDelta
* @return {MaybeDelta}
*/
const _markMaybeDeltaAsDone = maybeDelta => $deltaAny.check(maybeDelta) ? /** @type {MaybeDelta} */ (maybeDelta.done()) : maybeDelta
/**
* Invariants shared by all op classes below (TextOp, InsertOp, DeleteOp,
* RetainOp, ModifyOp, SetAttrOp, DeleteAttrOp, ModifyAttrOp):
*
* - **Only code inside `delta.js` may mutate op fields.** External consumers
* treat ops as immutable; structural fields are JSDoc-annotated `@readonly`
* to reinforce this. Mutation is permitted only while the owning Delta is
* not `done` — every builder entry point routes through `modDeltaCheck`
* to enforce this at runtime.
* - **Any mutation of a fingerprinted field MUST null `_fingerprint`.** The
* fingerprint is a lazy cache; if it has already been computed and the
* underlying data changes without invalidating it, every subsequent
* fingerprint read (and any `diff` / equality check that relies on it) is
* wrong. Fields covered: insert, delete, retain, format, attribution,
* value, key.
* - **A `clone` copies `_fingerprint` when the copy is content-identical:** a full range and the
* same key. Freezing a nested value with `done()` is fingerprint-neutral (trailing plain retains
* are not fingerprinted), so nothing else can differ. The root delta returned by
* `clone`/`slice`/`cloneDeep` never inherits a fingerprint.
*
* @internal not part of the consumer API — a content op; build deltas via {@link create}/{@link DeltaBuilder}.
*/
export class TextOp extends list.ListNode {
/**
* @param {string} insert
* @param {Formats|null} format
* @param {Attribution?} attribution
*/
constructor (insert, format, attribution) {
super()
// Whenever this is modified, make sure to clear _fingerprint
/**
* @readonly
* @type {string}
*/
this.insert = insert
/**
* @readonly
* @type {Formats|null}
*/
this.format = format
this.attribution = attribution
/**
* @type {string?}
*/
this._fingerprint = null
}
/**
* @return {'insert'}
*/
get type () {
return 'insert'
}
get length () {
return this.insert.length
}
get fingerprint () {
return this._fingerprint || (this._fingerprint = buffer.toBase64(encoding.encode(encoder => {
encoding.writeVarUint(encoder, 0) // textOp type: 0
encoding.writeVarString(encoder, this.insert)
encoding.writeAny(encoder, this.format)
encoding.writeAny(encoder, this.attribution)
})))
}
/**
* Remove a part of the operation (similar to Array.splice)
*
* @param {number} offset
* @param {number} len
*/
_splice (offset, len) {
this._fingerprint = null
// @ts-ignore
this.insert = this.insert.slice(0, offset) + this.insert.slice(offset + len)
return this
}
/**
* @return {DeltaListOpJSON}
*/
toJSON () {
const { insert, format, attribution } = this
return object.assign(/** @type {{type: 'insert', insert: string}} */ ({ type: 'insert', insert }), format != null ? { format } : ({}), attribution != null ? { attribution } : ({}))
}
/**
* @param {TextOp} other
*/
[equalityTrait.EqualityTraitSymbol] (other) {
return $textOp.check(other) &&
fun.equalityDeep(this.insert, other.insert) &&
(
(object.isEmpty(this.format) && object.isEmpty(other.format)) || fun.equalityDeep(this.format, other.format)
) &&
fun.equalityDeep(this.attribution, other.attribution)
}
/**
* @param {number} [start]
* @param {number} [end]
* @param {boolean} [_markAsDone] accepted for a uniform children-op `clone` signature; ignored (text
* holds no nested deltas to freeze).
* @return {TextOp}
*/
clone (start = 0, end = this.length, _markAsDone = true) {
const cpy = new TextOp(this.insert.slice(start, end), _cloneAttrs(this.format), _cloneAttrs(this.attribution))
if (start === 0 && end === this.length) cpy._fingerprint = this._fingerprint
return cpy
}
}
/**
* @template {any} ArrayContent
* @internal not part of the consumer API — a content op; build deltas via {@link create}/{@link DeltaBuilder}.
*/
export class InsertOp extends list.ListNode {
/**
* @param {Array<ArrayContent>} insert
* @param {Formats|null} format
* @param {Attribution?} attribution
*/
constructor (insert, format, attribution) {
super()
/**
* @readonly
* @type {Array<ArrayContent>}
*/
this.insert = insert
/**
* @readonly
* @type {Formats?}
*/
this.format = format
/**
* @readonly
* @type {Attribution?}
*/
this.attribution = attribution
/**
* @type {string?}
*/
this._fingerprint = null
}
/**
* @return {'insert'}
*/
get type () {
return 'insert'
}
get length () {
return this.insert.length
}
/**
* @param {number} i
* @return {Extract<ArrayContent,DeltaAny>}
*/
_modValue (i) {
/**
* @type {any}
*/
let d = this.insert[i]
this._fingerprint = null
$deltaAny.expect(d)
if (d.isDone) {
// @ts-ignore
this.insert[i] = (d = clone(d))
return d
}
return d
}
get fingerprint () {
return this._fingerprint || (this._fingerprint = buffer.toBase64(encoding.encode(encoder => {
encoding.writeVarUint(encoder, 1) // insertOp type: 1
encoding.writeVarUint(encoder, this.insert.length)
this.insert.forEach(ins => {
encoding.writeVarString(encoder, fingerprintTrait.fingerprint(/** @type {any} */ (ins)))
})
encoding.writeAny(encoder, this.format)
encoding.writeAny(encoder, this.attribution)
})))
}
/**
* Remove a part of the operation (similar to Array.splice)
*
* @param {number} offset
* @param {number} len
*/
_splice (offset, len) {
this._fingerprint = null
this.insert.splice(offset, len)
return this
}
/**
* @return {DeltaListOpJSON}
*/
toJSON () {
const { insert, format, attribution } = this
return object.assign({ type: /** @type {'insert'} */ ('insert'), insert: insert.map(ins => $deltaAny.check(ins) ? ins.toJSON() : ins) }, format ? { format } : ({}), attribution != null ? { attribution } : ({}))
}
/**
* @param {InsertOp<ArrayContent>} other
*/
[equalityTrait.EqualityTraitSymbol] (other) {
return $insertOp.check(other) &&
fun.equalityDeep(this.insert, other.insert) &&
(
(object.isEmpty(this.format) && object.isEmpty(other.format)) || fun.equalityDeep(this.format, other.format)
) &&
fun.equalityDeep(this.attribution, other.attribution)
}
/**
* @param {number} [start]
* @param {number} [end]
* @param {boolean} [markAsDone] freeze the cloned child deltas (the default — a shared clone must be
* immutable). Pass `false` only when the caller is the SOLE owner of the cloned range (a *move*, e.g.
* `splitHere` which `_splice`s the range out of the source), so the content can stay mutable and is
* not re-cloned on the next modify (see {@link modValue}).
* @return {InsertOp<ArrayContent>}
*/
clone (start = 0, end = this.length, markAsDone = true) {
const insert = this.insert.slice(start, end)
const cpy = new InsertOp(markAsDone ? insert.map(_markMaybeDeltaAsDone) : insert, _cloneAttrs(this.format), _cloneAttrs(this.attribution))
if (start === 0 && end === this.length) cpy._fingerprint = this._fingerprint
return cpy
}
}
/**
* @template {DeltaConf} [Conf={}]
* @internal not part of the consumer API — a content op; build deltas via {@link create}/{@link DeltaBuilder}.
*/
export class DeleteOp extends list.ListNode {
/**
* @param {number} len
*/
constructor (len) {
super()
this.delete = len
/**
* @type {string|null}
*/
this._fingerprint = null
}
/**
* @return {'delete'}
*/
get type () {
return 'delete'
}
get length () {
return this.delete
}
get fingerprint () {
return this._fingerprint || (this._fingerprint = buffer.toBase64(encoding.encode(encoder => {
encoding.writeVarUint(encoder, 2) // deleteOp type: 2
encoding.writeVarUint(encoder, this.delete)
})))
}
/**
* Remove a part of the operation (similar to Array.splice)
*
* @param {number} _offset
* @param {number} len
*/
_splice (_offset, len) {
this._fingerprint = null
this.delete -= len
return this
}
/**
* @return {DeltaListOpJSON}
*/
toJSON () {
return { delete: this.delete }
}
/**
* @param {DeleteOp<any>} other
*/
[equalityTrait.EqualityTraitSymbol] (other) {
return $deleteOp.check(other) && this.delete === other.delete
}
/**
* @param {number} [start]
* @param {number} [end]
* @param {boolean} [_markAsDone] accepted for a uniform children-op `clone` signature; ignored (a
* delete holds no nested content).
* @return {DeleteOp<any>}
*/
clone (start = 0, end = this.delete, _markAsDone = true) {
const cpy = new DeleteOp(end - start)
if (end - start === this.delete) cpy._fingerprint = this._fingerprint
return cpy
}
}
/**
* @internal not part of the consumer API — a content op; build deltas via {@link create}/{@link DeltaBuilder}.
*/
export class RetainOp extends list.ListNode {
/**
* @param {number} retain
* @param {Formats|null|undefined} format tri-state: `undefined` skip / `null` clear / object merge
* @param {Attribution|null|undefined} attribution tri-state: `undefined` skip / `null` clear / object merge
*/
constructor (retain, format, attribution) {
super()
/**
* @readonly
* @type {number}
*/
this.retain = retain
/**
* @readonly
* @type {Formats|null|undefined}
*/
this.format = format
/**
* @readonly
* @type {Attribution|null|undefined}
*/
this.attribution = attribution
/**
* @type {string|null}
*/
this._fingerprint = null
}
/**
* @return {'retain'}
*/
get type () {
return 'retain'
}
get length () {
return this.retain
}
get fingerprint () {
return this._fingerprint || (this._fingerprint = buffer.toBase64(encoding.encode(encoder => {
encoding.writeVarUint(encoder, 3) // retainOp type: 3
encoding.writeVarUint(encoder, this.retain)
encoding.writeAny(encoder, this.format)
encoding.writeAny(encoder, this.attribution)
})))
}
/**
* Remove a part of the operation (similar to Array.splice)
*
* @param {number} _offset
* @param {number} len
*/
_splice (_offset, len) {
// @ts-ignore
this.retain -= len
this._fingerprint = null
return this
}
/**
* @return {DeltaListOpJSON}
*/
toJSON () {
const { retain, format, attribution } = this
// retain is an instruction op: emit `null` (clear) distinctly from `undefined` (skip / omitted)
return object.assign({ type: /** @type {'retain'} */ ('retain'), retain }, format !== undefined ? { format } : {}, attribution !== undefined ? { attribution } : {})
}
/**
* @param {RetainOp} other
*/
[equalityTrait.EqualityTraitSymbol] (other) {
return $retainOp.check(other) &&
this.retain === other.retain &&
(
(object.isEmpty(this.format) && object.isEmpty(other.format)) || fun.equalityDeep(this.format, other.format)
) &&
fun.equalityDeep(this.attribution, other.attribution)
}
/**
* @param {number} [start]
* @param {number} [end]
* @param {boolean} [_markAsDone] accepted for a uniform children-op `clone` signature; ignored (retain
* holds no nested deltas).
* @return {RetainOp}
*/
clone (start = 0, end = this.retain, _markAsDone = true) {
const cpy = new RetainOp(end - start, _cloneAttrs(this.format), _cloneAttrs(this.attribution))
if (end - start === this.retain) cpy._fingerprint = this._fingerprint
return cpy
}
}
/**
* Delta that can be applied on a YType Embed
*
* @template {Delta<any>} [DTypes=DeltaAny]
* @internal not part of the consumer API — a content op; build deltas via {@link create}/{@link DeltaBuilder}.
*/
export class ModifyOp extends list.ListNode {
/**
* @param {DTypes} delta
* @param {Formats|null|undefined} format tri-state: `undefined` skip / `null` clear / object merge
* @param {Attribution|null|undefined} attribution tri-state: `undefined` skip / `null` clear / object merge
*/
constructor (delta, format, attribution) {
super()
/**
* @readonly
* @type {DTypes}
*/
this.value = delta
/**
* @readonly
* @type {Formats|null|undefined}
*/
this.format = format
/**
* @readonly
* @type {Attribution|null|undefined}
*/
this.attribution = attribution
/**
* @type {string|null}
*/
this._fingerprint = null
}
/**
* @return {'modify'}
*/
get type () {
return 'modify'
}
get length () {
return 1
}
/**
* @type {DeltaBuilderAny}
*/
get _modValue () {
return modValue(this)
}
get fingerprint () {
return this._fingerprint || (this._fingerprint = buffer.toBase64(encoding.encode(encoder => {
encoding.writeVarUint(encoder, 4) // modifyOp type: 4
encoding.writeVarString(encoder, this.value.fingerprint)
encoding.writeAny(encoder, this.format)
encoding.writeAny(encoder, this.attribution)
})))
}
/* c8 ignore start */
// ModifyOp has length 1, so callers never pass offset>0 or len>0 — splitHere
// is a no-op for length-1 ops. Kept for the structural _splice contract.
/**
* Remove a part of the operation (similar to Array.splice)
*
* @param {number} _offset
* @param {number} _len
*/
_splice (_offset, _len) {
return this
}
/* c8 ignore stop */
/**
* @return {DeltaListOpJSON}
*/
toJSON () {
const { value, attribution, format } = this
// modify is an instruction op: emit `null` (clear) distinctly from `undefined` (skip / omitted)
return object.assign(
{ type: /** @type {'modify'} */ ('modify'), value: value.toJSON() },
format !== undefined ? { format } : {},
attribution !== undefined ? { attribution } : {}
)
}
/**
* @param {ModifyOp<any>} other
*/
[equalityTrait.EqualityTraitSymbol] (other) {
return $modifyOp.check(other) &&
this.value[equalityTrait.EqualityTraitSymbol](other.value) &&
(
(object.isEmpty(this.format) && object.isEmpty(other.format)) || fun.equalityDeep(this.format, other.format)
) &&
fun.equalityDeep(this.attribution, other.attribution)
}
/**
* @param {number} [_start] ignored (a modify is atomic); accepted for a uniform children-op signature.
* @param {number} [_end] ignored.
* @param {boolean} [markAsDone] freeze the cloned value (default); `false` shares it mutable — see
* {@link InsertOp#clone}. The op itself is still a fresh node; only its `value` is consumed.
* @return {ModifyOp<DTypes>}
*/
clone (_start = 0, _end = 1, markAsDone = true) {
// `markAsDone=false` is currently unreachable: only `_splitOp` passes `false`, and a length-1
// modify is never split (apply's `move ? op : op.clone(0, 1, keep)` evaluates the clone only when
// `keep` is true). Kept for the uniform children-op `clone` signature.
/* c8 ignore next */
const cpy = new ModifyOp(/** @type {DTypes} */ (markAsDone ? this.value.done() : this.value), _cloneAttrs(this.format), _cloneAttrs(this.attribution))
cpy._fingerprint = this._fingerprint
return cpy
}
}
/**
* @template {any} [V=any]
* @template {string|number} [K=any]
* @internal not part of the consumer API — an attribute op; build deltas via {@link create}/{@link DeltaBuilder}.
*/
export class SetAttrOp {
/**
* @param {K} key
* @param {V} value
* @param {Attribution?} attribution
*/
constructor (key, value, attribution) {
/**
* @readonly
* @type {K}
*/
this.key = key
/**
* @readonly
* @type {V}
*/
this.value = value
/**
* @readonly
* @type {Attribution?}
*/
this.attribution = attribution
/**
* @type {string|null}
*/
this._fingerprint = null
}
/**
* @return {'insert'}
*/
get type () { return 'insert' }
/**
* @type {DeltaBuilderAny}
*/
get _modValue () {
return modValue(this)
}
get fingerprint () {
return this._fingerprint || (this._fingerprint = buffer.toBase64(encoding.encode(encoder => {
encoding.writeVarUint(encoder, 5) // map insert type: 5
encoding.writeAny(encoder, this.key)
if ($deltaAny.check(this.value)) {
encoding.writeUint8(encoder, 0)
encoding.writeVarString(encoder, this.value.fingerprint)
} else {
encoding.writeUint8(encoder, 1)
encoding.writeAny(encoder, /** @type {any} */ (this.value))
}
encoding.writeAny(encoder, this.attribution)
})))
}
toJSON () {
const v = this.value
const attribution = this.attribution
return object.assign(
{
type: this.type,
value: $deltaAny.check(v) ? v.toJSON() : v
},
attribution != null ? { attribution } : {}
)
}
/**
* @param {SetAttrOp<V>} other
*/
[equalityTrait.EqualityTraitSymbol] (other) {
return $setAttrOp.check(other) && this.key === other.key && fun.equalityDeep(this.value, other.value) && fun.equalityDeep(this.attribution, other.attribution)
}
/**
* Full (frozen) clone. A `move` apply reuses the source op instead of cloning, so there is no
* shared-mutable variant — see {@link DeltaBuilder#apply}.
*
* @template {string|number} [K2=K]
* @param {K2} [key] retarget the clone to another attribute key (a transformer projecting or
* renaming an attribute); defaults to this op's key.
* @return {SetAttrOp<V,K2>}
*/
clone (key = /** @type {any} */ (this.key)) {
const cpy = new SetAttrOp(key, _markMaybeDeltaAsDone(this.value), _cloneAttrs(this.attribution))
if (/** @type {string|number} */ (key) === this.key) cpy._fingerprint = this._fingerprint // the key is part of the fingerprint
return cpy
}
}
/**
* @template [V=any]
* @template {string|number} [K=string|number]
* @internal not part of the consumer API — an attribute op; build deltas via {@link create}/{@link DeltaBuilder}.
*/
export class DeleteAttrOp {
/**
* @param {K} key
* @param {Attribution?} attribution
*/
constructor (key, attribution) {
/**
* @type {K}
*/
this.key = key
this.attribution = attribution
/**
* @type {string|null}
*/
this._fingerprint = null
}
/**
* @type {'delete'}
*/
get type () { return 'delete' }
get value () { return undefined }
get fingerprint () {
return this._fingerprint || (this._fingerprint = buffer.toBase64(encoding.encode(encoder => {
encoding.writeVarUint(encoder, 6) // map delete type: 6
encoding.writeAny(encoder, this.key)
encoding.writeAny(encoder, this.attribution)
})))
}
/**
* @return {DeltaAttrOpJSON}
*/
toJSON () {
const {
type, attribution
} = this
return object.assign(
{ type },
attribution != null ? { attribution } : {}
)
}
/**
* @param {DeleteAttrOp<V>} other
*/
[equalityTrait.EqualityTraitSymbol] (other) {
return $deleteAttrOp.check(other) && this.key === other.key && fun.equalityDeep(this.attribution, other.attribution)
}
/**
* Full (frozen) clone; a `move` apply reuses the source op instead — see {@link DeltaBuilder#apply}.
*
* @template {string|number} [K2=K]
* @param {K2} [key] retarget the clone to another attribute key; defaults to this op's key.
* @return {DeleteAttrOp<V,K2>}
*/
clone (key = /** @type {any} */ (this.key)) {
const cpy = new DeleteAttrOp(key, _cloneAttrs(this.attribution))
if (/** @type {string|number} */ (key) === this.key) cpy._fingerprint = this._fingerprint // the key is part of the fingerprint
return cpy
}
}
/**
* @template {DeltaAny} [Modifier=DeltaAny]
* @template {string|number} [K=string]
* @internal not part of the consumer API — an attribute op; build deltas via {@link create}/{@link DeltaBuilder}.
*/
export class ModifyAttrOp {
/**
* @param {K} key
* @param {Modifier} delta
* @param {Attribution|null|undefined} attribution tri-state: `undefined` skip / `null` clear / object merge
*/
constructor (key, delta, attribution) {
/**
* @readonly
* @type {K}
*/
this.key = key
/**
* @readonly
* @type {Modifier}
*/
this.value = delta
/**
* @readonly
* @type {Attribution|null|undefined}
*/
this.attribution = attribution
/**
* @type {string|null}
*/
this._fingerprint = null
}
/**
* @type {'modify'}
*/
get type () { return 'modify' }
get fingerprint () {
return this._fingerprint || (this._fingerprint = buffer.toBase64(encoding.encode(encoder => {
encoding.writeVarUint(encoder, 7) // map modify type: 7
encoding.writeAny(encoder, this.key)
encoding.writeVarString(encoder, this.value.fingerprint)
encoding.writeAny(encoder, this.attribution)
})))
}
/**
* @return {DeltaBuilder<any>}
*/
get _modValue () {
return modValue(this)
}
/**
* @return {DeltaAttrOpJSON}
*/
toJSON () {
const attribution = this.attribution
return object.assign(
{
type: this.type,
value: this.value.toJSON()
},
// modifyAttr applies its attribution as an instruction: emit `null` (clear) distinctly from skip
attribution !== undefined ? { attribution } : {}
)
}
/**
* @param {ModifyAttrOp<Modifier>} other
*/
[equalityTrait.EqualityTraitSymbol] (other) {
return $modifyAttrOp.check(other) && this.key === other.key && this.value[equalityTrait.EqualityTraitSymbol](other.value) && fun.equalityDeep(this.attribution, other.attribution)
}
/**
* Full (frozen) clone; a `move` apply reuses the source op instead — see {@link DeltaBuilder#apply}.
*
* @template {string|number} [K2=K]
* @param {K2} [key] retarget the clone to another attribute key; defaults to this op's key.
* @return {ModifyAttrOp<Modifier,K2>}
*/
clone (key = /** @type {any} */ (this.key)) {
const cpy = new ModifyAttrOp(key, /** @type {Modifier} */ (this.value.done()), _cloneAttrs(this.attribution))
if (/** @type {string|number} */ (key) === this.key) cpy._fingerprint = this._fingerprint // the key is part of the fingerprint
return cpy
}
}
export const $insertOp = /** @type {s.Schema<InsertOp<any>>} */ (InsertOp.prototype.$type = /** @type {s.Schema<InsertOp<any>>} */ (s.$type('d:insertOp', InsertOp)))
export const $modifyOp = ModifyOp.prototype.$type = s.$type('d:modifyOp', ModifyOp)
export const $textOp = TextOp.prototype.$type = s.$type('d:textOp', TextOp)
export const $deleteOp = /** @type {s.Schema<DeleteOp<any>>} */ (DeleteOp.prototype.$type = s.$type('d:deleteOp', DeleteOp))
export const $retainOp = RetainOp.prototype.$type = s.$type('d:retainOp', RetainOp)
export const $anyOp = s.$union($insertOp, $deleteOp, $textOp, $modifyOp)
export const $setAttrOp = /** @type {s.Schema<SetAttrOp<any>>} */ (SetAttrOp.prototype.$type =/** @type {s.Schema<SetAttrOp<any>>} */ (s.$type('d:setAttrOp', SetAttrOp)))
export const $modifyAttrOp = /** @type {s.Schema<ModifyAttrOp<any>>} */ (ModifyAttrOp.prototype.$type = /** @type {s.Schema<ModifyAttrOp<any>>} */ (s.$type('d:modifyAttrOp', ModifyAttrOp)))
export const $deleteAttrOp = /** @type {s.Schema<DeleteAttrOp<any>>} */ (DeleteAttrOp.prototype.$type = /** @type {s.Schema<DeleteAttrOp<any>>} */ (s.$type('d:deleteAttrOp', DeleteAttrOp)))
export const $anyAttrOp = s.$union($setAttrOp, $deleteAttrOp, $modifyAttrOp)
/**
* @template {fingerprintTrait.Fingerprintable} Content
* @param {s.Schema<Content>} $content
* @return {s.Schema<SetAttrOp<Content>>}
*/
export const $setAttrOpWith = $content => s.$custom(o => $setAttrOp.check(o) && $content.check(o.value))
/**
* @template {fingerprintTrait.Fingerprintable} Content
* @param {s.Schema<Content>} $content
* @return {s.Schema<InsertOp<Content>>}
*/
export const $insertOpWith = $content => s.$custom(o => $insertOp.check(o) && o.insert.every(ins => $content.check(ins)))
/**
* @template {DeltaAny} Modify
* @param {s.Schema<Modify>} $content
* @return {s.Schema<ModifyOp<Modify>>}
*/
export const $modifyOpWith = $content => s.$custom(o => $modifyOp.check(o) && $content.check(o.value))
/**
* @template {DeltaAny} Modify
* @param {s.Schema<Modify>} $content
* @return {s.Schema<ModifyAttrOp<Modify>>}
*/
export const $modifyAttrOpWith = $content => s.$custom(o => $modifyAttrOp.check(o) && $content.check(/** @type {ModifyAttrOp<Modify>} */ (o).value))
/**
* @template {{[Key in string|number]: any}} Attrs
* @template {string|number} Key
* @template {any} Val
* @typedef {{ [K in (Key | keyof Attrs)]: (unknown extends Attrs[K] ? never : Attrs[K]) | (Key extends K ? Val : never) } & {}} AddToAttrs
*/
/**
* @template {{[Key in string|number|symbol]: any}} Attrs
* @template {{[Key in string|number|symbol]: any}} NewAttrs
* @typedef {{ [K in (keyof NewAttrs | keyof Attrs)]: (unknown extends Attrs[K] ? never : Attrs[K]) | (unknown extends NewAttrs[K] ? never : NewAttrs[K]) }} MergeAttrs
*/
/**
* A cursor/selection anchor stored inside a delta tree (see {@link import('./position.js').marksToPositions}).
*
* - `key` — the *terminal* step of the position: a content offset (number) or an attribute key
* (string) within the node that holds the mark.
* - `id` — a unique, user-defined identifier.
* - `assoc` — the gravity at a boundary (left `-1` / right `1`).
* - `attrs` — optional user-supplied data carried with the mark **by reference**: the same
* object is shared across the mark, its `copy`/`clone`s, `toJSON`, and every `MarkPos` from
* `marksToPositions`, so the caller must treat it as immutable (do not mutate it after attaching).
* - `transient` — the mark only rides through a mapping and must never be stored (see the field doc).
*
* A `Mark` is **immutable** — never mutate one in place; to "move" a mark, replace it with a fresh
* `Mark` via {@link Mark#copy} (the {@link Marks} set keys by id, so re-adding the same id replaces
* it). Immutability lets a `Mark` be shared freely across a delta and its clones. The class is not
* exported — construct via {@link createMark} and validate via {@link $mark}.
*/
class Mark {
/**
* @param {number|string} key
* @param {string} [id] unique id; defaults to a fresh GUID
* @param {1|-1} [assoc] gravity at a boundary; defaults to right (`1`)
* @param {object?} [attrs] optional user data, stored by reference; treat as immutable
* @param {boolean} [transient] the mark must never be stored; see the field doc
*/
constructor (key, id = rand.uuidv4(), assoc = 1, attrs = null, transient = false) {
/**
* @readonly
* @type {number|string}
*/
this.key = key
/**
* @readonly
* @type {string}
*/
this.id = id
/**
* @readonly
* @type {1|-1}
*/
this.assoc = assoc
/**
* @readonly
* @type {object?}
*/
this.attrs = attrs
/**
* A transient mark exists only to ride through a mapping (see
* {@link import('./position.js').mapPositionsA}) and must never be stored: a consumer that
* maintains persistent state (a document, a transformer's internal model of one) either filters
* transient marks before applying a change to that state, or removes them when found on
* final/settled state. How that is achieved is left to the implementation — the core does not
* enforce it (today nothing in lib0 stores marks outside a document, so nothing needs to).
*
* @readonly
* @type {boolean}
*/
this.transient = transient
}
/**
* A copy of this mark, optionally at a different `key` (used to "move" an otherwise-immutable mark).
*
* @param {number|string} [key]
* @return {Mark}
*/
copy (key = this.key) {
return new Mark(key, this.id, this.assoc, this.attrs, this.transient)
}
/**
* @return {MarkJSON}
*/
toJSON () {
/** @type {MarkJSON} */
const json = this.attrs === null
? { id: this.id, key: this.key, assoc: this.assoc }
: { id: this.id, key: this.key, assoc: this.assoc, attrs: this.attrs }
if (this.transient) json.transient = true
return json
}
/**
* @param {Mark} other
*/
[equalityTrait.EqualityTraitSymbol] (other) {
return $mark.check(other) && this.id === other.id && this.key === other.key && this.assoc === other.assoc && this.transient === other.transient && fun.equalityDeep(this.attrs, other.attrs)
}
}
export const $mark = /** @type {s.Schema<Mark>} */ (Mark.prototype.$type = s.$type('d:mark', Mark))
/**
* Create a {@link Mark} (use this instead of `new Mark(...)`). `id` defaults to a fresh GUID, `assoc`
* to right gravity (`1`), `attrs` to `null`, `transient` to `false`. A `Mark` is stored on a delta
* node's own {@link Marks} set (see {@link DeltaBuilder#addMark}).
*
* @param {number|string} key
* @param {string} [id]
* @param {1|-1} [assoc]
* @param {object?} [attrs]
* @param {boolean} [transient] never to be stored (see {@link Mark#transient})
* @return {Mark}
*/
export const createMark = (key, id, assoc, attrs, transient) => new Mark(key, id, assoc, attrs, transient)
/**
* @typedef {Delta<any>} DeltaAny
*/
/**
* @typedef {DeltaBuilder<any>} DeltaBuilderAny
*/
/**
* @typedef {object} DeltaConf
* @property {string} [DeltaConf.name]
* @property {fingerprintTrait.Fingerprintable} [DeltaConf.children=never]
* @property {boolean} [DeltaConf.text=never]
* @property {{[K:string|number]:fingerprintTrait.Fingerprintable}} [DeltaConf.attrs={}]
* @property {boolean} [DeltaConf.recursiveChildren=false]
* @property {boolean} [DeltaConf.recursiveAttrs=false]
*/
/**
* Coerce a computed conf to provably satisfy the `DeltaConf` constraint. Declaration emit re-checks
* type arguments eagerly, so a deeply-computed conf (mapped/recursive) passed to `Delta<…>` /
* `Transformer<…>` must be wrapped here: the conditional exposes `DeltaConf` as its upper bound,
* letting the constraint resolve without forcing TS to expand the (self-referential) body.
*
* @template T
* @typedef {T extends infer DC extends DeltaConf ? DC : never} AsDeltaConf
*/
/**
* @template {DeltaConf} Conf
* @typedef {Conf extends {name:infer Name} ? (unknown extends Name ? any : (Exclude<Name,undefined>)) : any} DeltaConfGetName
*/
/**
* @template {DeltaConf} Conf
* @typedef {(Conf extends {children:infer Children} ? (unknown extends Children ? any : Children) : never) | (Conf extends {recursiveChildren:true} ? Delta<Conf> : never)} DeltaConfGetChildren
*/
/**
* @template {DeltaConf} Conf
* @template {boolean} FixedConf
* @typedef {FixedConf extends true ? DeltaConfGetChildren<Conf> : any } DeltaConfGetAllowedChildren
*/
/**
* @template {DeltaConf} Conf
* @typedef {0 extends (1 & Conf) ? string : (Conf extends {text:true} ? string : never)} DeltaConfGetText
*/
/**
* @template {DeltaConf} Conf
* @typedef {import('../ts.js').TypeIsAny<Conf, {[K:string|number]:any}, (Conf extends {attrs:infer Attrs} ? (Attrs extends undefined ? {} : Attrs) : {})>} DeltaConfGetAttrs
*/
/**
* @template {DeltaConf} Conf
* @template {boolean} FixedConf
* @typedef {FixedConf extends true ? DeltaConfGetAttrs<Conf> : {[K:string|number]:any}} DeltaConfGetAllowedAttrs
*/
/**
* @template {DeltaConf} Conf
* @typedef {Conf extends {recursiveChildren:true} ? true : false} DeltaConfGetRecursiveChildren
*/
/**
* @template {DeltaConf} Conf
* @typedef {Conf extends {recursiveAttrs:true} ? true : false} DeltaConfigGetRecursiveAttrs
*/
/**
* Transform Delta(Builder) to a normal delta: a `DeltaBuilder<Conf>` becomes `Delta<Conf>`, anything
* else passes through.
*
* Infers the conf from `DeltaBuilder` on purpose, not from `Delta<infer Conf>`. lib0's own source
* check never had a problem with the latter, but a consumer type-checks the emitted declaration
* files, and there matching a builder against `Delta<infer Conf>` is structural: TypeScript walks
* `Delta`'s members and recurses through {@link DeltaConfGetChildren}'s recursive branch until its
* instantiation-depth limit, so every nested condensed builder (for example
* `create().insert([create('c', {}, [create('p', {}, 'x')])])`) failed with TS2589 in
* y-prosemirror and yjs. The structural walk also leaked the grandchild conf into the child
* union; inferring from the builder keeps the nesting exact. dist-check/consumer.js pins both
* against dist.
*
* @template V
* @typedef {V extends never ? never : (import('../ts.js').TypeIsAny<V,any,V extends DeltaBuilder<infer Conf, any> ? Delta<Conf> : V>)} _SanifyDelta
*/
/**
* @template {DeltaConf} Conf
* @typedef {import('../ts.js').Prettify<{[K in keyof Conf]: K extends 'attrs' ? import('../ts.js').Prettify<{ [KA in keyof Conf[K]]: _SanifyDelta<Conf[K][KA]> },1> : (K extends 'children' ? _SanifyDelta<Conf[K]> : Conf[K]) }, 1>} PrettifyDeltaConf
*/
/**
* @template {DeltaConf} D1
* @template D2
* @typedef {(import('../ts.js').TypeIsAny<D1, any, PrettifyDeltaConf<{[K in (keyof D1|keyof D2)]: K extends keyof D2 ? D2[K] : (K extends keyof D1 ? D1[K] : never)}>> & {}) extends infer DC extends DeltaConf ? DC : never} DeltaConfOverwrite
*/
/**
* @template {string} Name
* @template {{[K in string|number]:any}} Attrs
* @template Children
* @template {boolean} Text
*/
class DeltaData {
/**
* @param {string?} name
* @param {s.Schema<Delta<any>>?} $schema
*/
constructor (name, $schema) {
this.name = /** @type {Name} */ (name)
this.$schema = $schema
/**
* @type {{ [K in keyof Attrs]?: K extends string|number ? (SetAttrOp<Attrs[K],K>|DeleteAttrOp<Attrs[K],K>|(Attrs[K] extends never ? never : (Delta<any> extends Attrs[K] ? ModifyAttrOp<Extract<Attrs[K],Delta<any>>,K> : never))) : never }
* & { [Symbol.iterator]: () => Iterator<{ [K in keyof Attrs]: K extends string|number ? (SetAttrOp<Attrs[K],K>|DeleteAttrOp<Attrs[K],K>|(Attrs[K] extends never ? never : (Delta<any> extends Attrs[K] ? ModifyAttrOp<Extract<Attrs[K],Delta<any>>,K> : never))) : never }[keyof Attrs]> }
* }
*/
this.attrs = /** @type {any} */ ({
* [Symbol.iterator] () {
for (const k in this) {
yield this[k]
}
}
})
/**
* @type {list.List<
* | (Text extends true ? (RetainOp|TextOp|DeleteOp<any>) : never)
* | (RetainOp|InsertOp<Children>|DeleteOp<any>|(Delta<any> extends Children ? ModifyOp<Extract<Children,Delta<any>>> : never))
* >}
*/
this.children = /** @type {any} */ (list.create())
/**
* All child-ops sizes combined. Note that delete-ops also have a length
*/
this.childCnt = 0
/**
* @type {any}
*/
this.origin = null
/**
* @type {string|null}
*/
this._fingerprint = null
this.isDone = false
/**
* Is the final document and does not contain delete, modify, or retain ops.
*/
this.isFinal = false
/**
* Leaf {@link Mark marks} whose cursor sits in THIS node (in a settled delta), or the marks to ADD
* to the target node (in a change delta). Local/ephemeral cursor state — NOT part of the document
* fingerprint or equality. `null` when there are none. See {@link Marks}.
*
* @type {Marks?}
*/
this.marks = null
/**
* Mark ids to DELETE (tombstones). Only present in a (non-settled) change delta — like a
* `DeleteAttrOp`, a deletion is dropped from a final delta (a materialized document carries none, at
* any depth). `null` when there are none. Marks follow *last-writer-wins*, treating each op as an
* absolute per-id assignment (present vs absent): adding a mark cancels a pending delete of the same
* id ({@link addMarkTo}) and a delete cancels a present add ({@link deleteMarkTo}), so a node never
* holds both an add and a delete for one id. {@link deleteMarkTo} is the sole writer of this set
* (and `addMarkTo`'s mirror); see {@link applyMarkOps}.
*
* @type {Set<string>?}
*/
this.deleteMarks = null
/**
* Conservative "this subtree might hold a {@link Mark}" flag (own {@link DeltaData#marks} or any
* descendant's). Set `true` when a mark is introduced and OR-propagated up to every ancestor; it is
* **never** decremented (removal leaves it conservatively `true`). {@link
* import('./position.js').marksToPositions} is the sole resetter: it prunes subtrees where this is
* `false` (a guaranteed-empty subtree) and lazily clears a stale `true` to `false` when a descended
* subtree turns out to hold none. `false` therefore *guarantees* no marks; `true` only *maybe*.
*/
this.maybeHasMarks = false
}
}
/**
* @template {DeltaConf} [Conf={}]
* @extends {DeltaData<
* DeltaConfGetName<Conf>,
* DeltaConfGetAttrs<Conf>,
* DeltaConfGetChildren<Conf>,
* Conf extends {text:true} ? true : false
* >}
*/
export class Delta extends DeltaData {
/**
* @type {string}
*/
get fingerprint () {
return this._fingerprint || (this._fingerprint = buffer.toBase64(rabin.fingerprint(rabin.StandardIrreducible32, encoding.encode(encoder => {
encoding.writeUint32(encoder, 0xf2ae5680) // "magic number" that ensures that different types of content don't yield the same fingerprint
encoding.writeAny(encoder, this.name)
/**
* @type {Array<number|string>}
*/
const keys = []
for (const attr of this.attrs) {
keys.push(attr.key)
}
keys.sort((a, b) => {
const aIsString = s.$string.check(a)
const bIsString = s.$string.check(b)
// numbers first
// in ascending order
return (aIsString && bIsString)
? a.localeCompare(b)
: (aIsString ? 1 : (bIsString ? -1 : (a - b)))
})
encoding.writeVarUint(encoder, keys.length)
for (const key of keys) {
encoding.writeVarString(encoder, /** @type {any} */ (this.attrs[/** @type {keyof typeof this.attrs} */ (key)]).fingerprint)
}
// trailing plain retains are not content: `done()` trims them, and the fingerprint must not
// change when it does — so only the children up to the last content op are fingerprinted
const cs = this.children
let n = _contentLen(cs)
encoding.writeVarUint(encoder, n)
// non-null for the first `n` ops by construction (see _contentLen)
for (let child = /** @type {ChildrenOpAny} */ (cs.start); n > 0; n--, child = /** @type {ChildrenOpAny} */ (child.next)) {
encoding.writeVarString(encoder, child.fingerprint)
}
}))))
}
[fingerprintTrait.FingerprintTraitSymbol] () {
return this.fingerprint
}
isEmpty () {
// a children list holding only plain retains is empty (positional only — see _isPlainRetain)
return object.isEmpty(this.attrs) && _contentLen(this.children) === 0 && (this.marks === null || this.marks.size === 0) && (this.deleteMarks === null || this.deleteMarks.size === 0)
}
/**
* @return {DeltaJSON}
*/
toJSON () {
const name = this.name
/**
* @type {any}
*/
const attrs = {}
/**
* @type {any}
*/
const children = []
for (const attr of this.attrs) {
attrs[attr.key] = attr.toJSON()
}
this.children.forEach(val => {
children.push(val.toJSON())
})
return object.assign(
{ type: /** @type {'delta'} */ ('delta') },
(name != null ? { name } : {}),
(object.isEmpty(attrs) ? {} : { attrs }),
(children.length > 0 ? { children } : {}),
(this.marks !== null && this.marks.size > 0 ? { marks: [...this.marks].sort(compareMarksById).map(m => m.toJSON()) } : {}),
(this.deleteMarks !== null && this.deleteMarks.size > 0 ? { deleteMarks: [...this.deleteMarks].sort() } : {})
)
}
/**
* @param {Delta<any>} other
* @return {boolean}
*/
equals (other) {
return this[equalityTrait.EqualityTraitSymbol](other)
}
/**
* @param {any} other
* @return {boolean}
*/
[equalityTrait.EqualityTraitSymbol] (other) {
// marks are local/ephemeral cursor state and intentionally NOT part of document identity
if (this.name !== other.name || !fun.equalityDeep(this.attrs, other.attrs)) return false
// only the content ops count (a trailing plain-retain run is not content — see _isPlainRetain);
// comparing the counts first also makes two retain-only lists equal. `childCnt` (Σ op.length)
// needs no separate check: every content op's equality covers its length-bearing field.
const n = _contentLen(this.children)
if (n !== _contentLen(other.children)) return false
// non-null for the first `n` ops by construction (see _contentLen)
let a = /** @type {ChildrenOpAny} */ (this.children.start)
let b = /** @type {ChildrenOpAny} */ (other.children.start)
for (let i = 0; i < n; i++, a = /** @type {ChildrenOpAny} */ (a.next), b = /** @type {ChildrenOpAny} */ (b.next)) {
if (!fun.equalityDeep(a, b)) return false
}
return true
}
// toString () {
// /**
// * @type {Array<[string|number,string]>}
// */
// const attrs = []
// for (const attr of this.attrs) {
// attrs.push([attr.key, $deleteAttrOp.check(attr) ? 'delete' : attr.type + ' ' + (attr.value?.toString() ?? attr.value)])
// }
// attrs.sort((a, b) => a[0].toString() < b[0].toString() ? -1 : 1)
// const attrsString = attrs.map(attr => `${attr[0]} = ${attr[1]}`).join(', ')
// if (this.childCnt === 0) return `<${this.name ?? ''} ${attrsString}/>`
// this.children.toArray() drnt
// return '<>'
// }
//
/**
* Mark this delta as done and perform some cleanup (e.g. remove appended retains without
* formats&attributions). In the future, there might be additional merge operations that can be
* performed to result in smaller deltas. Set `markAsDone=false` to only perform the cleanup.
*
* @return {Delta<Conf>}
*/
done (markAsDone = true) {
if (!this.isDone) {
this.isDone = markAsDone
// trim the trailing plain-retain run — fingerprint-neutral, as fingerprint/equality never counted
// it (see _contentLen); any other cleanup added here must null `_fingerprint`
const cs = this.children
for (let end = cs.end; end !== null && _isPlainRetain(end); end = cs.end) {
this.childCnt -= end.length
list.popEnd(cs)
}
}
return this
}
}
/**
* @template {DeltaConf} Conf
* @param {Delta<Conf>} d
* @param {number} start
* @param {number} end
* @param {ChildrenOpAny?} currNode - start slicing at this node (instead of d.children.start)
* @return {DeltaBuilder<Conf>}
*/
export const slice = (d, start = 0, end = d.childCnt, currNode = d.children.start) => {
const cpy = /** @type {DeltaAny} */ (new DeltaBuilder(d.name, d.$schema))
cpy.origin = d.origin
const sliceStart = start // `start` is mutated by the walk below; keep the original for clamping marks
// copy attrs
for (const op of d.attrs) {
// @ts-ignore
cpy.attrs[op.key] = /** @type {any} */ (op.clone())
}
// copy children
const slicedLen = end - start
let remainingLen = slicedLen
let currNodeOffset = 0
while (start > 0 && currNode != null) {
if (currNode.length <= start) {
start -= currNode.length
currNode = currNode.next
} else {
currNodeOffset = start
start = 0
}
}
if (currNodeOffset > 0 && currNode) {
const ncpy = currNode.clone(currNodeOffset, currNodeOffset + math.min(remainingLen, currNode.length - currNodeOffset))
list.pushEnd(cpy.children, ncpy)
remainingLen -= ncpy.length
currNode = currNode.next
}
while (currNode != null && currNode.length <= remainingLen) {
list.pushEnd(cpy.children, currNode.clone())
remainingLen -= currNode.length
currNode = currNode.next
}
if (currNode != null && remainingLen > 0) {
list.pushEnd(cpy.children, currNode.clone(0, remainingLen))
remainingLen -= math.min(currNode.length, remainingLen)
}
cpy.childCnt = slicedLen - remainingLen
// copy marks (only if the subtree maybe has any). A full clone (the `clone` path) copies this node's
// own marks verbatim — a change delta's number-keyed root marks point into the TARGET's coordinate
// and may sit beyond the change's own (often empty) content, so they must not be clamped. A genuine
// partial slice instead clamps number-keyed marks to the sliced range (rebasing `key -= start`).
// String/attr marks ride with the copied attrs; child marks ride on the cloned children. The
// `maybeHasMarks` flag is copied (conservative); a change-only deleteMarks list is copied wholesale.
if (d.maybeHasMarks) {
if (d.marks !== null) {
const fullCopy = sliceStart === 0 && end >= d.childCnt
const cpyMarks = new Marks()
for (const m of d.marks) {
if (fullCopy || typeof m.key !== 'number') {
cpyMarks.add(m)
} else if (m.key >= sliceStart && m.key <= end) {
cpyMarks.add(m.copy(m.key - sliceStart))
}
}
if (cpyMarks.size > 0) cpy.marks = cpyMarks
}
cpy.maybeHasMarks = true
}
// a bulk copy of an already-disjoint source onto a fresh `cpy` (not a conflict-resolving write, so it
// bypasses the deleteMarkTo/addMarkTo primitives): `d`'s marks/deleteMarks are disjoint by id, and
// clamping only ever drops marks, so `cpy` stays disjoint
if (d.deleteMarks !== null) cpy.deleteMarks = new Set(d.deleteMarks)
// @ts-ignore
return cpy
}
/**
* @template {DeltaAny} D
* @param {D} d
* @return {D extends Delta<infer Conf> ? DeltaBuilder<Conf> : never}
*/
export const clone = d => /** @type {any} */ (slice(d, 0, d.childCnt))
/**
* Deep-clone a value that may be a nested delta: a fresh {@link cloneDeep} of it when it is a delta,
* otherwise the value unchanged (plain JSON content is treated as immutable and shared).
*
* @param {any} v
* @return {any}
*/
const _cloneMaybeDeltaDeep = v => $deltaAny.check(v) ? _cloneDeepNested(v) : v
/**
* Carry `src`'s cached fingerprint over to `cpy`, a content-identical copy of it. Only valid when
* nothing about the copy differs from the source in fingerprinted fields (a full-range clone, the same
* key, nested values cloned rather than `done()`-trimmed).
*
* @template {{ _fingerprint: string|null }} T
* @param {{ _fingerprint: string|null }} src
* @param {T} cpy
* @return {T}
*/
const _copyFp = (src, cpy) => {
cpy._fingerprint = src._fingerprint
return cpy
}
/**
* {@link cloneDeep} for a *nested* delta: content-identical, so the cached fingerprint rides along
* (only the root returned by the exported `cloneDeep` starts without one).
*
* @template {DeltaConf} Conf
* @param {Delta<Conf>} d
* @return {DeltaBuilder<Conf>}
*/
const _cloneDeepNested = d => _copyFp(d, cloneDeep(d))
/**
* Deep-clone one content (child) op for {@link cloneDeep}: a fresh op whose nested deltas (an insert's
* delta content, a modify's value) are themselves deep-cloned. `format`/`attribution` objects are
* retained (shared) — they are always copied before being mutated (see {@link cloneDeep}). Ops without a
* nested delta (`text`/`retain`/`delete`) use `op.clone()`. Every copy is content-identical, so the
* cached fingerprint is carried over.
*
* @param {ChildrenOpAny} op
* @return {ChildrenOpAny}
*/
const _cloneChildOpDeep = op =>
$insertOp.check(op)
? _copyFp(op, new InsertOp(op.insert.map(_cloneMaybeDeltaDeep), op.format, op.attribution))
: ($modifyOp.check(op)
? _copyFp(op, new ModifyOp(_cloneDeepNested(op.value), op.format, op.attribution))
: op.clone())
/**
* Deep-clone one attribute op for {@link cloneDeep} — the attribute-dimension mirror of
* {@link _cloneChildOpDeep}: a `setAttr`'s value and a `modifyAttr`'s value may be nested
* deltas and are deep-cloned.
*
* @param {AttrOpAny} op
* @return {AttrOpAny}
*/
const _cloneAttrOpDeep = op =>
$setAttrOp.check(op)
? _copyFp(op, new SetAttrOp(op.key, _cloneMaybeDeltaDeep(op.value), op.attribution))
: ($modifyAttrOp.check(op)
? _copyFp(op, new ModifyAttrOp(op.key, _cloneDeepNested(op.value), op.attribution))
// the only remaining attr op is a deleteAttr
: _copyFp(op, new DeleteAttrOp(op.key, op.attribution)))
/**
* A **deep** clone of `d`: like {@link clone}, but every nested delta — an insert's delta content, a
* `modify`/`modifyAttr` value, a delta-valued attribute — is itself recursively cloned into a fresh,
* **mutable** node, instead of being frozen (`done`) and shared as {@link clone} does. The result and
* its whole subtree are therefore independently editable. Cached fingerprints of the ops and nested
* deltas ride along (they are content-identical); only the returned root starts without one.
*
* ## What is cloned, and when to reach for this
*
* A delta owns two kinds of nested state, treated differently on purpose:
* - **Ops and (nested) deltas are cloned.** They carry structure edited *in place* — `apply`/`rebase`
* splice op lists and mutate op fields — so a shared copy would be corrupted by a later edit of either
* side.
* - **`format`/`attribution` objects and plain JSON insert values are retained (shared).** They are
* always *copied before being changed* (a format/attribution update allocates a new object — see
* {@link mergeAttr}), so sharing them is safe and avoids needless allocation.
*
* {@link clone} suffices for the common case because a delta is only ever edited through `apply`/`rebase`,
* which re-clone a `done` nested delta the first time they touch it (see {@link InsertOp#_modValue}) — the
* shared, frozen children are never mutated in place. Reach for `cloneDeep` when a nested delta will be
* **manipulated directly** instead of via `apply`/`rebase` — most notably by a
* {@link import('./transformer/core.js').Transformer transformer}, which walks and mutates a delta's
* ops/children in place. A `done` (frozen) delta must always be cloned before such direct manipulation
* (a plain `apply`/`rebase` handles the freeze itself, so it needs no pre-clone).
*
* @template {DeltaConf} Conf
* @param {Delta<Conf>} d
* @return {DeltaBuilder<Conf>}
*/
export const cloneDeep = d => {
const cpy = /** @type {DeltaAny} */ (new DeltaBuilder(d.name, d.$schema))
cpy.origin = d.origin
cpy.isFinal = d.isFinal
for (const op of d.attrs) {
// @ts-ignore dynamic attr key (the same limitation `slice` suppresses when copying attrs)
cpy.attrs[op.key] = _cloneAttrOpDeep(op)
}
for (const op of d.children) {
list.pushEnd(cpy.children, _cloneChildOpDeep(op))
}
cpy.childCnt = d.childCnt
// marks ride along verbatim — a full clone never clamps number-keyed root marks, and Mark objects are
// immutable so they are shared (mirrors slice's full-copy path); a change delta's deleteMarks is copied.
if (d.maybeHasMarks) {
if (d.marks !== null) {
const cpyMarks = new Marks()
for (const m of d.marks) cpyMarks.add(m)
if (cpyMarks.size > 0) cpy.marks = cpyMarks
}
cpy.maybeHasMarks = true
}
if (d.deleteMarks !== null) cpy.deleteMarks = new Set(d.deleteMarks)
return /** @type {any} */ (cpy)
}
/**
* A *shallow* clone of `d`: a fresh {@link DeltaBuilder} carrying `d`'s name, attribute ops, and own
* (root) marks — but **no children**. Content-transforming
* {@link import('./transformer/core.js').Transformer transformers} (e.g. `children`, `value`) rebuild
* the child list themselves yet must keep this node's own marks; the marks of nested children ride
* along on the children the caller rebuilds. The `maybeHasMarks` flag is copied from `d` (conservative)
* and the builder OR-propagates the flag as the caller appends children. Because mark-carrying lives
* here in the shared primitive, a transformer that builds its output via {@link cloneShallow} cannot
* silently drop the node's marks.
*
* @template {DeltaAny} D
* @param {D} d
* @return {D extends Delta<infer Conf> ? DeltaBuilder<Conf> : never}
* @internal transformer plumbing — drops children; consumers use {@link clone}.
*/
export const cloneShallow = d => {
const cpy = /** @type {DeltaAny} */ (new DeltaBuilder(d.name, d.$schema))
cpy.origin = d.origin
for (const op of d.attrs) {
// @ts-ignore (dynamic attr key — the same limitation slice suppresses when copying attrs)
cpy.attrs[op.key] = /** @type {any} */ (op.clone())
}
// root marks / deleteMarks (a delete-mark-only change has no marks but must still ride); `cpy` is
// fresh, so merging onto it is a plain copy
if (d.marks !== null || d.deleteMarks !== null) mergeRootMarks(cpy, d)
// carry the conservative flag (covers attr-value marks even when this node has no root marks of its
// own); the builder OR-propagates it further as the caller appends the rebuilt children
cpy.maybeHasMarks = d.maybeHasMarks
// @ts-ignore
return cpy
}
/**
* Make an op's modify `value` mutable for in-place editing: clear the op's cached fingerprint and, when
* the value is a `done` delta, replace it with a mutable clone. Shared by the `_modValue` getters of
* {@link ModifyOp}, {@link ModifyAttrOp}, and {@link SetAttrOp}; the `$deltaAny` guard lets a scalar
* `SetAttrOp` value pass through untouched.
*
* @param {{ value: any, _fingerprint: string|null }} op
* @return {any}
*/
const modValue = op => {
op._fingerprint = null
const v = op.value
if ($deltaAny.check(v) && v.isDone) op.value = clone(v)
return op.value
}
/*
* --- @internal low-level mutation API for a delta's children list ---
*
* These are the ONLY sanctioned way to structurally edit `d.children` in place. Each keeps the cached
* invariant `d.childCnt === Σ op.length` correct and resets the op's `_fingerprint`. Never mutate
* `op.retain` / `op.delete` / `op.insert`, call `list.*`, or touch `d.childCnt` directly from outside
* delta.js — go through these so the count (which `slice`/`apply` position by) never drifts.
*
* Cursor pattern (used by {@link DeltaBuilder#apply}, {@link DeltaBuilder#rebase}, and
* `transformer/conform.js`): walk the target with `(op, offset)`; {@link _splitChildAt} before inserting
* in the middle of an op so the cursor sits on a boundary; {@link _mergeChildWithPrev} after edits to
* coalesce adjacent same-kind runs. `_growRun` / `_spliceInsert` extend an existing run *without* a split,
* which keeps same-kind insertion (e.g. typing into a pass-through run) non-fragmenting.
*/
/**
* Coalesce `op` into its previous sibling when they are the same kind with equal format/attribution,
* removing `op`. `d.childCnt` is unchanged (the length moves to the previous op). Returns `true` when it
* merged — so a caller holding a cursor on `op` can move it onto the surviving previous op.
*
* @param {DeltaBuilderAny} d
* @param {InsertOp<any>|RetainOp|DeleteOp<any>|TextOp|ModifyOp<any>} op
* @return {boolean}
*/
export const _mergeChildWithPrev = (d, op) => {
const prevOp = op.prev
if (
prevOp?.constructor !== op.constructor ||
$modifyOp.check(op) ||
(
!$deleteOp.check(op) && (
!fun.equalityDeep(op.format, /** @type {InsertOp<any>} */ (prevOp).format) ||
!fun.equalityDeep(op.attribution, /** @type {InsertOp<any>} */ (prevOp).attribution)
)
)
) {
// constructor mismatch or format/attribution mismatch
return false
}
// can be merged
if ($insertOp.check(op)) {
/** @type {InsertOp<any>} */ (prevOp).insert.push(...op.insert)
} else if ($retainOp.check(op)) {
// @ts-ignore
/** @type {RetainOp} */ (prevOp).retain += op.retain
} else if ($deleteOp.check(op)) {
/** @type {DeleteOp<any>} */ (prevOp).delete += op.delete
} else if ($textOp.check(op)) {
// @ts-ignore
/** @type {TextOp} */ (prevOp).insert += op.insert
/* c8 ignore start */
} else {
// unreachable: the constructor check at the top of the function already
// limits `op` to one of the four kinds tested above
error.unexpectedCase()
}
prevOp._fingerprint = null
/* c8 ignore stop */
list.remove(d.children, op)
return true
}
/**
* Split `op` at `offset`, leaving `op` = `[0, offset)` and a fresh right node `[offset, op.length)`
* inserted immediately after it; returns the right node. `d.childCnt` is unchanged (net-zero). The clone
* uses `markAsDone=false` because the right node is `_splice`d out of `op` here, so it is the sole owner.
* Caller guarantees `0 < offset < op.length`.
*
* @param {DeltaBuilderAny} d
* @param {ChildrenOpAny} op
* @param {number} offset
* @return {ChildrenOpAny}
*/
export const _splitChildAt = (d, op, offset) => {
const right = /** @type {ChildrenOpAny} */ (op.clone(offset, op.length, false))
op._splice(offset, op.length - offset)
list.insertBetween(d.children, op, op.next || null, right)
return right
}
/**
* Insert `op` into `d.children` immediately before `ref` (or push at the end when `ref == null`);
* `d.childCnt += op.length`. Does not coalesce — call {@link _mergeChildWithPrev} afterwards if needed.
*
* @param {DeltaBuilderAny} d
* @param {ChildrenOpAny?} ref
* @param {ChildrenOpAny} op
* @return {ChildrenOpAny}
*/
export const _insertChild = (d, ref, op) => {
list.insertBetween(d.children, ref == null ? d.children.end : ref.prev, ref, op)
d.childCnt += op.length
return op
}
/**
* Remove child `op` entirely; `d.childCnt -= op.length`.
*
* @param {DeltaBuilderAny} d
* @param {ChildrenOpAny} op
*/
export const _removeChild = (d, op) => {
d.childCnt -= op.length
list.remove(d.children, op)
}
/**
* Drop `len` positions from `op` starting at `offset` (`op._splice`); `d.childCnt -= len`. Does not remove
* an emptied op — the caller decides (call {@link _removeChild} when `op.length === 0`), because removal is
* usually entangled with the caller's own cursor advance. Retain/Delete ignore `offset` (uniform runs);
* Insert drops `insert[offset, offset+len)`.
*
* @param {DeltaBuilderAny} d
* @param {ChildrenOpAny} op
* @param {number} offset
* @param {number} len
*/
export const _shrinkChild = (d, op, offset, len) => {
op._splice(offset, len)
d.childCnt -= len
}
/**
* Grow a Retain or Delete run `op` by `n` positions in place (the invariant-safe form of `op.retain += n`);
* `d.childCnt += n`. Use to extend an existing same-kind run without a split.
*
* @param {DeltaBuilderAny} d
* @param {RetainOp|DeleteOp<any>} op
* @param {number} n
*/
export const _growRun = (d, op, n) => {
// reason: RetainOp.retain / DeleteOp.delete are @readonly for consumers; the @internal mutators grow them in place, just as _splice shrinks them
// @ts-ignore
if ($retainOp.check(op)) op.retain += n; else op.delete += n
op._fingerprint = null
d.childCnt += n
}
/**
* Splice `elems` into an Insert run `op` at `offset` in place; `d.childCnt += elems.length`.
*
* @param {DeltaBuilderAny} d
* @param {InsertOp<any>} op
* @param {number} offset
* @param {Array<any>} elems
*/
export const _spliceInsert = (d, op, offset, elems) => {
op.insert.splice(offset, 0, ...elems)
op._fingerprint = null
d.childCnt += elems.length
}
/**
* Ensures that the delta can be edited. clears _fingerprint cache.
*
* @param {DeltaBuilderAny} d
*/
const modDeltaCheck = d => {
if (d.isDone) {
/**
* You tried to modify a delta after it has been marked as "done".
*/
throw error.create("Readonly Delta can't be modified")
}
d._fingerprint = null
}
/**
* @template {DeltaConf} [Conf={}]
* @template {boolean} [FixedConf=false]
* @extends {Delta<Conf>}
*/
export class DeltaBuilder extends Delta {
/**
* @param {string?} name
* @param {s.Schema<Delta<Conf>>?} $schema
*/
constructor (name, $schema) {
super(name, $schema)
/**
* @type {Formats?}
*/
this._usedFormats = null
/**
* @type {Attribution?}
*/
this._usedAttribution = null
/**
* Resolved data-companion cache for the `used*` contexts ({@link resolveUsedData}): what data
* ops inherit instead of the raw context. `undefined` = stale, recomputed on the next data-op
* consumption ({@link DeltaBuilder#_getUsedFormatsData}) — {@link DeltaBuilder#useFormats} & co
* clear it whenever they swap the context (which is why the public slots are read-only).
*
* @type {Formats|null|undefined}
*/
this._usedFormatsData = undefined
/**
* @type {Attribution|null|undefined}
*/
this._usedAttributionData = undefined
}
/**
* The ambient formats context inherited by subsequent ops. Read-only: set it via
* {@link DeltaBuilder#useFormats} / {@link DeltaBuilder#updateUsedFormats}, which also invalidate
* the cached data companion — a direct overwrite would leave it stale.
*
* @return {Formats?}
*/
get usedFormats () { return this._usedFormats }
/**
* The ambient attribution context inherited by subsequent ops. Read-only — see
* {@link DeltaBuilder#usedFormats}; set it via {@link DeltaBuilder#useAttribution} /
* {@link DeltaBuilder#updateUsedAttribution}.
*
* @return {Attribution?}
*/
get usedAttribution () { return this._usedAttribution }
/**
* Set the ambient attribution context inherited by subsequent ops (`undefined`/empty dim args).
* The object is interned — instruction ops store it, and data ops store its resolved companion
* ({@link resolveUsedData}), BY REFERENCE. Never mutate it after setting; pass a fresh object
* instead (same invariant as the objects stored on ops).
*
* @param {Attribution?} attribution
*/
useAttribution (attribution) {
modDeltaCheck(this)
if (this._usedAttribution !== attribution) {
this._usedAttribution = attribution
this._usedAttributionData = undefined // clear the data companion — recomputed on next consumption
}
return this
}
/**
* Set the ambient formats context inherited by subsequent ops (`undefined`/empty dim args). The
* object is interned — see {@link DeltaBuilder#useAttribution}: never mutate it after setting.
*
* @param {Formats?} attributes
* @return {this}
*/
useFormats (attributes) {
modDeltaCheck(this)
if (this._usedFormats !== attributes) {
this._usedFormats = attributes
this._usedFormatsData = undefined // clear the data companion — recomputed on next consumption
}
return this
}
/**
* The `usedFormats` context resolved for data-op consumption ({@link resolveUsedData}): the slot
* object itself when it carries no `{k:null}` removals, one shared stripped copy otherwise.
* Cached until the context is swapped — no per-op traversal.
*
* @return {Formats?}
*/
_getUsedFormatsData () {
const d = this._usedFormatsData
return d !== undefined ? d : (this._usedFormatsData = /** @type {Formats?} */ (resolveUsedData(this._usedFormats, false)))
}
/**
* See {@link DeltaBuilder#_getUsedFormatsData} — the `usedAttribution` analogue (its `format` key
* resolves one level deeper).
*
* @return {Attribution?}
*/
_getUsedAttributionData () {
const d = this._usedAttributionData
return d !== undefined ? d : (this._usedAttributionData = /** @type {Attribution?} */ (resolveUsedData(this._usedAttribution, true)))
}
/**
* @param {string} name
* @param {any} value
*/
updateUsedFormats (name, value) {
modDeltaCheck(this)
if (value == null) {
this._usedFormats = object.assign({}, this._usedFormats)
delete this._usedFormats?.[name]
if (object.isEmpty(this._usedFormats)) {
this._usedFormats = null
}
this._usedFormatsData = undefined // clear the data companion — recomputed on next consumption
} else if (!fun.equalityDeep(this._usedFormats?.[name], value)) {
this._usedFormats = object.assign({}, this._usedFormats)
this._usedFormats[name] = value
this._usedFormatsData = undefined // clear the data companion — recomputed on next consumption
}
return this
}
/**
* @template {keyof Attribution} NAME
* @param {NAME} name
* @param {Attribution[NAME]?} value
*/
updateUsedAttribution (name, value) {
modDeltaCheck(this)
if (value == null) {
this._usedAttribution = object.assign({}, this._usedAttribution)
delete this._usedAttribution?.[name]
if (object.isEmpty(this._usedAttribution)) {
this._usedAttribution = null
}
this._usedAttributionData = undefined // clear the data companion — recomputed on next consumption
} else if (!fun.equalityDeep(this._usedAttribution?.[name], value)) {
this._usedAttribution = object.assign({}, this._usedAttribution)
this._usedAttribution[name] = value
this._usedAttributionData = undefined // clear the data companion — recomputed on next consumption
}
return this
}
/**
* @template {(FixedConf extends true ? never : (Array<any>|string)) | (DeltaConfGetChildren<Conf> extends infer Children ? (Children extends never ? never : Array<Children>) : never) | DeltaConfGetText<Conf>} NewContent
* @param {NewContent} insert
* @param {Formats?} [formatting]
* @param {Attribution?} [attribution]
* @return {DeltaBuilder<FixedConf extends true ? Conf : DeltaConfOverwrite<Conf,
* (Exclude<NewContent,string> extends never ? {} : {
* children: Exclude<NewContent,string>[number]|DeltaConfGetChildren<Conf>
* }) & (Extract<NewContent,string> extends never ? {} : { text: true })>, FixedConf>}
*/
insert (insert, formatting, attribution) {
modDeltaCheck(this)
const mergedFormats = combineData(this._getUsedFormatsData(), formatting, false)
const mergedAttribution = combineData(this._getUsedAttributionData(), attribution, true)
/**
* @param {TextOp | InsertOp<any>} lastOp
*/
const checkMergedEquals = lastOp => (mergedFormats === lastOp.format || fun.equalityDeep(mergedFormats, lastOp.format)) && (mergedAttribution === lastOp.attribution || fun.equalityDeep(mergedAttribution, lastOp.attribution))
const end = this.children.end
if (s.$string.check(insert)) {
if ($textOp.check(end) && checkMergedEquals(end)) {
// @ts-ignore
end.insert += insert
end._fingerprint = null
} else if (insert.length > 0) {
list.pushEnd(this.children, new TextOp(insert, mergedFormats, mergedAttribution))
}
this.childCnt += insert.length
} else if (arr.isArray(insert)) {
if ($insertOp.check(end) && checkMergedEquals(end)) {
// @ts-ignore
end.insert.push(...insert)
end._fingerprint = null
} else if (insert.length > 0) {
list.pushEnd(this.children, new InsertOp(insert.slice() /* ensures that we don't reuse an existing array */, mergedFormats, mergedAttribution))
}
this.childCnt += insert.length
// inserting pre-marked content brings its subtree marks along (as apply's insert path does)
this.maybeHasMarks ||= elemsMaybeHaveMarks(insert)
}
return /** @type {any} */ (this)
}
/**
* @template {Extract<DeltaConfGetAllowedChildren<Conf, FixedConf>,Delta<any>|DeltaData<any,any,any,any>|DeltaBuilder<any>>} NewContent
* @param {NewContent} modify
* @param {Formats?} [formatting] tri-state: omit/`undefined` skip, `null` clear, `{k:v}`/`{k:null}` set/remove
* @param {Attribution?} [attribution] tri-state: omit/`undefined` skip, `null` clear, `{k:v}`/`{k:null}` set/remove
* @return {DeltaBuilder<DeltaConfOverwrite<Conf, {children: DeltaConfGetChildren<Conf>|NewContent}>, FixedConf>}
*/
modify (modify, formatting, attribution) {
modDeltaCheck(this)
const mergedFormats = combineInstr(this.usedFormats, formatting, false)
const mergedAttribution = combineInstr(this.usedAttribution, attribution, true)
list.pushEnd(this.children, new ModifyOp(modify, mergedFormats, mergedAttribution))
this.childCnt += 1
// the modify target carries marks in its subtree (own root marks + nested) - flag conservatively
this.maybeHasMarks ||= modify.maybeHasMarks
return /** @type {any} */ (this)
}
/**
* Add a cursor/selection {@link Mark} at `pos` — a {@link import('./position.js').Pos}: a `path` of
* content-index / attribute-key steps plus an `assoc` and optional immutable `attrs` (user data
* carried with the mark). Returns `this` for chaining (like every other builder method). Pass an
* explicit `id` to reference the mark later — re-adding the same `id` replaces it (e.g. to move a
* cursor or update its `attrs`) and {@link DeltaBuilder#removeMark} deletes it; an anonymous one-shot
* mark gets a fresh GUID otherwise.
*
* @param {import('./position.js').Pos} pos
* @param {string} [id]
* @param {boolean} [transient] flag the mark as never-to-be-stored (a one-shot mapping probe, see
* {@link Mark#transient} and {@link import('./position.js').mapPositionsA})
* @return {this}
*/
addMark (pos, id = rand.uuidv4(), transient = false) {
// apply with the default `final = this.isFinal` (do NOT force `{ final: true }`): a fresh change
// builder is non-final so a sibling `removeMark` stays transmittable; a final doc collects nothing
this.apply(/** @type {Delta<Conf>} */ (markChange(pos, id, false, transient)))
return this
}
/**
* Remove the mark with `id`, located via `pos` (the position it was added at). On a settled document
* that holds the mark this removes it in place; on a fresh/markless builder it records a transmittable
* `deleteMarks` change (symmetric to {@link DeltaBuilder#addMark}), so
* `delta.create().removeMark(pos, id)` yields the delete-mark change to apply / rebase / transmit.
*
* @param {import('./position.js').Pos} pos
* @param {string} id
* @return {this}
*/
removeMark (pos, id) {
// apply with the default `final = this.isFinal` (do NOT force `{ final: true }`): on a non-final
// change builder this records a transmittable `deleteMarks`; on a final doc it only removes in place
this.apply(/** @type {Delta<Conf>} */ (markChange(pos, id, true)))
return this
}
/**
* @param {number} len
* @param {Formats?} [format]
* @param {Attribution?} [attribution]
*/
retain (len, format, attribution) {
modDeltaCheck(this) // this clears _fingerprint
const mergedFormats = combineInstr(this.usedFormats, format, false)
const mergedAttribution = combineInstr(this.usedAttribution, attribution, true)
const lastOp = /** @type {RetainOp|InsertOp<any>} */ (this.children.end)
if ($retainOp.check(lastOp) && fun.equalityDeep(mergedFormats, lastOp.format) && fun.equalityDeep(mergedAttribution, lastOp.attribution)) {
// @ts-ignore
lastOp.retain += len
lastOp._fingerprint = null
} else if (len > 0) {
list.pushEnd(this.children, new RetainOp(len, mergedFormats, mergedAttribution))
}
this.childCnt += len
return this
}
/**
* @param {number} len
*/
delete (len) {
modDeltaCheck(this)
const lastOp = /** @type {DeleteOp<any>|InsertOp<any>} */ (this.children.end)
if ($deleteOp.check(lastOp)) {
lastOp.delete += len
lastOp._fingerprint = null
} else if (len > 0) {
list.pushEnd(this.children, new DeleteOp(len))
}
this.childCnt += len
return this
}
/**
* @template {Extract<keyof DeltaConfGetAllowedAttrs<Conf, FixedConf>,string|number>} Key
* @template {DeltaConfGetAllowedAttrs<Conf, FixedConf>[Key]} Val
* @param {Key} key
* @param {Val} val
* @param {Attribution?} [attribution] provenance for this attr (data: object or `null`/none)
* @return {DeltaBuilder<DeltaConfOverwrite<Conf,{attrs:AddToAttrs<DeltaConfGetAttrs<Conf>,Key,Val>}>, FixedConf>}
*/
setAttr (key, val, attribution) {
modDeltaCheck(this)
// @ts-ignore
this.attrs[key] /** @type {any} */ =
(new SetAttrOp(/** @type {any} */ (key), val, combineData(this._getUsedAttributionData(), attribution, true)))
// a delta-valued attribute carries marks in its subtree - flag conservatively (never decremented)
if ($deltaAny.check(val)) this.maybeHasMarks ||= val.maybeHasMarks
return /** @type {any} */ (this)
}
/**
* @template {DeltaConfGetAllowedAttrs<Conf, FixedConf>} NewAttrs
* @param {NewAttrs} attrs
* @param {Attribution?} [attribution] provenance applied to each set attr (data: object or `null`/none)
* @return {DeltaBuilder<DeltaConfOverwrite<
* Conf,
* { attrs: MergeAttrs<DeltaConfGetAttrs<Conf>,NewAttrs> }
* >, FixedConf>
* }
*/
setAttrs (attrs, attribution) {
modDeltaCheck(this)
for (const k in attrs) {
this.setAttr(/** @type {any} */ (k), /** @type {any} */ (attrs)[/** @type {any} */ (k)], attribution)
}
return /** @type {any} */ (this)
}
/**
* @template {Extract<keyof DeltaConfGetAllowedAttrs<Conf, FixedConf>,string|number>} Key
* @param {Key} key
* @param {Attribution?} [attribution] provenance for the deletion (data: object or `null`/none)
* @return {DeltaBuilder<DeltaConfOverwrite<Conf, {
* attrs: AddToAttrs<DeltaConfGetAttrs<Conf>,Key,never>
* }>, FixedConf>}
*/
deleteAttr (key, attribution) {
modDeltaCheck(this)
// dropping a delta-valued attribute drops its subtree's marks; the conservative `maybeHasMarks` flag
// is left as-is (never decremented - marksToPositions self-corrects it)
// @ts-ignore
this.attrs[key] /** @type {any} */ =
(new DeleteAttrOp(/** @type {any} */ (key), combineData(this._getUsedAttributionData(), attribution, true)))
return /** @type {any} */ (this)
}
/**
* @template {DeltaConfGetAllowedAttrs<Conf, FixedConf> extends infer As ? { [K in keyof As]: Extract<As[K],DeltaAny> extends never ? never : K }[keyof As] : never} Key
* @template {Extract<DeltaConfGetAllowedAttrs<Conf, FixedConf>[Key],DeltaAny>} D
* @param {Key} key
* @param {D} modify
* @param {Attribution?} [attribution] tri-state instruction merged onto the target attr op: omit/`undefined` skip, `null` clear, `{k:v}`/`{k:null}` set/remove
* @return {DeltaBuilder<DeltaConfOverwrite<Conf,{attrs:AddToAttrs<DeltaConfGetAttrs<Conf>,Key,D>}>, FixedConf>}
*/
modifyAttr (key, modify, attribution) {
modDeltaCheck(this)
const mergedAttribution = combineInstr(this.usedAttribution, attribution, true)
this.attrs[key] = /** @type {any} */ (new ModifyAttrOp(key, modify, mergedAttribution))
// the modify value carries marks in its subtree - flag conservatively (never decremented)
this.maybeHasMarks ||= /** @type {DeltaAny} */ (modify).maybeHasMarks
return /** @type {any} */ (this)
}
/**
* Apply other delta on this op. The result is the merged op of both of them.
*
* a.apply(b.apply(c))
*
* is equivalent to
*
* a.apply(b).apply(c)
*
* If `final = true`, we consider this delta the final state and drop deleteAttrOps from
* attributes. (E.g. if `otherOp` deletes an attribute, this op will simply not have the
* attribute). Any kind of `delete` op might be considered a bug. A final delta is not idempotent.
*
* @param {Delta<Conf>?} other
* @param {{ final?: boolean, move?: boolean }} [opts] `move`: the caller donates `other` (it is not
* read again), so its content is consumed (shared mutable) rather than frozen-cloned — see {@link
* InsertOp#clone}. UNSAFE if the caller keeps using `other`.
* @return {DeltaBuilder<Conf>}
*/
apply (other, { final = this.isFinal, move = false } = {}) {
if (other == null) return this
modDeltaCheck(this)
this.$schema?.expect(other)
// `move`: the caller donates `other` (it must not be read again), so content is consumed (shared
// mutable) instead of frozen-cloned — no `done()` and no re-clone on a later modify. UNSAFE if the
// caller keeps using `other`. `!move` is the default everywhere (today's behavior).
const keep = !move
// apply attrs
for (const op of other.attrs) {
// @ts-ignore
const c = /** @type {SetAttrOp<any,any>|DeleteAttrOp<any>|ModifyAttrOp<any,any>} */ (this.attrs[op.key])
if ($modifyAttrOp.check(op)) {
if ($deltaAny.check(c?.value)) {
const tgt = c._modValue
tgt.apply(op.value, { final, move })
this.maybeHasMarks ||= tgt.maybeHasMarks // flag the attr subtree's marks up
// modifyAttr carries a tri-state attribution update for the target attr op (merge/clear/skip)
if (applyDim(c, 'attribution', op.attribution)) {
/** @type {any} */ (c)._fingerprint = null
}
} else {
// then this is a simple modify (the attribute did not previously hold a delta)
// @ts-ignore
this.attrs[op.key] = move ? op : op.clone()
this.maybeHasMarks ||= op.value.maybeHasMarks // flag any marks the new modify value carries
}
} else if ($setAttrOp.check(op)) {
// a delta-valued attribute carries marks in its subtree - flag conservatively (never decremented)
if ($deltaAny.check(op.value)) this.maybeHasMarks ||= op.value.maybeHasMarks
// @ts-ignore
this.attrs[op.key] = move ? op : op.clone()
} else if ($deleteAttrOp.check(op)) {
// removing a delta-valued attribute drops its subtree's marks; the conservative `maybeHasMarks`
// flag is left as-is (never decremented - marksToPositions self-corrects it)
if (final) {
// @ts-ignore
delete this.attrs[op.key]
} else {
// @ts-ignore
this.attrs[op.key] = move ? op : op.clone()
}
}
}
// apply children
/**
* @type {ChildrenOpAny?}
*/
let opsI = this.children.start
if ($deleteOp.check(opsI)) {
opsI = opNextUndeleted(opsI)
}
let offset = 0
/**
* At the end, we will try to merge this op, and op.next op with their respective previous op.
*
* Hence, anytime an op is cloned, deleted, or inserted (anytime list.* api is used) we must add
* an op to maybeMergeable.
*
* @type {Array<InsertOp<any>|RetainOp|DeleteOp<any>|TextOp|ModifyOp<any>>}
*/
const maybeMergeable = []
/**
* Schedule an op to be merged later
* @param {ChildrenOpAny?} op
* @return {ChildrenOpAny}
*/
const scheduleForMerge = op => {
op && maybeMergeable.push(op)
return /** @type {any} */ (op)
}
/**
* Split opsI at the current offset - ensuring that we can insert safely at the current position
*/
const splitHere = () => {
if (offset > 0 && opsI != null) {
opsI = scheduleForMerge(_splitChildAt(this, opsI, offset))
offset = 0
}
}
/**
* @param {ChildrenOpAny} op
*/
const insertClonedOp = op => {
splitHere()
// `move` re-parents the source op directly (it is donated); else clone with `keep`. Freshly
// built `new DeleteOp(...)` callers are unaliased, so moving them is likewise safe.
_insertChild(this, opsI, scheduleForMerge(move ? op : op.clone(0, op.length, keep)))
// inserting pre-marked content (e.g. a node that already carries a cursor) brings its marks along
if ($insertOp.check(op)) this.maybeHasMarks ||= elemsMaybeHaveMarks(op.insert)
}
// manual iteration (not `for..of`): a `move` apply re-parents the source op into `this.children`,
// rewiring its `.next`; capture the successor first so the walk stays on `other`'s chain.
for (let op = /** @type {ChildrenOpAny?} */ (other.children.start), nextOp = op; op != null; op = nextOp) {
nextOp = op.next
// defensive: the per-branch logic below resets opsI/offset whenever it
// consumes an op exactly. This guard catches any path that forgets to.
/* c8 ignore start */
if (opsI?.length === offset) {
opsI = opNextUndeleted(opsI)
offset = 0
}
/* c8 ignore stop */
if ($textOp.check(op) || $insertOp.check(op)) {
insertClonedOp(op)
} else if ($retainOp.check(op)) {
let retainLen = op.length
// split the current op when this retain carries a format and/or attribution instruction
// (`!== undefined`) that differs from what the existing op holds — both unified tri-state. The
// split must fire on any differing instruction (incl. a `{k:null}` removal or a `null` clear), not
// just one that would change a *data* target: it marks the boundary between the already-advanced
// (skipped) prefix and the region this op formats, so the update can't bleed into the prefix.
if (offset > 0 && opsI != null && !$deleteOp.check(opsI) && (
(op.format !== undefined && !fun.equalityDeep(/** @type {InsertOp<any>|RetainOp|ModifyOp} */ (opsI).format, op.format)) ||
(op.attribution !== undefined && !fun.equalityDeep(/** @type {any} */ (opsI).attribution, op.attribution))
)) {
// need to split current op
splitHere()
}
while (opsI != null && opsI.length - offset <= retainLen) {
if (op.format !== undefined || op.attribution !== undefined) {
updateOpFormat(opsI, op.format, op.attribution)
scheduleForMerge(opsI)
}
retainLen -= opsI.length - offset
opsI = opNextUndeleted(opsI)
offset = 0
}
if (opsI != null) {
if ((op.format !== undefined || op.attribution !== undefined) && retainLen > 0) {
// accumulate onto the existing offset — the else-branch below uses
// `offset += retainLen`, and we must agree with it when prior
// iterations have advanced offset into opsI without splitting (e.g.
// a format-less retain followed by a same-format retain).
offset += retainLen
splitHere()
updateOpFormat(/** @type {ChildrenOpAny} */ (opsI.prev), op.format, op.attribution)
scheduleForMerge(opsI.prev)
} else {
offset += retainLen
}
} else if (retainLen > 0) {
// append the retain beyond existing content. On a NON-final/instruction apply keep it verbatim —
// a `null` (clear) format/attribution here is a meaningful change op (e.g. a format/attribution
// diff applied onto an as-yet-empty change delta). On a FINAL (materializing) apply the
// removal/clear has nothing to act on, so resolve it away ({@link resolveBeyond}): a `{k:null}`
// removal or `null` clear collapses to `undefined`, a set survives. We still append the (possibly
// now-bare) retain so a following op stays positioned; done() trims it iff it is trailing.
const fmt = final ? resolveBeyond(op.format, false) : op.format
const attr = final ? resolveBeyond(op.attribution, true) : op.attribution
_insertChild(this, null, scheduleForMerge(new RetainOp(retainLen, fmt, attr)))
}
} else if ($deleteOp.check(op)) {
let remainingLen = op.delete
while (remainingLen > 0) {
if (opsI == null) {
_insertChild(this, null, scheduleForMerge(new DeleteOp(remainingLen)))
break
} else if ($retainOp.check(opsI)) { // retain ⇒ splice retain, insert delete
const delLen = math.min(opsI.length - offset, remainingLen)
_shrinkChild(this, opsI, offset, delLen)
if (opsI.length === 0) {
_removeChild(this, opsI)
opsI = opNextUndeleted(opsI)
offset = 0
}
if (opsI?.length === offset) {
opsI = opNextUndeleted(opsI)
offset = 0
}
insertClonedOp(new DeleteOp(delLen))
remainingLen -= delLen
} else if ($modifyOp.check(opsI)) { // modify ⇒ delete the source position it stands for
// a modify op addresses an underlying source position (retain + change), so deleting it
// must emit a DeleteOp for that position - exactly like the retain case above. (The
// insert/embed branch below instead just cancels the inserted content, because that
// content is new and has no source position to delete.)
_removeChild(this, opsI)
opsI = opNextUndeleted(opsI)
offset = 0
insertClonedOp(new DeleteOp(1))
remainingLen -= 1
} else if (!$deleteOp.check(opsI)) { // insert / embed ⇒ replace
// case1: delete o fully
// case2: delete some part of beginning
// case3: delete some part of end
// case4: delete some part of center
const delLen = math.min(opsI.length - offset, remainingLen)
// deleting content drops any marks in the removed embeds; the conservative `maybeHasMarks`
// flag is left as-is (never decremented - marksToPositions self-corrects it)
if (opsI.length === delLen) {
// case 1: delete opsI fully
_removeChild(this, opsI)
scheduleForMerge(opsI = opNextUndeleted(opsI))
offset = 0
} else if (offset === 0) {
// case 2: delete from the beginning
_shrinkChild(this, opsI, offset, delLen)
} else if (offset + delLen === opsI.length) {
// case 3: delete to the end
_shrinkChild(this, opsI, offset, delLen)
opsI = opNextUndeleted(opsI)
offset = 0
} else {
// case 4: delete from the center
_shrinkChild(this, opsI, offset, delLen)
}
remainingLen -= delLen
/* c8 ignore start */
} else {
// unreachable: opsI was already typed as retain | non-delete-content | delete above
error.unexpectedCase()
}
/* c8 ignore stop */
}
} else if ($modifyOp.check(op)) {
if (opsI != null && (op.format !== undefined || op.attribution !== undefined) && (!$deleteOp.check(opsI) && !$retainOp.check(opsI))) { // retain handles splitting seperately, without copying attrs
splitHere()
if (opsI.length > 1) {
offset = 1
splitHere()
opsI = /** @type {InsertOp<any>} */ (opsI.prev)
}
// at this point, opsI is guaranteed to be !deleteOp && !retainOp and of length 1
updateOpFormat(opsI, op.format, op.attribution)
scheduleForMerge(opsI)
}
if (opsI == null) {
_insertChild(this, null, move ? op : op.clone(0, 1, keep))
this.maybeHasMarks ||= /** @type {DeltaAny} */ (op.value).maybeHasMarks // a freshly-inserted modify brings its value's root marks
} else if ($modifyOp.check(opsI)) {
const tgt = opsI._modValue
tgt.apply(/** @type {any} */ (op.value), { final, move })
this.maybeHasMarks ||= tgt.maybeHasMarks // flag the child subtree's marks up (incl. the value's own root marks)
opsI = opNextUndeleted(opsI)
} else if ($insertOp.check(opsI)) {
const tgt = opsI._modValue(offset)
tgt.apply(op.value, { final, move })
this.maybeHasMarks ||= tgt.maybeHasMarks // flag the child subtree's marks up (incl. the value's own root marks)
if (opsI.length === ++offset) {
opsI = opNextUndeleted(opsI)
offset = 0
}
} else if ($retainOp.check(opsI)) {
splitHere()
const insertModOp = scheduleForMerge(move ? op : op.clone(0, 1, keep))
this.maybeHasMarks ||= /** @type {DeltaAny} */ (op.value).maybeHasMarks // the inserted modify brings its value's root marks
;(opsI.format !== undefined || opsI.attribution !== undefined) && updateOpFormat(insertModOp, opsI.format, opsI.attribution)
// the modify replaces one retain position with a length-1 modify (net childCnt: +1 here, -1 below)
_insertChild(this, opsI, insertModOp)
if (opsI.length === 1) {
_removeChild(this, opsI)
opsI = opNextUndeleted(opsI)
offset = 0
} else {
_shrinkChild(this, opsI, 0, 1)
scheduleForMerge(opsI)
}
/* c8 ignore start */
} else {
// remaining branches: opsI is deleteOp or something unknown
// both branches are unreachable today: opNextUndeleted skips
// delete ops, so opsI is never a delete during iteration; and the four
// branches above exhaust the other op kinds. The deleteOp branch is
// kept as a defensive no-op (drops a modify that lands in a deleted
// region) rather than a throw.
error.unexpectedCase()
}
} else {
error.unexpectedCase()
}
/* c8 ignore stop */
}
// iterate backwards, to ensure that we merge all content
for (let i = maybeMergeable.length - 1; i >= 0; i--) {
const op = maybeMergeable[i]
// check if this is still integrated
if (op.prev != null ? op.prev.next === op : this.children.start === op) {
op.prev && _mergeChildWithPrev(this, op)
op.next && _mergeChildWithPrev(this, op.next)
}
}
// marks: shift this node's own leaf marks by the change, then fold in root-level mark adds/deletes
// (child subtree marks already flagged `maybeHasMarks` incrementally above)
shiftMarksByChange(this, other)
// pass `final` so a materializing apply (e.g. a deltaRDT's final state) applies deletes in place
// but collects no pending `deleteMarks`; a non-final change delta still records them (transmittable)
applyMarkOps(this, other.marks, other.deleteMarks, final)
return this
}
/**
* Rebase this op against a concurrent op. We can apply this op on a doc that already applied
* `other` op.
*
* @param {DeltaAny} other
* @param {boolean} priority
*/
rebase (other, priority) {
modDeltaCheck(this)
/**
* Rebase attributes
*
* - insert vs delete ⇒ insert takes precedence
* - insert vs modify ⇒ insert takes precedence
* - insert vs insert ⇒ priority decides
* - delete vs modify ⇒ delete takes precedence
* - delete vs delete ⇒ current delete op is removed because item has already been deleted
* - modify vs modify ⇒ rebase using priority
*/
for (const op of this.attrs) {
if ($setAttrOp.check(op)) {
if ($setAttrOp.check(other.attrs[op.key]) && !priority) {
// @ts-ignore
delete this.attrs[op.key]
}
} else if ($deleteAttrOp.check(op)) {
// @ts-ignore
const otherOp = other.attrs[/** @type {any} */ (op.key)]
if ($setAttrOp.check(otherOp)) {
// @ts-ignore
delete this.attrs[otherOp.key]
}
} else if ($modifyAttrOp.check(op)) {
const otherOp = other.attrs[/** @type {any} */ (op.key)]
if (otherOp == null) {
// nop
} else if ($modifyAttrOp.check(otherOp)) {
op._modValue.rebase(otherOp.value, priority)
} else {
// @ts-ignore
delete this.attrs[otherOp.key]
}
/* c8 ignore start */
} else {
// unreachable: attr ops are exhaustively setAttr | deleteAttr | modifyAttr
error.unexpectedCase()
}
/* c8 ignore stop */
}
/**
* Rebase children.
*
* Precedence: insert with higher priority comes first. Op with less priority is transformed to
* be inserted later.
*
* @todo always check if inser OR text
*/
/**
* @type {ChildrenOpAny?}
*/
let currChild = this.children.start
let currOffset = 0
/**
* @type {ChildrenOpAny?}
*/
let otherChild = other.children.start
let otherOffset = 0
while (currChild != null && otherChild != null) {
if ($insertOp.check(currChild) || $textOp.check(currChild)) {
/**
* Transforming *insert*. If other is..
* - insert: transform based on priority
* - retain/delete/modify: transform next op against other
*/
if ($insertOp.check(otherChild) || $textOp.check(otherChild)) {
if (!priority) {
_insertChild(this, currChild, new RetainOp(otherChild.length, undefined, undefined))
// curr is transformed against other, transform curr against next
otherOffset = otherChild.length
} else {
// curr stays as is, transform next op
currOffset = currChild.length
}
} else { // otherChild = delete | retain | modify - curr stays as is, transform next op
currOffset = currChild.length
}
} else if ($modifyOp.check(currChild)) {
/**
* Transforming *modify*. If other is..
* - insert: adjust position
* - modify: rebase curr modify on other modify
* - delete: remove modify
* - retain: adjust offset
*/
if ($insertOp.check(otherChild) || $textOp.check(otherChild)) {
// @todo: with all list changes (retain insertions, removal), try to merge the surrounding
// ops later
_insertChild(this, currChild, new RetainOp(otherChild.length, undefined, undefined))
// curr is transformed against other, transform curr against next
otherOffset = otherChild.length
} else {
if ($modifyOp.check(otherChild)) {
// _modValue (not .value) — ModifyOp.clone() marks its inner delta
// as `done`, so a cloned ModifyOp can only be rebased after the
// _modValue getter lazy-clones it back to mutable. This recursion also reconciles the
// value's own root marks (via rebaseRootMarks), so a nested mark needs no special handling.
currChild._modValue.rebase(otherChild.value, priority)
} else if ($deleteOp.check(otherChild)) {
_removeChild(this, currChild)
}
currOffset += 1
otherOffset += 1
}
} else { // DeleteOp | RetainOp
const maxCommonLen = math.min(currChild.length - currOffset, otherChild.length - otherOffset)
/**
* Transforming *retain* OR *delete*. If other is..
* - retain / modify: adjust offsets
* - delete: shorten curr op
* - insert: split curr op and insert retain
*/
if ($retainOp.check(otherChild) || $modifyOp.check(otherChild)) {
// Format reconciliation over the [currOffset..currOffset+maxCommonLen] overlap. Split currChild
// around the overlap so the prefix/suffix keep their original format and only the middle piece
// changes. Three rules:
// 1. A blanket `null` clear ALWAYS wins, priority-independent (this is what lets it converge — both
// rebase orderings agree the overlap ends up cleared, at the cost of dropping a concurrent set;
// hence `null` is a local-only utility, see the `Formats` docs). currChild's own
// `null` clear is left untouched (it wins); if *other* clears, currChild concedes its whole format.
// 2. Otherwise (both per-key objects) priority decides: priority=true is a no-op (currChild wins);
// for !priority currChild concedes any key otherChild also writes (a `{k:null}` removal is just
// such a key, so per-key removals reconcile here and converge without data loss — the recommended
// way to clear).
// 3. otherChild `undefined` (skip) never changes currChild.
if ($retainOp.check(currChild) && currChild.format !== null) {
let changed = false
/** @type {Formats|null|undefined} */
let newFormat
if (otherChild.format === null) {
// other clears all → clear wins → currChild concedes its entire format
if (currChild.format !== undefined) { newFormat = undefined; changed = true }
} else if (!priority && currChild.format != null && otherChild.format != null) {
/** @type {Formats} */
const stripped = {}
for (const k in currChild.format) {
if (k in otherChild.format) changed = true
else stripped[k] = currChild.format[k]
}
if (changed) newFormat = object.isEmpty(stripped) ? undefined : stripped
}
if (changed) {
// split off the suffix [currOffset+maxCommonLen..length] if any
if (currOffset + maxCommonLen < currChild.length) {
_splitChildAt(this, currChild, currOffset + maxCommonLen)
}
// split off the prefix [0..currOffset] if any: currChild becomes the [currOffset..] suffix
if (currOffset > 0) {
currChild = _splitChildAt(this, currChild, currOffset)
currOffset = 0
}
// currChild now spans exactly the overlap. A conceded format is `undefined` (skip / no format
// change), NOT `null` (which would CLEAR the format on apply).
/** @type {any} */ (currChild).format = newFormat
currChild._fingerprint = null
}
}
currOffset += maxCommonLen
otherOffset += maxCommonLen
} else if ($deleteOp.check(otherChild)) {
// currChild is Retain | Delete here (outer branch); shrink it by the overlap
_shrinkChild(this, currChild, currOffset, maxCommonLen)
// advance other so subsequent currChild ops see what comes AFTER this
// delete; without this we'd loop against the same delete forever and
// never reach other's later inserts.
otherOffset += maxCommonLen
} else { // insert/text.check(otherChild)
if (currOffset > 0) {
// split currChild so the cursor sits on the boundary; continue on the [currOffset..] suffix
currChild = _splitChildAt(this, currChild, currOffset)
currOffset = 0
}
_insertChild(this, currChild, new RetainOp(otherChild.length, undefined, undefined))
otherOffset = otherChild.length
}
}
if (currOffset >= currChild.length) {
currChild = currChild.next
currOffset = 0
}
if (otherOffset >= otherChild.length) {
otherChild = otherChild.next
otherOffset = 0
}
}
// marks: reconcile this node's own root mark ops against the concurrent change (also shifting and,
// where an anchor was deleted, converting an add to a delete). No count to maintain: `maybeHasMarks`
// is conservative (rebase only drops/reconciles marks, never adds), so a stale `true` is harmless
// and marksToPositions self-corrects it.
if (this.marks !== null || this.deleteMarks !== null) {
rebaseRootMarks(this, other, priority)
}
return this
}
/**
* Same as doing `delta.rebase(other.inverse())`, without creating a temporary delta.
*
* @param {DeltaAny} other
* @param {boolean} priority
*/
rebaseOnInverse (other, priority) {
modDeltaCheck(this)
// @todo
console.info('method rebaseOnInverse unimplemented')
return this
}
/**
* Append child ops from one op to the other.
*
* delta.create().insert('a').append(delta.create().insert('b')) // => insert "ab"
*
* @template {DeltaConf} OtherDeltaConf
* @param {Delta<OtherDeltaConf>} other
* @return {DeltaBuilder<FixedConf extends true ? Conf : DeltaConfOverwrite<
* Conf,
* (DeltaConfGetChildren<OtherDeltaConf> extends never ? {} : { children: DeltaConfGetChildren<Conf> | DeltaConfGetChildren<OtherDeltaConf> })
* & (DeltaConfGetText<OtherDeltaConf> extends string ? { text: true } : never)
* >, FixedConf>}
*/
append (other) {
modDeltaCheck(this)
const prevLast = this.children.end
// @todo Investigate. Above is a typescript issue. It is necessary to cast OtherDelta to a Delta first before
// inferring type, otherwise Children will contain Text.
for (const child of other.children) {
_insertChild(this, null, child.clone())
}
// appended children may carry marks in their subtrees - flag conservatively
this.maybeHasMarks ||= other.maybeHasMarks
prevLast?.next && _mergeChildWithPrev(this, prevLast.next)
// @ts-ignore
return this
}
}
/**
* Apply one tri-state dimension update (`format` or `attribution`) to `op[field]` and report whether it
* changed. `undefined` = skip; `null` = clear all keys; an object merges per-key (`{k:v}` sets, `{k:null}`
* removes key `k`). `format` and `attribution` are handled identically (the unified model).
*
* On an **instruction** op (`retain`/`modify`) the merge is kept verbatim — a `{k:null}` removal and a
* `null` clear must survive so they still apply when this op is later consumed (an empty merge result `{}`
* is a no-op → `undefined`/skip). On a **data** op (`insert`/`text`/attr op) the merge is resolved against
* the stored value (empty → `null`/none). (Limitation: a `null` clear already present on an instruction op
* does not compose with a subsequent set into a single op — only the set survives. The diff never produces
* this; it would need arbitrary clear-then-set change composition.)
*
* @param {_OpAny} op
* @param {'format'|'attribution'} field
* @param {{[k:string]:any}|null|undefined} update
* @return {boolean} whether `op[field]` was touched
*/
const applyDim = (op, field, update) => {
if (update === undefined) return false
if (update === null) {
/** @type {any} */ (op)[field] = null // clear all
} else if ($retainOp.check(op) || $modifyOp.check(op)) {
// instruction op: keep the merge verbatim (preserves `{k:null}` removals to apply later). Attribution
// additionally merges its nested `format` key one level (see {@link mergeAttr}); format stays shallow.
const cur = /** @type {any} */ (op)[field]
const merged = field === 'attribution' ? mergeAttr(cur, update, false) : object.assign({}, cur, update)
// the empty-merge guard is currently unreachable: a stored `update` is canonical (the builder's
// combine strips `{k:undefined}` keys and drops empty objects to `undefined`, which `applyDim`
// already returned on), and a verbatim (non-resolving) merge never removes keys — so `merged`
// keeps at least one key of `update`. Kept to document that an empty result means skip.
/* c8 ignore next */
;/** @type {any} */ (op)[field] = object.isEmpty(merged) ? undefined : merged
} else if (field === 'attribution') {
// data op, attribution: resolve per key; the nested `format` key merges one level (see {@link mergeAttr})
const merged = mergeAttr(/** @type {any} */ (op)[field], update, true)
;/** @type {any} */ (op)[field] = object.isEmpty(merged) ? null : merged
} else {
// data op (format): resolve per-key against the stored value (`{k:v}` sets, `{k:null}` removes, `{k:undefined}` skips)
let f = /** @type {any} */ (op)[field]
for (const k in update) {
const v = update[k]
// the skip is currently unreachable: a stored `update` never carries a `{k:undefined}` key
// (the builder's combine strips them). Kept for the unified tri-state contract.
/* c8 ignore next */
if (v === undefined) continue // skip this key
if (v === null) { // remove this key (no-op when the stored value is already `null`)
if (f !== null) {
const { [k]: _, ...rest } = f
f = object.isEmpty(rest) ? null : rest
}
} else { // set this key
f = object.assign({}, f, { [k]: v })
}
}
/** @type {any} */ (op)[field] = f
}
return true
}
/**
* Compute the tri-state update that turns stored value `aVal` into `bVal` (used by {@link diff} for both
* `format` and `attribution`): `undefined` when unchanged (skip), `null` when fully cleared, else a
* per-key object (`{k:v}` for changed/added keys, `{k:null}` for removed keys). With `deep` (attribution),
* the nested `format` key is diffed one level deeper so it round-trips through {@link mergeAttr}'s per-inner-key
* merge instead of a wholesale replace.
*
* @param {{[k:string]:any}|null|undefined} aVal
* @param {{[k:string]:any}|null|undefined} bVal
* @param {boolean} deep `true` for `attribution` (recurse its `format` key), `false` for `format` (shallow)
* @return {{[k:string]:any}|null|undefined}
*/
const diffDim = (aVal, bVal, deep) => {
if (fun.equalityDeep(aVal, bVal)) return undefined
if ((bVal === null || bVal === undefined || object.isEmpty(bVal)) && (deep || aVal == null)) {
return null // fully cleared: attribution (`deep`) clears wholesale; format with no prior keys has nothing to enumerate
}
/** @type {{[k:string]:any}} */
const u = {}
for (const k in bVal) {
if (!fun.equalityDeep(bVal[k], aVal?.[k] ?? null)) {
// attribution's nested `format` diffs one level deeper (mirrors mergeAttr); everything else is wholesale
u[k] = deep && k === 'format' && s.$objectAny.check(bVal[k]) && s.$objectAny.check(aVal?.[k])
? diffDim(aVal[k], bVal[k], false)
: bVal[k]
}
}
// removed keys. For `format` (shallow) a full clear enumerates one `{k:null}` per prior key instead of a
// wholesale `format:null` — individual clears rebase without clobbering concurrently-added formats.
// (`for..in` is a no-op when `aVal` is nullish; `k in bVal` is guarded against a nullish `bVal`.)
for (const k in aVal) { if (bVal == null || !(k in bVal)) u[k] = null }
return u
}
/**
* Apply a `format` and/or `attribution` update to an op (both unified tri-state — see {@link applyDim}).
* `format`/`attribution` are fingerprinted, so any change nulls the op's `_fingerprint`.
*
* @param {ChildrenOpAny} op
* @param {{[k:string]:any}|null|undefined} formatUpdate
* @param {{[k:string]:any}|null|undefined} attributionUpdate
*/
const updateOpFormat = (op, formatUpdate, attributionUpdate) => {
// defensive: apply's walk never passes a delete op (the initial `opsI` skips leading deletes and
// every advance goes through `opNextUndeleted`)
/* c8 ignore next */
if ($deleteOp.check(op)) return
const changedF = applyDim(op, 'format', formatUpdate)
const changedA = applyDim(op, 'attribution', attributionUpdate)
if (changedF || changedA) {
/** @type {any} */ (op)._fingerprint = null
}
}
/**
* @param {ChildrenOpAny?} op
*/
const opNextUndeleted = op => {
op = op?.next || null
while (op != null && $deleteOp.check(op)) {
op = op.next
}
return op
}
/**
* Total order on marks by their (unique) id, used to sort a mark list deterministically (e.g. in
* {@link Delta#toJSON}).
*
* @param {Mark} a
* @param {Mark} b
*/
const compareMarksById = (a, b) => a.id < b.id ? -1 : 1
/**
* Position-step order for a mark's `key`, mirroring the fingerprint key sort (see {@link Delta#fingerprint}):
* numbers (child indices) first, ascending; then string (attribute) keys, lexicographically.
*
* @param {number|string} a
* @param {number|string} b
*/
const cmpKey = (a, b) =>
s.$number.check(a) ? (s.$number.check(b) ? a - b : -1) : s.$number.check(b) ? 1 : (a < b ? -1 : a > b ? 1 : 0)
/**
* Total order over the marks of one node: by position-step {@link cmpKey}, tie-broken by the (unique)
* id so the order is fully deterministic even when two cursors share a key. Used by {@link sortMarks}.
*
* @param {Mark} a
* @param {Mark} b
*/
// the `: 0` id tie is unreachable: a Marks set is deduplicated by id (`add` replaces), so the sort
// never compares two marks with the same id
/* c8 ignore next */
const cmpMarkKey = (a, b) => cmpKey(a.key, b.key) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
/**
* Sort a {@link Marks} set in place into its canonical {@link cmpMarkKey} order and return it. Called
* lazily whenever the set is iterated, so every reader (e.g.
* {@link import('./position.js').marksToPositions}) sees one deterministic, position-ordered sequence
* regardless of the order marks were added in. Order is not part of a delta's identity, so reordering
* is safe even on a `done` delta.
*
* @param {Marks} marks
*/
const sortMarks = marks => { marks._marks.sort(cmpMarkKey); return marks }
/**
* The set of leaf {@link Mark marks} on a single delta node, deduplicated by id (so adding the same id
* again replaces it). An internal data-shape class (never a public/`new`-from-outside class): every
* mutation goes through its methods, so a stored {@link Mark} is treated as immutable and is never
* manipulated in place — to "move" a mark you replace it with a fresh `Mark`. {@link clone}/{@link
* slice} copy the set so a `done` delta's marks are never mutated through a shared reference. Marks are
* local/ephemeral cursor state
* and not part of a delta's identity, so this set has no fingerprint/equality of its own.
*/
class Marks {
constructor () {
/**
* @type {Array<Mark>}
*/
this._marks = []
}
get size () { return this._marks.length }
/**
* Add or replace `mark` (by id). Returns `true` if it was added as a new mark, `false` if it
* replaced an existing one with the same id.
*
* @param {Mark} mark
* @return {boolean}
*/
add (mark) {
const i = this._marks.findIndex(m => m.id === mark.id)
if (i < 0) {
this._marks.push(mark)
return true
}
this._marks[i] = mark
return false
}
/**
* Remove the mark with `id`. Returns `true` if a mark was present and removed, `false` if absent.
*
* @param {string} id
* @return {boolean}
*/
delete (id) {
const i = this._marks.findIndex(m => m.id === id)
if (i < 0) return false
this._marks.splice(i, 1)
return true
}
[Symbol.iterator] () { sortMarks(this); return this._marks[Symbol.iterator]() }
}
/**
* Whether any delta element in `insert` maybe carries a mark in its subtree (short-circuits). Used to
* OR-propagate the conservative {@link DeltaData#maybeHasMarks} flag when content is inserted.
*
* @param {Array<any>} insert
* @return {boolean}
*/
const elemsMaybeHaveMarks = insert => insert.some(el => $deltaAny.check(el) && el.maybeHasMarks)
/**
* Add (or replace) a {@link Mark} on `node`, flagging {@link DeltaData#maybeHasMarks}. Adding a mark
* cancels any pending delete of the same id on `node` (so a node never holds both an add and a delete
* for one id — the add wins), keeping the apply path consistent with rebase's add-vs-delete rule.
*
* @param {DeltaAny} node
* @param {Mark} mark
*/
const addMarkTo = (node, mark) => {
const marks = node.marks ?? new Marks()
marks.add(mark)
node.marks = marks
node.maybeHasMarks = true
if (node.deleteMarks !== null) {
node.deleteMarks.delete(mark.id)
if (node.deleteMarks.size === 0) node.deleteMarks = null
}
}
/**
* Mirror of {@link addMarkTo}: assert mark `id` is *absent* on `node` (last-writer-wins). Strips a
* present add of `id` (a delete cancels a pending add — newest wins), then, only for a CHANGE delta
* (`record`, i.e. a non-final apply), records the transmittable tombstone in `deleteMarks`. A
* final/materializing apply passes `record === false`: the mark is removed in place but no tombstone is
* collected (a final delta carries none — see {@link DeltaData#deleteMarks}). The `maybeHasMarks` flag is
* intentionally left as-is (never decremented - {@link import('./position.js').marksToPositions}
* self-corrects it). Together with `addMarkTo` these are the only two writers of `marks`/`deleteMarks`,
* so a node can never hold both an add and a delete for one id.
*
* @param {DeltaAny} node
* @param {string} id
* @param {boolean} record whether to keep a transmittable `deleteMarks` tombstone (`= !final`)
*/
const deleteMarkTo = (node, id, record) => {
if (node.marks !== null) {
node.marks.delete(id)
if (node.marks.size === 0) node.marks = null
}
if (record) {
const dm = node.deleteMarks ?? new Set()
dm.add(id)
node.deleteMarks = dm
}
}
/**
* Apply mark add/delete ops to `target` (flagging `target.maybeHasMarks` on add). `addMarks` may be a
* {@link Marks} set or a plain array; `deleteMarks` is a set of ids. The two id-keyed primitives
* {@link deleteMarkTo}/{@link addMarkTo} are the only writers of `marks`/`deleteMarks`, so a node can
* never end holding both for one id. Deletes are processed before adds so that a single change carrying
* both for one id resolves to *add wins* (the add re-runs last and strips the just-recorded tombstone),
* matching the rebase policy. `final` marks a document-materializing apply (propagated from
* {@link DeltaBuilder#apply}, defaulting to `this.isFinal`): a settled document removes a present mark in
* place but records no tombstone, while a non-final change delta records the delete (passed as
* `record = !final`) so `create().removeMark(pos, id)` stays transmittable.
*
* @param {DeltaAny} target
* @param {Iterable<Mark>?} addMarks
* @param {Set<string>?} deleteMarks
* @param {boolean} final whether this is a final (document-materializing) apply
*/
const applyMarkOps = (target, addMarks, deleteMarks, final) => {
if (deleteMarks !== null) {
for (const id of deleteMarks) deleteMarkTo(target, id, !final)
}
if (addMarks !== null) {
for (const m of addMarks) addMarkTo(target, m)
}
}
/**
* Re-key `node`'s *root* marks in place: each mark's `key` is mapped through `mapKey` (return `null` to
* drop it, an unchanged key to keep it, or a new key to move it). Used after a full {@link clone} by an
* attribute-renaming transformer so a mark on a renamed attribute follows the rename (and a mark on a
* dropped attribute is dropped). `node.maybeHasMarks` is left set (conservative; never decremented).
*
* @param {DeltaAny} node
* @param {(key: number|string) => number|string|null} mapKey
* @internal transformer plumbing (used by mark-carrying transformers), not consumer API.
*/
export const remapRootMarks = (node, mapKey) => {
const marks = node.marks
if (marks === null) return
for (const m of [...marks]) {
const k = mapKey(m.key)
if (k === m.key) continue
deleteMarkTo(node, m.id, false) // relocate a present mark: strip it, but synthesize no tombstone
if (k !== null) addMarkTo(node, m.copy(k))
}
}
/**
* Add (or replace, by id) a single root {@link Mark} on `node`, flagging `node.maybeHasMarks`. The
* public wrapper of the internal `addMarkTo`, used by the restructuring
* {@link import('./transformer/core.js').Transformer transformers} (`inline`, `project`) to place a
* mark at a position they compute themselves.
*
* @param {DeltaAny} node
* @param {Mark} mark
* @internal transformer plumbing (used by `inline`/`project`), not consumer API.
*/
export const addRootMark = (node, mark) => addMarkTo(node, mark)
/**
* Mark id `id` as deleted on `node` (the mirror of {@link addRootMark}): the public wrapper of the
* internal {@link deleteMarkTo} with `record === true`, used by the restructuring
* {@link import('./transformer/core.js').Transformer transformers} (`inline`, `project`) to carry a mark
* *delete* (which is id-keyed, so it needs no position) onto the node they build. Strips a present add of
* the same id (last-writer-wins) so the node never holds both.
*
* @param {DeltaAny} node
* @param {string} id
* @internal transformer plumbing (used by `inline`/`project`), not consumer API.
*/
export const deleteRootMark = (node, id) => deleteMarkTo(node, id, true)
/**
* Merge `source`'s *root* marks onto `target`, mapping each add mark's `key` through `mapKey` (return
* `null` to drop it, a new key to move it via {@link Mark#copy}). `source`'s `deleteMarks` are merged
* verbatim through {@link deleteMarkTo} — a delete is keyed only by its (cross-side-stable) id, so it
* maps in either direction and strips a conflicting add on `target` (last-writer-wins, never both). Safe
* to call repeatedly to accumulate marks from several sources onto one target (e.g.
* {@link import('./transformer/inline.js') inline} lifting the marks of every spliced node onto the
* flattened parent), and — onto a fresh target — to copy a node's marks across a key remapping (e.g.
* {@link cloneShallow}, and the `attr` transformer following an attribute rename). `target.maybeHasMarks`
* is flagged for the added marks; marks inside copied delta-valued attributes/children are flagged when
* the caller appends them via the builder (and {@link cloneShallow} copies the flag wholesale).
*
* @param {DeltaAny} target
* @param {DeltaAny} source
* @param {(key: number|string) => number|string|null} [mapKey]
* @internal transformer plumbing (used by `inline`/`attr`/`cloneShallow`), not consumer API.
*/
export const mergeRootMarks = (target, source, mapKey = k => k) => {
if (source.marks !== null) {
for (const m of source.marks) {
const k = mapKey(m.key)
if (k !== null) addMarkTo(target, k === m.key ? m : m.copy(k))
}
}
if (source.deleteMarks !== null) {
for (const id of source.deleteMarks) deleteMarkTo(target, id, true)
}
}
/**
* Map a number `key` (a content offset) of a leaf mark on a node through the content ops of `change`,
* returning its new offset. An insert before the key pushes it right (tie-broken at the exact offset by
* `assoc`). A delete covering the key applies *collapse-to-cut*: the mark slides to the cut point
* (`newPos`) — i.e. to *before* any replacement text inserted there — instead of being dropped, so a
* cursor whose content is deleted survives at the deletion site. `oldPos` walks the pre-change doc and
* `newPos` the post-change doc *independently* — `key` is never mutated, so an insert past the key is
* always compared against the mark's original offset even after earlier deletes shifted the content.
*
* NOTE: collapse-to-cut is intentionally NOT confluent under concurrent {@link DeltaBuilder#rebase} — a
* deletion maps a whole range to one point, losing where in the range the cursor was, so a concurrent
* insert there lands on different sides on the two replays. Marks are therefore treated as local /
* ephemeral cursor state, excluded from a delta's identity ({@link Delta#fingerprint} / equality); only
* the document *content* is guaranteed to converge.
*
* @param {DeltaAny} change
* @param {number} key
* @param {1|-1} assoc
* @return {number}
*/
const shiftMarkKey = (change, key, assoc) => {
let oldPos = 0
let newPos = 0
for (const op of change.children) {
if ($retainOp.check(op) || $modifyOp.check(op)) {
const n = op.length
if (key < oldPos + n) return newPos + (key - oldPos) // mark falls inside this retained run
oldPos += n
newPos += n
} else if ($insertOp.check(op) || $textOp.check(op)) {
if (oldPos < key || (oldPos === key && assoc === 1)) newPos += op.length
} else if ($deleteOp.check(op)) {
const n = op.delete
// mark lies in [oldPos, oldPos+n) — its content is being deleted ⇒ collapse to the cut point
if (key < oldPos + n) return newPos
// right boundary with LEFT gravity anchors to the last deleted char ⇒ also collapses to the cut
// (n > 0 guards the degenerate delete(0) rebase can emit, which deletes nothing)
if (n > 0 && key === oldPos + n && assoc === -1) return newPos
oldPos += n
}
}
return newPos + (key - oldPos)
}
/**
* Shift `node`'s own number-keyed leaf marks by the content ops of `change`, collapsing any covered by a
* delete to the cut point (see {@link shiftMarkKey}). Attribute-key (string) marks are untouched.
*
* @param {DeltaAny} node
* @param {DeltaAny} change
*/
const shiftMarksByChange = (node, change) => {
const marks = node.marks
if (marks === null) return
/** @type {Array<Mark>} */
const shifted = []
for (const m of marks) {
if (typeof m.key === 'number') {
const nk = shiftMarkKey(change, m.key, m.assoc)
if (nk !== m.key) shifted.push(m.copy(nk)) // a fresh Mark — never mutate in place
}
}
// re-add shifted marks under their (unchanged) ids ⇒ replace in place (the flag stays set)
for (const m of shifted) marks.add(m)
}
/**
* Collect the ids of a {@link Marks} set, a plain {@link Mark} array, or an id array (any may be
* `null`) into a `Set`, used for the by-id conflict checks during {@link DeltaBuilder#rebase}.
*
* @param {Iterable<Mark|string>?} marksOrIds
* @return {Set<string>}
*/
const markIdSet = marksOrIds => {
/** @type {Set<string>} */
const ids = new Set()
if (marksOrIds !== null) {
for (const x of marksOrIds) ids.add(typeof x === 'string' ? x : x.id)
}
return ids
}
/**
* Core mark reconciliation for one node level during {@link DeltaBuilder#rebase}. `adds` are this
* side's mark adds (with keys) and `deletes` its delete ids; `otherAddIds`/`otherDelIds` are the
* concurrent side's ids and `otherContent` the delta whose content ops shift surviving number keys.
*
* Rules (each by mark id): add-vs-add → `priority` decides; add-vs-delete → the add wins (a re-placed
* cursor is not killed by a stale removal); delete-vs-delete → the duplicate is dropped. A surviving
* number-keyed add is shifted by `otherContent`, collapsing to the cut point if its content was
* concurrently deleted (see {@link shiftMarkKey}). Cursor positions are best-effort, not guaranteed to
* converge (marks are excluded from the document's identity); only content convergence is guaranteed.
*
* @param {Iterable<Mark>} adds
* @param {Array<string>} deletes
* @param {Set<string>} otherAddIds
* @param {Set<string>} otherDelIds
* @param {DeltaAny} otherContent
* @param {boolean} priority
* @return {{ adds: Array<Mark>, deletes: Array<string> }}
*/
const rebaseMarkOps = (adds, deletes, otherAddIds, otherDelIds, otherContent, priority) => {
/** @type {Array<Mark>} */
const keptAdds = []
/** @type {Array<string>} */
const delIds = deletes.slice()
for (const m of adds) {
if (!priority && otherAddIds.has(m.id)) continue // lost an add-vs-add conflict
if (typeof m.key === 'number') {
const nk = shiftMarkKey(otherContent, m.key, m.assoc) // collapses to the cut if its content was deleted
keptAdds.push(nk === m.key ? m : m.copy(nk))
} else {
keptAdds.push(m)
}
}
// a delete survives only where the other side neither re-adds (add wins) nor already deletes (dedup)
/** @type {Set<string>} */
const seen = new Set()
/** @type {Array<string>} */
const keptDels = []
for (const id of delIds) {
if (!seen.has(id) && !otherAddIds.has(id) && !otherDelIds.has(id)) {
seen.add(id)
keptDels.push(id)
}
}
return { adds: keptAdds, deletes: keptDels }
}
/**
* Reconcile a node's own root mark add/delete ops against a concurrent change `other` during
* {@link DeltaBuilder#rebase}, rebuilding `node.marks`/`node.deleteMarks` from {@link rebaseMarkOps}.
* `maybeHasMarks` is left set (conservative; marksToPositions self-corrects a now-empty subtree).
*
* @param {DeltaAny} node
* @param {DeltaAny} other
* @param {boolean} priority
*/
const rebaseRootMarks = (node, other, priority) => {
const { adds, deletes } = rebaseMarkOps(
node.marks !== null ? [...node.marks] : [],
node.deleteMarks !== null ? [...node.deleteMarks] : [],
markIdSet(other.marks), markIdSet(other.deleteMarks), other, priority
)
// rebuild both fields wholesale from rebaseMarkOps's provably-disjoint output (concurrent reconciliation
// keeps its deliberate add-wins rule, NOT the apply path's last-writer-wins; not a primitive write)
if (adds.length === 0) {
node.marks = null
} else {
const m = new Marks()
for (const x of adds) m.add(x)
node.marks = m
}
node.deleteMarks = deletes.length === 0 ? null : new Set(deletes)
}
/**
* Build a change delta that adds (or, with `isDelete`, removes) the mark for `pos`. The descent is a
* `retain`/`modify`/`modifyAttr` chain re-derived from `pos.path`; the mark always rides on the **leaf
* delta's own marks** (root marks). Each interior step just wraps the next level in a `modify`
* (content index) or `modifyAttr` (attribute key) — apply/rebase carry the wrapped value's root marks
* onto/through the target for free.
*
* @param {import('./position.js').Pos} pos
* @param {string} id
* @param {boolean} isDelete
* @param {boolean} [transient] never to be stored (see {@link Mark#transient})
* @return {DeltaBuilderAny}
*/
const markChange = (pos, id, isDelete, transient = false) => {
const path = pos.path
// a mark anchors at the terminal step of its path (a content offset or attribute key); the root
// position `[]` has no terminal and cannot carry a mark - reject it instead of recursing forever
if (path.length === 0) throw error.create('cannot place a mark at the root position (empty path): a mark needs a terminal content-offset or attribute-key step')
const mark = isDelete ? null : createMark(path[path.length - 1], id, pos.assoc, pos.attrs ?? null, transient)
/**
* @param {number} i
* @return {DeltaBuilderAny}
*/
const build = i => {
if (i === path.length - 1) {
// leaf reached: carry the mark on the delta's own marks
const d = /** @type {DeltaBuilderAny} */ (create())
if (mark === null) deleteMarkTo(d, id, true) // a change delta records the transmittable delete
else addMarkTo(d, mark)
return d
}
const step = path[i]
return s.$string.check(step)
? /** @type {DeltaBuilderAny} */ (create()).modifyAttr(step, build(i + 1))
: /** @type {DeltaBuilderAny} */ (create()).retain(step).modify(build(i + 1))
}
return build(0)
}
/**
* @template {DeltaConf} Conf
* @extends {s.Schema<Delta<Conf>>}
* @internal use the {@link $delta} factory; this class is exported only for the `$$delta` predicate.
*/
export class $Delta extends s.Schema {
/**
* @param {s.Schema<any>} $name
* @param {s.Schema<any>} $attrs
* @param {s.Schema<any>} $children
* @param {any} hasText
* @param {any} recursiveChildren
* @param {s.Schema<{[K:string]:any}>} $formats
*/
constructor ($name, $attrs, $children, hasText, recursiveChildren, $formats) {
super()
const $attrsPartial = s.$$object.check($attrs) ? $attrs.partial : $attrs
if (recursiveChildren) {
// @ts-ignore
$children = s.$union($children, this)
}
/**
* @type {{
* $name: s.Schema<DeltaConfGetName<Conf>>,
* $attrs: s.Schema<DeltaConfGetAttrs<Conf>>,
* $children: s.Schema<DeltaConfGetChildren<Conf>>,
* hasText: DeltaConfGetText<Conf>
* recursiveChildren: DeltaConfGetRecursiveChildren<Conf>,
* $formats: s.Schema<{[K:string]:any}>
* }}
*/
this.shape = { $name, $attrs: $attrsPartial, $children, hasText, $formats, recursiveChildren }
}
/**
* @param {any} o
* @param {s.ValidationError} [err]
* @return {o is Delta<Conf>}
*/
check (o, err = undefined) {
const { $name, $attrs, $children, hasText, $formats } = this.shape
if (!$deltaAny.check(o, err)) {
/* c8 ignore next */
err?.extend(null, 'Delta', o?.constructor.name, 'Constructor match failed')
} else if (o.name != null && !$name.check(o.name, err)) {
/* c8 ignore next */
err?.extend('Delta.name', $name.toString(), o.name, 'Unexpected node name')
} else if (list.toArray(o.children).some(c => (!hasText && $textOp.check(c)) || (hasText && $textOp.check(c) && c.format != null && !$formats.check(c.format)) || ($insertOp.check(c) && !c.insert.every(ins => $children.check(ins))))) {
err?.extend('Delta.children', '', '', 'Children don\'t match the schema')
} else if (object.some(o.attrs, (op, k) => $setAttrOp.check(op) && !$attrs.check({ [k]: op.value }, err))) {
err?.extend('Delta.attrs', '', '', 'Attrs don\'t match the schema')
} else {
return true
}
return false
}
}
/**
* @typedef {{
* name?: s.Schema<string>|string|Array<string>,
* attrs?: s.Schema<{ [key: string|number]: any }>|{ [key:string|number]:any },
* children?: any,
* text?: boolean,
* recursiveChildren?: boolean,
* formats?: { [k:string]: any }
* }} ReadableDeltaConf
*/
/**
* Transforms a ReadableDeltaConf (the input shape of `$delta(spec)`) to a DeltaConf.
* @template {ReadableDeltaConf} DConfSpec
* @typedef {[
* DConfSpec extends {name: infer N} ? s.ReadSchemaUnwrapped<N> : any,
* DConfSpec extends {attrs: infer A} ? s.ReadSchemaUnwrapped<A> : {},
* DConfSpec extends {children: infer C} ? s.ReadSchemaUnwrapped<C> : never
* ] extends [infer NodeName, infer Attrs, infer Children]
* ? AsDeltaConf<PrettifyDeltaConf<(
* import('../ts.js').TypeIsAny<NodeName, {}, { name: NodeName }> &
* ([keyof Attrs] extends [never] ? {} : { attrs: Attrs }) &
* ([Children] extends [never] ? {} : { children: Children }) &
* (DConfSpec extends {text: true} ? { text: true } : {}) &
* (DConfSpec extends {recursiveChildren: true} ? { recursiveChildren: true } : {})
* )>>
* : never
* } ReadDeltaConf
*/
/**
* Name-first form: a string-literal `name` as the first argument is preserved as a literal type
* (generic argument inference), so callers don't need `s.$literal('x')` / `@type {const}` to keep the
* node name literal. The remaining options follow in `opts`.
*
* @template {string} Name
* @template {Omit<ReadableDeltaConf, 'name'>} [Opts={}]
* @overload
* @param {Name} name
* @param {Opts} [opts]
* @return {s.Schema<Delta<ReadDeltaConf<Opts & { name: Name }>>>}
*/
/**
* @template {ReadableDeltaConf} [Conf={}]
* @overload
* @param {Conf} opts
* @return {s.Schema<Delta<ReadDeltaConf<Conf>>>}
*/
/**
* @param {ReadableDeltaConf|string} optsOrName
* @param {Omit<ReadableDeltaConf, 'name'>} [opts]
* @return {any}
*/
export const $delta = (optsOrName, opts) => {
const { name, attrs, children, text, formats, recursiveChildren: recursive } =
s.$string.check(optsOrName) ? { ...opts, name: optsOrName } : optsOrName
return /** @type {any} */ (new $Delta(
/** @type {any} */ (name == null ? s.$any : s.$(name)),
/** @type {any} */ (attrs == null ? s.$object({}) : s.$(attrs)),
/** @type {any} */ (children == null ? s.$never : s.$(children)),
text ?? false,
recursive ?? false,
formats == null ? s.$any : s.$(formats)
))
}
export const $$delta = /* @__PURE__ */s.$constructedBy($Delta)
export const $deltaAny = /** @type {s.Schema<Delta<any>>} */ (Delta.prototype.$type = s.$type('d:delta', Delta))
export const $deltaBuilderAny = /** @type {s.Schema<DeltaBuilderAny>} */ (/* @__PURE__ */s.$custom(o => $deltaAny.check(o) && !o.isDone))
/**
* Helper function to merge attribution and attributes. The latter input "wins". The nested `format` key is
* merged per inner key (one level — see {@link mergeAttr}); every other key is replaced wholesale.
*
* @template {{ [key: string]: any }} T
* @param {T | null} a
* @param {T | null} b
*/
export const mergeAttributions = (a, b) => a == null
? b
: (b == null ? a : /** @type {T} */ (mergeAttr(a, b, true)))
/**
* Helper function to merge formats. The latter input "wins".
*
* @template {{ [key: string]: any }} T
* @param {T | null} a
* @param {T | null} b
*/
export const mergeFormats = (a, b) => object.isEmpty(a)
? (object.isEmpty(b) ? null : b)
: (object.isEmpty(b) ? a : object.assign({}, a, b))
/**
* @template {DeltaAny|null} D
* @param {D} a
* @param {D} b
* @return {D}
*/
export const mergeDeltas = (a, b) => {
if (a != null && b != null) {
const c = clone(a)
c.apply(b)
return /** @type {any} */ (c)
}
return /** @type {D} */ (a || b || null)
}
/**
* The node (child delta) at child-position `pos` of `source`, or `null` when `pos` lands on a text
* run or a non-delta child. Lets {@link random} target an existing node with a `modify` op.
*
* @param {DeltaAny} source
* @param {number} pos
* @return {DeltaAny?}
*/
const childNodeAt = (source, pos) => {
let p = 0
for (const op of source.children) {
if ($textOp.check(op)) {
if (pos < p + op.insert.length) return null
p += op.insert.length
} else if ($insertOp.check(op)) {
for (const el of op.insert) {
if (p === pos) return $deltaAny.check(el) ? el : null
p++
}
}
}
/* c8 ignore start */
// the sole caller passes pos < source.childCnt (sourceLen >= 1), so a node or text run always
// resolves first; this guards an out-of-range pos defensively.
return null
/* c8 ignore stop */
}
/**
* "Find the child schema for this node": the `$Delta` schemas within `$children` (a single schema or
* any member of a union) that accept `node`. Only `$Delta` members can drive a recursive `random`
* modify, so non-delta children (numbers, strings, `$any`) and non-matching names are dropped.
*
* @param {s.Schema<any>} $children
* @param {DeltaAny} node
* @return {Array<s.Schema<DeltaAny>>}
*/
const matchingNodeSchemas = ($children, node) => (s.$$union.check($children) ? $children.shape : [$children])
.filter($c => $$delta.check($c) && $c.check(node))
/**
* Random {@link Attribution} for fuzz tests. There is no schema for attributions; the canonical shape is
* `(insert|delete + …At)` intersected with an optional format part. The format part exists only on
* children (content ops), never on node attribute ops. Sometimes returns `undefined` (skip) or `null`
* (clear). The optional `format` part carries a random non-empty subset of the format names, so two random
* attributions differ in which inner `format` keys they carry — exercising the per-inner-key set/remove/merge
* of {@link mergeAttr}/{@link diffDim} on the diff round-trip.
*
* @param {prng.PRNG} gen
* @param {boolean} withFormat include the children-only `format`/`formatAt` part
* @return {Attribution|null|undefined}
*/
const randomAttribution = (gen, withFormat) => {
const r = prng.uint32(gen, 0, 5)
if (r <= 2) return undefined // ~half: skip (no attribution change)
if (r === 3) return null // clear all attribution
const user = () => [prng.oneOf(gen, ['alice', 'bob', 'carol'])]
/** @type {Attribution} */
const attribution = prng.bool(gen)
? { insert: user(), insertAt: prng.uint32(gen, 0, 100) }
: { delete: user(), deleteAt: prng.uint32(gen, 0, 100) }
if (withFormat && prng.bool(gen)) {
// a random non-empty subset of format names (never an empty `format`, per the storage invariant)
/** @type {Record<string, string[]>} */
const format = {}
for (const name of ['bold', 'italic', 'under']) { if (prng.bool(gen)) format[name] = user() }
if (!object.isEmpty(format)) {
attribution.format = format
attribution.formatAt = prng.uint32(gen, 0, 100)
}
}
return attribution
}
/**
* @template {DeltaConf} Conf
* @param {prng.PRNG} gen
* @param {s.Schema<Delta<Conf>>} $d
* @param {object} conf
* @param {DeltaAny?} [conf.source]
* @param {number} [conf.minChildOps]
* @param {number} [conf.maxChildOps]
* @param {boolean} [conf.attribution] generate random attributions (off by default; consumes no PRNG
* draws when off, so existing seeds are unaffected). Rebase does not converge attributions, so leave
* this off for rebase-convergence fuzzing and enable it for diff fuzzing.
* @return {DeltaBuilder<Conf>}
*/
export const random = (gen, $d, conf = {}) => {
const { source = null, minChildOps = 1, maxChildOps = 9, attribution = false } = conf
let sourceLen = source == null ? 0 : source.childCnt
const { $name, $attrs, $children, hasText, $formats: $formats_ } = /** @type {$Delta<any>} */ (/** @type {any} */ ($d)).shape
const d = s.$$any.check($name) ? create($deltaAny) : create(s.random(gen, $name), $deltaAny)
const $formats = s.$$any.check($formats_) ? s.$null : $formats_
const genAttribution = /** @param {boolean} withFormat */ withFormat => attribution ? randomAttribution(gen, withFormat) : undefined
// set random attrs (node attribute ops carry the insert/delete part of an attribution only — no format part)
prng.bool(gen) && d.setAttrs(s.random(gen, $attrs, random), genAttribution(false))
// delete a single attr
if (source && !object.isEmpty(source.attrs) && prng.bool(gen)) {
d.deleteAttr(prng.oneOf(gen, object.keys(source.attrs)), genAttribution(false))
}
for (let i = prng.uint32(gen, minChildOps, maxChildOps); i > 0; i--) {
/**
* @type {Array<() => void>}
*/
const possibleOps = []
if (hasText) {
possibleOps.push(() => {
d.insert(prng.oneOf(gen, ['a', 'b', ' ', '\n', '.']), s.random(gen, $formats), genAttribution(true))
})
}
if (!s.$$never.check($children)) {
possibleOps.push(() => {
/**
* @type {Array<any>}
*/
const ins = []
let insN = prng.int32(gen, 0, 5)
while (insN--) {
ins.push(s.random(gen, $children, random))
}
d.insert(ins, s.random(gen, $formats), genAttribution(true))
})
}
if (sourceLen > 0) {
possibleOps.push(() => {
const len = prng.uint32(gen, 1, sourceLen)
sourceLen -= len
d.delete(len)
})
possibleOps.push(() => {
const len = prng.uint32(gen, 1, sourceLen)
sourceLen -= len
// a retain's format is a skip (`undefined`) or a per-key set object — never a blanket `null` clear
// ($null formats coerce to skip). Blanket clears are a local-only utility and discouraged on a
// channel (they "win" a rebase, dropping concurrent edits — see Formats); the diff fuzz
// already exercises the recommended per-key `{k:null}` removals (which converge without data loss).
d.retain(len, prng.bool(gen) ? undefined : (s.random(gen, $formats) ?? undefined), genAttribution(true))
})
// if the op we currently point at is a node, it's also a choice to modify it: find the child
// schema that matches the node and recursively generate a change against it.
const src = /** @type {DeltaAny} */ (source)
const node = childNodeAt(src, src.childCnt - sourceLen)
const $nodeMatches = node != null ? matchingNodeSchemas($children, node) : []
if ($nodeMatches.length > 0) {
possibleOps.push(() => {
d.modify(random(gen, prng.oneOf(gen, $nodeMatches), { source: node }), undefined, genAttribution(true))
sourceLen -= 1
})
}
}
if (possibleOps.length > 0) {
prng.oneOf(gen, possibleOps)()
} else {
break
}
}
return /** @type {any} */ (d)
}
/**
* @overload
* @return {DeltaBuilder<{}>}
*/
/**
* @template {string|null} NodeName
* @overload
* @param {NodeName} nodeName
* @return {DeltaBuilder<NodeName extends string ? { name: NodeName } : {}>}
*/
/**
* @template {string} NodeName
* @template {s.Schema<DeltaAny>} Schema
* @overload
* @param {NodeName} nodeName
* @param {Schema} schema
* @return {Schema extends s.Schema<Delta<infer Conf>> ? DeltaBuilder<Conf, true> : never}
*/
/**
* @template {s.Schema<DeltaAny>} Schema
* @overload
* @param {Schema} schema
* @return {Schema extends s.Schema<Delta<infer Conf>> ? DeltaBuilder<Conf, true> : never}
*/
/**
* The conf produced by the condensed factories ({@link create}'s 3-arg form and {@link from}) -
* the whole node declared in one call instead of built up with chained `setAttr`/`insert`. Routed
* through {@link DeltaConfOverwrite} - and hence `PrettifyDeltaConf`/`_SanifyDelta` - so a condensed
* call yields exactly the conf its `create(name).setAttrs(attrs).insert(children)`
* equivalent would: delta-valued attrs/children normalize to `Delta<..>` (not `DeltaBuilder<..>`),
* and an unsupplied slot is omitted rather than declared as `children: never` / `text: false`.
*
* The `[X] extends [never]` bracketing is load-bearing: `Extract<never,Array<any>>` is `never`, and
* a bare `never extends Array<infer Ac>` matches with `Ac = never`.
*
* @template {string|null} NodeName
* @template {{[k:string|number]:any}|null} Attrs
* @template {Array<any>|string} Children
* @typedef {DeltaConfOverwrite<
* NodeName extends string ? { name: NodeName } : {},
* (Attrs extends null ? {} : { attrs: Attrs })
* & ([Extract<Children,Array<any>>] extends [never] ? {} : (Extract<Children,Array<any>> extends Array<infer Ac> ? (unknown extends Ac ? {} : { children: Ac }) : {}))
* & ([Extract<Children,string>] extends [never] ? {} : { text: true })
* >} CondensedDeltaConf
*/
/**
* @template {string|null} NodeName
* @template {{[k:string|number]:any}|null} Attrs
* @template {Array<any>|string} [Children=never]
* @overload
* @param {NodeName} nodeName
* @param {Attrs} attrs
* @param {Children} [children]
* @return {DeltaBuilder<CondensedDeltaConf<NodeName,Attrs,Children>>}
*/
/**
* @param {string|s.Schema<DeltaAny>|null} [nodeNameOrSchema]
* @param {{[K:string|number]:any}|s.Schema<DeltaAny>} [attrsOrSchema]
* @param {(Array<any>|string)} [children]
* @return {DeltaBuilder<{}>}
*/
export const create = (nodeNameOrSchema, attrsOrSchema, children) => {
const nodeName = /** @type {any} */ (s.$string.check(nodeNameOrSchema) ? nodeNameOrSchema : null)
const schema = /** @type {any} */ (s.$$schema.check(nodeNameOrSchema) ? nodeNameOrSchema : (s.$$schema.check(attrsOrSchema) ? attrsOrSchema : null))
const d = /** @type {DeltaBuilder<any>} */ (new DeltaBuilder(nodeName, schema))
if (s.$objectAny.check(attrsOrSchema)) {
d.setAttrs(attrsOrSchema)
}
children && d.insert(children)
return d
}
/**
* Returns `any` because the resulting conf is computed by the call signatures of {@link from} at
* the type level only - as in a ts overload implementation, the body can't be checked against
* each of them.
*
* @param {...any} args
* @return {any}
*/
const _from = (...args) => {
const hasName = s.$string.check(args[0])
let i = hasName ? 1 : 0
const d = create(hasName ? /** @type {string} */ (args[0]) : null)
if (s.$objectAny.check(args[i])) {
d.setAttrs(args[i++])
}
for (; i < args.length; i++) {
d.insert(args[i])
}
return d
}
/**
* Build a delta from positional args: an optional node name, optional attrs, then any number of
* children (each either a string or an array of embeds) - see {@link create} for the fixed-arity
* condensed form.
*
* Typed as a set of call signatures instead of `@overload` tags, because a rest parameter can't be
* declared consistently in an `@overload` tag: the checker only turns `@param {...X} children`
* into `X[]` if the implementation's rest parameter has the same name, otherwise it takes `X` as
* the type of the whole rest array - while the declaration emit always writes `...children: X[]`.
* lib0's source check and the consumers of the emitted declarations hence saw different
* signatures. `Children` is the tuple of all children arguments, so that string and array children
* can be mixed (`Children[number]` is the union of them).
*
* @type {{
* <NodeName extends string|null, Children extends Array<Array<any>|string> = []>(nodeName: NodeName, ...children: Children): DeltaBuilder<CondensedDeltaConf<NodeName,null,Children[number]>>,
* <Children extends Array<Array<any>|string> = []>(...children: Children): DeltaBuilder<CondensedDeltaConf<null,null,Children[number]>>,
* <Attrs extends {[k:string|number]:any}|null, Children extends Array<Array<any>|string> = []>(attrs: Attrs, ...children: Children): DeltaBuilder<CondensedDeltaConf<null,Attrs,Children[number]>>,
* <NodeName extends string|null, Attrs extends {[k:string|number]:any}|null, Children extends Array<Array<any>|string> = []>(nodeName: NodeName, attrs: Attrs, ...children: Children): DeltaBuilder<CondensedDeltaConf<NodeName,Attrs,Children[number]>>
* }}
*/
export const from = _from
/**
* Shorthand for `delta.create().insert(..)` — see {@link DeltaBuilder#insert}.
*
* ## Builder shorthands
*
* A changeset is written as a chain (see the readme's "condensed vs. builder chain"), and the
* `create()` head of that chain carries no information. Every content/attr builder method is
* therefore also exported standalone — {@link insert}, {@link modify}, {@link retain},
* {@link delete_} (trailing underscore: `delete` is a reserved word),
* {@link setAttr}, {@link setAttrs}, {@link deleteAttr}, {@link modifyAttr} — each returning the very
* same {@link DeltaBuilder}, so the rest of the chain is unchanged:
*
* delta.retain(5).delete(6).insert('!') // ⇔ delta.create().retain(5).delete(6).insert('!')
* delta.delete_(6) // ⇔ delta.create().delete(6)
*
* There is deliberately no `format` shorthand: formatting is the second argument of
* `insert`/`retain`/`modify` (`delta.retain(5, { bold: true })`). The ambient-context setters
* (`useFormats` & co), the mark methods, `apply`/`append`/`rebase` and the terminal methods (`done`,
* `toJSON`, ..) are not mirrored — they either say nothing on an empty delta or don't build one.
*
* Each shorthand is typed by indexing the method off {@link DeltaBuilder}, pinned to `create()`'s
* zero-arg overload — so a shorthand and its `create()`-headed equivalent infer the identical conf,
* with no re-declared generics to drift. The type arguments must be written out
* (`DeltaBuilder<{}, false>['insert']`, not `DeltaBuilder['insert']`): a bare reference to a generic
* type in JSDoc resolves its parameters to `any` rather than to the declared defaults, and
* `FixedConf = any` satisfies both arms of `insert`'s `FixedConf extends true ? ..` branch, which
* collapses the accreted conf.
*
* @type {DeltaBuilder<{}, false>['insert']}
*/
export const insert = (content, formatting, attribution) => create().insert(content, formatting, attribution)
/**
* Shorthand for `delta.create().modify(..)` — see {@link DeltaBuilder#modify} and {@link insert}.
*
* @type {DeltaBuilder<{}, false>['modify']}
*/
export const modify = (modify, formatting, attribution) => create().modify(modify, formatting, attribution)
/**
* Shorthand for `delta.create().retain(..)` — see {@link DeltaBuilder#retain} and {@link insert}.
*
* @type {DeltaBuilder<{}, false>['retain']}
*/
export const retain = (len, format, attribution) => create().retain(len, format, attribution)
/**
* Shorthand for `delta.create().delete(..)` — see {@link DeltaBuilder}'s `delete` and {@link insert}.
*
* Trailing underscore because `delete` is a reserved word: `export const delete` is a syntax error,
* and the rename clause that would work at runtime (`export { _delete as delete }`) makes `tsc` 6
* emit a duplicated specifier into the `.d.ts` (`export { _delete as delete, _delete as delete }`),
* which then fails `npm run check-dist-types` with "Duplicate identifier". The bug is specific to
* declaration emit from a `.js` file — the same clause emits correctly from a `.ts` — so it cannot
* be worked around in source; re-test it when the TypeScript floor moves.
*
* @type {DeltaBuilder<{}, false>['delete']}
*/
export const delete_ = len => create().delete(len)
/**
* Shorthand for `delta.create().setAttr(..)` — see {@link DeltaBuilder#setAttr} and {@link insert}.
*
* @type {DeltaBuilder<{}, false>['setAttr']}
*/
export const setAttr = (key, val, attribution) => create().setAttr(key, val, attribution)
/**
* Shorthand for `delta.create().setAttrs(..)` — see {@link DeltaBuilder#setAttrs} and {@link insert}.
*
* @type {DeltaBuilder<{}, false>['setAttrs']}
*/
export const setAttrs = (attrs, attribution) => create().setAttrs(attrs, attribution)
/**
* Shorthand for `delta.create().deleteAttr(..)` — see {@link DeltaBuilder#deleteAttr} and {@link insert}.
*
* @type {DeltaBuilder<{}, false>['deleteAttr']}
*/
export const deleteAttr = (key, attribution) => create().deleteAttr(key, attribution)
/**
* Shorthand for `delta.create().modifyAttr(..)` — see {@link DeltaBuilder#modifyAttr} and {@link insert}.
*
* @type {DeltaBuilder<{}, false>['modifyAttr']}
*/
export const modifyAttr = (key, modify, attribution) => create().modifyAttr(key, modify, attribution)
/**
* An array item that is a string, wrapped so the content passes treat it as one unit (a raw string token
* would be split by the separators) and fingerprint it once.
*/
class _DiffStringWrapper {
/**
* @param {string} str
*/
constructor (str) {
this.str = str
/**
* @type {string?}
*/
this._fingerprint = null
}
[fingerprintTrait.FingerprintTraitSymbol] () {
return this._fingerprint || (this._fingerprint = fingerprintTrait.fingerprint(this.str))
}
}
/*
* # Diffing two states — how `diff` chooses among the possible diffs
*
* `diff(d1, d2, options)` computes a change delta that turns the *state* `d1` (insert-only) into the
* state `d2`, with the round-trip guarantee `clone(d1).apply(diff(d1, d2)).equals(d2)`.
*
* ## Intentions
*
* A diff between two states is rarely unique. Deleting one of the `x`s in `xx` can be expressed as
* "delete index 0" or "delete index 1" — both are correct, both are minimal. A content-only diff breaks
* such ties by accident of its implementation (a greedy common-prefix scan that is blind to everything
* but characters), and that accident is wrong in two ways that matter for collaborative editing:
*
* 1. **Provenance and formatting.** `[bold x][x]` → `[x]` would come out as "un-bold the first x, delete
* the second" instead of "delete the bold x". With attribution instead of bold this *rewrites who wrote
* the surviving character* — it strips alice's provenance from a character she still owns and deletes
* one that was never hers. Content-wise both diffs are the same size; only one of them is true.
* 2. **Cursors.** When a user deletes the *second* `a` of `aaa`, a content-only diff reports "delete
* index 2". Remote cursors are transformed against that report, so a caret sitting after the second `a`
* does not move although the character before it vanished. The editor that produced `d2` knows where
* the edit happened; `options.hint` lets it say so.
*
* The goal is *correct always, decent most of the time* — never a wrong diff, usually the natural one,
* minimality preferred but not guaranteed — at low overhead: no post-processing, nothing proportional to
* the document beyond the alignment itself.
*
* ## How it works
*
* The key observation: for a single edit, the *only* decision that places the change is the common
* prefix/suffix strip. Patience (the alignment used for the interior) never sees a pure insertion or
* deletion — by the time it runs, one side of the interior is empty and there is nothing left to align.
* So the tie-breaking lives in the strip; everything after it is a plain content diff.
*
* 1. **Fingerprint short-circuit.** Equal fingerprints (content + format + attribution, marks excluded)
* ⇒ empty diff.
* 2. **Styled, hint-capped strip** ({@link commonLen}). Both states are walked in lockstep, unit by unit
* (a character or an array item is one unit), from the start for the prefix and from the end for the
* suffix:
* - two ops with equal fingerprints are skipped whole (the fast path for large documents);
* - inside two ops that differ, units keep matching only while the ops carry the **same format and
* attribution**. Style is uniform per op, so that is one comparison per op pair, and it is what makes
* `[bold x][x]` → `[x]` strip the trailing plain `x` as a suffix and leave the bold one to be deleted;
* - a hint caps the prefix at the offset where the caller says the change starts; the greedy suffix
* then does the rest, so a single pure hunk lands exactly at the hint;
* - a text match that stops in the middle of both ops (not at the cap, not at an op boundary) is
* committed only up to the last line break. Without this the strip would swallow the head of a
* line that the line-level pass wanted as an anchor and produce a worse alignment (e.g.
* `- item 1\n- item 2\n` → `- item 1\n- item 3\n- item 2\n` must insert a whole line, not
* `3\n- item ` somewhere in the middle). Op boundaries and the hint are real boundaries, line breaks
* are what the first content pass splits on, so those are the only places a partial match commits.
* Inside a single line the strip commits nothing on its own and the content passes decide.
* 3. **Content passes.** The interior (what the strip did not consume) is tokenised — text joined across
* ops, array items one token each — and aligned by three patience passes
* ({@link diffChangesetWithSeparator}: lines, then words, then characters). When the
* hint bound the prefix, the hunk that starts there is diffed with patience's `leftmost` placement, so
* its first sub-hunk stays at the hint even when the interior holds several edits (the passes' own
* greedy prefix strip would otherwise push it to the end of its run again — `aaa bbb` → `aa bb` with a
* hint at 1 must delete index 1, not 2).
* 4. **Changeset → ops.** Retains, deletes and inserts are emitted; nested deltas that `options.compare`
* pairs become `modify(diff(child1, child2))`. The hint descends into the one child it names.
* 5. **Styles** are reconciled while the changeset is emitted ({@link applyChangesetToDelta}): a retained
* range carries the tri-state format/attribution updates ({@link diffDim}) between the `d1` and `d2`
* ops it spans, inserted content carries `d2`'s style. Only the interior is walked — the strip
* guarantees the stripped prefix and suffix are style-equal.
* 6. **Attributes** ({@link applyAttrDiff}): delta-valued attributes that `compare` pairs are diffed
* recursively, the rest set/deleted wholesale. The hint descends into the attribute it names.
*
* ## The hint
*
* `options.hint` is a {@link import('./position.js').Pos position} into `d1` whose `path` names where the
* change *starts* (`assoc` and `attrs` are ignored): numbers are content offsets, a trailing number is the
* gap the change starts at, string steps descend into attributes (`[5]`, `[1, 5]` = inside the child at
* slot 1, `['body', 5]`). It is the `from` of the editor transaction that produced `d2`, in pre-edit
* coordinates: typing at gap `p` → `[p]` (not the caret *after* typing), backspace at caret `p` →
* `[p - 1]`, delete-forward at `p` → `[p]`, replacing the selection `[s, e)` → `[s]`. The offset is
* identical in `d1` and `d2` because it lies on the common prefix. It is an upper bound on the prefix,
* nothing more: a wrong hint moves the interior boundary and the content passes re-align greedily —
* placement may shift, correctness never does. A hint past the content, an empty path, or a string step
* at a children level is simply no hint. When the interior holds several edits (a transaction with more
* than one step), only the first one is placed by the hint; the rest land where the passes put them.
*
* ## Optimisations done, and deliberately not done
*
* - Text units are compared by native string equality of galloping slices (grow the chunk while equal,
* shrink to the mismatch), never by a per-character JS loop — 10-25 % slower than regex tokenisation on
* a 200k-char op — and never by `startsWith`/`endsWith`, which V8 lowers to a per-character loop after
* warm-up.
* - Nested deltas are compared by their cached fingerprint; other array items by `equalityDeep`. Plain
* objects have no cached fingerprint (`fingerprint(obj)` re-encodes and hashes every time: ~6 µs each),
* so 5k embeds compare in 1 ms instead of 65.
* - `hint` forwarding allocates nothing when there is no hint.
* - The strip hands its stop cursors ({@link Cursors}) to the tokeniser and the emit, so nothing in front
* of the interior is walked twice (that re-walk was ~1.1 of ~2.6 ms at 20k ops).
* - Adjacent text ops are joined before tokenisation ({@link interiorTokens}): to the passes, an op
* boundary is a style boundary, never a content boundary. So a style-only edit — the interior's text is
* identical on both sides — always comes out as retains carrying the style updates, never as a delete
* plus re-insert that would synthesise provenance (`insert('lorem lorem lorem', {bold})` →
* `'lorem lo'(bold) 're' 'm lo'(bold) 'rem'` is four retains; `[bold foo][ bar foo]` →
* `[foo bar ][bold foo]` no longer anchors crosswise on `foo`). The price: op boundaries no longer
* supply accidental anchors, so text in which no line, word or character is unique between two edits
* (a long line of a few repeated words) can align worse than before — patience's own limitation, which
* the per-op tokens used to mask.
* - NOT done: splitting a hunk. `[bold x][x][italic y][y]` → `xy` is one pure hunk `xy`; the ideal
* diff deletes the bold `x` and the italic `y` separately, which no strip can express.
*
* ## Cost
*
* O(ops skipped whole + units matched inside the boundary ops + the content diff of the interior). A
* single edit in a 200k-char op costs ~0.1 ms, one edit in 20k small formatted ops ~1.6 ms — of which the
* strip's two lockstep walks are ~1.4 (≈35 ns per op visit), the floor without an index over the ops.
* All of that assumes cached op fingerprints:
* a freshly built state pays for them on the first read (the top-level short-circuit forces every op's
* fingerprint, ~6 µs each — ~135 ms for 20k ops), which predates this algorithm and dwarfs the diff
* itself. Measured numbers: `testDiffBenchmark` in `delta.test.js`.
*/
/**
* @typedef {object} DiffOptions
* @property {(d1: DeltaAny, d2: DeltaAny) => boolean} [compare] Predicate deciding when two nodes
* are paired into a `modify` (vs. replaced wholesale). Defaults to comparing names
* (`(d1, d2) => d1.name === d2.name`). Called as `compare(fromNode, toNode)`.
* @property {boolean} [clone=false] By default `diff` does **not** produce a fully fresh delta: wherever
* it emits content from `d2` — a wholesale-inserted child node, or a delta-valued attribute — it *shares*
* that nested delta by reference, so the result aliases `d2`'s subtrees. Set `true` to
* {@link cloneDeep deep-clone} every such delta (children **and** attributes) as it is included in the
* output, yielding a result that shares no structure with `d2` (and is safe to hand to something that
* manipulates deltas in place, e.g. a transformer).
* @property {import('./position.js').Pos} [hint] Where the change starts, as a position into `d1`: the
* `path`'s numbers are content offsets (the trailing number is the gap the change starts at, in pre-edit
* coordinates — the `from` of the editor transaction), string steps descend into attributes; `assoc` and
* `attrs` are ignored. Places the first edit there when the content allows it (a wrong hint only shifts
* placement, never correctness) — see "The hint" above.
*/
/**
* Both ops carry the same format and attribution. Style is uniform per op, so this is decided once per op
* pair.
*
* @param {TextOp|InsertOp<any>} a
* @param {TextOp|InsertOp<any>} b
*/
const sameStyle = (a, b) => fun.equalityDeep(a.format, b.format) && fun.equalityDeep(a.attribution, b.attribution)
/**
* Two array-insert items are the same unit. Nested deltas compare by their cached fingerprint; everything
* else (strings, numbers, embed objects) structurally.
*
* @param {any} x
* @param {any} y
*/
const sameItem = (x, y) => $deltaAny.check(x) ? $deltaAny.check(y) && x.fingerprint === y.fingerprint : fun.equalityDeep(x, y)
/**
* Count the equal units of `a` and `b` after `ai` / `bi` units were already consumed from the walked end
* (the start, or the end when `back`), at most `max`. Text runs are compared as native string equality of
* galloping chunks — grow while equal, shrink to the mismatch — so a long equal run costs a few memcmps.
*
* @param {TextOp|InsertOp<any>} a
* @param {number} ai
* @param {TextOp|InsertOp<any>} b
* @param {number} bi
* @param {number} max
* @param {boolean} back
*/
const commonUnits = (a, ai, b, bi, max, back) => {
let k = 0
if ($textOp.check(a) && $textOp.check(b)) {
const s1 = a.insert
const s2 = b.insert
const aEnd = s1.length - ai
const bEnd = s2.length - bi
for (let step = 32; k < max;) {
const n = math.min(step, max - k)
if (back ? s1.slice(aEnd - k - n, aEnd - k) === s2.slice(bEnd - k - n, bEnd - k) : s1.slice(ai + k, ai + k + n) === s2.slice(bi + k, bi + k + n)) {
k += n
step *= 2
} else if (n === 1) {
break
} else {
step = n >> 1
}
}
} else {
const c1 = /** @type {InsertOp<any>} */ (a).insert
const c2 = /** @type {InsertOp<any>} */ (b).insert
while (k < max && sameItem(c1[back ? c1.length - 1 - ai - k : ai + k], c2[back ? c2.length - 1 - bi - k : bi + k])) k++
}
return k
}
/**
* Where a lockstep walk of two states stands: the current op of each side and the units of it already
* consumed from the walked end.
*
* @typedef {{ a: ChildrenOpAny?, ai: number, b: ChildrenOpAny?, bi: number }} Cursors
*/
/**
* Length of the common prefix (`back` = false, walking `.next`) or suffix (`back` = true, walking
* `.prev`) of two states from the cursors `c`, in content units, at most `max`. `c` is left where the
* match stopped, so the interior can be walked from there without seeking to it again.
*
* Whole ops with equal fingerprints are skipped in one step. Inside two ops that differ, units are matched
* while both ops carry the same style ({@link sameStyle}). A text match that ends on a content mismatch
* inside *both* ops is committed only up to its last line break (partial lines are left to the content
* passes, which split on lines first — see the diffing notes above).
*
* @param {Cursors} c
* @param {number} max
* @param {boolean} back
* @return {number}
*/
const commonLen = (c, max, back) => {
let { a, ai, b, bi } = c
let len = 0
while (a !== null && b !== null && len < max) {
let k = 0
if (ai === 0 && bi === 0 && len + a.length <= max && a.fingerprint === b.fingerprint) {
// equal ops (content + style) are skipped whole — unless they aren't content ops at all (two equal
// deletes): those never match and land in the interior, where they are rejected
if (!$textOp.check(a) && !$insertOp.check(a)) break
k = a.length
} else if (($textOp.check(a) && $textOp.check(b)) || ($insertOp.check(a) && $insertOp.check(b))) {
// only a text op pairs with a text op and an array insert with an array insert: to the diff, the
// char `'a'` and the array item `'a'` are different content (`insert('a')` vs `insert(['a'])`)
k = sameStyle(a, b) ? commonUnits(a, ai, b, bi, math.min(math.min(a.length - ai, b.length - bi), max - len), back) : 0
}
if (k === 0) break
if (len + k < max && ai + k < a.length && bi + k < b.length && $textOp.check(a)) {
// stopped on a content mismatch inside both ops: commit whole lines only, and stop there
const s = a.insert
const start = back ? s.length - ai - k : ai
const end = start + k
const nl = back ? s.indexOf('\n', start) : s.lastIndexOf('\n', end - 1)
k = nl < start || nl >= end ? 0 : back ? end - nl - 1 : nl - start + 1
max = len + k
}
len += k
ai += k
bi += k
if (ai === a.length) {
a = back ? a.prev : a.next
ai = 0
}
if (bi === b.length) {
b = back ? b.prev : b.next
bi = 0
}
}
c.a = a
c.ai = ai
c.b = b
c.bi = bi
return len
}
/**
* Push the diff tokens of the `len` content units starting at unit `off` of `op` onto `tokens`: adjacent
* text ops as one raw string (the content passes split it; a style boundary must not act as a content
* boundary), array items one token each (strings wrapped so they stay one unit).
*
* @param {ChildrenOpAny?} op
* @param {number} off
* @param {number} len
* @param {Array<any>} tokens
*/
const interiorTokens = (op, off, len, tokens) => {
let text = '' // adjacent text ops are joined: an op boundary is a style boundary, not a content boundary
for (; op !== null && len > 0; op = op.next, off = 0) {
const stop = math.min(off + len, op.length)
if ($textOp.check(op)) {
text += op.insert.slice(off, stop)
} else if ($insertOp.check(op)) {
text.length > 0 && tokens.push(text)
text = ''
for (let i = off; i < stop; i++) {
const item = op.insert[i]
tokens.push(typeof item === 'string' ? new _DiffStringWrapper(item) : item)
}
} else {
throw error.create('[lib0/delta] diffing deletes unsupported')
}
len -= stop - off
}
text.length > 0 && tokens.push(text)
}
/**
* Options for the diff of a nested delta at `step` (a child's content index, or an attribute key): the
* hint descends into the one child/attribute it names and is dropped for all others.
*
* @param {DiffOptions} options
* @param {number|string} step
* @return {DiffOptions}
*/
const childOptions = (options, step) => {
const hint = options.hint
return hint == null
? options
: { ...options, hint: hint.path[0] === step ? { path: hint.path.slice(1), assoc: hint.assoc } : undefined }
}
/**
* Diff the node attributes of `d1` and `d2` onto `d`: a delta-valued attribute whose nodes `compare`
* pairs is diffed recursively (`modifyAttr`, the hint descending when it names the key), anything else
* is set or deleted wholesale.
*
* @param {DeltaBuilderAny} d
* @param {DeltaAny} d1
* @param {DeltaAny} d2
* @param {DiffOptions} options
*/
const applyAttrDiff = (d, d1, d2, options) => {
const compare = options.compare ?? defaultCompare
for (const attr2 of d2.attrs) {
const key = attr2.key
// @ts-ignore
const attr1 = d1.attrs[key]
if (attr1 == null || attr1.fingerprint !== attr2.fingerprint) {
if ($setAttrOp.check(attr2)) {
const prevVal = attr1?.value
const nextVal = attr2.value
if ($deltaAny.check(prevVal) && $deltaAny.check(nextVal) && compare(prevVal, nextVal)) {
// modifyAttr carries the *incremental* attribution update (apply merges it onto the target attr
// op's attribution); the inner diff updates its value.
// reason: diffDim returns an opaque update (a `{k:null}` removal isn't a canonical Attribution),
// while modifyAttr's param is typed Attribution for API ergonomics
d.modifyAttr(key, diff(prevVal, nextVal, childOptions(options, key)), /** @type {Attribution|null|undefined} */ (diffDim(attr1?.attribution, attr2.attribution, true)))
} else {
// setAttr replaces the whole attr, so it carries the new attribution as data (object-or-none).
// `nextVal` is shared from `d2`; deep-clone it when the caller wants an independent result.
d.setAttr(key, options.clone ? _cloneMaybeDeltaDeep(nextVal) : nextVal, attr2.attribution)
}
/* c8 ignore start */
} else {
// unreachable: a state (insert-only) delta holds only setAttr ops
error.unexpectedCase()
}
/* c8 ignore stop */
}
}
for (const { key } of d1.attrs) {
// @ts-ignore
if (d2.attrs[key] == null) {
d.deleteAttr(key)
}
}
}
/**
* Compute a delta that, when applied to the state `d1`, produces the state `d2`. Only the children and
* attributes of `d1` and `d2` are compared; the top-level node names of `d1` and `d2` are *not*. Diffing
* `<div>a</div>` against `<span>a</span>` is valid and yields an empty diff — they have the same
* children and attributes, so as far as `diff` is concerned they are equal at the level it cares
* about. The top-level name is treated as a document-type marker, not as diffable content.
*
* Names *are* compared on children: a child node whose name changes between `d1` and `d2` is
* replaced wholesale (delete + insert), not converted into a `modify` op. Same-name child nodes
* at aligned positions are paired and recursed into via `modify`.
*
* Pairing is decided by `options.compare`, which defaults to comparing names
* (`(d1, d2) => d1.name === d2.name`). Supply a stricter predicate to tighten the granularity —
* e.g. to also require their first child to match — in which case nodes that don't satisfy it are
* replaced wholesale instead. `options` is forwarded to every child diff, so the chosen granularity
* applies consistently all the way down the tree. `compare` is always called as
* `compare(fromNode, toNode)` (the node from `d1` first).
*
* Where several diffs are equally valid (repeated characters), `diff` prefers the one that leaves the
* format/attribution of retained content untouched, and `options.hint` — the position where the change
* starts — places the first edit there (a wrong hint only shifts placement, never correctness). See the
* diffing notes above for how and why.
*
* NOTE: `diff` compares **content only** — it short-circuits on the {@link Delta#fingerprint}, which
* excludes marks. Two states that differ *only* in their marks diff to an empty change, so cursor marks
* do NOT cross a `diff` (e.g. a `Binding`'s initial-state sync). Marks survive on the live
* `apply`/`rebase`/transform path; carrying them across a diff-based boundary needs a separate channel.
*
* @template {DeltaConf} Conf
* @param {Delta<Conf>} d1
* @param {NoInfer<Delta<Conf>>} d2
* @param {DiffOptions} [options]
* @return {Delta<Conf>}
*/
export const diff = (d1, d2, options = {}) => {
const d = create(d1.name === d2.name ? d1.name : null, $deltaAny)
if (d1.fingerprint !== d2.fingerprint) {
const n = d1.childCnt
const m = d2.childCnt
const hint = options.hint?.path[0]
const c = { a: d1.children.start, ai: 0, b: d2.children.start, bi: 0 }
const prefixLen = commonLen(c, math.min(math.min(n, m), typeof hint === 'number' ? hint : n), false)
const suffixLen = commonLen({ a: d1.children.end, ai: 0, b: d2.children.end, bi: 0 }, math.min(n, m) - prefixLen, true)
/**
* @type {Array<any>}
*/
const cs1 = []
/**
* @type {Array<any>}
*/
const cs2 = []
interiorTokens(c.a, c.ai, n - suffixLen - prefixLen, cs1)
interiorTokens(c.b, c.bi, m - suffixLen - prefixLen, cs2)
// when the hint bound the prefix, the hunk starting there keeps its leftmost placement through the
// passes (otherwise their own greedy prefix strip would move it to the end of its run again)
const leftmostIndex = prefixLen === hint ? prefixLen : -1
// align the interior: by lines, then by words, then by characters
let changeset = [{ index: prefixLen, insert: cs2, remove: cs1 }]
changeset = diffChangesetWithSeparator(changeset, /[\n]+/g, leftmostIndex)
changeset = diffChangesetWithSeparator(changeset, patience.smartSplitRegex, leftmostIndex)
changeset = diffChangesetWithSeparator(changeset, /./g, leftmostIndex)
applyChangesetToDelta(d, changeset, c, prefixLen, n - suffixLen, options)
applyAttrDiff(d, d1, d2, options)
}
return d.done(false)
}
/**
* The tri-state update that undoes a `format`/`attribution` instruction `update` against the stored
* value `stored` (object or `null`/none) it was applied to: applying the result after `update` restores
* `stored`. Merge the instruction onto `stored` the way an apply resolves it ({@link applyDim}), then
* diff the merged result back to `stored` ({@link diffDim}). `undefined` (skip) inverts to `undefined`.
*
* @param {{[k:string]:any}|null|undefined} stored
* @param {{[k:string]:any}|null|undefined} update
* @param {boolean} deep `true` for the `attribution` dimension (its `format` key merges per inner key —
* see {@link mergeAttr}); `false` for `format` (shallow)
* @return {{[k:string]:any}|null|undefined}
*/
const inverseDim = (stored, update, deep) => {
if (update === undefined) return undefined
const merged = update === null ? null : (deep ? mergeAttr(stored, update, true) : mergeShallow(stored, update, true))
return diffDim(object.isEmpty(merged) ? null : merged, stored ?? null, deep)
}
/**
* Compute a delta that undoes `d` — a change that was applied to the settled document state `base`
* (inserts and set attributes only, e.g. an RDT's final state): `base.apply(d).apply(inverse(d, base))`
* equals `base`. Inspired by Quill's `Delta#invert`, `base` supplies what the change alone doesn't
* carry: deleted content is re-inserted from `base` (with its stored format/attribution), an
* overwritten or deleted attribute is restored to its `base` value, and an inverted `retain`/`modify`
* instruction is diffed against the `base` op's stored format/attribution.
*
* Content only: marks are local/ephemeral cursor state and are not inverted (mirroring {@link diff}).
* Like {@link diff}, the result *shares* re-inserted children and restored attribute values with `base`.
*
* The round-trip holds for changes whose content-consuming ops (`retain`/`delete`/`modify`) stay within
* `base`'s bounds. Beyond-base ops degrade gracefully: following ops stay positioned, but there is no
* stored content to restore — and what a final apply *materializes* beyond content (e.g. a format set
* kept as a trailing formatted retain, or an appended modify) cannot be removed by any change, so such
* a change is not fully undone.
*
* @template {DeltaConf} Conf
* @param {DeltaAny} d
* @param {Delta<Conf>} base
* @return {Delta<Conf>}
*/
export const inverse = (d, base) => {
const inv = create(d.name === base.name ? d.name : null, $deltaAny)
for (const op of d.attrs) {
const baseOp = /** @type {AttrOpAny|undefined} */ ((/** @type {any} */ (base.attrs))[op.key])
if ($modifyAttrOp.check(op) && $setAttrOp.check(baseOp) && $deltaAny.check(baseOp.value)) {
// the modify edited base's nested delta in place: undo it there, and undo the attribution
// instruction it merged onto the attr op
inv.modifyAttr(op.key, inverse(op.value, baseOp.value), /** @type {Attribution?} */ (inverseDim(baseOp.attribution, op.attribution, true)))
} else if ($setAttrOp.check(baseOp)) {
// a set/delete (or a modify that replaced a non-delta value) overwrote a settled attr:
// restore value + attribution from base
inv.setAttr(op.key, baseOp.value, baseOp.attribution)
} else if (!$deleteAttrOp.check(op)) {
// a set/modify introduced an attr base didn't have: undo by deleting it
inv.deleteAttr(op.key)
} // else: deleted an attr base didn't have — nothing to restore
}
// walk base's children alongside d's ops: an insert consumes no base positions; retain/delete/modify
// consume their length in base coordinates
let baseOp = /** @type {ChildrenOpAny?} */ (base.children.start)
let baseOffset = 0
/**
* Advance the base cursor by `len`, reporting each traversed chunk `(op, start, end)` (stored
* format/attribution are uniform per op, so per-op chunks are exact); returns the length that
* extends beyond base's content.
*
* @param {number} len
* @param {(op: TextOp|InsertOp<any>, start: number, end: number) => void} f
*/
const forBase = (len, f) => {
while (len > 0 && baseOp != null) {
const chunk = math.min(len, baseOp.length - baseOffset)
f(/** @type {TextOp|InsertOp<any>} */ (baseOp), baseOffset, baseOffset + chunk)
len -= chunk
baseOffset += chunk
if (baseOffset === baseOp.length) {
baseOp = baseOp.next
baseOffset = 0
}
}
return len
}
for (const op of d.children) {
if ($textOp.check(op) || $insertOp.check(op)) {
inv.delete(op.length)
} else if ($retainOp.check(op)) {
const rest = forBase(op.retain, (bop, start, end) => {
inv.retain(end - start, inverseDim(bop.format, op.format, false), /** @type {Attribution?} */ (inverseDim(bop.attribution, op.attribution, true)))
})
// beyond base there is no stored content to restore — keep following ops positioned (see the
// beyond-base note in the fn docs)
rest > 0 && inv.retain(rest)
} else if ($modifyOp.check(op)) {
if (baseOp == null) {
// a modify beyond base edited no stored content — keep the position (mirrors retain-beyond)
inv.retain(1)
} else {
const bop = /** @type {InsertOp<any>} */ (baseOp)
const node = /** @type {DeltaAny} */ (bop.insert[baseOffset])
inv.modify(inverse(/** @type {DeltaAny} */ (op.value), node), inverseDim(bop.format, op.format, false), /** @type {Attribution?} */ (inverseDim(bop.attribution, op.attribution, true)))
forBase(1, () => {})
}
} else if ($deleteOp.check(op)) {
// re-insert the deleted base content with its stored format/attribution
forBase(op.delete, (bop, start, end) => {
inv.insert(bop.insert.slice(start, end), bop.format, bop.attribution)
})
}
}
return inv.done(false)
}
/**
* Default pairing predicate for {@link diff}: two delta nodes are paired (and recursed into via
* `modify`) when their names match.
*
* @param {DeltaAny} d1
* @param {DeltaAny} d2
*/
const defaultCompare = (d1, d2) => d1.name === d2.name
/**
* Content units of a diff token: a text run counts its chars, anything else (an array item) is one unit.
*
* @param {string|any} c
*/
const contentLen = c => typeof c === 'string' ? c.length : 1
/**
* Emit the aligned changeset onto `d` while reconciling styles. `[from, to)` is the interior in `d1`
* units — the stripped prefix and suffix are style-equal by construction ({@link commonLen}), so only the
* interior is walked, from the cursors the strip left: a retained range carries the tri-state
* format/attribution updates ({@link diffDim}) between the `d1` and `d2` ops it spans, inserted content
* carries `d2`'s style. Nested deltas of a hunk are paired via `options.compare` into `modify` ops (a
* removed delta pairs with the first inserted delta it compares equal to); the hint descends into the
* child at the content index it names.
*
* @param {DeltaBuilderAny} d
* @param {Array<{ index: number, remove: Array<any>, insert: Array<any> }>} changeset
* @param {Cursors} c where the interior starts in `d1` / `d2` (the prefix strip's stop)
* @param {number} from
* @param {number} to
* @param {DiffOptions} options
*/
const applyChangesetToDelta = (d, changeset, c, from, to, options) => {
const compare = options.compare ?? defaultCompare
// reason: d1/d2 are insert-only (interiorTokens rejects anything else)
let a = /** @type {TextOp|InsertOp<any>|null} */ (c.a)
let b = /** @type {TextOp|InsertOp<any>|null} */ (c.b)
let aOff = c.ai
let bOff = c.bi
// advance a cursor by `len` units (a joined text token may span several ops)
/** @param {number} len */
const advanceA = len => {
while (len > 0) {
const op = /** @type {TextOp|InsertOp<any>} */ (a)
const step = math.min(len, op.length - aOff)
if ((aOff += step) === op.length) {
a = op.next
aOff = 0
}
len -= step
}
}
/** @param {number} len */
const advanceB = len => {
while (len > 0) {
const op = /** @type {TextOp|InsertOp<any>} */ (b)
const step = math.min(len, op.length - bOff)
if ((bOff += step) === op.length) {
b = op.next
bOff = 0
}
len -= step
}
}
/** @param {number} len */
const retain = len => {
while (len > 0 && a !== null && b !== null) {
const step = math.min(len, math.min(a.length - aOff, b.length - bOff))
d.retain(step, diffDim(a.format, b.format, false), diffDim(a.attribution, b.attribution, true))
if ((aOff += step) === a.length) {
a = a.next
aOff = 0
}
if ((bOff += step) === b.length) {
b = b.next
bOff = 0
}
len -= step
}
}
/**
* Emit a delete for the first `len` tokens of `tokens` (removing them). Returns the units removed.
*
* @param {Array<any>} tokens
* @param {number} len
*/
const remove = (tokens, len) => {
let units = 0
for (const tok of tokens.splice(0, len)) {
const n = contentLen(tok)
advanceA(n)
units += n
}
d.delete(units)
return units
}
/**
* Emit inserts for the first `len` tokens of `tokens` (removing them), styled like `d2`. A
* wholesale-inserted child is a nested delta shared from `d2`; deep-clone it when `options.clone` so the
* result aliases nothing in `d2` (see {@link DiffOptions}).
*
* @param {Array<any>} tokens
* @param {number} len
*/
const insert = (tokens, len) => {
for (const tok of tokens.splice(0, len)) {
if (typeof tok === 'string') {
// one insert per `d2` op the text spans, so each carries that op's style
for (let i = 0; i < tok.length;) {
const op = /** @type {TextOp} */ (b)
const step = math.min(tok.length - i, op.length - bOff)
d.insert(tok.slice(i, i + step), op.format, op.attribution)
advanceB(step)
i += step
}
} else {
const op = /** @type {InsertOp<any>} */ (b)
d.insert([tok instanceof _DiffStringWrapper ? tok.str : (options.clone ? _cloneMaybeDeltaDeep(tok) : tok)], op.format, op.attribution)
advanceB(1)
}
}
}
d.retain(from)
let pos = from // content index in d1 of the next unit to be retained or removed
for (const c of changeset) {
retain(c.index - pos)
pos = c.index
while (true) {
const ri = c.remove.findIndex(tok => $deltaAny.check(tok))
if (ri < 0) break
const ii = c.insert.findIndex(tok => $deltaAny.check(tok) && compare(c.remove[ri], tok))
if (ii < 0) {
pos += remove(c.remove, ri + 1)
continue
}
pos += remove(c.remove, ri)
insert(c.insert, ii)
const opA = /** @type {TextOp|InsertOp<any>} */ (a)
const opB = /** @type {TextOp|InsertOp<any>} */ (b)
d.modify(diff(c.remove[0], c.insert[0], childOptions(options, pos++)), diffDim(opA.format, opB.format, false), diffDim(opA.attribution, opB.attribution, true))
advanceA(1)
advanceB(1)
c.remove.splice(0, 1)
c.insert.splice(0, 1)
}
pos += remove(c.remove, c.remove.length)
insert(c.insert, c.insert.length)
}
retain(to - pos)
}
/**
* Refine every hunk of `changeset` by splitting its content at `separator` and diffing the pieces
* (see {@link patience.diff}). The hunk that starts at `leftmostIndex` is diffed with patience's
* `leftmost` placement, so its first sub-hunk stays at that index (used by {@link diff} to honour a hint
* inside a multi-hunk interior); `-1` (the default) leaves every hunk at patience's default placement.
*
* @param {Array<{ index: number, remove: Array<any>, insert: Array<any> }>} changeset
* @param {RegExp} separator
* @param {number} [leftmostIndex]
*/
export const diffChangesetWithSeparator = (changeset, separator, leftmostIndex = -1) => {
/**
* @type {Array<any>}
*/
const next = []
changeset.forEach(change => {
// @todo actually split here
const cs1 = splitContentArrayByRegexp(change.remove, separator)
const cs2 = splitContentArrayByRegexp(change.insert, separator)
const fp1 = cs1.map(c => typeof c === 'string' ? c : fingerprintTrait.fingerprint(c))
const fp2 = cs2.map(c => typeof c === 'string' ? c : fingerprintTrait.fingerprint(c))
const changesetF = patience.diff(fp1, fp2, change.index === leftmostIndex)
const nextChangeset = fillFingerprintDiffFromContent(changesetF, cs1, cs2)
let prevDiffIndex = 0
let nextChangeIndex = change.index
// adjust indexes for actual content length and add results to `next`
nextChangeset.forEach(c => {
// adjust equal content
for (; prevDiffIndex < c.index; prevDiffIndex += 1) {
nextChangeIndex += contentLen(cs1[prevDiffIndex])
}
// need to adjust index with the actual length of the previous items
c.index = nextChangeIndex
next.push(c)
})
})
return next
}
/**
* @param {Array<any>} cs
* @param {RegExp} regexp
*/
const splitContentArrayByRegexp = (cs, regexp) => {
/**
* @type {Array<any>}
*/
const res = []
cs.forEach(c => {
if (typeof c === 'string') {
// no spread: a separator-free run of ≥ ~125k chars yields as many tokens and overflows the call stack
arr.appendTo(res, patience.splitByRegexp(c, regexp, true))
} else {
res.push(c)
}
})
return res
}
/**
* @template C
* @param {Array<{ index: number, remove: Array<string>, insert: Array<string> }>} changeset
* @param {Array<C>} is
* @param {Array<C>} should
* @return {Array<{ index: number, remove: Array<C>, insert: Array<C> }>}
*/
const fillFingerprintDiffFromContent = (changeset, is, should) => {
// overwrite fingerprinted content with actual content
for (let i = 0, adj = 0; i < changeset.length; i++) {
const cd = changeset[i]
// @ts-ignore
cd.remove = is.slice(cd.index, cd.index + cd.remove.length)
// @ts-ignore
cd.insert = should.slice(cd.index + adj, cd.index + adj + cd.insert.length)
adj += cd.insert.length - cd.remove.length
}
return /** @type {any} */ (changeset)
}