lib0
Version:
isomorphic utility functions
283 lines (270 loc) • 12.6 kB
JavaScript
import * as delta from '../delta.js'
import * as s from '../../schema.js'
import * as object from '../../object.js'
import * as math from '../../math.js'
import { Transformer, Template, createTransformResult } from './core.js'
/**
* # `fullAttributions`
*
* A **stateful** transformer that normalizes the `attribution` dimension: whenever an op changes
* attribution, it re-emits the **complete accumulated** attribution for that position instead of just
* the incremental change. So a stream like `insert('a', undefined, { insert: [] })` then
* `retain(1, undefined, { insertAt: 4 })` is forwarded as `insert('a', …, { insert: [] })` then
* `retain(1, undefined, { insert: [], insertAt: 4 })`. Content, formats, structure and marks pass
* through untouched. It is the natural upstream of {@link import('./attribution-to-format.js').attributionToFormat},
* lifting that transformer's "assume the full attribution is present" requirement.
*
* State is a private **content-free attribution overlay** (`this.overlay`): a delta mirroring the
* document shape but carrying no content — text/scalar runs are `retain(len, _, attr)`, child nodes are
* `modify(childOverlay, _, nodeAttr)`, node attributes are per-key attr ops — each holding that
* position's accumulated attribution as an *instruction* (`{k:v}` set, `{k:null}` cleared).
*
* Per `applyA(d)`: a single read-only walk over `d` (in step with the overlay) builds one content-free
* delta `full` carrying the resolved full attribution at exactly `d`'s touched positions. `full` is then
* used twice — `d.apply(full)` enriches the input *in place* (that is the output: no content is copied,
* `apply` only splits content-free retains), and `overlay.rebase(d)` + `overlay.apply(full)` update the
* state, both in place and bounded by the change. `applyB` is passthrough + overlay realign (the view
* never attributes).
*
* Object ownership: `applyA` mutates the input delta's **structure** in place (legitimate — the framework
* hands a transformer a non-done change builder to consume, cf. `children.js`) and returns it as the
* output. It never mutates a `format`/`attribution` **object** in place. We do **not** own those objects —
* they may belong to done (shared) data — so they are read-only here, and every attribution we emit is a
* freshly allocated object (`mergeLeaves`/`applyFull`, mirroring delta.js's `mergeAttr`). The overlay and
* the output only ever receive fresh top-level objects (`apply`/`rebase` merge via `mergeAttr`; `clone`
* shallow-copies via `_cloneAttrs`), so no shared object is updated through a reference. Nested `format`
* maps and leaf values are shared by reference exactly as delta.js shares them — safe because nothing is
* written in place.
*
* Per-change cost is O(change); the overlay is O(document attribution structure). See {@link fullAttributions}.
*
* @module delta/transformer/full-attributions
*/
/**
* Instruction-form shallow merge of `update` into `base`: set every present key, **keep** a `{k:null}`
* removal verbatim (so it still clears downstream), skip `undefined`. Used for attribution leaves and for
* the nested `format` map. Mirrors `mergeShallow` with `resolve = false` (delta.js). Allocates a fresh
* object; never mutates `base`/`update` (they may be done, shared data — read-only).
*
* @param {{[k:string]:any}|null|undefined} base
* @param {{[k:string]:any}} update
* @return {{[k:string]:any}}
*/
const mergeLeaves = (base, update) => {
const r = s.$objectAny.check(base) ? { ...base } : {}
for (const k in update) if (update[k] !== undefined) r[k] = update[k] // set, or keep `null` (instruction)
return r
}
/**
* The full attribution at a position after applying a tri-state attribution `update` onto the
* accumulated `base`, in instruction form. `undefined` ⇒ unchanged, `null` ⇒ clear all; otherwise a
* shallow tri-state merge whose single nested `format` key merges one level. Mirrors `mergeAttr` with
* `resolve = false` (delta.js) — kept in instruction form so a cleared key survives as `{k:null}` and is
* re-emitted (the "set present + clear removed" contract). Allocates a fresh object for the merge case;
* never mutates `base`/`update` (they may be done, shared data — read-only).
*
* @param {{[k:string]:any}|null|undefined} base
* @param {{[k:string]:any}|null|undefined} update
* @return {{[k:string]:any}|null|undefined}
*/
const applyFull = (base, update) => {
if (update === undefined) return base
if (update === null) return null
const r = s.$objectAny.check(base) ? { ...base } : {}
for (const k in update) {
const v = update[k]
if (v === undefined) continue
if (k === 'format' && s.$objectAny.check(v)) {
const sub = mergeLeaves(r.format, v)
if (object.isEmpty(sub)) delete r.format
else r.format = sub
} else r[k] = v // leaf set, or `null` kept verbatim (instruction)
}
return r
}
/**
* Normalize a *data* op's attribution (insert/text/setAttr/deleteAttr) for storage in `full`: `null`
* means "none" (⇒ `undefined`, i.e. a gap), an object is already the complete attribution of new content.
*
* @param {{[k:string]:any}|null|undefined} a
* @return {{[k:string]:any}|undefined}
*/
const dataAttr = a => (a == null ? undefined : a)
/**
* Build the content-free `full` delta for change `d` against the read-only `overlay` (the accumulated
* attribution, or `null` for freshly inserted content). Carries the resolved full attribution at exactly
* `d`'s touched positions; recurses into child nodes.
*
* @param {import('../delta.js').DeltaAny} d
* @param {import('../delta.js').DeltaAny|null} overlay
* @return {import('../delta.js').DeltaBuilderAny}
*/
const buildFull = (d, overlay) => {
const full = /** @type {any} */ (delta.create())
let cur = overlay == null ? null : overlay.children.start
let off = 0
/**
* Advance the read cursor by `n` positions without reading (untouched retains, deletes).
* @param {number} n
*/
const skip = n => {
let rem = n
while (rem > 0 && cur != null) {
const take = math.min(cur.length - off, rem)
off += take; rem -= take
if (off >= cur.length) { cur = cur.next; off = 0 }
}
}
/**
* Read the attribution run at the cursor (≤ `rem` positions), advancing. Beyond the overlay ⇒ no prior
* attribution.
* @param {number} rem
* @return {[number, {[k:string]:any}|null|undefined]}
*/
const readRun = rem => {
if (cur == null) return [rem, undefined]
const take = math.min(cur.length - off, rem)
const attr = /** @type {any} */ (cur).attribution
off += take
if (off >= cur.length) { cur = cur.next; off = 0 }
return [take, attr]
}
/**
* The nested overlay + node-attribution at the cursor (a Modify run; else `null`/leaf attr), advancing
* one position.
* @return {{ nested: import('../delta.js').DeltaAny|null, attr: {[k:string]:any}|null|undefined }}
*/
const readModify = () => {
const node = cur
const attr = node == null ? undefined : /** @type {any} */ (node).attribution
const nested = (node != null && delta.$modifyOp.check(node)) ? node.value : null
if (cur != null) { off += 1; if (off >= cur.length) { cur = cur.next; off = 0 } }
return { nested, attr }
}
for (const op of d.children) {
if (delta.$retainOp.check(op)) {
if (op.attribution === undefined) {
full.retain(op.retain); skip(op.retain) // untouched ⇒ gap
} else {
let rem = op.retain
while (rem > 0) {
const [take, runAttr] = readRun(rem)
full.retain(take, undefined, applyFull(runAttr, op.attribution))
rem -= take
}
}
} else if (delta.$textOp.check(op)) {
full.retain(op.insert.length, undefined, dataAttr(op.attribution)) // new content: attribution already full
} else if (delta.$insertOp.check(op)) {
for (const el of op.insert) {
if (delta.$deltaAny.check(el)) full.modify(buildFull(el, null), undefined, dataAttr(op.attribution))
else full.retain(1, undefined, dataAttr(op.attribution))
}
} else if (delta.$deleteOp.check(op)) {
skip(op.delete) // positions removed: nothing in `full`
} else { // $modifyOp
const { nested, attr } = readModify()
full.modify(buildFull(op.value, nested), undefined, applyFull(attr, op.attribution))
}
}
for (const aop of d.attrs) {
// `setAttr`/`deleteAttr` *replace* the attr op, so their attribution is already complete (pass it
// through unchanged). Only `modifyAttr` is an instruction that *merges* attribution — that is the one
// whose increment we expand against the overlay's accumulated attr attribution.
if (delta.$modifyAttrOp.check(aop)) {
const oa = overlay == null ? undefined : /** @type {any} */ (overlay).attrs[aop.key]
const childOverlay = (oa != null && delta.$modifyAttrOp.check(oa) && delta.$deltaAny.check(oa.value)) ? oa.value : null
full.modifyAttr(aop.key, delta.$deltaAny.check(aop.value) ? buildFull(aop.value, childOverlay) : aop.value, applyFull(oa == null ? undefined : oa.attribution, aop.attribution))
} else if (delta.$setAttrOp.check(aop)) {
full.setAttr(aop.key, aop.value, aop.attribution)
} else if (delta.$deleteAttrOp.check(aop)) {
full.deleteAttr(aop.key, aop.attribution)
}
}
full.done(false)
return full
}
/**
* Stateful transformer holding the accumulated attribution overlay.
*
* @extends {Transformer<any,any>}
*/
export class FullAttributionsTransformer extends Transformer {
/**
* @param {import('../../schema.js').Schema<delta.Delta<any>>} $in
* @param {import('../../schema.js').Schema<delta.Delta<any>>} $out
*/
constructor ($in, $out) {
super($in, $out)
/**
* Content-free overlay: the full accumulated attribution at every document position (instruction
* form). Mutated in place each change by `rebase` + `apply`.
*
* @type {delta.DeltaBuilderAny}
*/
this.overlay = delta.create()
}
/**
* Forward: expand each attribution-bearing op to the complete accumulated attribution. Mutates and
* returns the input change.
*
* @param {delta.DeltaBuilderAny} d
* @return {import('./core.js').TransformResultAny}
*/
applyA (d) {
const full = buildFull(d, this.overlay)
// STATE (in place): align/grow the overlay to post-change coords, then accumulate the increments.
this.overlay.rebase(d, true)
this.overlay.apply(full, { move: false })
// OUTPUT (in place): enrich the input — `apply` splits content-free retains; no content is copied.
d.apply(full, { move: true })
return createTransformResult(null, d)
}
/**
* Reverse: passthrough; only realign the overlay so positions track view inserts/deletes. The view
* never attributes, so there is nothing to expand.
*
* @param {delta.DeltaBuilderAny} d
* @return {import('./core.js').TransformResultAny}
*/
applyB (d) {
this.overlay.rebase(d, true)
return createTransformResult(d, null)
}
}
/**
* Template for {@link FullAttributionsTransformer}. The transform touches only the `attribution`
* dimension (metadata, not part of the delta schema), so the output schema equals the input schema.
*
* @template {delta.DeltaConf} [IN=any]
* @extends {Template<IN, IN>}
*/
export class FullAttributions extends Template {
/**
* @param {import('../../schema.js').Schema<delta.Delta<IN>>} $d
*/
constructor ($d) {
super($d, $d)
}
get name () { return 'lib0:fullAttributions' }
/**
* @return {Transformer<IN, IN>}
*/
init () {
return new FullAttributionsTransformer(this.$in, this.$out)
}
}
/**
* Normalize a delta's `attribution` dimension so every attribution change carries the **complete**
* accumulated attribution for that position (not just the increment) — see the
* {@link module:delta/transformer/full-attributions module doc}. Stateful: it keeps a content-free
* overlay of the accumulated attribution and updates it in place per change. `applyA` mutates and returns
* its input change. Returns a reusable {@link FullAttributions} template (`.init()` for a standalone
* transformer); typically piped before
* {@link import('./attribution-to-format.js').attributionToFormat}.
*
* @template {delta.DeltaConf} IN
* @param {import('../../schema.js').Schema<delta.Delta<IN>>} $d
* @return {FullAttributions<IN>}
*/
export const fullAttributions = ($d) => new FullAttributions($d)