lib0 1.0.0-rc.2 → 1.0.0-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1315
- package/{types → dist}/buffer.d.ts +2 -10
- package/{types → dist}/delta/delta.d.ts +582 -144
- package/dist/delta/position.d.ts +65 -0
- package/dist/delta/rdt/delta.d.ts +57 -0
- package/dist/delta/rdt/dom.d.ts +102 -0
- package/dist/delta/rdt.d.ts +180 -0
- package/dist/delta/transformer/attr.d.ts +64 -0
- package/dist/delta/transformer/attribution-to-format.d.ts +52 -0
- package/dist/delta/transformer/children.d.ts +69 -0
- package/dist/delta/transformer/conform.d.ts +83 -0
- package/dist/delta/transformer/core.d.ts +189 -0
- package/dist/delta/transformer/full-attributions.d.ts +37 -0
- package/dist/delta/transformer/id.d.ts +2 -0
- package/dist/delta/transformer/inline.d.ts +89 -0
- package/dist/delta/transformer/pipe.d.ts +182 -0
- package/dist/delta/transformer/project.d.ts +223 -0
- package/dist/delta/transformer/rename-attrs.d.ts +75 -0
- package/dist/delta/transformer/rename.d.ts +41 -0
- package/dist/delta/transformer/value.d.ts +92 -0
- package/dist/delta/transformer.d.ts +14 -0
- package/dist/diff/patience.d.ts +25 -0
- package/{types → dist}/environment.d.ts +10 -0
- package/dist/hash/fnv1a.d.ts +11 -0
- package/{types → dist}/schema.d.ts +49 -38
- package/{types → dist}/ts.d.ts +4 -0
- package/dist/webcrypto.d.ts +3 -0
- package/package.json +148 -82
- package/src/bin/0serve.js +11 -3
- package/src/broadcastchannel.js +6 -1
- package/src/buffer.js +35 -25
- package/src/decoding.js +1 -1
- package/src/delta/delta.js +2531 -564
- package/src/delta/position.js +165 -0
- package/src/delta/rdt/delta.js +105 -0
- package/src/delta/rdt/dom.js +307 -0
- package/src/delta/rdt.js +255 -0
- package/src/delta/transformer/attr.js +126 -0
- package/src/delta/transformer/attribution-to-format.js +335 -0
- package/src/delta/transformer/children.js +219 -0
- package/src/delta/transformer/conform.js +401 -0
- package/src/delta/transformer/core.js +323 -0
- package/src/delta/transformer/full-attributions.js +282 -0
- package/src/delta/transformer/id.js +13 -0
- package/src/delta/transformer/inline.js +818 -0
- package/src/delta/transformer/pipe.js +272 -0
- package/src/delta/transformer/project.js +526 -0
- package/src/delta/transformer/rename-attrs.js +152 -0
- package/src/delta/transformer/rename.js +84 -0
- package/src/delta/transformer/value.js +214 -0
- package/src/delta/transformer.js +38 -0
- package/src/diff/patience.js +22 -11
- package/src/dom.js +2 -2
- package/src/encoding.js +1 -1
- package/src/environment.js +15 -0
- package/src/hash/fnv1a.js +82 -0
- package/src/number.js +1 -3
- package/src/performance.node.js +3 -3
- package/src/schema.js +231 -177
- package/src/string.js +3 -5
- package/src/testing.js +1 -1
- package/src/trait/equality.js +1 -1
- package/src/trait/fingerprint.js +1 -1
- package/src/ts.js +11 -0
- package/src/bin/gendocs.js +0 -117
- package/src/component.js +0 -414
- package/src/delta/binding.js +0 -372
- package/src/tree.js +0 -546
- package/types/bin/gendocs.d.ts +0 -3
- package/types/component.d.ts +0 -86
- package/types/delta/binding.d.ts +0 -106
- package/types/diff/patience.d.ts +0 -16
- package/types/tree.d.ts +0 -96
- package/types/webcrypto.d.ts +0 -3
- /package/{types → dist}/array.d.ts +0 -0
- /package/{types → dist}/bin/0ecdsa-generate-keypair.d.ts +0 -0
- /package/{types → dist}/bin/0serve.d.ts +0 -0
- /package/{types → dist}/bin/gentesthtml.d.ts +0 -0
- /package/{types → dist}/binary.d.ts +0 -0
- /package/{types → dist}/broadcastchannel.d.ts +0 -0
- /package/{types → dist}/cache.d.ts +0 -0
- /package/{types → dist}/conditions.d.ts +0 -0
- /package/{types → dist}/crypto/aes-gcm.d.ts +0 -0
- /package/{types → dist}/crypto/common.d.ts +0 -0
- /package/{types → dist}/crypto/ecdsa.d.ts +0 -0
- /package/{types → dist}/crypto/jwt.d.ts +0 -0
- /package/{types → dist}/crypto/rsa-oaep.d.ts +0 -0
- /package/{types → dist}/decoding.d.ts +0 -0
- /package/{types → dist}/diff.d.ts +0 -0
- /package/{types → dist}/dom.d.ts +0 -0
- /package/{types → dist}/encoding.d.ts +0 -0
- /package/{types → dist}/error.d.ts +0 -0
- /package/{types → dist}/eventloop.d.ts +0 -0
- /package/{types → dist}/function.d.ts +0 -0
- /package/{types → dist}/hash/rabin-gf2-polynomial.d.ts +0 -0
- /package/{types → dist}/hash/rabin-uncached.d.ts +0 -0
- /package/{types → dist}/hash/rabin.d.ts +0 -0
- /package/{types → dist}/hash/sha256.d.ts +0 -0
- /package/{types → dist}/hash/sha256.node.d.ts +0 -0
- /package/{types → dist}/indexeddb.d.ts +0 -0
- /package/{types → dist}/indexeddbV2.d.ts +0 -0
- /package/{types → dist}/iterator.d.ts +0 -0
- /package/{types → dist}/json.d.ts +0 -0
- /package/{types → dist}/list.d.ts +0 -0
- /package/{types → dist}/logging.common.d.ts +0 -0
- /package/{types → dist}/logging.d.ts +0 -0
- /package/{types → dist}/logging.node.d.ts +0 -0
- /package/{types → dist}/map.d.ts +0 -0
- /package/{types → dist}/math.d.ts +0 -0
- /package/{types → dist}/metric.d.ts +0 -0
- /package/{types → dist}/mutex.d.ts +0 -0
- /package/{types → dist}/number.d.ts +0 -0
- /package/{types → dist}/object.d.ts +0 -0
- /package/{types → dist}/observable.d.ts +0 -0
- /package/{types → dist}/pair.d.ts +0 -0
- /package/{types → dist}/performance.d.ts +0 -0
- /package/{types → dist}/performance.node.d.ts +0 -0
- /package/{types → dist}/pledge.d.ts +0 -0
- /package/{types → dist}/prng/Mt19937.d.ts +0 -0
- /package/{types → dist}/prng/Xoroshiro128plus.d.ts +0 -0
- /package/{types → dist}/prng/Xorshift32.d.ts +0 -0
- /package/{types → dist}/prng.d.ts +0 -0
- /package/{types → dist}/promise.d.ts +0 -0
- /package/{types → dist}/queue.d.ts +0 -0
- /package/{types → dist}/random.d.ts +0 -0
- /package/{types → dist}/set.d.ts +0 -0
- /package/{types → dist}/sort.d.ts +0 -0
- /package/{types → dist}/statistics.d.ts +0 -0
- /package/{types → dist}/storage.d.ts +0 -0
- /package/{types → dist}/string.d.ts +0 -0
- /package/{types → dist}/symbol.d.ts +0 -0
- /package/{types → dist}/testing.d.ts +0 -0
- /package/{types → dist}/time.d.ts +0 -0
- /package/{types → dist}/trait/equality.d.ts +0 -0
- /package/{types → dist}/trait/fingerprint.d.ts +0 -0
- /package/{types → dist}/trait/traits.d.ts +0 -0
- /package/{types → dist}/url.d.ts +0 -0
- /package/{types → dist}/webcrypto.deno.d.ts +0 -0
- /package/{types → dist}/webcrypto.node.d.ts +0 -0
- /package/{types → dist}/webcrypto.react-native.d.ts +0 -0
- /package/{types → dist}/websocket.d.ts +0 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # Positions in a delta tree
|
|
3
|
+
*
|
|
4
|
+
* A {@link Pos} is a resolution-independent location in a *final* (insert-only) {@link
|
|
5
|
+
* import('./delta.js').Delta delta} tree — a cursor between two characters, a point inside a nested
|
|
6
|
+
* node, or a reference to an attribute value. It is the value an editor stores for a caret/selection
|
|
7
|
+
* and the thing a transformer maps from one bound RDT to the other (see `./transformer/`).
|
|
8
|
+
*
|
|
9
|
+
* A position is a `path` of steps plus an association (gravity):
|
|
10
|
+
* - a **string** step descends into the attribute with that key,
|
|
11
|
+
* - a **number** step is a *content* index (each character and each array element counts as 1,
|
|
12
|
+
* matching {@link import('./delta.js').Delta}'s `childCnt`): a non-terminal number descends into
|
|
13
|
+
* the child node at that slot, and the **trailing** number is the terminal cursor *gap*.
|
|
14
|
+
*
|
|
15
|
+
* ```
|
|
16
|
+
* [5] cursor gap at offset 5 in the root
|
|
17
|
+
* ['a', 1] inside attribute 'a', cursor gap at offset 1
|
|
18
|
+
* [1, 5] descend into the child node at slot 1, cursor gap at offset 5 inside it
|
|
19
|
+
* ['a'] the value of attribute 'a' (attribute leaf; no offset)
|
|
20
|
+
* [] the root node itself
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Numbers are always read as child indices. A *number-keyed attribute* therefore has no unambiguous
|
|
24
|
+
* position: {@link marksToPositions} still emits a mark inside one, but its numeric attribute step is
|
|
25
|
+
* indistinguishable from a content index, so such a {@link Pos} mis-resolves and must not be round-
|
|
26
|
+
* tripped through {@link import('./delta.js').DeltaBuilder#addMark}. Use string attribute keys for any
|
|
27
|
+
* node that may carry marks (numeric attribute keys are not used in practice).
|
|
28
|
+
*
|
|
29
|
+
* @module delta/position
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import * as s from '../schema.js'
|
|
33
|
+
import * as fun from '../function.js'
|
|
34
|
+
import * as delta from './delta.js'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A single step of a {@link Pos} path: a `string` attribute key, or a `number` content index.
|
|
38
|
+
*
|
|
39
|
+
* @typedef {string|number} PosStep
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A location in a delta tree. `path` descends from the node the position is relative to and ends in
|
|
44
|
+
* the terminal (a trailing-number cursor gap, or a trailing-string attribute leaf). `assoc` is the
|
|
45
|
+
* gravity at a boundary: `-1` binds to the preceding content, `1` to the following content. `attrs` is
|
|
46
|
+
* optional immutable user data carried with the position (e.g. an RDT's clientID/user metadata); it is
|
|
47
|
+
* stored on the {@link import('./delta.js').createMark mark} when the position is written with
|
|
48
|
+
* {@link import('./delta.js').DeltaBuilder#addMark} and read back by {@link marksToPositions}.
|
|
49
|
+
*
|
|
50
|
+
* @typedef {{ path: Array<PosStep>, assoc: -1|1, attrs?: object|null }} Pos
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A {@link Pos} tagged with the unique id of a stored {@link import('./delta.js').createMark mark} —
|
|
55
|
+
* what {@link marksToPositions} returns (its `attrs` is the mark's stored metadata). Add marks with
|
|
56
|
+
* {@link import('./delta.js').DeltaBuilder#addMark}.
|
|
57
|
+
*
|
|
58
|
+
* @typedef {{ id: string } & Pos} MarkPos
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Schema for a {@link Pos}.
|
|
63
|
+
*
|
|
64
|
+
* @type {s.Schema<Pos>}
|
|
65
|
+
*/
|
|
66
|
+
export const $pos = /* @__PURE__ */ s.$object({
|
|
67
|
+
path: s.$array(s.$union(s.$string, s.$number)),
|
|
68
|
+
assoc: s.$literal(-1, 1),
|
|
69
|
+
attrs: s.$any.optional
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a {@link Pos} from a path, an (optional) association (gravity, default right `1`), and
|
|
74
|
+
* (optional) immutable `attrs` carried with the mark. `attrs` is omitted from the result when `null`,
|
|
75
|
+
* so a plain cursor position stays `{ path, assoc }`.
|
|
76
|
+
*
|
|
77
|
+
* @param {Array<PosStep>} path
|
|
78
|
+
* @param {-1|1} [assoc]
|
|
79
|
+
* @param {object?} [attrs]
|
|
80
|
+
* @return {Pos}
|
|
81
|
+
*/
|
|
82
|
+
export const create = (path, assoc = 1, attrs = null) => attrs === null ? { path, assoc } : { path, assoc, attrs }
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Structural equality of two positions: same `assoc`, `id` (a plain {@link Pos} has none ⇒ both
|
|
86
|
+
* `undefined`), `attrs` (deep), and `path`.
|
|
87
|
+
*
|
|
88
|
+
* @param {Pos|MarkPos} a
|
|
89
|
+
* @param {Pos|MarkPos} b
|
|
90
|
+
* @return {boolean}
|
|
91
|
+
*/
|
|
92
|
+
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])
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Schema for a {@link MarkPos}.
|
|
96
|
+
*
|
|
97
|
+
* @type {s.Schema<MarkPos>}
|
|
98
|
+
*/
|
|
99
|
+
export const $markPos = /* @__PURE__ */ s.$object({
|
|
100
|
+
id: s.$string,
|
|
101
|
+
path: s.$array(s.$union(s.$string, s.$number)),
|
|
102
|
+
assoc: s.$literal(-1, 1),
|
|
103
|
+
attrs: s.$any.optional
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reconstruct every {@link MarkPos} stored as a {@link import('./delta.js').Mark mark} inside `d` (a
|
|
108
|
+
* settled delta). A mark's `path` is the content indices / attribute keys walked to reach it, plus the
|
|
109
|
+
* mark's own `key`. Add marks with {@link import('./delta.js').DeltaBuilder#addMark}.
|
|
110
|
+
*
|
|
111
|
+
* Subtrees are pruned via each node's conservative `maybeHasMarks` flag (`false` ⇒ guaranteed empty,
|
|
112
|
+
* skipped). This is also the **sole resetter** of that flag: a descended subtree that turns out to hold
|
|
113
|
+
* no marks has its (stale) `true` cleared to `false`, so later calls prune it. The flag is therefore a
|
|
114
|
+
* cheap hint that this read keeps eventually-accurate (a write during a read, like a lazy cache).
|
|
115
|
+
*
|
|
116
|
+
* @param {delta.DeltaAny} d
|
|
117
|
+
* @return {Array<MarkPos>}
|
|
118
|
+
*/
|
|
119
|
+
export const marksToPositions = d => {
|
|
120
|
+
/** @type {Array<MarkPos>} */
|
|
121
|
+
const out = []
|
|
122
|
+
/**
|
|
123
|
+
* Emit the marks in `node`'s subtree and return whether any were found (used to self-correct the flag).
|
|
124
|
+
*
|
|
125
|
+
* @param {delta.DeltaAny} node
|
|
126
|
+
* @param {Array<PosStep>} path
|
|
127
|
+
* @return {boolean}
|
|
128
|
+
*/
|
|
129
|
+
const walk = (node, path) => {
|
|
130
|
+
if (!node.maybeHasMarks) return false // guaranteed no marks in this subtree
|
|
131
|
+
let found = false
|
|
132
|
+
if (node.marks !== null) {
|
|
133
|
+
for (const m of node.marks) {
|
|
134
|
+
found = true
|
|
135
|
+
out.push(m.attrs === null
|
|
136
|
+
? { id: m.id, path: [...path, m.key], assoc: m.assoc }
|
|
137
|
+
: { id: m.id, path: [...path, m.key], assoc: m.assoc, attrs: m.attrs })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// a settled delta has only text/insert children; descend the delta-valued embeds
|
|
141
|
+
let i = 0
|
|
142
|
+
for (const op of node.children) {
|
|
143
|
+
if (delta.$insertOp.check(op)) {
|
|
144
|
+
for (const el of op.insert) {
|
|
145
|
+
if (delta.$deltaAny.check(el) && el.maybeHasMarks && walk(el, [...path, i])) found = true
|
|
146
|
+
i++
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
i += op.length
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const op of node.attrs) {
|
|
153
|
+
// descend `setAttr` (a materialized value) AND `modifyAttr` (an incremental change into a not-yet-
|
|
154
|
+
// materialized sub-document attribute, which `apply` may leave on a settled node) - both hold a
|
|
155
|
+
// delta value reachable here.
|
|
156
|
+
if ((delta.$setAttrOp.check(op) || delta.$modifyAttrOp.check(op)) && delta.$deltaAny.check(op.value) && op.value.maybeHasMarks && walk(op.value, [...path, op.key])) {
|
|
157
|
+
found = true
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
node.maybeHasMarks = found // self-correct: clear a stale `true` so later reads prune this subtree
|
|
161
|
+
return found
|
|
162
|
+
}
|
|
163
|
+
walk(d, [])
|
|
164
|
+
return out
|
|
165
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # In-memory delta RDT
|
|
3
|
+
*
|
|
4
|
+
* {@link deltaRDT} creates an RDT (see `../rdt.js`) whose state is the accumulated delta. Calling
|
|
5
|
+
* `applyDelta(delta, origin)` merges the incoming delta into `state` and re-emits it (with the given
|
|
6
|
+
* {@link import('../rdt.js').RDT origin}) as a `'delta'` event, so it can be bound to any other RDT. It
|
|
7
|
+
* accepts every valid delta as-is, so it never returns a fix.
|
|
8
|
+
*
|
|
9
|
+
* `state` is kept as a **final** delta (`isFinal`): it represents the current document, so a `delete`
|
|
10
|
+
* or `deleteAttr` removes the content/attribute outright instead of accumulating a delete-op marker.
|
|
11
|
+
* The `delta` getter therefore always returns a clean insert-only document.
|
|
12
|
+
*
|
|
13
|
+
* @module delta/rdt/delta
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ObservableV2 } from '../../observable.js'
|
|
17
|
+
import * as delta from '../delta.js'
|
|
18
|
+
import * as mux from '../../mutex.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @template T
|
|
22
|
+
* @typedef {import('../../schema.js').Schema<T>} Schema
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @template {delta.DeltaConf} Conf
|
|
27
|
+
* @typedef {import('../rdt.js').RDT<Conf>} RDT
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An in-memory RDT whose state is the accumulated delta. Calling `applyDelta` merges the incoming delta
|
|
32
|
+
* into `state` (via {@link delta.DeltaBuilder#apply}) and re-emits it as a `'delta'`.
|
|
33
|
+
*
|
|
34
|
+
* @template {delta.DeltaConf} Conf
|
|
35
|
+
* @implements {RDT<Conf>}
|
|
36
|
+
* @extends {ObservableV2<{ delta: (delta: delta.Delta<Conf>, origin: any) => void, destroy: (rdt: DeltaRDT<Conf>) => void }>}
|
|
37
|
+
*/
|
|
38
|
+
class DeltaRDT extends ObservableV2 {
|
|
39
|
+
/**
|
|
40
|
+
* @param {Schema<delta.Delta<Conf>>} $delta schema of the deltas this RDT produces
|
|
41
|
+
*/
|
|
42
|
+
constructor ($delta) {
|
|
43
|
+
super()
|
|
44
|
+
this.$delta = $delta
|
|
45
|
+
/**
|
|
46
|
+
* @type {delta.DeltaBuilderAny?}
|
|
47
|
+
*/
|
|
48
|
+
this.state = null
|
|
49
|
+
this._mux = mux.createMutex()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Merge an incoming delta into the current state and notify observers. Always returns `null`: this
|
|
54
|
+
* RDT accepts every valid delta unchanged, so it never produces a fix.
|
|
55
|
+
*
|
|
56
|
+
* @param {delta.Delta<Conf>} d
|
|
57
|
+
* @param {any} [origin] who produced the change; forwarded verbatim on the emitted `'delta'` event so
|
|
58
|
+
* listeners can recognise (and skip) changes they made themselves — see {@link RDT} “Origins”. Defaults
|
|
59
|
+
* to `null` (an anonymous/local change).
|
|
60
|
+
* @return {null}
|
|
61
|
+
*/
|
|
62
|
+
applyDelta (d, origin = null) {
|
|
63
|
+
if (d.isEmpty()) return null
|
|
64
|
+
this._mux(() => {
|
|
65
|
+
if (this.state == null) {
|
|
66
|
+
// start from an empty *final* document (see module doc) so deletes/deleteAttrs — even in the
|
|
67
|
+
// very first delta — remove content instead of leaving delete-op markers
|
|
68
|
+
this.state = delta.create(d.name)
|
|
69
|
+
this.state.isFinal = true
|
|
70
|
+
}
|
|
71
|
+
this.state.apply(d) // `final` defaults to `state.isFinal`, so deletes clean up the state
|
|
72
|
+
})
|
|
73
|
+
// emit AFTER releasing the mutex. When bound to a fix-producing RDT, that side's fix is mapped
|
|
74
|
+
// back here and applied via a synchronous re-entrant `applyDelta` while this call is still on the
|
|
75
|
+
// stack; with the emit inside the mutex that re-entrant mutation would be dropped. The binding's
|
|
76
|
+
// own mutex still breaks the echo loop. `d` is emitted as a shared read delta (the RDT only read it
|
|
77
|
+
// into `state`); a consumer that needs to mutate it deep-clones it first (see the `RDT` typedef).
|
|
78
|
+
this.emit('delta', [d, origin])
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The current state as a delta: the accumulated `state`, or an empty delta when nothing has been
|
|
84
|
+
* applied yet.
|
|
85
|
+
*
|
|
86
|
+
* @return {delta.Delta<Conf>}
|
|
87
|
+
*/
|
|
88
|
+
get delta () {
|
|
89
|
+
// `state` is a loosely-typed `DeltaBuilderAny`; narrow it to this RDT's read `Delta<Conf>` view.
|
|
90
|
+
return /** @type {delta.Delta<Conf>} */ (/** @type {unknown} */ (this.state ?? delta.create(this.$delta)))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
destroy () {
|
|
94
|
+
this.emit('destroy', [this])
|
|
95
|
+
super.destroy()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create a {@link DeltaRDT} for deltas described by `$delta`.
|
|
101
|
+
*
|
|
102
|
+
* @template {delta.DeltaConf} Conf
|
|
103
|
+
* @param {Schema<delta.Delta<Conf>>} $delta
|
|
104
|
+
*/
|
|
105
|
+
export const deltaRDT = $delta => new DeltaRDT($delta)
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/* global MutationObserver */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* # DOM delta RDT
|
|
5
|
+
*
|
|
6
|
+
* {@link domRDT} creates an RDT (see `../rdt.js`) backed by a live DOM subtree. DOM mutations are
|
|
7
|
+
* observed with a `MutationObserver`, turned into deltas (by diffing the new DOM state against the
|
|
8
|
+
* last-known one) and emitted as `'delta'` events (with the RDT itself as their
|
|
9
|
+
* {@link import('../rdt.js').RDT origin}); incoming deltas are applied back onto the DOM. This lets a
|
|
10
|
+
* DOM subtree be bound to any other RDT.
|
|
11
|
+
*
|
|
12
|
+
* @module delta/rdt/dom
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { ObservableV2 } from '../../observable.js'
|
|
16
|
+
import * as delta from '../delta.js'
|
|
17
|
+
import * as dom from '../../dom.js'
|
|
18
|
+
import * as error from '../../error.js'
|
|
19
|
+
import * as math from '../../math.js'
|
|
20
|
+
import * as s from '../../schema.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @template T
|
|
24
|
+
* @typedef {import('../../schema.js').Schema<T>} Schema
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @template {delta.DeltaConf} Conf
|
|
29
|
+
* @typedef {import('../rdt.js').RDT<Conf>} RDT
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Recursively convert a DOM node into a {@link DomDelta} (an "insert everything" delta describing the
|
|
34
|
+
* node, its attributes, and its subtree).
|
|
35
|
+
*
|
|
36
|
+
* @param {Node} domNode
|
|
37
|
+
* @return {DomDelta}
|
|
38
|
+
*/
|
|
39
|
+
const domToDelta = domNode => {
|
|
40
|
+
if (dom.$element.check(domNode)) {
|
|
41
|
+
const d = delta.create(domNode.nodeName.toLowerCase())
|
|
42
|
+
for (let i = 0; i < domNode.attributes.length; i++) {
|
|
43
|
+
const attr = /** @type {Attr} */ (domNode.attributes.item(i))
|
|
44
|
+
d.setAttr(attr.nodeName, attr.value)
|
|
45
|
+
}
|
|
46
|
+
domNode.childNodes.forEach(child => {
|
|
47
|
+
d.insert(dom.$text.check(child) ? (child.textContent ?? '') : [domToDelta(child)])
|
|
48
|
+
})
|
|
49
|
+
return /** @type {DomDelta} */ (d)
|
|
50
|
+
}
|
|
51
|
+
/* c8 ignore next */ // defensive: only element nodes are ever rendered/observed
|
|
52
|
+
error.unexpectedCase()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render a {@link DomDelta} into a fresh DOM element (the inverse of {@link domToDelta}).
|
|
57
|
+
*
|
|
58
|
+
* @param {DomDelta} d
|
|
59
|
+
* @return {Element}
|
|
60
|
+
*/
|
|
61
|
+
const deltaToDom = d => {
|
|
62
|
+
if (delta.$deltaAny.check(d)) {
|
|
63
|
+
const n = dom.element(/** @type {string} */ (d.name))
|
|
64
|
+
for (const change of d.attrs) {
|
|
65
|
+
if (delta.$setAttrOp.check(change)) {
|
|
66
|
+
n.setAttribute(change.key, change.value)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
d.children.forEach(child => {
|
|
70
|
+
if (delta.$insertOp.check(child)) {
|
|
71
|
+
n.append(...child.insert.map(el => deltaToDom(/** @type {DomDelta} */ (el))))
|
|
72
|
+
} else if (delta.$textOp.check(child)) {
|
|
73
|
+
n.append(dom.text(child.insert))
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
return n
|
|
77
|
+
}
|
|
78
|
+
/* c8 ignore next */ // defensive: deltaToDom is only ever called on delta nodes
|
|
79
|
+
error.unexpectedCase()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Apply a {@link DomDelta} as an incremental change onto an existing DOM element, mutating it in place.
|
|
84
|
+
*
|
|
85
|
+
* The element's children are addressed in *content* coordinates: each element child is one position,
|
|
86
|
+
* each character of a text node is one position. `childIndex` walks `el.childNodes`; `childOffset` is
|
|
87
|
+
* the offset within the current text node. `child` is re-read on every loop step because the previous
|
|
88
|
+
* step may have inserted, removed or split nodes.
|
|
89
|
+
*
|
|
90
|
+
* @param {Element} el
|
|
91
|
+
* @param {DomDelta} d
|
|
92
|
+
*/
|
|
93
|
+
const applyDeltaToDom = (el, d) => {
|
|
94
|
+
for (const change of d.attrs) {
|
|
95
|
+
if (delta.$deleteAttrOp.check(change)) {
|
|
96
|
+
el.removeAttribute(change.key)
|
|
97
|
+
} else if (delta.$setAttrOp.check(change)) {
|
|
98
|
+
el.setAttribute(change.key, change.value)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let childIndex = 0
|
|
102
|
+
let childOffset = 0
|
|
103
|
+
d.children.forEach(change => {
|
|
104
|
+
if (delta.$retainOp.check(change)) {
|
|
105
|
+
let len = change.retain
|
|
106
|
+
while (len > 0) {
|
|
107
|
+
const child = el.childNodes[childIndex]
|
|
108
|
+
/* c8 ignore next */ // defensive: a well-formed delta never advances past the children
|
|
109
|
+
if (child == null) break
|
|
110
|
+
if (dom.$text.check(child)) {
|
|
111
|
+
const remaining = child.length - childOffset
|
|
112
|
+
if (remaining <= len) {
|
|
113
|
+
len -= remaining
|
|
114
|
+
childOffset = 0
|
|
115
|
+
childIndex++
|
|
116
|
+
} else {
|
|
117
|
+
childOffset += len
|
|
118
|
+
len = 0
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
childIndex++
|
|
122
|
+
len--
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} else if (delta.$deleteOp.check(change)) {
|
|
126
|
+
let len = change.length
|
|
127
|
+
while (len > 0) {
|
|
128
|
+
const child = el.childNodes[childIndex]
|
|
129
|
+
/* c8 ignore next */ // defensive: a well-formed delta never deletes past the children
|
|
130
|
+
if (child == null) break
|
|
131
|
+
if (dom.$text.check(child)) {
|
|
132
|
+
const childLen = child.length
|
|
133
|
+
if (childOffset === 0 && childLen <= len) {
|
|
134
|
+
child.remove()
|
|
135
|
+
len -= childLen
|
|
136
|
+
} else {
|
|
137
|
+
const spliceLen = math.min(len, childLen - childOffset)
|
|
138
|
+
child.deleteData(childOffset, spliceLen)
|
|
139
|
+
len -= spliceLen
|
|
140
|
+
if (child.length <= childOffset) {
|
|
141
|
+
childOffset = 0
|
|
142
|
+
childIndex++
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
child.remove()
|
|
147
|
+
len--
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else if (delta.$insertOp.check(change) || delta.$textOp.check(change)) {
|
|
151
|
+
if (childOffset > 0) {
|
|
152
|
+
dom.$text.cast(el.childNodes[childIndex]).splitText(childOffset)
|
|
153
|
+
childIndex++
|
|
154
|
+
childOffset = 0
|
|
155
|
+
}
|
|
156
|
+
const ref = el.childNodes[childIndex] ?? null
|
|
157
|
+
if (delta.$textOp.check(change)) {
|
|
158
|
+
el.insertBefore(dom.text(change.insert), ref)
|
|
159
|
+
childIndex++
|
|
160
|
+
} else {
|
|
161
|
+
el.insertBefore(dom.fragment(change.insert.map(c => deltaToDom(/** @type {DomDelta} */ (c)))), ref)
|
|
162
|
+
childIndex += change.insert.length
|
|
163
|
+
}
|
|
164
|
+
} else if (delta.$modifyOp.check(change)) {
|
|
165
|
+
applyDeltaToDom(dom.$element.cast(el.childNodes[childIndex]), /** @type {DomDelta} */ (change.value))
|
|
166
|
+
childIndex++
|
|
167
|
+
/* c8 ignore next 3 */ // unreachable: $domDelta children are only retain/delete/insert/text/modify
|
|
168
|
+
} else {
|
|
169
|
+
error.unexpectedCase()
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Schema describing the deltas produced/consumed by a {@link DomRDT}: a recursive node with a string
|
|
176
|
+
* name, string→string attributes, and text content.
|
|
177
|
+
*/
|
|
178
|
+
export const $domDelta = /* @__PURE__ */ delta.$delta({ name: s.$string, attrs: s.$record(s.$string, s.$string), children: s.$never, text: true, recursiveChildren: true })
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The {@link delta.DeltaConf} of the deltas a {@link DomRDT} produces/consumes (see {@link $domDelta}).
|
|
182
|
+
*
|
|
183
|
+
* @typedef {{ name: string, attrs: { [key:string]: string }, text: true, recursiveChildren: true }} DomConf
|
|
184
|
+
*/
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @typedef {delta.Delta<DomConf>} DomDelta
|
|
188
|
+
*/
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* An RDT backed by a live DOM subtree. DOM mutations observed via `MutationObserver` are diffed
|
|
192
|
+
* against the last-known state and emitted as deltas; incoming deltas are applied back onto the DOM.
|
|
193
|
+
*
|
|
194
|
+
* @implements {RDT<DomConf>}
|
|
195
|
+
* @extends {ObservableV2<{ delta: (delta: delta.Delta<DomConf>, origin: any) => void, destroy: (rdt: DomRDT) => void }>}
|
|
196
|
+
*/
|
|
197
|
+
class DomRDT extends ObservableV2 {
|
|
198
|
+
/**
|
|
199
|
+
* @param {Element} observedNode
|
|
200
|
+
*/
|
|
201
|
+
constructor (observedNode) {
|
|
202
|
+
super()
|
|
203
|
+
/**
|
|
204
|
+
* @type {Schema<DomDelta>}
|
|
205
|
+
*/
|
|
206
|
+
this.$delta = /** @type {any} */ ($domDelta)
|
|
207
|
+
this.observedNode = observedNode
|
|
208
|
+
/**
|
|
209
|
+
* Last-known DOM state. The observe path diffs the live DOM against this to compute the change to
|
|
210
|
+
* emit, then advances it; `applyDelta` re-syncs it after mutating the DOM.
|
|
211
|
+
*
|
|
212
|
+
* @type {DomDelta}
|
|
213
|
+
*/
|
|
214
|
+
this._state = domToDelta(observedNode)
|
|
215
|
+
this.observer = new MutationObserver(this._mutationHandler)
|
|
216
|
+
this.observer.observe(observedNode, {
|
|
217
|
+
subtree: true,
|
|
218
|
+
childList: true,
|
|
219
|
+
attributes: true,
|
|
220
|
+
characterDataOldValue: true
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Pull the local DOM changes accumulated since the last sync as a delta (by diffing the live DOM
|
|
226
|
+
* against `_state`), advancing `_state` to the current DOM.
|
|
227
|
+
*
|
|
228
|
+
* @return {DomDelta}
|
|
229
|
+
*/
|
|
230
|
+
_pull () {
|
|
231
|
+
const next = domToDelta(this.observedNode)
|
|
232
|
+
const change = delta.diff(this._state, next)
|
|
233
|
+
this._state = next
|
|
234
|
+
return change
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {MutationRecord[]} mutations
|
|
239
|
+
*/
|
|
240
|
+
_mutationHandler = mutations => {
|
|
241
|
+
if (mutations.length === 0) return
|
|
242
|
+
const change = this._pull()
|
|
243
|
+
// a locally-observed DOM edit: this RDT is the producer, so it is the origin (see {@link RDT})
|
|
244
|
+
if (!change.isEmpty()) this.emit('delta', [change, this])
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Apply a foreign delta onto the DOM.
|
|
249
|
+
*
|
|
250
|
+
* The DOM may have been edited locally (`b`) since the last sync, concurrently with the incoming
|
|
251
|
+
* change (`d`). We pull `b` first and rebase the two against each other (OT) so neither is lost:
|
|
252
|
+
* `d` rebased onto `b` is applied to the DOM, and `b` rebased onto `d` is returned as the fix so the
|
|
253
|
+
* binding pipes it back through the transformer to the other side.
|
|
254
|
+
*
|
|
255
|
+
* @param {delta.Delta<DomConf>} d
|
|
256
|
+
* @param {any} [origin] who produced `d`; forwarded verbatim on the emitted `'delta'` event so
|
|
257
|
+
* listeners can recognise (and skip) their own changes — see {@link RDT} “Origins”. Defaults to `null`
|
|
258
|
+
* (an anonymous/local change).
|
|
259
|
+
* @return {delta.DeltaBuilder<DomConf> | null} the rebased local change (`b`), or `null` when there
|
|
260
|
+
* were no concurrent edits
|
|
261
|
+
*/
|
|
262
|
+
applyDelta (d, origin = null) {
|
|
263
|
+
const b = this._pull()
|
|
264
|
+
/** @type {DomDelta} */
|
|
265
|
+
let toApply = d
|
|
266
|
+
/** @type {DomDelta?} */
|
|
267
|
+
let fix = null
|
|
268
|
+
if (!b.isEmpty()) {
|
|
269
|
+
// clone both sides so neither original is mutated by the in-place `rebase`
|
|
270
|
+
toApply = delta.clone(d).rebase(b, true) // d on top of the local DOM (which has b)
|
|
271
|
+
const bOnD = delta.clone(b).rebase(d, false) // b on top of d, for the other side
|
|
272
|
+
fix = bOnD.isEmpty() ? null : bOnD
|
|
273
|
+
}
|
|
274
|
+
applyDeltaToDom(this.observedNode, toApply)
|
|
275
|
+
this._state = domToDelta(this.observedNode)
|
|
276
|
+
// MutationObserver callbacks are async, so the Binding's (synchronous) mutex cannot suppress the
|
|
277
|
+
// echo of our own write — draining the self-caused records here is what prevents it.
|
|
278
|
+
this.observer.takeRecords()
|
|
279
|
+
// Forward the effective change so a chained binding on the other side picks it up; the binding
|
|
280
|
+
// that fed us `d` swallows this re-emit via its own mutex (see ../rdt.js). `fix` is a freshly
|
|
281
|
+
// rebased builder at runtime, returned as the owned change the binding maps on.
|
|
282
|
+
this.emit('delta', [toApply, origin])
|
|
283
|
+
return /** @type {delta.DeltaBuilder<DomConf>?} */ (fix)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The current state as a delta: an "insert everything" delta describing the observed subtree.
|
|
288
|
+
*
|
|
289
|
+
* @return {delta.Delta<DomConf>}
|
|
290
|
+
*/
|
|
291
|
+
get delta () {
|
|
292
|
+
return domToDelta(this.observedNode)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
destroy () {
|
|
296
|
+
this.emit('destroy', [this])
|
|
297
|
+
super.destroy()
|
|
298
|
+
this.observer.disconnect()
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Create a {@link DomRDT} that observes and edits `dom`.
|
|
304
|
+
*
|
|
305
|
+
* @param {Element} dom
|
|
306
|
+
*/
|
|
307
|
+
export const domRDT = dom => new DomRDT(dom)
|