UNPKG

lib0

Version:

isomorphic utility functions

421 lines (398 loc) • 16.4 kB
/* global MutationObserver */ /** * # DOM delta RDT * * {@link domRDT} creates an RDT (see `../rdt.js`) backed by a live DOM subtree. A `MutationObserver` * reports which nodes changed; the mirror ({@link DomRDT#_state}) is reconciled *incrementally* — only * the changed paths are re-read from the DOM, every unchanged subtree is reused by reference (via the * {@link DomRDT#_nodes} `WeakMap`), and the rebuilt tree is diffed against the previous mirror to emit a * minimal `'delta'` (with the RDT itself as its {@link import('../rdt.js').RDT origin}). Incoming deltas * are applied back onto the DOM. This lets a DOM subtree be bound to any other RDT. * * @module delta/rdt/dom */ import { ObservableV2 } from '../../observable.js' import * as delta from '../delta.js' import * as dom from '../../dom.js' import * as error from '../../error.js' import * as math from '../../math.js' import * as s from '../../schema.js' /** * @template T * @typedef {import('../../schema.js').Schema<T>} Schema */ /** * @template {delta.DeltaConf} Conf * @typedef {import('../rdt.js').RDT<Conf>} RDT */ /** * A shared, never-mutated empty `onPath` set for the initial full read (see {@link reconcile}), where an * empty `_nodes` map means nothing is reused anyway. * * @type {Set<Node>} */ const EMPTY_ON_PATH = new Set() /** * Render a {@link DomDelta} into a fresh DOM element (the inverse of {@link reconcile}). * * @param {DomDelta} d * @return {Element} */ const deltaToDom = d => { if (delta.$deltaAny.check(d)) { const n = dom.element(/** @type {string} */ (d.name)) for (const change of d.attrs) { if (delta.$setAttrOp.check(change)) { n.setAttribute(change.key, change.value) } } d.children.forEach(child => { if (delta.$insertOp.check(child)) { n.append(...child.insert.map(el => deltaToDom(/** @type {DomDelta} */ (el)))) } else if (delta.$textOp.check(child)) { n.append(dom.text(child.insert)) } }) return n } /* c8 ignore next */ // defensive: deltaToDom is only ever called on delta nodes error.unexpectedCase() } /** * Apply a {@link DomDelta} as an incremental change onto an existing DOM element, mutating it in place. * * The element's children are addressed in *content* coordinates: each element child is one position, * each character of a text node is one position. `childIndex` walks `el.childNodes`; `childOffset` is * the offset within the current text node. `child` is re-read on every loop step because the previous * step may have inserted, removed or split nodes. * * @param {Element} el * @param {DomDelta} d */ const applyDeltaToDom = (el, d) => { for (const change of d.attrs) { if (delta.$deleteAttrOp.check(change)) { el.removeAttribute(change.key) } else if (delta.$setAttrOp.check(change)) { el.setAttribute(change.key, change.value) } } let childIndex = 0 let childOffset = 0 d.children.forEach(change => { if (delta.$retainOp.check(change)) { let len = change.retain while (len > 0) { const child = el.childNodes[childIndex] /* c8 ignore next */ // defensive: a well-formed delta never advances past the children if (child == null) break if (dom.$text.check(child)) { const remaining = child.length - childOffset if (remaining <= len) { len -= remaining childOffset = 0 childIndex++ } else { childOffset += len len = 0 } } else { childIndex++ len-- } } } else if (delta.$deleteOp.check(change)) { let len = change.length while (len > 0) { const child = el.childNodes[childIndex] /* c8 ignore next */ // defensive: a well-formed delta never deletes past the children if (child == null) break if (dom.$text.check(child)) { const childLen = child.length if (childOffset === 0 && childLen <= len) { child.remove() len -= childLen } else { const spliceLen = math.min(len, childLen - childOffset) child.deleteData(childOffset, spliceLen) len -= spliceLen if (child.length <= childOffset) { childOffset = 0 childIndex++ } } } else { child.remove() len-- } } } else if (delta.$insertOp.check(change) || delta.$textOp.check(change)) { if (childOffset > 0) { dom.$text.cast(el.childNodes[childIndex]).splitText(childOffset) childIndex++ childOffset = 0 } const ref = el.childNodes[childIndex] ?? null if (delta.$textOp.check(change)) { el.insertBefore(dom.text(change.insert), ref) childIndex++ } else { el.insertBefore(dom.fragment(change.insert.map(c => deltaToDom(/** @type {DomDelta} */ (c)))), ref) childIndex += change.insert.length } } else if (delta.$modifyOp.check(change)) { applyDeltaToDom(dom.$element.cast(el.childNodes[childIndex]), /** @type {DomDelta} */ (change.value)) childIndex++ /* c8 ignore next 3 */ // unreachable: $domDelta children are only retain/delete/insert/text/modify } else { error.unexpectedCase() } }) } /** * Schema describing the deltas produced/consumed by a {@link DomRDT}: a recursive node with a string * name, string→string attributes, and text content. */ export const $domDelta = /* @__PURE__ */ delta.$delta({ name: s.$string, attrs: s.$record(s.$string, s.$string), children: s.$never, text: true, recursiveChildren: true }) /** * The {@link delta.DeltaConf} of the deltas a {@link DomRDT} produces/consumes (see {@link $domDelta}). * * @typedef {{ name: string, attrs: { [key:string]: string }, text: true, recursiveChildren: true }} DomConf */ /** * @typedef {delta.Delta<DomConf>} DomDelta */ // The mirror is maintained by these module-private functions (kept as functions, not methods, per the // lib0 style guide — methods are reserved for the duck-typed RDT interface: `applyDelta`/`delta`/ // `destroy`). Each takes the {@link DomRDT} whose `_state`/`_nodes`/`observedNode` it reads or advances. /** * Reconcile `node` against the DOM, reusing subtrees by reference. When `node` is not on the dirty * `onPath` and is already mapped in `rdt._nodes`, its existing mirror subtree is returned unread; * otherwise the node's shell (name + attributes + child list) is rebuilt — text children become an * `insert` of their content (adjacent text coalesces, normalising `splitText`), element children recurse * — and re-registered in `rdt._nodes`. Purely constructive: it never mutates an existing mirror node, * only allocates fresh shells and shares unchanged subtrees. * * @param {DomRDT} rdt * @param {Node} node * @param {Set<Node>} onPath dirty elements ∪ their ancestors up to the observed root * @return {DomDelta} */ const reconcile = (rdt, node, onPath) => { if (!onPath.has(node) && rdt._nodes.has(node)) { return /** @type {DomDelta} */ (rdt._nodes.get(node)) } if (dom.$element.check(node)) { const d = delta.create(node.nodeName.toLowerCase()) for (let i = 0; i < node.attributes.length; i++) { const attr = /** @type {Attr} */ (node.attributes.item(i)) d.setAttr(attr.nodeName, attr.value) } node.childNodes.forEach(child => { d.insert(dom.$text.check(child) ? (child.textContent ?? '') : [reconcile(rdt, child, onPath)]) }) rdt._nodes.set(node, /** @type {DomDelta} */ (d)) return /** @type {DomDelta} */ (d) } /* c8 ignore next */ // defensive: only element nodes are ever rendered/observed error.unexpectedCase() } /** * The set of elements whose OWN content (attributes, character data, or child list) a mutation batch * changed: a `characterData` record dirties the text node's parent, every other record its target. * Targets no longer under the observed root (removed in this same batch) are dropped — their removal is * captured by a surviving ancestor's `childList` record. * * @param {DomRDT} rdt * @param {MutationRecord[]} records * @return {Set<Element>} */ const dirtyElements = (rdt, records) => { /** @type {Set<Element>} */ const dirty = new Set() for (const r of records) { const el = r.type === 'characterData' ? r.target.parentNode : r.target // `contains` is reflexive, so it admits the observed root itself and drops disconnected targets if (el != null && dom.$element.check(el) && rdt.observedNode.contains(el)) { dirty.add(el) } } return dirty } /** * The dirty elements together with all their ancestors up to (and including) the observed root — the * nodes whose shells {@link reconcile} must rebuild (everything off this path is reused). * * @param {DomRDT} rdt * @param {Set<Element>} dirty * @return {Set<Node>} */ const computeOnPath = (rdt, dirty) => { /** @type {Set<Node>} */ const onPath = new Set() for (const el of dirty) { /** @type {Node?} */ let p = el while (p != null && !onPath.has(p)) { onPath.add(p) if (p === rdt.observedNode) break p = p.parentNode } } return onPath } /** * Reconcile the mirror against the DOM described by `records`, returning the freshly-rebuilt root — or * `null` when nothing relevant changed (in which case no `rdt._nodes` entries were touched, so the * caller may safely leave `rdt._state` as-is). * * @param {DomRDT} rdt * @param {MutationRecord[]} records * @return {DomDelta?} */ const reconcileFromRecords = (rdt, records) => { const dirty = dirtyElements(rdt, records) if (dirty.size === 0) return null return reconcile(rdt, rdt.observedNode, computeOnPath(rdt, dirty)) } /** * Reconcile against the DOM described by `records` and return the change to emit (the diff of the * previous mirror against the rebuilt one), advancing `rdt._state`. Returns an empty delta when nothing * relevant changed. * * @param {DomRDT} rdt * @param {MutationRecord[]} records * @return {DomDelta} */ const pull = (rdt, records) => { const fresh = reconcileFromRecords(rdt, records) if (fresh == null) return /** @type {DomDelta} */ (delta.create()) // `clone: true` is REQUIRED here (not merely an optimisation as on a throwaway diff): `fresh` becomes // the persistent `_state`, so the emitted change must share none of its subtrees — a consumer that // freezes/mutates a shared child would otherwise corrupt the live mirror. const change = /** @type {DomDelta} */ (delta.diff(rdt._state, fresh, { clone: true })) // MUST commit: `reconcile` already re-pointed `rdt._nodes` at the rebuilt shells, so keeping the old // `_state` would desync it. (Skipping is safe only when nothing was reconciled — the `fresh == null` // short-circuit above.) rdt._state = fresh return change } /** * The `MutationObserver` callback: reconcile the batch and, if it produced a change, emit it with the * RDT itself as origin (a locally-observed DOM edit is produced by this RDT — see {@link RDT}). * * @param {DomRDT} rdt * @param {MutationRecord[]} mutations */ const mutationHandler = (rdt, mutations) => { const change = pull(rdt, mutations) if (!change.isEmpty()) rdt.emit('delta', [change, rdt]) } /** * An RDT backed by a live DOM subtree. DOM mutations observed via `MutationObserver` reconcile the * mirror incrementally (re-reading only changed paths, reusing unchanged subtrees by reference) and the * resulting minimal diff is emitted as a delta; incoming deltas are applied back onto the DOM. * * @implements {RDT<DomConf>} * @extends {ObservableV2<{ delta: (delta: delta.Delta<DomConf>, origin: any) => void, destroy: (rdt: DomRDT) => void }>} */ class DomRDT extends ObservableV2 { /** * @param {Element} observedNode */ constructor (observedNode) { super() /** * @type {Schema<DomDelta>} */ this.$delta = /** @type {any} */ ($domDelta) this.observedNode = observedNode /** * Maps each observed DOM node to its mirror delta node in {@link DomRDT#_state}. A {@link reconcile} * reuses an unchanged subtree by reference (skipping its DOM read) via this map; entries for removed * nodes are reclaimed automatically. * * @type {WeakMap<Node, DomDelta>} */ this._nodes = new WeakMap() /** * The live delta mirror of the observed subtree, maintained incrementally. A reconcile rebuilds only * the changed paths and swaps this whole root (see {@link pull}); it is never mutated in place, so a * mirror handed out by {@link DomRDT#delta} stays a valid immutable snapshot. * * @type {DomDelta} */ this._state = reconcile(this, observedNode, EMPTY_ON_PATH) this.observer = new MutationObserver(mutations => mutationHandler(this, mutations)) this.observer.observe(observedNode, { subtree: true, childList: true, attributes: true, characterData: true }) } /** * Apply a foreign delta onto the DOM. * * The DOM may have been edited locally (`b`) since the last sync, concurrently with the incoming * change (`d`). We pull `b` first and rebase the two against each other (OT) so neither is lost: * `d` rebased onto `b` is applied to the DOM, and `b` rebased onto `d` is returned as the fix so the * binding pipes it back through the transformer to the other side. * * @param {delta.Delta<DomConf>} d * @param {any} [origin] who produced `d`; forwarded verbatim on the emitted `'delta'` event so * listeners can recognise (and skip) their own changes — see {@link RDT} “Origins”. Defaults to `null` * (an anonymous/local change). * @return {delta.DeltaBuilder<DomConf> | null} the rebased local change (`b`), or `null` when there * were no concurrent edits. A binding maps a returned fix back onto the other side with the * `correction` origin (see `../rdt.js`) */ applyDelta (d, origin = null) { // Drain + diff any pending local edits (their async MutationObserver callback may not have fired). const b = pull(this, this.observer.takeRecords()) /** @type {DomDelta} */ let toApply = d /** @type {DomDelta?} */ let fix = null if (!b.isEmpty()) { // clone both sides so neither original is mutated by the in-place `rebase` toApply = delta.clone(d).rebase(b, true) // d on top of the local DOM (which has b) const bOnD = delta.clone(b).rebase(d, false) // b on top of d, for the other side fix = bOnD.isEmpty() ? null : bOnD } applyDeltaToDom(this.observedNode, toApply) // Re-sync `_state` from the DOM our own write produced, reconciling from its self-caused records // (taken here — MutationObserver callbacks are async, so the Binding's synchronous mutex cannot // suppress this echo; draining the records is what prevents it). We reconcile rather than // `_state.apply(toApply)` because apply's copy-on-write would re-clone nodes out of `_nodes`, and // its inserted nodes carry no mapping — the DOM (incl. its text coalescing) is the authority. const fresh = reconcileFromRecords(this, this.observer.takeRecords()) if (fresh != null) this._state = fresh // Forward the effective change so a chained binding on the other side picks it up; the binding // that fed us `d` swallows this re-emit via its own mutex (see ../rdt.js). `fix` is a freshly // rebased builder at runtime, returned as the owned change the binding maps on. this.emit('delta', [toApply, origin]) return /** @type {delta.DeltaBuilder<DomConf>?} */ (fix) } /** * The current state as a delta: the live mirror ({@link DomRDT#_state}), an "insert everything" delta * describing the observed subtree. A shared read snapshot — consumers clone before mutating (see * {@link RDT}); the mirror is never mutated in place, so a returned snapshot stays valid. * * @return {delta.Delta<DomConf>} */ get delta () { return this._state } destroy () { this.emit('destroy', [this]) super.destroy() this.observer.disconnect() } } /** * Create a {@link DomRDT} that observes and edits `dom`. * * @param {Element} dom */ export const domRDT = dom => new DomRDT(dom)