lib0
Version:
isomorphic utility functions
343 lines (322 loc) • 14.6 kB
JavaScript
import * as delta from '../delta.js'
import * as s from '../../schema.js'
import * as object from '../../object.js'
import { Transformer, Template, createTransformResult } from './core.js'
/**
* # `attributionToFormat`
*
* Render a delta's **`attribution`** dimension (provenance — who inserted/deleted/formatted) into a
* reserved `y-attributed-*` slice of the **`format`** dimension, so a rich-text editor can display
* provenance as decorations. The reverse direction strips the decorations back out.
*
* Forward (`applyA`, data → view), per content op:
* - `attribution` whose keys start with `insert`/`delete`/`format` ⇒ `format['y-attributed-insert' |
* 'y-attributed-delete' | 'y-attributed-format']`, computed by the matching `conf` handler.
*
* A node's attribute ops carry attribution but have **no format slot**, and a node has no format of
* its own — its format lives on the *parent* op that holds it. So a node-attr's attribution is lifted
* to that parent (`insert`/`modify`) op as `format['y-attributed-attrs'] = { <attrKey>: <value> }`,
* observed at the boundary before recursing into the child ("the parent formats the child").
*
* Reverse (`applyB`, view → data) is a pure strip: every `y-attributed-*` format key is removed and
* everything else passes through. *The view never attributes.*
*
* The transform is **structure-preserving** (1:1 positions) and **stateless** — it walks each change
* op-by-op and recurses through the whole nested delta in one pass (no per-child transformer
* instances). See {@link attributionToFormat}.
*
* @module delta/transformer/attribution-to-format
*/
// Reserved, fixed format-key namespace. Every key produced here starts with `Y_PREFIX`, which is also
// how `applyB` recognises (and strips) them.
const Y_PREFIX = 'y-attributed-'
const Y_ATTRS = 'y-attributed-attrs'
/** Content-attribution dimension → its namespaced format key. */
const CONTENT_KEYS = { insert: 'y-attributed-insert', delete: 'y-attributed-delete', format: 'y-attributed-format' }
const CONTENT_DIMS = /** @type {Array<'insert'|'delete'|'format'>} */ (object.keys(CONTENT_KEYS))
/**
* Maps an attribution object to the format value placed under its namespaced key (or `null` to clear
* that key, `{}`/`undefined` to leave it unchanged).
*
* @typedef {(attribution: import('../delta.js').Attribution) => any} Handler
*/
/**
* Per-dimension handlers. Each fires when the op's attribution has a key with that dimension's prefix
* (`insert`/`insertAt` → `insert`, etc.); the whole attribution object is passed in. A missing handler
* means "do not render that dimension".
*
* @typedef {{ insert?: Handler, delete?: Handler, format?: Handler, attrs?: Handler }} Conf
*/
/**
* A handler result that means "no change" (skip): `undefined` or an empty plain object `{}`. A bare
* scalar (string/number) or a non-empty object is a real value; `null` is handled separately (clear).
*
* @param {any} r
* @return {boolean}
*/
const isSkip = (r) => r === undefined || (s.$objectAny.check(r) && object.isEmpty(r))
/**
* Build the `y-attributed-*` content-format increment from a content op's `attribution`. `isData`
* distinguishes a settled data op (`insert`/`text`: `attribution` is an object or `null`=none) from an
* instruction op (`retain`/`modify`: `undefined`=skip, `null`=clear-all).
*
* @param {Conf} conf
* @param {import('../delta.js').Attribution|null|undefined} attribution
* @param {boolean} isData
* @return {{[k:string]:any}|undefined}
*/
const contentFmt = (conf, attribution, isData) => {
if (attribution === undefined) return undefined
/** @type {{[k:string]:any}} */
const f = {}
if (attribution === null) {
if (isData) return undefined // data "none" — nothing to render
for (const dim of CONTENT_DIMS) if (conf[dim] != null) f[CONTENT_KEYS[dim]] = null // instruction clear-all
return object.isEmpty(f) ? undefined : f
}
const ks = object.keys(attribution)
for (const dim of CONTENT_DIMS) {
const handler = conf[dim]
if (handler != null && ks.some(k => k.startsWith(dim))) {
const r = handler(attribution)
// `null` ⇒ clear the key, but only as an *instruction* — a data op stores resolved formats with
// no `{k:null}` leaves, so on a data op a `null` result is "nothing to render" (skip).
if (r === null) { if (!isData) f[CONTENT_KEYS[dim]] = null } else if (!isSkip(r)) f[CONTENT_KEYS[dim]] = r
}
}
return object.isEmpty(f) ? undefined : f
}
/**
* Build the `y-attributed-attrs` format value from a child node's attribute-op attributions — the
* lift that lets the *parent* op carry a node's attr provenance (attr ops have no format slot).
* Whether a `null` attribution clears or is skipped depends on the *container context* `instr`, not
* only the op kind: inside a `modify` op (`instr` true) the node delta is a change, so a
* `setAttr(key, value, null)` means "the attr is no longer attributed" (e.g. a suggestion was
* accepted — `diff` emits exactly this op when only the attribution went away) ⇒ emit the
* `{ <key>: null }` clear instruction. Inside an `insert` op (`instr` false) the node delta is
* settled data, where `null` is simply "unattributed" ⇒ skip. A `modifyAttr` op is an instruction in
* either context. The emitted map is *partial* — downstream consumers must merge it per-key
* (`null` deletes, value sets), never replace the whole map.
*
* @param {Conf} conf
* @param {import('../delta.js').DeltaAny} node
* @param {boolean} instr `true` when `node` is a change (`modify` op value), `false` for settled data (`insert` op element)
* @return {{[k:string]:any}|undefined}
*/
const attrsFmt = (conf, node, instr) => {
const handler = conf.attrs
if (handler == null) return undefined
/** @type {{[k:string]:any}} */
const map = {}
for (const op of node.attrs) {
const isInstr = instr || delta.$modifyAttrOp.check(op)
const a = op.attribution
if (a === undefined) continue // instruction skip
if (a === null) {
if (isInstr) map[op.key] = null // instruction clear (data "none" ⇒ skip)
continue
}
const r = handler(a)
if (r === null) { if (isInstr) map[op.key] = null } else if (!isSkip(r)) map[op.key] = r
}
return object.isEmpty(map) ? undefined : { [Y_ATTRS]: map }
}
/**
* Merge `y-attributed-*` increments onto a content op's existing `format`, preserving real formats
* (`bold`, …) and the op's tri-state. With no increment, `base` is returned verbatim (so a `null`
* clear-all or a `{k:null}` removal survives). With increments, a `null`/`undefined` base contributes
* nothing and only the namespaced keys are emitted.
*
* @param {{[k:string]:any}|null|undefined} base op.format (skip `undefined` / clear-all `null` / object)
* @param {...({[k:string]:any}|undefined)} adds namespaced format increments
* @return {{[k:string]:any}|null|undefined}
*/
const combineFmt = (base, ...adds) => {
const extra = object.assign({}, ...adds)
if (object.isEmpty(extra)) return base
return base == null ? extra : object.assign({}, base, extra)
}
/**
* Remove every reserved `y-attributed-*` key from a `format`. Returns the input unchanged when it has
* none (preserving `undefined`/`null`/object verbatim), or `undefined` when stripping empties it.
*
* @param {{[k:string]:any}|null|undefined} format
* @return {{[k:string]:any}|null|undefined}
*/
const stripY = (format) => {
if (format == null) return format
/** @type {{[k:string]:any}} */
const r = {}
let stripped = false
for (const k in format) {
if (k.startsWith(Y_PREFIX)) stripped = true
else r[k] = format[k]
}
return stripped ? (object.isEmpty(r) ? undefined : r) : format
}
/**
* Re-emit a node's own attribute ops without attribution. The view never attributes — a node's
* attr-attribution is rendered on the *parent* op (see {@link attrsFmt}) — so it must not also ride on
* the attr op. Rebuilt via the builder rather than mutating cloned ops.
*
* @param {import('../delta.js').DeltaBuilderAny} out
*/
const stripAttrAttributions = (out) => {
/** @type {Array<any>} */
const ops = []
for (const op of out.attrs) ops.push(op)
for (const op of ops) {
if (delta.$modifyAttrOp.check(op)) {
if (op.attribution !== undefined) out.modifyAttr(op.key, op.value)
} else if (delta.$setAttrOp.check(op)) {
if (op.attribution != null) out.setAttr(op.key, op.value)
} else if (op.attribution != null) { // DeleteAttrOp
out.deleteAttr(op.key)
}
}
}
/**
* The whole transform, both directions, in one stateless recursive pass over the nested delta.
*
* @param {Conf} conf
* @param {import('../delta.js').DeltaAny} d
* @param {boolean} fwd `true` maps A→B (attribution→format), `false` maps B→A (strip).
* @return {import('./core.js').TransformResultAny}
*/
const transform = (conf, d, fwd) => {
const out = /** @type {any} */ (delta.cloneShallow(d))
if (fwd) {
stripAttrAttributions(out)
for (const op of d.children) {
if (delta.$textOp.check(op)) {
out.insert(op.insert, combineFmt(op.format, contentFmt(conf, op.attribution, true)))
} else if (delta.$retainOp.check(op)) {
out.retain(op.retain, combineFmt(op.format, contentFmt(conf, op.attribution, false)))
} else if (delta.$insertOp.check(op)) {
// one element at a time: the builder re-coalesces equal formats and splits differing ones, so
// children needing distinct `y-attributed-attrs` land in their own insert ops automatically
for (const el of op.insert) {
if (delta.$deltaAny.check(el)) {
out.insert([transform(conf, el, true).b], combineFmt(op.format, contentFmt(conf, op.attribution, true), attrsFmt(conf, el, false)))
} else {
out.insert([el], combineFmt(op.format, contentFmt(conf, op.attribution, true)))
}
}
} else if (delta.$deleteOp.check(op)) {
out.delete(op.delete)
} else { // $modifyOp
out.modify(transform(conf, op.value, true).b, combineFmt(op.format, contentFmt(conf, op.attribution, false), attrsFmt(conf, op.value, true)))
}
}
} else {
for (const op of d.children) {
if (delta.$textOp.check(op)) {
out.insert(op.insert, stripY(op.format))
} else if (delta.$retainOp.check(op)) {
out.retain(op.retain, stripY(op.format))
} else if (delta.$insertOp.check(op)) {
for (const el of op.insert) {
out.insert([delta.$deltaAny.check(el) ? transform(conf, el, false).a : el], stripY(op.format))
}
} else if (delta.$deleteOp.check(op)) {
out.delete(op.delete)
} else { // $modifyOp
out.modify(transform(conf, op.value, false).a, stripY(op.format))
}
}
}
out.done(false)
return fwd ? createTransformResult(null, out) : createTransformResult(out, null)
}
/**
* Stateless transformer (holds only its `conf`); a single shared instance per template suffices.
*
* @extends {Transformer<any,any>}
*/
export class AttributionToFormatTransformer extends Transformer {
/**
* @param {import('../../schema.js').Schema<delta.Delta<any>>} $in
* @param {import('../../schema.js').Schema<delta.Delta<any>>} $out
* @param {Conf} conf
*/
constructor ($in, $out, conf) {
super($in, $out)
this.conf = conf
}
/**
* @param {delta.DeltaBuilderAny} d
* @return {import('./core.js').TransformResultAny}
*/
applyA (d) {
return transform(this.conf, d, true)
}
/**
* @param {delta.DeltaBuilderAny} d
* @return {import('./core.js').TransformResultAny}
*/
applyB (d) {
return transform(this.conf, d, false)
}
}
/**
* Compute the output schema: widen the input `$formats` to admit the four `y-attributed-*` keys (only
* text-op formats are schema-checked, delta.js `$Delta#check`). Falls back to `$deltaAny` when `$in`
* is loose. A local rebuild (not `withAttrs`/`withName`, which hardcode `recursiveChildren:false`) so
* recursive node trees survive.
*
* @param {import('../../schema.js').Schema<delta.DeltaAny>} $in
* @return {import('../../schema.js').Schema<delta.DeltaAny>}
*/
const computeOut = ($in) => {
if (!delta.$$delta.check($in)) return delta.$deltaAny
const $formats = $in.shape.$formats
const $wide = s.$$object.check($formats)
? s.$object(object.assign({}, $formats.shape, { 'y-attributed-insert': s.$any.optional, 'y-attributed-delete': s.$any.optional, 'y-attributed-format': s.$any.optional, [Y_ATTRS]: s.$any.optional }))
: $formats // `s.$any` (the default) already admits the namespaced keys
return new delta.$Delta($in.shape.$name, $in.shape.$attrs, $in.shape.$children, $in.shape.hasText, $in.shape.recursiveChildren, $wide)
}
/**
* Template for {@link AttributionToFormatTransformer}.
*
* @template {delta.DeltaConf} [IN=any]
* @extends {Template<IN, any>}
*/
export class AttributionToFormat extends Template {
/**
* @param {import('../../schema.js').Schema<delta.Delta<IN>>} $d
* @param {Conf} conf
*/
constructor ($d, conf) {
super($d, /** @type {any} */ (computeOut($d)))
/**
* @type {Conf}
*/
this.conf = conf
}
get name () { return 'lib0:attributionToFormat' }
/**
* @return {Transformer<IN, any>}
*/
init () {
return new AttributionToFormatTransformer(this.$in, this.$out, this.conf)
}
}
/**
* Render a delta's `attribution` dimension into a reserved `y-attributed-*` format namespace (forward)
* and strip it back out (reverse) — see the {@link module:delta/transformer/attribution-to-format
* module doc}. `conf` supplies per-dimension handlers (`insert`/`delete`/`format` for content ops,
* `attrs` for node-attribute provenance); each maps the op's attribution to the value placed under the
* namespaced format key (`null` clears it, `{}`/`undefined` leaves it unchanged).
*
* The transformer assumes every attribution-bearing op carries the **complete** attribution: the
* format dimension merges flat/wholesale, so it cannot replicate `attribution.format`'s deep merge,
* and an incremental update would overwrite a whole `y-attributed-attrs` map or `y-attributed-format`
* object. Returns a reusable {@link AttributionToFormat} template (`.init()` for a standalone
* transformer).
*
* @template {delta.DeltaConf} IN
* @param {import('../../schema.js').Schema<delta.Delta<IN>>} $d
* @param {Conf} conf
* @return {AttributionToFormat<IN>}
*/
export const attributionToFormat = ($d, conf) => new AttributionToFormat($d, conf)