lib0 1.0.0-rc.32 → 1.0.0-rc.33
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.
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* schema) is coalesced into pass-through runs, so a `$deltaAny` schema is a true zero-overhead identity
|
|
11
11
|
* and an incremental edit costs O(change), not O(document).
|
|
12
12
|
*
|
|
13
|
-
* `applyA` (A → B) does the full recursive filtering. `applyB` (B → A)
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* `applyA` (A → B) does the full recursive filtering. `applyB` (B → A) maps a change authored on the
|
|
14
|
+
* conformed view back onto A through the same layout: B positions are remapped across the content A has
|
|
15
|
+
* but B does not (see {@link ConformTransformer#applyB} for the rules), and the change is first validated
|
|
16
|
+
* against `$schema` — any op that would break conformance throws, before any state is touched.
|
|
17
17
|
* Marks are best-effort — marks on kept children/attrs ride through the position map; marks anchored to
|
|
18
18
|
* dropped content are dropped.
|
|
19
19
|
*
|
|
@@ -33,11 +33,12 @@ export class Conform<SchemaConf extends delta.DeltaConf, IN extends delta.DeltaC
|
|
|
33
33
|
/**
|
|
34
34
|
* Stateful, recursive transformer produced by {@link Conform}. See {@link Conform} for semantics.
|
|
35
35
|
*
|
|
36
|
-
* The A
|
|
36
|
+
* The A↔B layout lives in {@link ConformTransformer#cmap} (the *child* map), a delta reused as a
|
|
37
37
|
* coalesced positional map (like `children`'s `childTs`): a `retain(n)` run = pass-through positions, an
|
|
38
38
|
* `insert([t])` = a child routed through nested conform `t`, a `delete(n)` run = dropped positions
|
|
39
|
-
* (width 0 on B).
|
|
40
|
-
* reference so their state survives; a change with only retains/modifies is routed
|
|
39
|
+
* (width 0 on B). Both directions walk it with a {@link Cursor} and edit it in place, carrying the nested
|
|
40
|
+
* transformers by reference so their state survives; a change with only retains/modifies is routed
|
|
41
|
+
* without touching the map.
|
|
41
42
|
*
|
|
42
43
|
* @extends {Transformer<any,any>}
|
|
43
44
|
*/
|
package/package.json
CHANGED
package/src/decoding.js
CHANGED
|
@@ -103,6 +103,9 @@ export const clone = (decoder, newPos = decoder.pos) => {
|
|
|
103
103
|
* @return {Uint8Array<Buf>}
|
|
104
104
|
*/
|
|
105
105
|
export const readUint8Array = (decoder, len) => {
|
|
106
|
+
if (len < 0 || len > decoder.arr.length - decoder.pos) {
|
|
107
|
+
throw errorUnexpectedEndOfArray
|
|
108
|
+
}
|
|
106
109
|
const view = new Uint8Array(decoder.arr.buffer, decoder.pos + decoder.arr.byteOffset, len)
|
|
107
110
|
decoder.pos += len
|
|
108
111
|
return view
|
|
@@ -4,6 +4,17 @@ import * as math from '../../math.js'
|
|
|
4
4
|
import * as error from '../../error.js'
|
|
5
5
|
import { Transformer, Template, createTransformResult } from './core.js'
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* # `conform` — proof of concept
|
|
9
|
+
*
|
|
10
|
+
* **Warning:** the API of this transformer is fully prototyped, but the implementation is a proof of
|
|
11
|
+
* concept with a real performance impact: every change, in both directions, walks the layout (`cmap`, a
|
|
12
|
+
* linked list) from its start to the edit position, so the per-edit cost grows linearly with the number
|
|
13
|
+
* of layout runs before the edit (~200µs per keystroke at ~6000 runs). A more performant implementation
|
|
14
|
+
* is planned once Yjs v14 matures; until then treat this one as the reference for the semantics, not as
|
|
15
|
+
* the final design.
|
|
16
|
+
*/
|
|
17
|
+
|
|
7
18
|
/**
|
|
8
19
|
* The literal node-names a `$Delta`'s `$name` schema accepts, or `null` when the name is loose
|
|
9
20
|
* (`$any`/`$string`) — such a schema acts as a wildcard that matches any node name.
|
|
@@ -121,17 +132,278 @@ const needsTransform = (node, allowed) => {
|
|
|
121
132
|
return allowed.$scalar.check(node) ? true : null
|
|
122
133
|
}
|
|
123
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Whether `node` — new B-side content (an inserted child or a `setAttr` value) — conforms at a position
|
|
137
|
+
* matched by `allowed`: it validates against the schema AND, for a delta node, is routable by
|
|
138
|
+
* {@link needsTransform} (matched by name, by the wildcard, or passed by `$deltaAny`/`$any`). The second
|
|
139
|
+
* half matters for anonymous nodes: `$Delta.check` skips the name test when `name == null`, yet a
|
|
140
|
+
* re-render (`applyA`) would DROP such a node from a name-matched schema — forwarding it to A would
|
|
141
|
+
* desync the sides. Pure (allocates nothing), so the validation pre-pass can call it freely.
|
|
142
|
+
*
|
|
143
|
+
* @param {any} node
|
|
144
|
+
* @param {Allowed} allowed
|
|
145
|
+
* @return {boolean}
|
|
146
|
+
*/
|
|
147
|
+
const conforms = (node, allowed) => allowed.$scalar.check(node) && (!delta.$deltaAny.check(node) || (node.name != null && allowed.byName.has(node.name)) || allowed.wild != null || allowed.passAny)
|
|
148
|
+
|
|
124
149
|
/**
|
|
125
150
|
* Whether `allowed` admits at least one delta value at this position — a `$deltaAny`/`$any` member, a
|
|
126
151
|
* wildcard `$Delta`, or any name-matched `$Delta`. A `modify` / `modifyAttr` op edits a delta child or
|
|
127
152
|
* delta-valued attribute, so it can only conform where this holds; against a scalar/text-only matcher it
|
|
128
|
-
* is necessarily invalid. Used by {@link
|
|
153
|
+
* is necessarily invalid. Used by {@link validateB} to reject such modifies.
|
|
129
154
|
*
|
|
130
155
|
* @param {Allowed} allowed
|
|
131
156
|
* @return {boolean}
|
|
132
157
|
*/
|
|
133
158
|
const admitsDelta = allowed => allowed.passAny || allowed.wild != null || allowed.byName.size > 0
|
|
134
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The nested conform a `modifyAttr` of attribute `key` routes through, or `null` when there is nothing
|
|
162
|
+
* nested to route through: a `$deltaAny`/`$any` attr (`passAny` — the change is forwarded verbatim) or a
|
|
163
|
+
* scalar attr (nothing to modify). When no `setAttr` was seen first, the instance is built lazily from the
|
|
164
|
+
* wildcard or the first by-name sub-schema and cached; it starts with an empty layout, i.e. the attr's
|
|
165
|
+
* current content is unseen and passes through (see {@link Cursor}).
|
|
166
|
+
*
|
|
167
|
+
* @param {ConformTransformer} self
|
|
168
|
+
* @param {string|number} key
|
|
169
|
+
* @param {Allowed} allowed
|
|
170
|
+
* @return {ConformTransformer?}
|
|
171
|
+
*/
|
|
172
|
+
const attrTransformerOf = (self, key, allowed) => {
|
|
173
|
+
let t = self.transformAttrs.get(key)
|
|
174
|
+
if (t === undefined) {
|
|
175
|
+
const $m = allowed.passAny ? null : (allowed.wild ?? (allowed.byName.size > 0 ? /** @type {delta.$Delta<any>} */ (allowed.byName.values().next().value) : null))
|
|
176
|
+
if ($m === null) return null
|
|
177
|
+
t = nest($m)
|
|
178
|
+
self.transformAttrs.set(key, t)
|
|
179
|
+
}
|
|
180
|
+
return t
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A forward cursor over a conform's layout (its {@link ConformTransformer#cmap}) — the list node `src`
|
|
185
|
+
* and the offset `off` inside it — with the in-place splice helpers `applyA` / `applyB` share, so both
|
|
186
|
+
* directions edit the layout with the same code. One cursor is created per change and walked once, in
|
|
187
|
+
* step with the change's ops. Invariant: `src === null || 0 <= off < src.length`, so a split at the
|
|
188
|
+
* cursor ({@link splitCursor}) always has a legal offset.
|
|
189
|
+
*
|
|
190
|
+
* `src === null` is the end of the layout. Positions past it were never seen by the conform (it was not
|
|
191
|
+
* fed the initial render, or it is a lazily built nested attr conform) and pass through in both
|
|
192
|
+
* directions; walking over them appends a pass-through run so the layout stays aligned with A.
|
|
193
|
+
*/
|
|
194
|
+
class Cursor {
|
|
195
|
+
/**
|
|
196
|
+
* @param {delta.DeltaBuilderAny} cmap
|
|
197
|
+
*/
|
|
198
|
+
constructor (cmap) {
|
|
199
|
+
this.cmap = cmap
|
|
200
|
+
/** @type {delta.ChildrenOpAny?} */
|
|
201
|
+
this.src = cmap.children.start
|
|
202
|
+
this.off = 0
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Advance `n` positions inside the cursor's run `src` (`n <= src.length - off`), hopping to the next run
|
|
208
|
+
* at its end.
|
|
209
|
+
*
|
|
210
|
+
* @param {Cursor} c
|
|
211
|
+
* @param {delta.ChildrenOpAny} src
|
|
212
|
+
* @param {number} n
|
|
213
|
+
*/
|
|
214
|
+
const advance = (c, src, n) => {
|
|
215
|
+
c.off += n
|
|
216
|
+
if (c.off >= src.length) { c.src = src.next; c.off = 0 }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Move the cursor onto an op boundary (splitting the run it sits inside) so a fresh op can be inserted there.
|
|
221
|
+
*
|
|
222
|
+
* @param {Cursor} c
|
|
223
|
+
*/
|
|
224
|
+
const splitCursor = c => { if (c.off > 0 && c.src !== null) { c.src = delta._splitChildAt(c.cmap, c.src, c.off); c.off = 0 } }
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Splice one uniform run into the layout at the cursor: grow the current run when it is the same kind
|
|
228
|
+
* (no split), else split + insert a fresh op + coalesce. Same-kind insertion grows the run in place, so
|
|
229
|
+
* the layout stays coalesced — typing into a pass-through run is O(1) and never fragments. The cursor
|
|
230
|
+
* ends up after the new positions. `$kind`/`make` describe a uniform retain/delete run.
|
|
231
|
+
*
|
|
232
|
+
* @param {Cursor} c
|
|
233
|
+
* @param {import('../../schema.js').Schema<any>} $kind
|
|
234
|
+
* @param {number} n
|
|
235
|
+
* @param {() => delta.RetainOp | delta.DeleteOp<any>} make
|
|
236
|
+
*/
|
|
237
|
+
const addUniform = (c, $kind, n, make) => {
|
|
238
|
+
if (c.src !== null && $kind.check(c.src)) { delta._growRun(c.cmap, /** @type {delta.RetainOp | delta.DeleteOp<any>} */ (c.src), n); c.off += n } else { splitCursor(c); delta._mergeChildWithPrev(c.cmap, delta._insertChild(c.cmap, c.src, make())) }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `n` pass-through positions.
|
|
243
|
+
*
|
|
244
|
+
* @param {Cursor} c
|
|
245
|
+
* @param {number} n
|
|
246
|
+
*/
|
|
247
|
+
const addRetain = (c, n) => addUniform(c, delta.$retainOp, n, () => new delta.RetainOp(n, null, null))
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* `n` dropped positions (A-only: they have no B width).
|
|
251
|
+
*
|
|
252
|
+
* @param {Cursor} c
|
|
253
|
+
* @param {number} n
|
|
254
|
+
*/
|
|
255
|
+
const addDrop = (c, n) => addUniform(c, delta.$deleteOp, n, () => new delta.DeleteOp(n))
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One child routed through nested conform `t`.
|
|
259
|
+
*
|
|
260
|
+
* @param {Cursor} c
|
|
261
|
+
* @param {ConformTransformer} t
|
|
262
|
+
*/
|
|
263
|
+
const addTransformed = (c, t) => {
|
|
264
|
+
if (c.src !== null && delta.$insertOp.check(c.src)) { delta._spliceInsert(c.cmap, c.src, c.off, [t]); c.off += 1 } else { splitCursor(c); delta._mergeChildWithPrev(c.cmap, delta._insertChild(c.cmap, c.src, new delta.InsertOp([t], null, null))) }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Remove `take` positions at the cursor from the layout (`take <= src.length - off`, `src` the cursor's
|
|
269
|
+
* run), leaving the cursor on the position after them. Does not coalesce — see {@link mergeAtCursor}.
|
|
270
|
+
*
|
|
271
|
+
* @param {Cursor} c
|
|
272
|
+
* @param {delta.ChildrenOpAny} src
|
|
273
|
+
* @param {number} take
|
|
274
|
+
*/
|
|
275
|
+
const removeAt = (c, src, take) => {
|
|
276
|
+
if (c.off === 0 && take === src.length) { c.src = src.next; delta._removeChild(c.cmap, src) } else { delta._shrinkChild(c.cmap, src, c.off, take); if (c.off >= src.length) { c.src = src.next; c.off = 0 } }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* After a removal, coalesce the run at the cursor into its predecessor when they are the same kind,
|
|
281
|
+
* keeping the cursor on the survivor (inside it, at the old boundary).
|
|
282
|
+
*
|
|
283
|
+
* @param {Cursor} c
|
|
284
|
+
*/
|
|
285
|
+
const mergeAtCursor = c => {
|
|
286
|
+
const src = c.src
|
|
287
|
+
if (src !== null) {
|
|
288
|
+
const prev = src.prev
|
|
289
|
+
const prevLen = prev != null ? prev.length : 0
|
|
290
|
+
if (delta._mergeChildWithPrev(c.cmap, src)) { c.src = prev; c.off += prevLen }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Step the cursor over the hidden runs at its position — A content that was dropped on B (B-width 0) —
|
|
296
|
+
* emitting a plain `retain` over them on the A-side change `out`: a B op never touches hidden content, it
|
|
297
|
+
* only reaches past it. Called at the head of every B op (eager, like `inline`'s `skipEmpty`), so a B
|
|
298
|
+
* insert lands AFTER the hidden content at its position. `src.length - off` because a merge
|
|
299
|
+
* ({@link mergeAtCursor}) can leave the cursor inside a hidden run.
|
|
300
|
+
*
|
|
301
|
+
* @param {Cursor} c
|
|
302
|
+
* @param {delta.DeltaBuilderAny} out
|
|
303
|
+
*/
|
|
304
|
+
const skipHidden = (c, out) => {
|
|
305
|
+
while (c.src !== null && delta.$deleteOp.check(c.src)) { out.retain(c.src.length - c.off); c.src = c.src.next; c.off = 0 }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Map an A-side content offset `k` to its B-side offset through the layout (O(#runs)): hidden runs
|
|
310
|
+
* contribute no B width (an offset inside one collapses to its start on B); past the end is pass-through.
|
|
311
|
+
*
|
|
312
|
+
* @param {delta.DeltaBuilderAny} cmap
|
|
313
|
+
* @param {number} k
|
|
314
|
+
* @return {number}
|
|
315
|
+
*/
|
|
316
|
+
const offsetToB = (cmap, k) => {
|
|
317
|
+
let a = 0
|
|
318
|
+
let b = 0
|
|
319
|
+
for (const cop of cmap.children) {
|
|
320
|
+
if (a >= k) break
|
|
321
|
+
const take = math.min(cop.length, k - a)
|
|
322
|
+
if (!delta.$deleteOp.check(cop)) b += take
|
|
323
|
+
a += take
|
|
324
|
+
}
|
|
325
|
+
return b + (k - a)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Map a B-side content offset `k` to its A-side offset through the layout (O(#runs)): every hidden run up
|
|
330
|
+
* to — and at — the reached point is stepped over, matching where a B op at `k` lands (see
|
|
331
|
+
* {@link skipHidden}); past the end is pass-through.
|
|
332
|
+
*
|
|
333
|
+
* @param {delta.DeltaBuilderAny} cmap
|
|
334
|
+
* @param {number} k
|
|
335
|
+
* @return {number}
|
|
336
|
+
*/
|
|
337
|
+
const offsetToA = (cmap, k) => {
|
|
338
|
+
let a = 0
|
|
339
|
+
let b = 0
|
|
340
|
+
for (const cop of cmap.children) {
|
|
341
|
+
if (delta.$deleteOp.check(cop)) { a += cop.length; continue }
|
|
342
|
+
if (b >= k) break
|
|
343
|
+
const take = math.min(cop.length, k - b)
|
|
344
|
+
a += take
|
|
345
|
+
b += take
|
|
346
|
+
}
|
|
347
|
+
return a + (k - b)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Validate a B-side change against the schema of conform `t` **without touching any state** — the
|
|
352
|
+
* throw-before-mutate contract of {@link ConformTransformer#applyB}: a rejected change leaves the
|
|
353
|
+
* transformer (and its nested conforms) exactly as it was, so the caller can drop the change and keep
|
|
354
|
+
* using it. Throws on an unknown attribute key, a `setAttr` value / inserted child that does not
|
|
355
|
+
* {@link conforms conform}, text where the schema forbids it, a `modify` / `modifyAttr` where the schema
|
|
356
|
+
* has no delta, and — recursively, walking `t.cmap` read-only to the nested conform a `modify` lands on
|
|
357
|
+
* — the same inside a modified child / attribute. `retain` / `delete` are structural and always conform.
|
|
358
|
+
*
|
|
359
|
+
* @param {ConformTransformer} t
|
|
360
|
+
* @param {delta.DeltaAny} dB
|
|
361
|
+
*/
|
|
362
|
+
const validateB = (t, dB) => {
|
|
363
|
+
const cfg = t.config // never a pass-through config: applyB returns before validating, and a nested conform is always bound to a concrete $Delta
|
|
364
|
+
if (!cfg.attrsLoose) { // loose attrs ($any) accept any key/value — nothing to validate
|
|
365
|
+
for (const op of dB.attrs) {
|
|
366
|
+
const allowed = cfg.attrAllowed.get(op.key)
|
|
367
|
+
if (allowed === undefined) throw error.create('[lib0/delta] conform: unknown attribute "' + op.key + '"')
|
|
368
|
+
if (delta.$setAttrOp.check(op)) {
|
|
369
|
+
if (!conforms(op.value, allowed)) throw error.create('[lib0/delta] conform: attribute "' + op.key + '" value does not conform to the schema')
|
|
370
|
+
} else if (delta.$modifyAttrOp.check(op)) {
|
|
371
|
+
if (!admitsDelta(allowed)) throw error.create('[lib0/delta] conform: attribute "' + op.key + '" is a scalar and cannot be modified')
|
|
372
|
+
const nt = attrTransformerOf(t, op.key, allowed)
|
|
373
|
+
if (nt !== null) validateB(nt, op.value)
|
|
374
|
+
}
|
|
375
|
+
// deleteAttr: removing an attribute always conforms
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// retain / delete / modify consume B positions; only a modify needs the run it lands on (to validate
|
|
379
|
+
// against that child's nested conform), so the read-only cursor over the layout is advanced lazily —
|
|
380
|
+
// a change without modifies (typing) never walks the layout here
|
|
381
|
+
let src = t.cmap.children.start
|
|
382
|
+
let off = 0
|
|
383
|
+
let pending = 0 // B positions consumed since the cursor was last advanced
|
|
384
|
+
for (const op of dB.children) {
|
|
385
|
+
if (delta.$textOp.check(op)) {
|
|
386
|
+
if (!cfg.hasText) throw error.create('[lib0/delta] conform: text is not allowed by the schema')
|
|
387
|
+
} else if (delta.$insertOp.check(op)) {
|
|
388
|
+
for (const el of op.insert) if (!conforms(el, cfg.childAllowed)) throw error.create('[lib0/delta] conform: inserted content does not conform to the schema')
|
|
389
|
+
} else {
|
|
390
|
+
if (delta.$modifyOp.check(op)) {
|
|
391
|
+
if (!admitsDelta(cfg.childAllowed)) throw error.create('[lib0/delta] conform: the schema has no delta child to modify')
|
|
392
|
+
while (src !== null) { // advance by `pending`, stepping over hidden runs (no B width), onto the run the modify lands on
|
|
393
|
+
if (delta.$deleteOp.check(src)) { src = src.next; off = 0; continue }
|
|
394
|
+
if (pending === 0) break
|
|
395
|
+
const take = math.min(src.length - off, pending)
|
|
396
|
+
off += take
|
|
397
|
+
pending -= take
|
|
398
|
+
if (off >= src.length) { src = src.next; off = 0 }
|
|
399
|
+
}
|
|
400
|
+
if (src !== null && delta.$insertOp.check(src)) validateB(src.insert[off], op.value)
|
|
401
|
+
}
|
|
402
|
+
pending += op.length
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
135
407
|
/**
|
|
136
408
|
* Makes the projected (side-B) delta conform to `$schema`, **recursively**: drops attributes, attribute
|
|
137
409
|
* values, child nodes, and text the schema does not recognize, and descends into kept delta-valued
|
|
@@ -144,10 +416,10 @@ const admitsDelta = allowed => allowed.passAny || allowed.wild != null || allowe
|
|
|
144
416
|
* schema) is coalesced into pass-through runs, so a `$deltaAny` schema is a true zero-overhead identity
|
|
145
417
|
* and an incremental edit costs O(change), not O(document).
|
|
146
418
|
*
|
|
147
|
-
* `applyA` (A → B) does the full recursive filtering. `applyB` (B → A)
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
419
|
+
* `applyA` (A → B) does the full recursive filtering. `applyB` (B → A) maps a change authored on the
|
|
420
|
+
* conformed view back onto A through the same layout: B positions are remapped across the content A has
|
|
421
|
+
* but B does not (see {@link ConformTransformer#applyB} for the rules), and the change is first validated
|
|
422
|
+
* against `$schema` — any op that would break conformance throws, before any state is touched.
|
|
151
423
|
* Marks are best-effort — marks on kept children/attrs ride through the position map; marks anchored to
|
|
152
424
|
* dropped content are dropped.
|
|
153
425
|
*
|
|
@@ -181,11 +453,12 @@ export class Conform extends Template {
|
|
|
181
453
|
/**
|
|
182
454
|
* Stateful, recursive transformer produced by {@link Conform}. See {@link Conform} for semantics.
|
|
183
455
|
*
|
|
184
|
-
* The A
|
|
456
|
+
* The A↔B layout lives in {@link ConformTransformer#cmap} (the *child* map), a delta reused as a
|
|
185
457
|
* coalesced positional map (like `children`'s `childTs`): a `retain(n)` run = pass-through positions, an
|
|
186
458
|
* `insert([t])` = a child routed through nested conform `t`, a `delete(n)` run = dropped positions
|
|
187
|
-
* (width 0 on B).
|
|
188
|
-
* reference so their state survives; a change with only retains/modifies is routed
|
|
459
|
+
* (width 0 on B). Both directions walk it with a {@link Cursor} and edit it in place, carrying the nested
|
|
460
|
+
* transformers by reference so their state survives; a change with only retains/modifies is routed
|
|
461
|
+
* without touching the map.
|
|
189
462
|
*
|
|
190
463
|
* @extends {Transformer<any,any>}
|
|
191
464
|
*/
|
|
@@ -215,8 +488,6 @@ export class ConformTransformer extends Transformer {
|
|
|
215
488
|
const cfg = this.config
|
|
216
489
|
if (cfg.passthrough) return createTransformResult(null, dA) // identity fast-path, no walk, no copy
|
|
217
490
|
const out = /** @type {any} */ (delta.create(/** @type {any} */ (dA.name)))
|
|
218
|
-
/** @type {Set<string|number>} */
|
|
219
|
-
const keptAttrKeys = new Set()
|
|
220
491
|
// --- attributes ---
|
|
221
492
|
for (const op of dA.attrs) {
|
|
222
493
|
const key = op.key
|
|
@@ -224,7 +495,6 @@ export class ConformTransformer extends Transformer {
|
|
|
224
495
|
// loose schema attrs ($any): keep every attribute verbatim (`out` is `any`, so the dynamic-key
|
|
225
496
|
// write is fine; this mirrors how `slice`/`cloneShallow` copy attr ops)
|
|
226
497
|
out.attrs[key] = op.clone()
|
|
227
|
-
keptAttrKeys.add(key)
|
|
228
498
|
continue
|
|
229
499
|
}
|
|
230
500
|
const allowed = cfg.attrAllowed.get(key)
|
|
@@ -233,124 +503,90 @@ export class ConformTransformer extends Transformer {
|
|
|
233
503
|
const r = needsTransform(op.value, allowed)
|
|
234
504
|
if (r === null) continue
|
|
235
505
|
if (r === true) { out.setAttr(key, op.value, op.attribution); this.transformAttrs.delete(key) } else { this.transformAttrs.set(key, r); out.setAttr(key, r.applyA(op.value).b, op.attribution) }
|
|
236
|
-
keptAttrKeys.add(key)
|
|
237
506
|
} else if (delta.$modifyAttrOp.check(op)) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (allowed.passAny) { out.modifyAttr(key, op.value); keptAttrKeys.add(key); continue }
|
|
243
|
-
const $m = allowed.wild ?? (allowed.byName.size > 0 ? /** @type {delta.$Delta<any>} */ (allowed.byName.values().next().value) : null)
|
|
244
|
-
if ($m != null) { t = nest($m); this.transformAttrs.set(key, t) }
|
|
245
|
-
}
|
|
246
|
-
if (t != null) { out.modifyAttr(key, t.applyA(op.value).b); keptAttrKeys.add(key) }
|
|
507
|
+
const t = attrTransformerOf(this, key, allowed)
|
|
508
|
+
// no nested conform: a $deltaAny attr forwards verbatim; a scalar attr has nothing to modify (drop)
|
|
509
|
+
if (t !== null) out.modifyAttr(key, t.applyA(op.value).b, op.attribution)
|
|
510
|
+
else if (allowed.passAny) out.modifyAttr(key, op.value, op.attribution)
|
|
247
511
|
} else { // deleteAttr (the only remaining attr-op kind)
|
|
248
512
|
const dop = /** @type {delta.DeleteAttrOp<any>} */ (op)
|
|
249
513
|
out.deleteAttr(key, dop.attribution)
|
|
250
514
|
this.transformAttrs.delete(key)
|
|
251
|
-
keptAttrKeys.add(key)
|
|
252
515
|
}
|
|
253
516
|
}
|
|
254
|
-
// --- children: one cursor
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
// stays coalesced — typing into a pass-through run is O(1) and never fragments. The untouched tail
|
|
260
|
-
// past the cursor is simply left in place (no copy), so a structural edit costs O(change).
|
|
261
|
-
const cmap = this.cmap
|
|
262
|
-
let src = cmap.children.start
|
|
263
|
-
let off = 0
|
|
264
|
-
// move the cursor onto an op boundary so a fresh op can be inserted there
|
|
265
|
-
const splitCursor = () => { if (off > 0 && src != null) { src = delta._splitChildAt(cmap, src, off); off = 0 } }
|
|
266
|
-
// splice one run into the map at the cursor: grow the current op when it is the same kind (no split),
|
|
267
|
-
// else split + insert a fresh op + coalesce. `$kind`/`make` describe a uniform retain/delete run.
|
|
268
|
-
/**
|
|
269
|
-
* @param {import('../../schema.js').Schema<any>} $kind
|
|
270
|
-
* @param {number} n
|
|
271
|
-
* @param {() => delta.RetainOp | delta.DeleteOp<any>} make
|
|
272
|
-
*/
|
|
273
|
-
const addUniform = ($kind, n, make) => {
|
|
274
|
-
if (src != null && $kind.check(src)) { delta._growRun(cmap, /** @type {delta.RetainOp | delta.DeleteOp<any>} */ (src), n); off += n } else { splitCursor(); delta._mergeChildWithPrev(cmap, delta._insertChild(cmap, src, make())) }
|
|
275
|
-
}
|
|
276
|
-
/** @param {number} n */
|
|
277
|
-
const addRetain = n => addUniform(delta.$retainOp, n, () => new delta.RetainOp(n, null, null)) // n pass-through positions
|
|
278
|
-
/** @param {number} n */
|
|
279
|
-
const addDrop = n => addUniform(delta.$deleteOp, n, () => new delta.DeleteOp(n)) // n dropped positions
|
|
280
|
-
/** @param {ConformTransformer} t */
|
|
281
|
-
const addTransformed = t => { // one child routed through nested conform `t`
|
|
282
|
-
if (src != null && delta.$insertOp.check(src)) { delta._spliceInsert(cmap, src, off, [t]); off += 1 } else { splitCursor(); delta._mergeChildWithPrev(cmap, delta._insertChild(cmap, src, new delta.InsertOp([t], null, null))) }
|
|
283
|
-
}
|
|
517
|
+
// --- children: one cursor walks `this.cmap`, editing it in place ---
|
|
518
|
+
// retain/modify advance the cursor without touching the map; insert/text/delete edit it in place via
|
|
519
|
+
// delta's @internal child mutators (which keep `cmap.childCnt` in sync). The untouched tail past the
|
|
520
|
+
// cursor is simply left in place (no copy), so a structural edit costs O(change).
|
|
521
|
+
const c = new Cursor(this.cmap)
|
|
284
522
|
for (const op of dA.children) {
|
|
285
523
|
if (delta.$retainOp.check(op)) {
|
|
286
524
|
let rem = op.retain
|
|
287
|
-
while (rem > 0 && src
|
|
288
|
-
const
|
|
525
|
+
while (rem > 0 && c.src !== null) {
|
|
526
|
+
const src = c.src
|
|
527
|
+
const take = math.min(src.length - c.off, rem)
|
|
289
528
|
if (!delta.$deleteOp.check(src)) out.retain(take, op.format, op.attribution) // pass & transform: width = take
|
|
290
|
-
|
|
291
|
-
|
|
529
|
+
advance(c, src, take)
|
|
530
|
+
rem -= take
|
|
292
531
|
}
|
|
532
|
+
if (rem > 0) { out.retain(rem, op.format, op.attribution); addRetain(c, rem) } // past the layout: unseen content passes through
|
|
293
533
|
} else if (delta.$modifyOp.check(op)) {
|
|
294
|
-
|
|
295
|
-
|
|
534
|
+
const src = c.src
|
|
535
|
+
if (src === null) { out.modify(delta.clone(op.value), op.format, op.attribution); addRetain(c, 1) } else { // past the layout: unseen child passes through
|
|
536
|
+
if (delta.$insertOp.check(src)) out.modify(/** @type {ConformTransformer} */ (src.insert[c.off]).applyA(delta.clone(op.value)).b, op.format, op.attribution)
|
|
296
537
|
else if (delta.$retainOp.check(src)) out.modify(delta.clone(op.value), op.format, op.attribution) // pass-through child
|
|
297
538
|
// dropped child (delete in map): emit nothing
|
|
298
|
-
|
|
539
|
+
advance(c, src, 1)
|
|
299
540
|
}
|
|
300
541
|
} else if (delta.$textOp.check(op)) {
|
|
301
|
-
if (cfg.hasText) { out.insert(op.insert, op.format, op.attribution); addRetain(op.insert.length) } else addDrop(op.insert.length)
|
|
542
|
+
if (cfg.hasText) { out.insert(op.insert, op.format, op.attribution); addRetain(c, op.insert.length) } else addDrop(c, op.insert.length)
|
|
302
543
|
} else if (delta.$insertOp.check(op)) {
|
|
303
544
|
for (const el of op.insert) {
|
|
304
545
|
const r = needsTransform(el, cfg.childAllowed)
|
|
305
|
-
if (r === null) addDrop(1) // dropped: no name match, no scalar match
|
|
306
|
-
else if (r === true) { out.insert([el], op.format, op.attribution); addRetain(1) } else { out.insert([r.applyA(el).b], op.format, op.attribution); addTransformed(r) }
|
|
546
|
+
if (r === null) addDrop(c, 1) // dropped: no name match, no scalar match
|
|
547
|
+
else if (r === true) { out.insert([el], op.format, op.attribution); addRetain(c, 1) } else { out.insert([r.applyA(el).b], op.format, op.attribution); addTransformed(c, r) }
|
|
307
548
|
}
|
|
308
549
|
} else { // delete: remove the deleted A-positions from the map
|
|
309
550
|
let rem = /** @type {delta.DeleteOp<any>} */ (op).delete
|
|
310
|
-
while (rem > 0 && src
|
|
311
|
-
const
|
|
551
|
+
while (rem > 0 && c.src !== null) {
|
|
552
|
+
const src = c.src
|
|
553
|
+
const take = math.min(src.length - c.off, rem)
|
|
312
554
|
if (!delta.$deleteOp.check(src)) out.delete(take) // pass & transform had B-width; dropped had 0
|
|
313
|
-
|
|
555
|
+
removeAt(c, src, take)
|
|
314
556
|
rem -= take
|
|
315
557
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const prev = src.prev
|
|
319
|
-
const prevLen = prev != null ? prev.length : 0
|
|
320
|
-
if (delta._mergeChildWithPrev(cmap, src)) { src = prev; off += prevLen }
|
|
321
|
-
}
|
|
558
|
+
if (rem > 0) out.delete(rem) // past the layout: unseen content passes through
|
|
559
|
+
mergeAtCursor(c) // the deletion may have joined two same-kind runs
|
|
322
560
|
}
|
|
323
561
|
}
|
|
324
|
-
// --- marks (best-effort):
|
|
562
|
+
// --- marks (best-effort): an attr mark rides iff the schema knows the attr; a content mark maps through the layout ---
|
|
325
563
|
if (dA.marks !== null || dA.deleteMarks !== null) {
|
|
326
|
-
delta.mergeRootMarks(out, dA, k =>
|
|
327
|
-
if (typeof k !== 'number') return keptAttrKeys.has(k) ? k : null // attr mark rides iff kept
|
|
328
|
-
let a = 0; let b = 0
|
|
329
|
-
for (const cop of this.cmap.children) {
|
|
330
|
-
if (a >= k) break
|
|
331
|
-
const take = math.min(cop.length, k - a)
|
|
332
|
-
if (!delta.$deleteOp.check(cop)) b += take // pass & transform keep width; drop contributes 0
|
|
333
|
-
a += take
|
|
334
|
-
}
|
|
335
|
-
return b
|
|
336
|
-
})
|
|
564
|
+
delta.mergeRootMarks(out, dA, k => typeof k !== 'number' ? (cfg.attrsLoose || cfg.attrAllowed.has(k) ? k : null) : offsetToB(this.cmap, k))
|
|
337
565
|
}
|
|
338
566
|
out.done(false)
|
|
339
567
|
return createTransformResult(null, out)
|
|
340
568
|
}
|
|
341
569
|
|
|
342
570
|
/**
|
|
343
|
-
* Map a B-side change back to A. The conformed view B
|
|
344
|
-
* authored on
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
571
|
+
* Map a B-side change back to A. The conformed view B is A minus the content the schema rejects, so a
|
|
572
|
+
* change authored on B is valid on A once its positions are remapped across the hidden content —
|
|
573
|
+
* which is what the layout ({@link ConformTransformer#cmap}) records. The result is `{ a: out, b: null }`
|
|
574
|
+
* (mirroring `applyA`'s `{ a: null, b: out }`). Rules, matching `inline`'s handling of zero-width content:
|
|
575
|
+
* - hidden A content (dropped on B) is stepped over eagerly at the head of every B op with a plain
|
|
576
|
+
* `retain` on A: a B insert lands *after* the hidden content at its position, a B `retain`'s
|
|
577
|
+
* format/attribution never touches it, and a B `delete` spanning it keeps it (the view only deletes
|
|
578
|
+
* what it can see);
|
|
579
|
+
* - a `modify` of a kept child / `modifyAttr` of a kept delta attribute routes through that child's
|
|
580
|
+
* nested conform (recursively remapped); a pass-through child forwards verbatim;
|
|
581
|
+
* - B-inserted content is recorded in the layout (a node matched to a narrower `$Delta` gets a nested
|
|
582
|
+
* conform, warmed on the node), so later edits on either side route correctly;
|
|
583
|
+
* - positions past the end of the layout (content the conform never saw) pass through.
|
|
349
584
|
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
585
|
+
* The change is validated first (see {@link validateB}) and **throws** before any state is touched on
|
|
586
|
+
* any op that would break conformance: text where the schema forbids text, an `insert` / `setAttr`
|
|
587
|
+
* whose content fails the schema (or an anonymous node where the schema names its children), an
|
|
588
|
+
* unknown attribute key, a `modify` / `modifyAttr` targeting a position the schema gives no delta —
|
|
589
|
+
* recursively inside modified children / attributes.
|
|
354
590
|
*
|
|
355
591
|
* @param {delta.DeltaBuilderAny} dB
|
|
356
592
|
* @return {import('./core.js').TransformResultAny}
|
|
@@ -358,29 +594,81 @@ export class ConformTransformer extends Transformer {
|
|
|
358
594
|
applyB (dB) {
|
|
359
595
|
const cfg = this.config
|
|
360
596
|
if (cfg.passthrough) return createTransformResult(dB, null) // identity: every op conforms
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
//
|
|
597
|
+
validateB(this, dB)
|
|
598
|
+
const out = /** @type {any} */ (delta.create(/** @type {any} */ (dB.name)))
|
|
599
|
+
// --- attributes: forwarded verbatim (B's attrs are A's attrs); the nested conforms are kept in step ---
|
|
600
|
+
for (const op of dB.attrs) {
|
|
601
|
+
const key = op.key
|
|
602
|
+
if (cfg.attrsLoose) { out.attrs[key] = op.clone(); continue } // loose schema attrs ($any): verbatim
|
|
603
|
+
if (delta.$setAttrOp.check(op)) {
|
|
604
|
+
// reason: validateB rejected every value needsTransform maps to null
|
|
605
|
+
const r = /** @type {ConformTransformer | true} */ (needsTransform(op.value, /** @type {Allowed} */ (cfg.attrAllowed.get(key))))
|
|
606
|
+
// a narrowed delta value gets a fresh nested conform, warmed on the value so its layout describes
|
|
607
|
+
// it (the output is discarded — the value conforms, so it is the value itself; `applyA` reads its
|
|
608
|
+
// input without mutating it, so a frozen value is fine here)
|
|
609
|
+
if (r === true) this.transformAttrs.delete(key); else { r.applyA(op.value); this.transformAttrs.set(key, r) }
|
|
610
|
+
out.setAttr(key, op.value, op.attribution)
|
|
611
|
+
} else if (delta.$modifyAttrOp.check(op)) {
|
|
612
|
+
// reason: validateB rejected unknown keys
|
|
613
|
+
const t = attrTransformerOf(this, key, /** @type {Allowed} */ (cfg.attrAllowed.get(key)))
|
|
614
|
+
out.modifyAttr(key, t !== null ? t.applyB(op.value).a : op.value, op.attribution) // no nested conform ($deltaAny attr): verbatim
|
|
615
|
+
} else { // deleteAttr
|
|
616
|
+
out.deleteAttr(key, /** @type {delta.DeleteAttrOp<any>} */ (op).attribution)
|
|
617
|
+
this.transformAttrs.delete(key)
|
|
371
618
|
}
|
|
372
619
|
}
|
|
620
|
+
// --- children: one cursor walks `this.cmap`; hidden runs are stepped over at the head of every op ---
|
|
621
|
+
const c = new Cursor(this.cmap)
|
|
373
622
|
for (const op of dB.children) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
623
|
+
skipHidden(c, out)
|
|
624
|
+
if (delta.$retainOp.check(op)) {
|
|
625
|
+
let rem = op.retain
|
|
626
|
+
while (rem > 0 && c.src !== null) {
|
|
627
|
+
const src = c.src
|
|
628
|
+
const take = math.min(src.length - c.off, rem)
|
|
629
|
+
out.retain(take, op.format, op.attribution)
|
|
630
|
+
advance(c, src, take)
|
|
631
|
+
rem -= take
|
|
632
|
+
skipHidden(c, out)
|
|
633
|
+
}
|
|
634
|
+
if (rem > 0) { out.retain(rem, op.format, op.attribution); addRetain(c, rem) } // past the layout: unseen content passes through
|
|
635
|
+
} else if (delta.$deleteOp.check(op)) {
|
|
636
|
+
let rem = op.delete
|
|
637
|
+
while (rem > 0 && c.src !== null) {
|
|
638
|
+
const src = c.src
|
|
639
|
+
const take = math.min(src.length - c.off, rem)
|
|
640
|
+
out.delete(take)
|
|
641
|
+
removeAt(c, src, take)
|
|
642
|
+
rem -= take
|
|
643
|
+
mergeAtCursor(c) // a hidden run kept inside the range can now sit between two removed runs: re-coalesce after each removal
|
|
644
|
+
skipHidden(c, out)
|
|
645
|
+
}
|
|
646
|
+
if (rem > 0) out.delete(rem) // past the layout: unseen content passes through
|
|
378
647
|
} else if (delta.$modifyOp.check(op)) {
|
|
379
|
-
|
|
648
|
+
const src = c.src
|
|
649
|
+
if (src === null) { out.modify(delta.clone(op.value), op.format, op.attribution); addRetain(c, 1) } else { // past the layout: unseen child passes through
|
|
650
|
+
// a transformed child routes through its nested conform; a pass-through child forwards verbatim
|
|
651
|
+
out.modify(delta.$insertOp.check(src) ? /** @type {ConformTransformer} */ (src.insert[c.off]).applyB(delta.clone(op.value)).a : delta.clone(op.value), op.format, op.attribution)
|
|
652
|
+
advance(c, src, 1)
|
|
653
|
+
}
|
|
654
|
+
} else if (delta.$textOp.check(op)) {
|
|
655
|
+
out.insert(op.insert, op.format, op.attribution)
|
|
656
|
+
addRetain(c, op.insert.length)
|
|
657
|
+
} else { // insert: every element conforms (validated); record each in the layout, then forward verbatim
|
|
658
|
+
for (const el of /** @type {delta.InsertOp<any>} */ (op).insert) {
|
|
659
|
+
// reason: validateB rejected every child needsTransform maps to null
|
|
660
|
+
const r = /** @type {ConformTransformer | true} */ (needsTransform(el, cfg.childAllowed))
|
|
661
|
+
if (r === true) addRetain(c, 1); else { r.applyA(el); addTransformed(c, r) } // warm the nested conform on the node (see setAttr above)
|
|
662
|
+
}
|
|
663
|
+
out.insert(/** @type {delta.InsertOp<any>} */ (op).insert, op.format, op.attribution)
|
|
380
664
|
}
|
|
381
|
-
// retain / delete: structural, no content -> always conform
|
|
382
665
|
}
|
|
383
|
-
|
|
666
|
+
// --- marks (best-effort): an attr mark rides verbatim (B's attrs are A's attrs); a content mark maps through the layout ---
|
|
667
|
+
if (dB.marks !== null || dB.deleteMarks !== null) {
|
|
668
|
+
delta.mergeRootMarks(out, dB, k => typeof k !== 'number' ? k : offsetToA(this.cmap, k))
|
|
669
|
+
}
|
|
670
|
+
out.done(false)
|
|
671
|
+
return createTransformResult(out, null)
|
|
384
672
|
}
|
|
385
673
|
}
|
|
386
674
|
|
|
@@ -389,8 +677,9 @@ export class ConformTransformer extends Transformer {
|
|
|
389
677
|
* child node, and text run the schema does not recognize and descending into kept delta children /
|
|
390
678
|
* delta-valued attributes. Content the schema accepts passes through with near-zero overhead;
|
|
391
679
|
* `conform($d, delta.$deltaAny)` is the identity. Returns a reusable {@link Conform} template (a
|
|
392
|
-
* `project` hole, or `.init()` for a standalone transformer). `applyB` (B → A)
|
|
393
|
-
*
|
|
680
|
+
* `project` hole, or `.init()` for a standalone transformer). `applyB` (B → A) maps a change authored on
|
|
681
|
+
* the conformed view back onto A, remapping its positions across the content the schema hid; it validates
|
|
682
|
+
* the change against `$schema` first and throws on any non-conformant op, before touching any state.
|
|
394
683
|
*
|
|
395
684
|
* @template {delta.DeltaConf} IN
|
|
396
685
|
* @template {delta.DeltaConf} SchemaConf
|