lib0
Version:
isomorphic utility functions
527 lines (505 loc) • 23.6 kB
JavaScript
import * as delta from '../delta.js'
import * as math from '../../math.js'
import { Transformer, Template, createTransformResult, $template } from './core.js'
/**
* Resolve a hole template's output conf `OUT` to its projected placement type: a `lib0:value` carrier
* lifts to its bare scalar; any other node is embedded as a `Delta<OUT>`.
*
* @template {delta.DeltaConf} OUT
* @typedef {OUT extends { name: 'lib0:value', attrs: { value: infer V } } ? V : delta.Delta<OUT>} ResolveHoleOut
*/
/**
* Resolve one spec child element to its projected output type, distributing over a children union: a
* hole ({@link Template}, whose output conf the spec carries) resolves via its `OUT`, a nested spec
* delta recurses, and anything else (text, a static scalar/node) is kept verbatim.
*
* @template C
* @typedef {C extends Template<any, infer OUT extends delta.DeltaConf> ? ResolveHoleOut<OUT>
* : C extends delta.Delta<infer NC extends delta.DeltaConf> ? delta.Delta<ProjectOutput<NC>>
* : C} ResolveChild
*/
/**
* Resolve a spec's attrs map: a hole attribute value resolves via its output conf; a static value is kept.
*
* @template A
* @typedef {{ [K in keyof A]: A[K] extends Template<any, infer OUT extends delta.DeltaConf> ? ResolveHoleOut<OUT> : A[K] }} ResolveAttrs
*/
/**
* The concrete output conf of a `project($d, spec)` whose spec has conf `SpecConf`: static structure
* is kept, value holes lift to their scalar, nested specs / node holes recurse. Holes are schema-bound
* {@link Template}s, so their output conf is already concrete in `SpecConf` (no input param needed).
*
* @template {delta.DeltaConf} SpecConf
* @typedef {import('./core.js').ResolveOut<delta.AsDeltaConf<
* Omit<SpecConf, 'attrs'|'children'>
* & (SpecConf extends { attrs: infer A } ? { attrs: ResolveAttrs<A> } : {})
* & (SpecConf extends { children: infer Ch } ? { children: ResolveChild<Ch> } : {})
* >>} ProjectOutput
*/
/**
* The scalar (or full replacement value) a `lib0:value` carrier *sets*: its `value` attribute op's
* value, but **only when that op is a `SetAttrOp`** (a full value set). Returns `undefined` for a
* `ModifyAttrOp` carrier (an incremental change to a delta-valued attribute — see {@link modifyOf}) or
* a `DeleteAttrOp`/absent op, so an incremental change is never mistaken for a replacement value.
*
* @param {delta.DeltaAny?} carrier
*/
const scalarOf = carrier => {
const op = carrier == null ? undefined : /** @type {any} */ (carrier.attrs).value
return delta.$setAttrOp.check(op) ? op.value : undefined
}
/**
* The inner change delta a `lib0:value` carrier *modifies* with: its `value` attribute op's value when
* that op is a `ModifyAttrOp` (an incremental change to a delta-valued projected attribute), else
* `undefined`. Routed through the slot's modify channel instead of replacing the slot value.
*
* @param {delta.DeltaAny?} carrier
*/
const modifyOf = carrier => {
const op = carrier == null ? undefined : /** @type {any} */ (carrier.attrs).value
return delta.$modifyAttrOp.check(op) ? /** @type {any} */ (op).value : undefined
}
/**
* Whether `el` is a `lib0:value` carrier node (the convention produced by the
* {@link import('./attr.js').attr} transformer). Such a carrier at a child position is lifted to a
* bare scalar embed; any other node is embedded verbatim (e.g. a `lib0:inline` fragment, which a
* downstream `inline(['lib0:inline'])` may splice).
*
* @param {any} el
*/
const isValueNode = el => delta.$deltaAny.check(el) && el.name === 'lib0:value'
/**
* Whether the delta `d` contains a transformer {@link Template} anywhere in its attribute values or
* (recursively) its inserted child nodes. Used by {@link ProjectionTemplate#init} to decide whether
* a nested static subtree must be auto-wrapped into a nested {@link project} (it has a hole inside)
* or embedded verbatim (it is purely static). A plain delta has no `init` method, so `$template`
* correctly returns false for non-template subtrees.
*
* @param {delta.DeltaAny} d
* @return {boolean}
*/
const containsTemplate = d => {
for (const op of d.attrs) {
if (delta.$setAttrOp.check(op) && $template.check(op.value)) return true
}
for (const op of d.children) {
if (delta.$insertOp.check(op)) {
for (const el of op.insert) {
if ($template.check(el)) return true
if (delta.$deltaAny.check(el) && containsTemplate(el)) return true
}
}
}
return false
}
/**
* Place a carried cursor mark from a value/attr hole's output `rb` (a `lib0:value` carrier whose mark
* is keyed `'value'`) as a root mark on `out` at `slotKey` - the attribute key or child position the
* hole occupies. A scalar/attribute slot has no mark channel of its own, so this root mark is the
* cursor's only home; with several same-id value/attr slots the last placement wins (`Marks` is
* id-keyed per node, so an id cannot exist twice on one node - true duplication is impossible here).
* Node holes need none of this: their marks ride nested inside the embedded node. `deleteMarks` ride
* verbatim (id-keyed).
*
* @param {delta.DeltaBuilderAny} out
* @param {delta.DeltaAny?} rb
* @param {string|number} slotKey
*/
const placeCarrierMark = (out, rb, slotKey) => {
if (rb == null) return
if (rb.marks !== null) {
for (const m of rb.marks) if (m.key === 'value') delta.addRootMark(out, m.copy(slotKey))
}
if (rb.deleteMarks !== null) {
for (const id of rb.deleteMarks) delta.deleteRootMark(out, id)
}
}
/**
* One child slot of a {@link ProjectionTransformer}'s fixed output layout: a run of static text, a
* single static node/embed, or a single hole (filled by a sub-transformer's output).
*
* A hole's `carrier` records how its sub-transformer's output is placed: `'value'` lifts a
* `lib0:value` carrier to a bare scalar embed (1 position, replaced on update); `'node'` embeds a
* structural node verbatim (a nested `project`'s render, or a non-value carrier) and forwards its
* incremental change as a `modify`. The kind is fixed once: known at `init` for auto-wrapped nested
* projects, otherwise sniffed from the first render and cached (`lastScalar` keeps the last lifted
* scalar so a view deletion of a value slot can be self-healed).
*
* @typedef {{ kind: 'text', str: string, format?: any, attribution?: any }
* | { kind: 'static', el: any, format?: any, attribution?: any }
* | { kind: 'hole', t: Transformer<any,any>, el: any, carrier?: 'value'|'node', lastScalar?: any, format?: any, attribution?: any }} ChildItem
*/
/**
* Stateful transformer produced by {@link ProjectionTemplate}. Side A is the data (the
* *projectionDelta*); side B is the projected structure - the spec with each hole filled by its
* sub-transformer's output. `project` resolves the `lib0:value` carrier itself: at an *attribute*
* position (keyed) and at a *child* position (positional, 1->1) it lifts the carrier to its bare
* scalar. A child hole that is a nested `project` (or any non-`lib0:value` carrier) is embedded as a
* node. A `lib0:inline` fragment is left intact for a downstream `inline(['lib0:inline'])`.
*
* The output layout is *fixed*: every hole occupies exactly one attribute or one child node, and
* static content is constant. So `applyA` routes data changes into holes, and `applyB` routes view
* edits at *node*-hole positions back to holes while **self-healing** edits to static content and to
* value slots (a bare scalar has no `modify` channel, so a value-slot view edit is not round-tripped
* to data in v1 - use an attribute hole for an editable binding).
*
* @extends {Transformer<any,any>}
*/
export class ProjectionTransformer extends Transformer {
/**
* @param {import('../../schema.js').Schema<delta.Delta<any>>} $in
* @param {import('../../schema.js').Schema<delta.Delta<any>>} $out
* @param {string} name
* @param {Array<{ key: string|number, value: any, attribution?: any }>} staticAttrs
* @param {Array<{ key: string|number, t: Transformer<any,any> }>} attrHoles
* @param {Array<ChildItem>} items child slots in output order
*/
constructor ($in, $out, name, staticAttrs, attrHoles, items) {
super($in, $out)
this.name = name
this.staticAttrs = staticAttrs
this.attrHoles = attrHoles
this.items = items
// absolute output position of each child slot (static text spans its length, everything else 1)
let pos = 0
/**
* @type {Array<{ start: number, len: number, item: ChildItem }>}
*/
this.segs = items.map(item => {
const len = item.kind === 'text' ? item.str.length : 1
const seg = { start: pos, len, item }
pos += len
return seg
})
this.initialized = false
}
/**
* @param {delta.DeltaBuilderAny} d a data change (or the full data on the first call)
* @return {import('./core.js').TransformResultAny}
*/
applyA (d) {
return this.initialized ? this._update(d) : this._render(d)
}
/**
* Lift a child hole's sub-transformer output and place it on `out`. Returns the output count (1).
* For a value carrier the bare scalar is inserted (and cached for self-heal); otherwise the node is
* embedded verbatim. The hole's `carrier` kind is sniffed once here and cached.
*
* @param {delta.DeltaBuilderAny} out
* @param {ChildItem & { kind: 'hole' }} item
* @param {delta.DeltaAny?} rb the sub-transformer's B output
*/
_placeHole (out, item, rb) {
if (item.carrier == null) item.carrier = isValueNode(rb) ? 'value' : 'node'
if (item.carrier === 'value') {
const sc = scalarOf(rb)
const v = sc === undefined ? null : sc
item.lastScalar = v
out.insert([v], item.format, item.attribution)
} else {
out.insert([rb], item.format, item.attribution)
}
}
/**
* Initial render: emit the full structure with every hole filled.
*
* @param {delta.DeltaBuilderAny} d
*/
_render (d) {
this.initialized = true
const out = delta.create(/** @type {any} */ (this.name))
const res = createTransformResult(null, out)
for (const a of this.staticAttrs) out.setAttr(/** @type {any} */ (a.key), a.value, a.attribution)
for (const h of this.attrHoles) {
const r = h.t.applyA(d)
const sc = scalarOf(r.b)
if (sc !== undefined) out.setAttr(/** @type {any} */ (h.key), sc)
placeCarrierMark(out, r.b, h.key) // a cursor on this data attr anchors at the output attr key
res.applyA(r.a)
}
let childPos = 0 // output child position, to anchor a value-hole's carried cursor mark
for (const item of this.items) {
if (item.kind === 'text') {
out.insert(item.str, item.format, item.attribution)
childPos += item.str.length
} else if (item.kind === 'static') {
out.insert([item.el], item.format, item.attribution)
childPos += 1
} else {
const r = item.t.applyA(d)
this._placeHole(out, item, r.b)
// value holes lift a bare scalar (no mark channel) - anchor the cursor at the slot position;
// node holes carry their marks nested inside the embedded node (no action needed)
if (item.carrier === 'value') placeCarrierMark(out, r.b, childPos)
childPos += 1
res.applyA(r.a)
}
}
return res
}
/**
* Incremental data change: emit only the hole updates. A value hole is *replaced*
* (`delete(1).insert([scalar])`, since a scalar embed has no inner structure to modify); a node
* hole forwards its sub-transformer's incremental change as a `modify`.
*
* @param {delta.DeltaBuilderAny} d
*/
_update (d) {
const out = delta.create()
const res = createTransformResult(null, out)
for (const h of this.attrHoles) {
const r = h.t.applyA(d)
// emit the attr update by the carrier's op KIND (a mark-only change carries no value op at all):
// SetAttr -> full set/replace; ModifyAttr -> incremental change to a delta-valued attr, forwarded
// through the modify channel (NOT written as the value); DeleteAttr -> remove the attr.
const carrierOp = r.b == null ? undefined : /** @type {any} */ (r.b.attrs).value
if (delta.$setAttrOp.check(carrierOp)) {
out.setAttr(/** @type {any} */ (h.key), scalarOf(r.b))
} else if (delta.$modifyAttrOp.check(carrierOp)) {
out.modifyAttr(/** @type {any} */ (h.key), modifyOf(r.b))
} else if (delta.$deleteAttrOp.check(carrierOp)) {
out.deleteAttr(/** @type {any} */ (h.key))
}
placeCarrierMark(out, r.b, h.key)
res.applyA(r.a)
}
let emitted = 0 // output position already covered by `out`'s child ops
for (const seg of this.segs) {
if (seg.item.kind !== 'hole') continue
const item = seg.item
const r = item.t.applyA(d)
if (item.carrier === 'value') {
// route by the carrier's op KIND (a mark-only change carries no value op, so the slot is left
// intact and only the cursor is anchored): SetAttr -> replace the scalar embed; ModifyAttr ->
// forward an incremental change to a delta-valued embed through the modify channel; DeleteAttr ->
// reset the slot to its `null` placeholder (matching a fresh render of the now-absent attr).
const carrierOp = r.b == null ? undefined : /** @type {any} */ (r.b.attrs).value
if (delta.$setAttrOp.check(carrierOp)) {
if (seg.start > emitted) out.retain(seg.start - emitted)
const sc = scalarOf(r.b)
const v = sc === undefined ? null : sc
item.lastScalar = v
out.delete(1)
out.insert([v])
emitted = seg.start + 1
} else if (delta.$modifyAttrOp.check(carrierOp)) {
if (seg.start > emitted) out.retain(seg.start - emitted)
out.modify(modifyOf(r.b)) // incremental change to the delta-valued embed
emitted = seg.start + 1
} else if (delta.$deleteAttrOp.check(carrierOp)) {
// the bound data attr was removed: a child value slot has no data channel, so deletion only
// updates the view - reset the scalar embed to the `null` placeholder a fresh render shows.
if (seg.start > emitted) out.retain(seg.start - emitted)
item.lastScalar = null
out.delete(1)
out.insert([null])
emitted = seg.start + 1
}
placeCarrierMark(out, r.b, seg.start)
} else if (r.b != null && !r.b.isEmpty()) {
// node hole: forward the incremental change as a modify; its marks ride nested
if (seg.start > emitted) out.retain(seg.start - emitted)
out.modify(r.b)
emitted = seg.start + 1
}
res.applyA(r.a)
}
return res
}
/**
* Reconstruct the content occupying output positions `[start, start+len)` as insert ops on `out`
* (used to self-heal a view delete). Static text/nodes are restored from the spec, and a value
* slot is restored from its last-rendered scalar. Node-hole positions are skipped (their dynamic
* structure cannot be restored without the current data).
*
* @param {delta.DeltaBuilderAny} out
* @param {number} start
* @param {number} len
*/
_restore (out, start, len) {
const end = start + len
for (const seg of this.segs) {
if (seg.start >= end || seg.start + seg.len <= start) continue
const from = math.max(start, seg.start)
const to = math.min(end, seg.start + seg.len)
const item = seg.item
if (item.kind === 'text') {
out.insert(item.str.slice(from - seg.start, to - seg.start), item.format, item.attribution)
} else if (item.kind === 'static') {
out.insert([item.el], item.format, item.attribution)
} else if (item.kind === 'hole' && item.carrier === 'value') {
out.insert([item.lastScalar], item.format, item.attribution)
}
}
}
/**
* @param {delta.DeltaBuilderAny} d a view change in the output space
* @return {import('./core.js').TransformResultAny}
*/
applyB (d) {
const res = createTransformResult(null, null)
const heal = delta.create()
let healed = false
// attributes: route hole-attr edits to their holes, self-heal edits to static attrs
for (const op of d.attrs) {
const hole = this.attrHoles.find(h => h.key === op.key)
if (hole != null) {
if (delta.$setAttrOp.check(op)) {
res.applyA(hole.t.applyB(delta.create('lib0:value', { value: op.value })).a)
}
} else {
const st = this.staticAttrs.find(a => a.key === op.key)
if (st != null && !(delta.$setAttrOp.check(op) && op.value === st.value)) {
heal.setAttr(/** @type {any} */ (st.key), st.value)
healed = true
}
}
}
// children: walk with a pre-change position cursor, routing node-hole modifies and self-healing
// everything else (drift, edited static content, edited value slots)
let pos = 0
const segAt = (/** @type {number} */ p) => this.segs.find(s => s.start <= p && p < s.start + s.len)
for (const op of d.children) {
if (delta.$retainOp.check(op)) {
heal.retain(op.retain)
pos += op.retain
} else if (delta.$textOp.check(op)) {
heal.delete(op.insert.length) // inserted text is drift
healed = true
} else if (delta.$insertOp.check(op)) {
heal.delete(op.insert.length) // inserted nodes/embeds are drift
healed = true
} else if (delta.$deleteOp.check(op)) {
this._restore(heal, pos, op.delete) // re-insert the deleted static / value content
healed = true
pos += op.delete
} else if (delta.$modifyOp.check(op)) {
const seg = segAt(pos)
if (seg != null && seg.item.kind === 'hole' && seg.item.carrier === 'node') {
// op.value is an immutable Delta view; transformers consume a DeltaBuilder
res.applyA(seg.item.t.applyB(delta.clone(op.value)).a)
heal.retain(1)
} else {
// a static node or a value slot was modified: restore it
heal.delete(1)
this._restore(heal, pos, 1)
healed = true
}
pos += 1
}
}
// route view-side cursor marks on ATTRIBUTE holes back to their data attribute (the editable,
// round-tripping binding): a root mark keyed to an attr hole's output key rides on a `lib0:value`
// carrier so the attr sub-transformer maps it to the data attribute. Best-effort for the rest -
// node-hole nested marks already ride on the routed `modify` above; value child slots are
// display-only (no data channel); marks at static / non-hole positions and view-side mark
// *deletions* are not routed back (marks are ephemeral cursor state - see the readme).
if (d.marks !== null) {
for (const m of d.marks) {
const hole = this.attrHoles.find(h => h.key === m.key)
if (hole != null) {
const carrier = /** @type {delta.DeltaBuilderAny} */ (delta.create('lib0:value'))
delta.addRootMark(carrier, m.copy('value'))
res.applyA(hole.t.applyB(carrier).a)
}
}
}
if (healed) {
heal.done(false)
res.applyB(heal)
}
return res
}
}
/**
* Template that projects the *projectionDelta* onto a fixed structure described by `spec` - an
* ordinary {@link import('../delta.js').create delta} whose attribute values and inserted children
* may be transformer {@link Template templates} ("holes"). Each hole receives the whole
* projectionDelta; its output is placed at that position. The spec is a *write-only template*: it is
* walked once at {@link ProjectionTemplate#init} and never fingerprinted/diffed.
*
* Holes resolve themselves: a `lib0:value` carrier (produced by {@link import('./attr.js').attr}) is
* lifted to a bare scalar; a nested subtree that contains a hole is **auto-wrapped** into a nested
* `project` at `init`, so deep structure composes through nested templates with the recursion built
* once (bounded by spec depth). Plain static subtrees are embedded verbatim.
*
* @template {delta.DeltaConf} [IN=any]
* @template {delta.DeltaConf} [SpecConf=any]
* @extends {Template<IN, ProjectOutput<SpecConf>>}
*/
export class ProjectionTemplate extends Template {
/**
* @param {import('../../schema.js').Schema<delta.Delta<IN>>} $d
* @param {delta.Delta<SpecConf>} spec
*/
constructor ($d, spec) {
super($d, /** @type {any} */ (delta.$deltaAny))
this.spec = spec
}
get name () { return 'lib0:project' }
/**
* @return {Transformer<IN, ProjectOutput<SpecConf>>}
*/
init () {
const spec = this.spec
const $d = this.$in
/**
* @type {Array<{ key: string|number, value: any, attribution?: any }>}
*/
const staticAttrs = []
/**
* @type {Array<{ key: string|number, t: Transformer<any,any> }>}
*/
const attrHoles = []
for (const op of spec.attrs) {
if (delta.$setAttrOp.check(op)) {
if ($template.check(op.value)) {
attrHoles.push({ key: op.key, t: /** @type {Template} */ (op.value).init() })
} else {
staticAttrs.push({ key: op.key, value: op.value, attribution: op.attribution })
}
}
}
/**
* @type {Array<ChildItem>}
*/
const items = []
for (const op of spec.children) {
if (delta.$textOp.check(op)) {
items.push({ kind: 'text', str: op.insert, format: op.format, attribution: op.attribution })
} else if (delta.$insertOp.check(op)) {
for (const el of op.insert) {
if ($template.check(el)) {
items.push({ kind: 'hole', t: /** @type {Template} */ (el).init(), el, format: op.format, attribution: op.attribution })
} else if (delta.$deltaAny.check(el) && containsTemplate(el)) {
// a static subtree with a hole inside it - auto-wrap as a nested project (a node hole).
// `el` is an immutable inserted `Delta`, which is exactly what `project` accepts (it only
// reads attrs/children/name through `ProjectionTemplate.init`).
const nested = project($d, el).init()
items.push({ kind: 'hole', t: nested, el, carrier: 'node', format: op.format, attribution: op.attribution })
} else {
items.push({ kind: 'static', el, format: op.format, attribution: op.attribution })
}
}
}
}
return /** @type {any} */ (new ProjectionTransformer(this.$in, this.$out, /** @type {any} */ (spec.name), staticAttrs, attrHoles, items))
}
}
/**
* Project the *projectionDelta* onto a fixed structure described by `spec` — an ordinary
* {@link import('../delta.js').create delta} whose attribute values and inserted children may be
* transformer {@link Template templates} ("holes", built with the same `$d`). A child hole whose
* output is a `lib0:value` carrier is lifted to its scalar; a nested subtree containing a hole is
* auto-wrapped into a nested `project`. Returns a reusable {@link ProjectionTemplate} whose output
* type is computed by {@link ProjectOutput} (holes are
* {@link import('../../trait/fingerprint.js').Fingerprintable}, so the spec carries their types);
* `.init()` builds the transformer.
*
* @template {delta.DeltaConf} IN
* @template {delta.DeltaConf} SpecConf
* @param {import('../../schema.js').Schema<delta.Delta<IN>>} $d
* @param {delta.Delta<SpecConf>} spec
* @return {ProjectionTemplate<IN, SpecConf>}
*/
export const project = ($d, spec) => new ProjectionTemplate($d, spec)