UNPKG

lib0

Version:

isomorphic utility functions

238 lines (225 loc) • 11 kB
/** * # Positions in a delta tree * * A {@link Pos} is a resolution-independent location in a *final* (insert-only) {@link * import('./delta.js').Delta delta} tree — a cursor between two characters, a point inside a nested * node, or a reference to an attribute value. It is the value an editor stores for a caret/selection * and the thing a transformer maps from one bound RDT to the other (see `./transformer/`). * * A position is a `path` of steps plus an association (gravity): * - a **string** step descends into the attribute with that key, * - a **number** step is a *content* index (each character and each array element counts as 1, * matching {@link import('./delta.js').Delta}'s `childCnt`): a non-terminal number descends into * the child node at that slot, and the **trailing** number is the terminal cursor *gap*. * * ``` * [5] cursor gap at offset 5 in the root * ['a', 1] inside attribute 'a', cursor gap at offset 1 * [1, 5] descend into the child node at slot 1, cursor gap at offset 5 inside it * ['a'] the value of attribute 'a' (attribute leaf; no offset) * [] the root node itself * ``` * * Numbers are always read as child indices. A *number-keyed attribute* therefore has no unambiguous * position: {@link marksToPositions} still emits a mark inside one, but its numeric attribute step is * indistinguishable from a content index, so such a {@link Pos} mis-resolves and must not be round- * tripped through {@link import('./delta.js').DeltaBuilder#addMark}. Use string attribute keys for any * node that may carry marks (numeric attribute keys are not used in practice). * * @module delta/position */ import * as s from '../schema.js' import * as fun from '../function.js' import * as delta from './delta.js' /** * A single step of a {@link Pos} path: a `string` attribute key, or a `number` content index. * * @typedef {string|number} PosStep */ /** * A location in a delta tree. `path` descends from the node the position is relative to and ends in * the terminal (a trailing-number cursor gap, or a trailing-string attribute leaf). `assoc` is the * gravity at a boundary: `-1` binds to the preceding content, `1` to the following content. `attrs` is * optional immutable user data carried with the position (e.g. an RDT's clientID/user metadata); it is * stored on the {@link import('./delta.js').createMark mark} when the position is written with * {@link import('./delta.js').DeltaBuilder#addMark} and read back by {@link marksToPositions}. * * @typedef {{ path: Array<PosStep>, assoc: -1|1, attrs?: object|null }} Pos */ /** * A {@link Pos} tagged with the unique id of a stored {@link import('./delta.js').createMark mark} — * what {@link marksToPositions} returns (its `attrs` is the mark's stored metadata). Add marks with * {@link import('./delta.js').DeltaBuilder#addMark}. * * @typedef {{ id: string } & Pos} MarkPos */ /** * Schema for a {@link Pos}. * * @type {s.Schema<Pos>} */ export const $pos = /* @__PURE__ */ s.$object({ path: s.$array(s.$union(s.$string, s.$number)), assoc: s.$literal(-1, 1), attrs: s.$any.optional }) /** * Create a {@link Pos} from a path, an (optional) association (gravity, default right `1`), and * (optional) immutable `attrs` carried with the mark. `attrs` is omitted from the result when `null`, * so a plain cursor position stays `{ path, assoc }`. * * @param {Array<PosStep>} path * @param {-1|1} [assoc] * @param {object?} [attrs] * @return {Pos} */ export const create = (path, assoc = 1, attrs = null) => attrs === null ? { path, assoc } : { path, assoc, attrs } /** * Structural equality of two positions: same `assoc`, `id` (a plain {@link Pos} has none ⇒ both * `undefined`), `attrs` (deep), and `path`. * * @param {Pos|MarkPos} a * @param {Pos|MarkPos} b * @return {boolean} */ export const equals = (a, b) => a.assoc === b.assoc && /** @type {any} */ (a).id === /** @type {any} */ (b).id && fun.equalityDeep(a.attrs ?? null, b.attrs ?? null) && a.path.length === b.path.length && a.path.every((step, i) => step === b.path[i]) /** * Schema for a {@link MarkPos}. * * @type {s.Schema<MarkPos>} */ export const $markPos = /* @__PURE__ */ s.$object({ id: s.$string, path: s.$array(s.$union(s.$string, s.$number)), assoc: s.$literal(-1, 1), attrs: s.$any.optional }) /** * Reconstruct every {@link MarkPos} stored as a {@link import('./delta.js').Mark mark} inside `d` — a * settled delta or a change delta (e.g. a raw transformer output, or the change `addMark` builds). A * mark's `path` is the content indices / attribute keys walked to reach it, plus the mark's own `key`; * the walk descends insert embeds, `modify` values, and `setAttr`/`modifyAttr` values. In a change * delta the indices are post-change coordinates (delete ops span no slot). Add marks with * {@link import('./delta.js').DeltaBuilder#addMark}. * * Subtrees are pruned via each node's conservative `maybeHasMarks` flag (`false` ⇒ guaranteed empty, * skipped). This is also the **sole resetter** of that flag: a descended subtree that turns out to hold * no marks has its (stale) `true` cleared to `false`, so later calls prune it. The flag is therefore a * cheap hint that this read keeps eventually-accurate (a write during a read, like a lazy cache). * * @param {delta.DeltaAny} d * @return {Array<MarkPos>} */ export const marksToPositions = d => { /** @type {Array<MarkPos>} */ const out = [] /** * Emit the marks in `node`'s subtree and return whether any were found (used to self-correct the flag). * * @param {delta.DeltaAny} node * @param {Array<PosStep>} path * @return {boolean} */ const walk = (node, path) => { if (!node.maybeHasMarks) return false // guaranteed no marks in this subtree let found = false if (node.marks !== null) { for (const m of node.marks) { found = true out.push(m.attrs === null ? { id: m.id, path: [...path, m.key], assoc: m.assoc } : { id: m.id, path: [...path, m.key], assoc: m.assoc, attrs: m.attrs }) } } // descend the delta-valued insert embeds and the modify values (a change delta's mark rides on a // modify's nested root marks - see markChange; `apply` may also leave a modify on a settled node) let i = 0 for (const op of node.children) { if (delta.$insertOp.check(op)) { for (const el of op.insert) { if (delta.$deltaAny.check(el) && el.maybeHasMarks && walk(el, [...path, i])) found = true i++ } } else if (delta.$modifyOp.check(op)) { if (op.value.maybeHasMarks && walk(op.value, [...path, i])) found = true i++ } else if (!delta.$deleteOp.check(op)) { // text / retain advance the content index; a delete occupies only the pre-change coordinate - // mark paths (like mark keys, see delta.js's shiftMarkKey) are post-change content indices i += op.length } } for (const op of node.attrs) { // descend `setAttr` (a materialized value) AND `modifyAttr` (an incremental change into a not-yet- // materialized sub-document attribute, which `apply` may leave on a settled node) - both hold a // delta value reachable here. if ((delta.$setAttrOp.check(op) || delta.$modifyAttrOp.check(op)) && delta.$deltaAny.check(op.value) && op.value.maybeHasMarks && walk(op.value, [...path, op.key])) { found = true } } node.maybeHasMarks = found // self-correct: clear a stale `true` so later reads prune this subtree return found } walk(d, []) return out } /** * Shared body of {@link mapPositionsA}/{@link mapPositionsB}: encode `positions` as marks on one * synthetic change (ids are the input indices), run it through `transform` once, and read the images * back off the raw output. Nothing is ever applied to a document, so no mark persists anywhere. * * @param {Array<Pos>} positions * @param {(change: delta.DeltaBuilderAny) => delta.DeltaAny?} transform * @return {Array<Pos?>} */ const mapPositionsThrough = (positions, transform) => { if (positions.length === 0) return [] const change = /** @type {delta.DeltaBuilderAny} */ (delta.create()) positions.forEach((pos, i) => change.addMark(pos, '' + i, true)) // transient probes - never to be stored const mapped = transform(change) /** * @type {Map<string, MarkPos>} */ const byId = new Map() if (mapped != null) { // the raw output may alias transformer-internal state; marksToPositions' `maybeHasMarks` // self-correction is safe there (`false` is only written where verifiably no marks exist) for (const m of marksToPositions(mapped)) { if (!byId.has(m.id)) byId.set(m.id, m) // fan-out (e.g. one datum in several project holes): first image in doc order wins } } return positions.map((_, i) => { const m = byId.get('' + i) return m === undefined ? null : create(m.path, m.assoc, m.attrs ?? null) }) } /** * One-shot map of `positions` living on a transformer's **A side** to the B side (the mirror of * {@link mapPositionsB}) — for when you want an answer *now* without maintaining a position-set as * stored marks. One transform pass maps the whole batch; the result is in input order, each entry the * B-side image or `null` when the position has none (a transformer maps marks best-effort — see the * readme). `assoc` and `attrs` ride through on the probe mark. * * The probes are `transient` marks ({@link import('./delta.js').createMark Mark}`#transient`): a * transformer that keeps marks in internal state must not store them (none does today). Beyond that * the call is state-neutral by construction — a mark-only change shifts no content and is never * applied to any document, so neither the documents nor the transformer are (semantically) modified. * * The transformer must already have been fed the document state (a live * {@link import('./rdt.js').Binding} always has): with no document behind it there is nothing for a * position to point into — and e.g. a fresh `project` transformer would consume its initial render. * * @param {import('./transformer/core.js').Transformer<any, any>} tr * @param {Array<Pos>} positions positions on the A side * @return {Array<Pos?>} their images on the B side (input order; `null` = no image) */ export const mapPositionsA = (tr, positions) => mapPositionsThrough(positions, c => tr.applyA(c).b) /** * One-shot map of `positions` living on a transformer's **B side** to the A side — see * {@link mapPositionsA} for the contract (this is the same mapping, fed through `applyB`). * * @param {import('./transformer/core.js').Transformer<any, any>} tr * @param {Array<Pos>} positions positions on the B side * @return {Array<Pos?>} their images on the A side (input order; `null` = no image) */ export const mapPositionsB = (tr, positions) => mapPositionsThrough(positions, c => tr.applyB(c).a)