@quillmark/wasm 0.92.1 → 0.95.1

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.
@@ -52,7 +52,29 @@
52
52
  // (it is) so it tolerates handles from any core instance. The `runtime.test.js`
53
53
  // "re-exports the internal core build classes verbatim" case
54
54
  // (`Quill === CoreQuill`) is the executable guard for this invariant.
55
- export { Quill, Document, init } from '../core/wasm.js';
55
+ //
56
+ // Imported (not bare re-exported) so `Quill` is a local binding this module can
57
+ // augment — `quill.writer(doc)` is patched onto its prototype below. The
58
+ // re-export keeps the identity: the exported `Quill` IS the core class.
59
+ import { Quill, Document, init } from '../core/wasm.js';
60
+ export { Quill, Document, init };
61
+ // The document-free content codec — re-exported verbatim from the core build so
62
+ // the runtime subpath exposes `exportMarkdown(body)` (the on-demand markdown
63
+ // projection), `importMarkdown`, and the position-mapping pair (`rebase`,
64
+ // `mapPos`).
65
+ export { importMarkdown, exportMarkdown, rebase, mapPos } from '../core/wasm.js';
66
+
67
+ // ── The main-card address ───────────────────────────────────────────────────
68
+ /**
69
+ * The main card's address — the default target of the card-scoped verbs
70
+ * (`storeFields` / `storeExt` / `commitFields` / …). A named, `CardAddr`-typed
71
+ * alias for the empty address `{}`, so a main-card write names its target:
72
+ * `doc.storeFields(MAIN_CARD_ADDR, fields)`. It IS `{}` (frozen), a pure alias —
73
+ * `{}` and `undefined` stay equally valid. Card axis only: a card selector,
74
+ * never a field address.
75
+ * @type {import('../core/wasm.js').CardAddr}
76
+ */
77
+ export const MAIN_CARD_ADDR = Object.freeze({});
56
78
 
57
79
  /**
58
80
  * Narrow an unknown caught value to a `QuillmarkError` — the error every
@@ -79,15 +101,22 @@ export function isQuillmarkError(e) {
79
101
  // and `formats`/`canvas` are the REQUIRED static capability manifest so the
80
102
  // cheap probes (`supportedFormats`/`supportsCanvas`) ALWAYS answer without
81
103
  // loading the binary or cloning the quill. The manifest values are verified
82
- // against the backend's Rust source (`crates/backends/typst/src/lib.rs`
83
- // `SUPPORTED_FORMATS` and `supports_canvas`) and pinned by the `runtime.test.js`
84
- // drift-guard test, which renders once and asserts the loaded backend reports
85
- // the same list.
104
+ // against each backend's Rust source (`crates/backends/<id>/src/lib.rs`
105
+ // `SUPPORTED_FORMATS`) and pinned by the `runtime.test.js` drift-guard test,
106
+ // which renders once and asserts the loaded backend reports the same list.
107
+ // `canvas` mirrors `quillmark_core::formats_support_canvas`: true iff the
108
+ // format list includes a visual-page format (`svg` or `png`).
86
109
  const DEFAULT_BACKENDS = {
87
110
  typst: {
88
111
  load: () => import('../backends/typst/wasm.js'),
89
112
  formats: ['pdf', 'svg', 'png'], // crates/backends/typst/src/lib.rs SUPPORTED_FORMATS
90
- canvas: true // crates/backends/typst/src/lib.rs supports_canvas() == true
113
+ canvas: true // has svg/png formats_support_canvas == true
114
+ },
115
+ pdfform: {
116
+ load: () => import('../backends/pdfform/wasm.js'),
117
+ // crates/backends/pdfform/src/lib.rs SUPPORTED_FORMATS == [Pdf, Svg, Png]
118
+ formats: ['pdf', 'svg', 'png'],
119
+ canvas: true // has svg/png → formats_support_canvas == true
91
120
  }
92
121
  };
93
122
 
@@ -210,16 +239,18 @@ export class Engine {
210
239
 
211
240
  /**
212
241
  * Get (or materialize-and-cache) the backend-memory `Quill` clone for
213
- * `quill` under `backendId`. On a cache miss the clone is built via
214
- * `toTree`→`fromTree` and stored in the per-backend `WeakMap` keyed on the
242
+ * `quill` under `backendId`. On a cache miss the clone is built from `tree`
243
+ * — the caller's pre-await `toTree()` snapshot; the canonical handle may be
244
+ * freed by now — and stored in the per-backend `WeakMap` keyed on the
215
245
  * canonical `Quill` instance, so a later call with the same instance reuses
216
246
  * it (the hot-path fix: no re-serialize / re-copy / re-validate per call).
217
247
  * @param {any} mod the backend build module
218
248
  * @param {string} backendId
219
- * @param {{ toTree(): Map<string, Uint8Array> }} quill
249
+ * @param {object} quill the canonical instance (cache key only)
250
+ * @param {Map<string, Uint8Array> | null} tree pre-await snapshot; `null` on a cache hit
220
251
  * @returns {any} the backend-memory quill clone
221
252
  */
222
- #cachedQuillClone(mod, backendId, quill) {
253
+ #cachedQuillClone(mod, backendId, quill, tree) {
223
254
  let perQuill = this.#quillClones.get(backendId);
224
255
  if (!perQuill) {
225
256
  perQuill = new WeakMap();
@@ -227,7 +258,7 @@ export class Engine {
227
258
  }
228
259
  let backendQuill = perQuill.get(quill);
229
260
  if (!backendQuill) {
230
- backendQuill = mod.Quill.fromTree(quill.toTree());
261
+ backendQuill = mod.Quill.fromTree(tree);
231
262
  perQuill.set(quill, backendQuill);
232
263
  }
233
264
  return backendQuill;
@@ -238,6 +269,13 @@ export class Engine {
238
269
  * memory and run `fn` against the backend engine. Only `render`/`open` call
239
270
  * this, so `doc` is always present.
240
271
  *
272
+ * OWNERSHIP WINDOW: both caller handles are snapshotted (`doc.toJson()`, and
273
+ * `quill.toTree()` on a clone-cache miss) BEFORE the first await. The backend
274
+ * load below is a real suspension point — a multi-MB `import()` on first
275
+ * render — so reading the handles after it would race a caller that
276
+ * `free()`s them as soon as this call returns its promise ("null pointer
277
+ * passed to rust"). The snapshot makes that natural calling pattern correct.
278
+ *
241
279
  * Clone lifetimes differ by design: the `doc` clone is TRANSIENT — freed in
242
280
  * the `finally` of every call. The `quill` clone is CACHED per (engine,
243
281
  * backend, canonical quill instance) and is NOT freed here; a `Quill`
@@ -251,6 +289,8 @@ export class Engine {
251
289
  * @param {(ctx: { mod: any, engine: any, quill: any, doc: any }) => any} fn
252
290
  */
253
291
  async #withClones(backendId, quill, doc, fn) {
292
+ const docJson = doc.toJson();
293
+ const quillTree = this.#quillClones.get(backendId)?.has(quill) ? null : quill.toTree();
254
294
  const { mod, engine } = await this.#resolveBackend(backendId);
255
295
  // The quill clone is cached (see #cachedQuillClone); only the per-call doc
256
296
  // clone is transient. Bring the doc clone + `fn` under one try so the doc
@@ -258,10 +298,10 @@ export class Engine {
258
298
  // rejecting a cross-version DTO). The cached quill clone is intentionally
259
299
  // NOT freed here. `fn` MUST be synchronous — the doc clone is freed as soon
260
300
  // as it returns, so an async `fn` would have it freed mid-flight.
261
- const backendQuill = this.#cachedQuillClone(mod, backendId, quill);
301
+ const backendQuill = this.#cachedQuillClone(mod, backendId, quill, quillTree);
262
302
  let backendDoc = null;
263
303
  try {
264
- backendDoc = mod.Document.fromJson(doc.toJson());
304
+ backendDoc = mod.Document.fromJson(docJson);
265
305
  return fn({ mod, engine, quill: backendQuill, doc: backendDoc });
266
306
  } finally {
267
307
  backendDoc?.free();
@@ -270,6 +310,8 @@ export class Engine {
270
310
 
271
311
  /**
272
312
  * Render `doc` against `quill` in one shot, returning a `RenderResult`.
313
+ * Both handles are read synchronously before the first await, so the caller
314
+ * may `free()` them as soon as this call returns.
273
315
  * @param {Quill} quill
274
316
  * @param {Document} doc
275
317
  * @param {object} [options] render options (`{ format, ppi, pages, producer }`)
@@ -282,23 +324,22 @@ export class Engine {
282
324
  }
283
325
 
284
326
  /**
285
- * Open an iterative render session (for canvas preview / per-page paint).
286
- * The session is a self-contained compiled snapshot, so the transient quill
287
- * and document clones are freed before this returns; the caller owns the
288
- * returned session and must `.free()` it.
289
- * @experimental Ships ahead of its first production consumer (the designed
290
- * canvas live-preview path); the session/paint surface may change in any
291
- * 0.x release. `render()` is the stable path.
327
+ * Open a live render session (canvas preview / per-page paint / `apply`).
328
+ * The session is self-contained (it retains what it needs for `apply`), so
329
+ * the transient quill and document clones are freed before this returns;
330
+ * the caller owns the returned session and must `.free()` it. The `quill`
331
+ * and `doc` handles are read synchronously before the first await, so the
332
+ * caller may `free()` them as soon as this call returns.
292
333
  * @param {Quill} quill
293
334
  * @param {Document} doc
294
- * @returns {Promise<RenderSession>}
335
+ * @returns {Promise<LiveSession>}
295
336
  */
296
337
  async open(quill, doc) {
297
338
  return this.#withClones(
298
339
  quill.backendId,
299
340
  quill,
300
341
  doc,
301
- ({ engine, quill: q, doc: d }) => new RenderSession(engine.open(q, d))
342
+ ({ mod, engine, quill: q, doc: d }) => new LiveSession(engine.open(q, d), mod)
302
343
  );
303
344
  }
304
345
 
@@ -317,9 +358,13 @@ export class Engine {
317
358
  }
318
359
 
319
360
  /**
320
- * Whether `quill`'s backend can paint sessions to a canvas. Same ALWAYS-free
321
- * probe as `supportedFormats`: answered from the descriptor's required
322
- * `canvas` manifest, no load and no clone.
361
+ * Whether `quill`'s BACKEND can paint sessions to a canvas a pre-session
362
+ * ESTIMATE, not a fact about any particular compile. Same ALWAYS-free probe
363
+ * as `supportedFormats`: answered from the descriptor's required `canvas`
364
+ * manifest, no load and no clone. A specific compile can still refuse to
365
+ * paint (e.g. a 0-page document), so this can answer `true` while the
366
+ * resulting `LiveSession.supportsCanvas` answers `false` — gate mounting a
367
+ * canvas UI on this, gate the actual `paint` call on the session's getter.
323
368
  * @param {Quill} quill
324
369
  * @returns {Promise<boolean>}
325
370
  */
@@ -330,15 +375,49 @@ export class Engine {
330
375
  }
331
376
 
332
377
  /**
333
- * Thin wrapper over a backend's iterative render session. Holds the compiled
334
- * snapshot; the quill/document it was opened from have already been freed.
378
+ * Thin wrapper over a backend's live render session. Reads serve the current
379
+ * compile; `apply(doc)` recompiles in place (transactional: on throw, reads
380
+ * keep serving the last-good compile). The quill/document clones it was
381
+ * opened from have already been freed — the session retains what `apply`
382
+ * needs.
383
+ *
384
+ * Geometry reads (`regions`, `positionAt`, `locate`) resolve against the
385
+ * current compile; anchoring a caret or selection across edits is the editor's
386
+ * job (its own transaction mapping) — re-read geometry after each committed
387
+ * `apply`.
388
+ *
389
+ * `paint` writes a COMPLETE page raster — all content visible, no caller-side
390
+ * compositing — for every backend that supports canvas (Typst rasterizes
391
+ * natively; pdfform rasterizes its pre-flattened page). See `runtime.d.ts`.
335
392
  */
336
- export class RenderSession {
337
- /** @param {import('../backends/typst/wasm').RenderSession} inner */
338
- constructor(inner) {
393
+ export class LiveSession {
394
+ /**
395
+ * @param {{ pageCount: number, backendId: string, supportsCanvas: boolean, warnings: any[], apply: Function, render: Function, regions: Function, pageSize: Function, paint: Function, free: Function }} inner backend-build LiveSession (typst or pdfform)
396
+ * @param {{ Document: { fromJson(json: string): any } }} mod the session's backend build, used to materialize `apply` documents in its linear memory
397
+ */
398
+ constructor(inner, mod) {
339
399
  this.#inner = inner;
400
+ this.#mod = mod;
340
401
  }
341
402
  #inner;
403
+ #mod;
404
+
405
+ /**
406
+ * Recompile the session against `doc` — the edit verb of a live preview.
407
+ * Transactional: on throw every read keeps serving the last-good compile.
408
+ * On success reads serve the new compile; repaint `dirtyPages ∩ visible`.
409
+ * @param {Document} doc
410
+ * @returns {import('./runtime.d.ts').ChangeSet}
411
+ */
412
+ apply(doc) {
413
+ let backendDoc = null;
414
+ try {
415
+ backendDoc = this.#mod.Document.fromJson(doc.toJson());
416
+ return this.#inner.apply(backendDoc);
417
+ } finally {
418
+ backendDoc?.free();
419
+ }
420
+ }
342
421
 
343
422
  get pageCount() {
344
423
  return this.#inner.pageCount;
@@ -346,6 +425,16 @@ export class RenderSession {
346
425
  get backendId() {
347
426
  return this.#inner.backendId;
348
427
  }
428
+ /**
429
+ * `true` iff `paint`/`pageSize` will succeed for THIS compile — the
430
+ * authoritative answer, derived from the session's canvas seam, so it can
431
+ * never disagree with what `paint` actually does. This can be `false` even
432
+ * when `Engine.supportsCanvas` answered `true` for the same `quill` (that
433
+ * probe is a pre-session backend estimate; e.g. a canvas-capable backend
434
+ * compiled to a 0-page document has nothing to paint). Re-check this getter
435
+ * after `open()` rather than relying on the engine hint alone.
436
+ * @returns {boolean}
437
+ */
349
438
  get supportsCanvas() {
350
439
  return this.#inner.supportsCanvas;
351
440
  }
@@ -358,12 +447,75 @@ export class RenderSession {
358
447
  return this.#inner.render(options ?? undefined);
359
448
  }
360
449
 
450
+ /**
451
+ * Schema-field geometry for this compiled session — one region per
452
+ * schema-bound field, keyed on its quill schema field path. A session-level
453
+ * query (no render); read it to place field overlays / cross-navigation over
454
+ * a `paint`-ed canvas.
455
+ * @returns {import('./runtime.d.ts').FieldRegion[]}
456
+ */
457
+ regions() {
458
+ return this.#inner.regions();
459
+ }
460
+
461
+ /**
462
+ * The whole-field highlight boxes for `field` — one union rect per page,
463
+ * over the field's `span`-bearing content segments. Owns the union
464
+ * `regions()` leaves derived (span-filter + per-page union), so a "highlight
465
+ * the focused field" consumer stops reimplementing it. Content only: a field
466
+ * placed solely as a scalar reference or a bound widget returns `[]` — its
467
+ * box is a single `regions()` rect.
468
+ * @param {string} field
469
+ * @returns {import('./runtime.d.ts').FieldRegion[]}
470
+ */
471
+ fieldBoxes(field) {
472
+ return this.#inner.fieldBoxes(field);
473
+ }
474
+
475
+ /**
476
+ * The schema field whose content is under a point on `page` — the forward
477
+ * (click → field) direction, resolving *every* placement, not just the first
478
+ * that `regions` enumerates. `x`/`y` are PDF points with a bottom-left origin
479
+ * (the `FieldRegion.rect` space). See `runtime.d.ts` for the click-to-point
480
+ * inverse transform.
481
+ * @param {number} page
482
+ * @param {number} x
483
+ * @param {number} y
484
+ * @returns {string | undefined}
485
+ */
486
+ fieldAt(page, x, y) {
487
+ return this.#inner.fieldAt(page, x, y);
488
+ }
489
+
490
+ /**
491
+ * @param {number} page
492
+ * @param {number} x
493
+ * @param {number} y
494
+ * @returns {import('./runtime.d.ts').ContentHit | undefined}
495
+ */
496
+ positionAt(page, x, y) {
497
+ return this.#inner.positionAt(page, x, y);
498
+ }
499
+
500
+ /**
501
+ * @param {string} field
502
+ * @param {number} pos
503
+ * @returns {import('./runtime.d.ts').FieldRegion | undefined}
504
+ */
505
+ locate(field, pos) {
506
+ return this.#inner.locate(field, pos);
507
+ }
508
+
361
509
  /** @param {number} page */
362
510
  pageSize(page) {
363
511
  return this.#inner.pageSize(page);
364
512
  }
365
513
 
366
514
  /**
515
+ * Paint `page` into a 2D canvas context. The painted raster is COMPLETE —
516
+ * all page content visible, no caller-side compositing — for both the Typst
517
+ * and pdfform backends. See `runtime.d.ts` for the DPR/clamp math and the
518
+ * region-overlay coordinate transform.
367
519
  * @param {CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D} ctx
368
520
  * @param {number} page
369
521
  * @param {object} [options]
@@ -376,3 +528,357 @@ export class RenderSession {
376
528
  this.#inner.free();
377
529
  }
378
530
  }
531
+
532
+ // ── Typed-writer sugar: bind the quill once ─────────────────────────────────
533
+ // Rust exposes `quill.writer(&mut doc)` so a caller issues bare `set` / `set_all`
534
+ // without threading the schema per write. The WASM `commit*` verbs can't borrow
535
+ // like that — a `Document` carries only a `$quill` REFERENCE, not the resolved
536
+ // schema, so each `commit*` method takes the `quill` handle as its first
537
+ // argument. These pure-JS classes restore the Rust ergonomics: bind `quill` +
538
+ // `doc` once, then issue `set` / `setAll` / `card(i).set`.
539
+ //
540
+ // They hold JS references to the caller's EXISTING handles — no WASM object of
541
+ // their own, no `free()` burden, no second owner of either handle — and every
542
+ // write delegates straight to the underlying `commit*` verb: a schema field is
543
+ // typed-committed (coerced to canonical form, mismatch throws now), and a name
544
+ // the schema does not declare throws `UnknownField` rather than falling to the
545
+ // opaque store — on the typed path an undeclared name is a typo. Opaque storage
546
+ // stays available through the raw addressed `Document.storeField` verb.
547
+
548
+ /**
549
+ * A {@link Document} bound to its {@link Quill} for typed writes — the JS twin
550
+ * of Rust's `quill.writer(&mut doc)`. Writes target the main card; use
551
+ * {@link card} for a composable card. Holds both handles by reference and owns
552
+ * neither, so there is nothing to `free()`.
553
+ */
554
+ export class DocumentWriter {
555
+ #quill;
556
+ #doc;
557
+ /**
558
+ * @param {Quill} quill the schema source for typed commits
559
+ * @param {Document} doc the document to mutate, held by reference (not owned)
560
+ */
561
+ constructor(quill, doc) {
562
+ this.#quill = quill;
563
+ this.#doc = doc;
564
+ }
565
+ /** The bound document — the same instance passed in, mutated in place. */
566
+ get document() {
567
+ return this.#doc;
568
+ }
569
+ /**
570
+ * Typed-commit one main-card field (strict coerce, mismatch throws now).
571
+ * Throws `UnknownField` for a name the schema does not declare.
572
+ * @param {string} name
573
+ * @param {unknown} value
574
+ * @returns {void}
575
+ */
576
+ set(name, value) {
577
+ return this.#doc._commitField(this.#quill, name, value);
578
+ }
579
+ /**
580
+ * Typed-commit several main-card fields atomically — nothing is applied on
581
+ * error (throws a per-field diagnostic bundle, including an `UnknownField`
582
+ * for each undeclared name).
583
+ * @param {Record<string, unknown>} fields
584
+ * @returns {void}
585
+ */
586
+ setAll(fields) {
587
+ return this.#doc._commitFields(this.#quill, MAIN_CARD_ADDR, fields);
588
+ }
589
+ /**
590
+ * Set the main body from markdown (edit semantics: surviving anchors rebase),
591
+ * discarding the text delta — the receipt-free body write. Call
592
+ * `doc.revise({}, md)` for the {@link Delta} receipt.
593
+ * @param {string} markdown
594
+ * @returns {void}
595
+ */
596
+ setBody(markdown) {
597
+ this.#doc.revise({}, markdown);
598
+ }
599
+ /**
600
+ * Revise the richtext main-card field `name` from markdown — typed *and*
601
+ * anchor-preserving. Surviving anchors rebase, then the diffed result is
602
+ * schema-conformed (`richtext(inline)` rejects a multi-block result). Throws
603
+ * `UnknownField` for a name the schema does not declare. Returns the text
604
+ * {@link Delta}.
605
+ * @param {string} name
606
+ * @param {string} markdown
607
+ * @returns {import('../core/wasm.js').Delta}
608
+ */
609
+ reviseField(name, markdown) {
610
+ return this.#doc._reviseField(this.#quill, name, markdown);
611
+ }
612
+ /**
613
+ * Build a composable card of `kind`, typed-commit `fields` onto it, set its
614
+ * body from optional markdown, and place it — the fused `makeCard` + typed
615
+ * commit + insertion. `at` picks the position: omitted appends, a number
616
+ * inserts at that index (`0..=cardCount`), so a positioned typed insert is one
617
+ * atomic call rather than `addCard` + `moveCard`. Transactional: the card is
618
+ * committed in full before it joins the document, so a rejected field (throws
619
+ * a per-field diagnostic bundle, `UnknownField` per undeclared name) or an
620
+ * invalid kind/body/position leaves the document untouched.
621
+ * @param {string} kind
622
+ * @param {Record<string, unknown>} [fields]
623
+ * @param {string} [body]
624
+ * @param {number} [at] insertion index; appends when omitted
625
+ * @returns {void}
626
+ */
627
+ addCard(kind, fields, body, at) {
628
+ return this.#doc._addCard(this.#quill, kind, fields, body, at);
629
+ }
630
+ /**
631
+ * Remove the composable card at `index`, returning it (or `undefined` if the
632
+ * index is out of range) — the writer spelling of `Document.removeCard`.
633
+ * @param {number} index
634
+ * @returns {import('../core/wasm.js').Card | undefined}
635
+ */
636
+ removeCard(index) {
637
+ return this.#doc.removeCard(index);
638
+ }
639
+ /**
640
+ * A {@link CardWriter} bound to the composable card at `index`. Index
641
+ * validity is checked lazily by the underlying write (it throws
642
+ * `IndexOutOfRange` at commit time), so constructing one never throws.
643
+ *
644
+ * The cursor is ephemeral — bind, write, discard. It holds `index`, not the
645
+ * card: a `removeCard`/`addCard` between binding and writing silently
646
+ * retargets it. For durable addressing stamp `$id` and re-resolve the index
647
+ * at write time.
648
+ * @param {number} index
649
+ * @returns {CardWriter}
650
+ */
651
+ card(index) {
652
+ return new CardWriter(this.#quill, this.#doc, index);
653
+ }
654
+ }
655
+
656
+ /**
657
+ * A single composable card bound to its {@link Quill} for typed writes, from
658
+ * {@link DocumentWriter.card}. Same `set` / `setAll` verbs as
659
+ * {@link DocumentWriter}, targeting the card at its bound index.
660
+ */
661
+ export class CardWriter {
662
+ #quill;
663
+ #doc;
664
+ #index;
665
+ /**
666
+ * @param {Quill} quill the schema source
667
+ * @param {Document} doc the document to mutate, held by reference (not owned)
668
+ * @param {number} index the composable card's index
669
+ */
670
+ constructor(quill, doc, index) {
671
+ this.#quill = quill;
672
+ this.#doc = doc;
673
+ this.#index = index;
674
+ }
675
+ /** The bound card index. */
676
+ get index() {
677
+ return this.#index;
678
+ }
679
+ /**
680
+ * The bound card's `$kind` (empty string when it carries none), read through
681
+ * the document — mirrors core `CardWriter::kind()`. Ephemeral like the cursor
682
+ * itself: throws `IndexOutOfRange` if the bound index is out of range.
683
+ * @returns {string}
684
+ */
685
+ get kind() {
686
+ return this.#doc.card(this.#index).kind;
687
+ }
688
+ /**
689
+ * Typed-commit one field on this card, addressed at `{ card, field }`. Throws
690
+ * `UnknownField` for an undeclared name and `IndexOutOfRange` if the bound
691
+ * index is out of range.
692
+ * @param {string} name
693
+ * @param {unknown} value
694
+ * @returns {void}
695
+ */
696
+ set(name, value) {
697
+ return this.#doc._commitField(this.#quill, { card: this.#index, field: name }, value);
698
+ }
699
+ /**
700
+ * Typed-commit several fields on this card atomically, addressed at
701
+ * `{ card }`. Throws a per-field diagnostic bundle on error and
702
+ * `IndexOutOfRange` if the bound index is out of range.
703
+ * @param {Record<string, unknown>} fields
704
+ * @returns {void}
705
+ */
706
+ setAll(fields) {
707
+ return this.#doc._commitFields(this.#quill, { card: this.#index }, fields);
708
+ }
709
+ /**
710
+ * Set this card's body from markdown (edit semantics), discarding the delta —
711
+ * the card twin of {@link DocumentWriter.setBody}.
712
+ * @param {string} markdown
713
+ * @returns {void}
714
+ */
715
+ setBody(markdown) {
716
+ this.#doc.revise({ card: this.#index }, markdown);
717
+ }
718
+ /**
719
+ * Revise the richtext field `name` on this card from markdown — typed *and*
720
+ * anchor-preserving; the card twin of {@link DocumentWriter.reviseField}.
721
+ * Throws `UnknownField` for an undeclared name and `IndexOutOfRange` if the
722
+ * bound index is out of range. Returns the text {@link Delta}.
723
+ * @param {string} name
724
+ * @param {string} markdown
725
+ * @returns {import('../core/wasm.js').Delta}
726
+ */
727
+ reviseField(name, markdown) {
728
+ return this.#doc._reviseField(this.#quill, { card: this.#index, field: name }, markdown);
729
+ }
730
+ }
731
+
732
+ // ── `quill.writer(doc)` — the typed front door ──────────────────────────────
733
+ // The schema-bound writer: bind the quill's schema to a document and issue bare
734
+ // typed writes. Mirrors core's `quill.writer(&mut doc)` — the schema grants the
735
+ // typing, so the quill (not the document) is the factory. Patched onto the
736
+ // re-exported `Quill` prototype rather than wrapped: `Quill === CoreQuill`
737
+ // stays true (the identity invariant above); this only adds a method that
738
+ // constructs the pure-JS writer, which owns no WASM handle.
739
+ /**
740
+ * A {@link DocumentWriter} binding this quill's schema to `doc` for typed
741
+ * writes — the documented front door. The returned writer holds both handles by
742
+ * reference and owns neither, so there is nothing to `free()`. Ephemeral by
743
+ * convention: bind, write, discard.
744
+ * @this {Quill}
745
+ * @param {Document} doc the document to mutate, held by reference (not owned)
746
+ * @returns {DocumentWriter}
747
+ */
748
+ Quill.prototype.writer = function writer(doc) {
749
+ return new DocumentWriter(this, doc);
750
+ };
751
+
752
+ // ── Typed-reader sugar: the schema-plane read view ──────────────────────────
753
+ // The read twin of the writer above. The transport `Document.get` is schema-free
754
+ // — a `Document` cannot say which fields are richtext, so an unknown field name
755
+ // reads back `undefined` rather than as the typo it is. Binding the quill's
756
+ // schema (`_viewGet` takes the handle, like the `commit*` verbs) lets one `get`
757
+ // interpret by declared type: a richtext field to markdown, a plaintext field to
758
+ // its literal text, every other type verbatim, and an unknown name throws
759
+ // `UnknownField`. A field's markdown lives here, not on the body-only
760
+ // `getMarkdown`. Like the writer classes these hold the caller's handles by
761
+ // reference, own no WASM object, and have nothing to `free()`.
762
+
763
+ /**
764
+ * A {@link Document} bound to its {@link Quill} for typed reads — the JS twin of
765
+ * Rust's `quill.view(&doc)` and the read counterpart of {@link DocumentWriter}.
766
+ * Reads target the main card; use {@link card} for a composable card. Holds both
767
+ * handles by reference and owns neither, so there is nothing to `free()`.
768
+ */
769
+ export class DocumentView {
770
+ #quill;
771
+ #doc;
772
+ /**
773
+ * @param {Quill} quill the schema source for interpreted reads
774
+ * @param {Document} doc the document to read, held by reference (not owned)
775
+ */
776
+ constructor(quill, doc) {
777
+ this.#quill = quill;
778
+ this.#doc = doc;
779
+ }
780
+ /** The bound document — the same instance passed in. */
781
+ get document() {
782
+ return this.#doc;
783
+ }
784
+ /**
785
+ * Read the value at `addr`, interpreted by its declared type: a richtext field
786
+ * to markdown, every other type verbatim. A bare string is `Addr` shorthand
787
+ * for `{ field }`; an absent `addr.field` reads the body markdown. `undefined`
788
+ * for an absent field; throws `UnknownField` for a name the schema does not
789
+ * declare, `FieldRichtextDecode` for a richtext field holding an undecodable
790
+ * value, and `IndexOutOfRange` for a bad `addr.card`.
791
+ * @param {import('../core/wasm.js').Addr | string} addr
792
+ * @returns {unknown}
793
+ */
794
+ get(addr) {
795
+ return this.#doc._viewGet(this.#quill, addr);
796
+ }
797
+ /**
798
+ * The main body's markdown — the quill-free body read (a body's type is a
799
+ * format fact, not a schema fact). Equivalent to `get({})`.
800
+ * @returns {string}
801
+ */
802
+ getBody() {
803
+ return this.#doc._viewGet(this.#quill, {});
804
+ }
805
+ /**
806
+ * A {@link CardView} bound to the composable card at `index`. Index validity
807
+ * is checked lazily by the underlying read (it throws `IndexOutOfRange` at read
808
+ * time), so constructing one never throws. Ephemeral like the writer cursor —
809
+ * it holds `index`, not the card, so a `removeCard`/`addCard` between binding
810
+ * and reading silently retargets it.
811
+ * @param {number} index
812
+ * @returns {CardView}
813
+ */
814
+ card(index) {
815
+ return new CardView(this.#quill, this.#doc, index);
816
+ }
817
+ }
818
+
819
+ /**
820
+ * A single composable card bound to its {@link Quill} for typed reads, from
821
+ * {@link DocumentView.card}. Same `get` / `getBody` verbs as
822
+ * {@link DocumentView}, reading the card at its bound index.
823
+ */
824
+ export class CardView {
825
+ #quill;
826
+ #doc;
827
+ #index;
828
+ /**
829
+ * @param {Quill} quill the schema source
830
+ * @param {Document} doc the document to read, held by reference (not owned)
831
+ * @param {number} index the composable card's index
832
+ */
833
+ constructor(quill, doc, index) {
834
+ this.#quill = quill;
835
+ this.#doc = doc;
836
+ this.#index = index;
837
+ }
838
+ /** The bound card index. */
839
+ get index() {
840
+ return this.#index;
841
+ }
842
+ /**
843
+ * The bound card's `$kind` (empty string when it carries none), read through
844
+ * the document. Throws `IndexOutOfRange` if the bound index is out of range.
845
+ * @returns {string}
846
+ */
847
+ get kind() {
848
+ return this.#doc.card(this.#index).kind;
849
+ }
850
+ /**
851
+ * Read the field `name` on this card, interpreted by its declared type,
852
+ * addressed at `{ card, field }`. `undefined` when absent; throws
853
+ * `UnknownField` for an undeclared name and `IndexOutOfRange` for a bad index.
854
+ * @param {string} name
855
+ * @returns {unknown}
856
+ */
857
+ get(name) {
858
+ return this.#doc._viewGet(this.#quill, { card: this.#index, field: name });
859
+ }
860
+ /**
861
+ * This card's body markdown — the card twin of {@link DocumentView.getBody}.
862
+ * @returns {string}
863
+ */
864
+ getBody() {
865
+ return this.#doc._viewGet(this.#quill, { card: this.#index });
866
+ }
867
+ }
868
+
869
+ // ── `quill.view(doc)` — the schema-plane read front door ─────────────────────
870
+ // The read twin of `quill.writer(doc)`, patched onto the same re-exported `Quill`
871
+ // prototype (the `Quill === CoreQuill` identity invariant holds — this only adds
872
+ // a method constructing the pure-JS view, which owns no WASM handle).
873
+ /**
874
+ * A {@link DocumentView} binding this quill's schema to `doc` for interpreted
875
+ * reads — the read front door, mirroring core's `quill.view(&doc)`. The returned
876
+ * view holds both handles by reference and owns neither, so there is nothing to
877
+ * `free()`. Ephemeral by convention: bind, read, discard.
878
+ * @this {Quill}
879
+ * @param {Document} doc the document to read, held by reference (not owned)
880
+ * @returns {DocumentView}
881
+ */
882
+ Quill.prototype.view = function view(doc) {
883
+ return new DocumentView(this, doc);
884
+ };