lib0
Version:
isomorphic utility functions
220 lines (210 loc) • 9.28 kB
JavaScript
import * as delta from '../delta.js'
import * as math from '../../math.js'
import { Transformer, Template, createTransformResult } from './core.js'
/**
* Stateful transformer that descends one level into a node's child *nodes* and applies a per-child
* sub-transformer, chosen by `handler`. Attributes and text pass through untouched. Designed to be
* applied recursively (see {@link children}).
*
* The per-child sub-transformer instances are kept in {@link ChildrenTransformer#childTs}, positionally
* aligned to the parent's child list (children carry no stable id - identity is positional). `children`
* never changes the child count at its own level, so side A and side B stay aligned position-for-
* position and the same `childTs` indexes both directions.
*
* `childTs` is a sparse positional map kept as a delta: each transformed child is an `insert([t])` op
* holding its sub-transformer; every other position (text runs, opted-out nodes) is a coalesced
* `retain(n)` gap. A run of N text characters costs one retain op, not N array slots. It is walked with
* a forward cursor in step with the change (see {@link ChildrenTransformer#transform}). Transformer
* instances are carried by reference across rebuilds, so their per-instance state survives.
*
* @extends {Transformer<any,any>}
*/
export class ChildrenTransformer extends Transformer {
/**
* @param {import('../../schema.js').Schema<delta.Delta<any>>} $in
* @param {import('../../schema.js').Schema<delta.Delta<any>>} $out
* @param {(child: delta.DeltaAny, $child: import('../../schema.js').Schema<delta.DeltaAny>) => Template | null} handler
* picks the (schema-bound) template for a child node, or `null` to leave it untransformed. Evaluated
* once, when the child is inserted, with the derived per-child schema.
* @param {import('../../schema.js').Schema<delta.DeltaAny>} childSchema per-child-node schema each
* sub-template is built against (derived from the parent's input schema; `$deltaAny` when not derivable).
*/
constructor ($in, $out, handler, childSchema) {
super($in, $out)
this.handler = handler
/**
* Sparse positional map of sub-transformers: `insert([t])` at each transformed child, `retain(n)`
* over gaps (text and opted-out nodes).
*
* @type {delta.DeltaBuilderAny}
*/
this.childTs = delta.create()
this.childSchema = childSchema
}
/**
* @param {delta.DeltaBuilderAny} d the change to map
* @param {boolean} fwd `true` maps side A -> B (applying sub-transformers' `applyA`), `false` maps
* side B -> A. `children` is 1:1 over children, so the same `childTs` indexes both directions.
* @return {import('./core.js').TransformResultAny}
*/
transform (d, fwd) {
// `cloneShallow` carries the node's name, attribute ops, and own (root) marks; only the child nodes
// are (re)built below, and nested children's marks ride on those rebuilt children. It is the shared
// mark-carrying primitive, so this node's marks cannot be silently dropped. `any` keeps `out` within
// the shared TS instantiation budget (see {@link import('./inline.js')}).
const out = /** @type {any} */ (delta.cloneShallow(d))
// Walk the old sparse map with a forward cursor while rebuilding a fresh one. The cursor tracks an
// old-side child position (list node + offset within it).
const newTs = delta.create()
let srcNode = this.childTs.children.start
let srcOff = 0
/**
* Copy `n` positions from the old map to `newTs`, advancing the cursor. Transformer references are
* preserved (the array slice is shallow), so their per-instance state survives the rebuild.
*
* @param {number} n
*/
const carry = n => {
let rem = n
while (rem > 0 && srcNode != null) {
const take = math.min(srcNode.length - srcOff, rem)
if (delta.$insertOp.check(srcNode)) {
newTs.insert(srcNode.insert.slice(srcOff, srcOff + take))
} else {
newTs.retain(take)
}
srcOff += take
rem -= take
if (srcOff >= srcNode.length) { srcNode = srcNode.next; srcOff = 0 }
}
}
/**
* Advance the cursor `n` positions without copying (a delete drops those transformers).
*
* @param {number} n
*/
const drop = n => {
let rem = n
while (rem > 0 && srcNode != null) {
const take = math.min(srcNode.length - srcOff, rem)
srcOff += take
rem -= take
if (srcOff >= srcNode.length) { srcNode = srcNode.next; srcOff = 0 }
}
}
// the sub-transformer at the cursor, or null for a gap (text / opted-out node)
const peek = () => (srcNode != null && delta.$insertOp.check(srcNode)) ? srcNode.insert[srcOff] : null
for (const op of d.children) {
if (delta.$retainOp.check(op)) {
out.retain(op.retain, op.format, op.attribution)
carry(op.retain)
} else if (delta.$textOp.check(op)) {
out.insert(op.insert, op.format, op.attribution)
newTs.retain(op.insert.length) // text is a gap - no sub-transformer
} else if (delta.$insertOp.check(op)) {
for (const el of op.insert) {
const tmpl = delta.$deltaAny.check(el) ? this.handler(el, this.childSchema) : null
if (tmpl != null) {
const t = tmpl.init()
const r = fwd ? t.applyA(el) : t.applyB(el)
out.insert([fwd ? r.b : r.a], op.format, op.attribution)
newTs.insert([t])
} else {
out.insert([el], op.format, op.attribution)
newTs.retain(1) // opted-out node is a gap
}
}
} else if (delta.$deleteOp.check(op)) {
out.delete(op.delete)
drop(op.delete)
} else if (delta.$modifyOp.check(op)) {
const t = peek()
if (t != null) {
// op.value is an immutable Delta view; transformers consume a DeltaBuilder, so clone it
const r = fwd ? t.applyA(delta.clone(op.value)) : t.applyB(delta.clone(op.value))
out.modify(fwd ? r.b : r.a, op.format, op.attribution)
} else {
out.modify(delta.clone(op.value), op.format, op.attribution)
}
carry(1)
}
}
// the change implicitly retains everything after its last op - carry those untouched positions'
// sub-transformers into the rebuilt map (out, being a change, needs no trailing retain)
carry(Infinity)
out.done(false)
this.childTs = newTs
return fwd ? createTransformResult(null, out) : createTransformResult(out, null)
}
/**
* @param {delta.DeltaBuilderAny} da
*/
applyA (da) {
return this.transform(da, true)
}
/**
* @param {delta.DeltaBuilderAny} db
*/
applyB (db) {
return this.transform(db, false)
}
}
/**
* Derive the per-child-node schema from the parent's input schema, so each child's sub-transformer is
* built against a concrete schema instead of `$deltaAny`. Falls back to `$deltaAny`.
*
* @param {import('../../schema.js').Schema<delta.DeltaAny>} $in
* @return {import('../../schema.js').Schema<delta.DeltaAny>}
*/
const childSchemaOf = ($in) => {
if (!delta.$$delta.check($in)) return delta.$deltaAny
if ($in.shape.recursiveChildren) return $in
const $ch = $in.shape.$children
return delta.$$delta.check($ch) ? $ch : delta.$deltaAny
}
/**
* Template for {@link ChildrenTransformer}.
*
* @template {delta.DeltaConf} [IN=any]
* @extends {Template<IN, any>}
*/
export class Children extends Template {
/**
* @param {import('../../schema.js').Schema<delta.Delta<IN>>} $d
* @param {(child: delta.DeltaAny, $child: import('../../schema.js').Schema<delta.DeltaAny>) => Template | null} handler
*/
constructor ($d, handler) {
super($d, /** @type {any} */ (delta.$deltaAny))
this.handler = handler
/**
* @type {import('../../schema.js').Schema<delta.DeltaAny>}
*/
this.childSchema = childSchemaOf($d)
}
get name () { return 'lib0:children' }
/**
* @return {Transformer<IN, any>}
*/
init () {
return new ChildrenTransformer(this.$in, this.$out, this.handler, this.childSchema)
}
}
/**
* Descend one level into a node's child *nodes* and apply a sub-transformer to each, chosen by
* `handler(childNode, $child)`: return a {@link Template} to transform that child, or `null` to leave
* it untransformed. The handler receives the derived per-child schema `$child` so it can build
* schema-bound sub-templates. Attributes and text pass through. The handler is evaluated once, when
* the child is inserted (returning `null` opts the child out permanently). Returns a reusable
* {@link Children} template (a `project` hole, or `.init()` for a standalone transformer).
*
* Composes recursively - e.g. inline every anonymous node at every depth:
* ```js
* const inlineAll = $d => children($d, (_c, $c) => pipe($c, $c1 => inline($c1, [null]), inlineAll))
* ```
*
* @template {delta.DeltaConf} IN
* @param {import('../../schema.js').Schema<delta.Delta<IN>>} $d
* @param {(child: delta.DeltaAny, $child: import('../../schema.js').Schema<delta.DeltaAny>) => Template | null} handler
* @return {Children<IN>}
*/
export const children = ($d, handler) => new Children($d, handler)