@quillmark/wasm 0.111.0 → 0.112.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,260 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.112.0 - 2026-09-01
4
+
5
+ - feat(content)!: **every vocabulary member spells its payload in `attrs`.** The
6
+ canonical content had two spellings for one thing: a built-in put its payload
7
+ in named siblings (`{"kind":"heading","level":1}`), an unknown put it in one
8
+ opaque bag, and which a document used depended on whether the build that wrote
9
+ it happened to know the name. Promoting a name from unknown to built-in was
10
+ therefore an encoding change, and five mechanisms existed to bridge the split.
11
+ Now `Heading{level}` ⇄ `attrs.level`, `Code{lang}` ⇄ `attrs.lang`, `Link{url}`
12
+ ⇄ `attrs.url`, `Anchor{id}` ⇄ `attrs.id`, and `ListItem`'s
13
+ `ordered`/`start`/`ordinal` ⇄ the same three under `attrs`, with the envelope
14
+ keys (`kind`/`type`/`container`, `containers`, `continues`, `start`/`end`,
15
+ `instance`) staying siblings and an empty bag omitted. The Rust types are
16
+ unchanged — typed in memory, uniform on the wire. `fold_legacy_attrs`, the
17
+ `reject_*_attrs` family, `MarkKind::ord` and its placement rule, and
18
+ `RESERVED_*`'s wire role are all deleted; the canonical mark tie-break becomes
19
+ `MarkKind::sort_key`, the `(type, attrs)` pair the wire carries, which two
20
+ builds compute identically whether or not either knows the member. **Stored
21
+ documents need no migration**: the decoder reads the old spelling wherever the
22
+ `attrs` bag is absent. That is the load-bearing half — a `richtext` field
23
+ rests as a content object inside an opaque payload value carrying no schema
24
+ tag, so most stored content reaches no migration and a walk cannot safely find
25
+ it (`$ext` is arbitrary host data by contract). The fallback is frozen: unlike
26
+ the fold, a promotion neither grows it nor inherits it. The **break is at the
27
+ seam**: a host reading `line.level` / `line.lang` / `mark.url` / `mark.id` or a
28
+ list item's shape reads them under `attrs` (the TS narrowing types move too, so
29
+ `typecheck` reports it), and the authored lane — `overwrite`, `install`,
30
+ `CardInput.body`, and the op wire — *rejects* the old spelling rather than
31
+ reading it, so a stale write throws instead of landing somewhere it did not
32
+ aim. A foreign bag beside a built-in becomes legal in exchange, dropping unread
33
+ as it always did in effect. Canonical bytes move (`attrs` on built-ins, and
34
+ coincident marks reorder on the new tie-break), so **content hashes recompute
35
+ once**; generated Typst nests coincident wraps the other way for identical
36
+ glyphs, and `exportMarkdown` nests them the same way (`**~~x~~**` becomes
37
+ `~~**x**~~`, parsing back to the same content), so goldens and any hash over
38
+ either projection move too. A consumer holding bare seam JSON outside a
39
+ stored document gets a hard break, having no tag to dispatch on. The storage
40
+ tag becomes `quillmark/document@0.112.0`; `@0.93.0` rows migrate forward on
41
+ read. See [0.111 → 0.112](docs/migrations/0.111-to-0.112.md).
42
+ - feat(core)!: **`fieldAt` and `positionAt` take a pointer tolerance, and
43
+ resolve to the nearest ink rather than the first containing it.** A glyph's
44
+ box is its run's ink height by its own advance, so a text column answers over
45
+ a fraction of the area it occupies: the boxes of one line abut, but the
46
+ leading between two lines is inside the paragraph and on no glyph. An 11pt
47
+ paragraph is live over about two thirds of its own height at default leading
48
+ and under half of it double-spaced, which is the whole of why clicking the
49
+ preview to place a caret misses. Both queries now take `tol` in PDF points
50
+ (`tolPt`, optional, on the WASM seam) and answer with the nearest placement
51
+ within it, a radius in both axes. The caller derives it from the scale it drew
52
+ the page at, slack being a property of the pointer rather than of the
53
+ document: a tolerance fixed in points shrinks under the cursor exactly as the
54
+ target does. Ranking by distance rather than growing each rect is what keeps
55
+ the answer the nearer item's — outset boxes overlap, and a first match over
56
+ them decides by paint order — and makes the tolerance a pure widening:
57
+ containment is distance zero, so no point that resolves exactly changes answer
58
+ however high `tol` goes, and later-painted still wins a tie.
59
+ `RenderedRegion::distance` is the measure, and `contains` is now stated in
60
+ terms of it. The break is the added argument on
61
+ `SessionHandle::{field_at,position_at}` and their `LiveSession` forwarders,
62
+ which a type checker reports; `0.0` is the previous behaviour exactly. The
63
+ WASM argument is optional and defaults to `0`, so no JS caller changes.
64
+ - fix(content): **markdown export escapes what markdown strips at a line's
65
+ edges.** A line's leading and trailing space/tab crossed verbatim, so
66
+ `from_markdown(to_markdown(rt))` dropped it — and where the leading run
67
+ opened an indented code block, or hid a `- ` / `# ` / `> ` / `N. ` marker
68
+ from the position-0 escapes, the re-import rewrote the line's kind and
69
+ containers with it: `" foo"` returned as `"foo"` under `Code`, and
70
+ `" - item"` as `"item"` inside a `ListItem` nobody wrote. Neither takes
71
+ adversarial input, since `apply_text_delta` and `from_plaintext` both mint
72
+ edge whitespace: a `Codec::Plaintext` field holding an indented sample was
73
+ corrupted by `Document::to_markdown`. The verify-and-drop net could not catch
74
+ it, being gated on flanking marks — a mark-free line never reaches it, and
75
+ the `,…,` probe wrapping the lines that do blocks every leading-block
76
+ construct by design, so it reads such a line as safe while the `**` ships
77
+ into the text. Space and tab at an edge now cross as character references
78
+ (` ` / `	`), markdown having no backslash escape for whitespace. A
79
+ literal `&` still escapes, so text authored as ` ` returns as itself. An
80
+ image `alt` is the one edge run a reference cannot carry — the parser trims
81
+ alt after decoding it — and joins the module's documented codec limits.
82
+ Emitted markdown moves for content holding an edge run.
83
+ - fix(content): **a `=` run on a continuation line stays text.** It underlined
84
+ the paragraph line above it into a setext heading, taking the hard break's
85
+ `\` into the text with it: `"abc\n==="` returned as an H1 reading `"abc\"`.
86
+ `\=` joins the block starters escaped at position 0.
87
+ - fix(content): **an unknown island's type cannot leave its placeholder.**
88
+ `emit_island` wrote `island_type` — an open wire string no lane constrains —
89
+ raw into `<!-- island:… -->`, where a `-->` closes the comment early and a
90
+ line break ends the HTML block, either way leaking the rest as content text.
91
+ At column zero that opens whatever the rest spells, a `~~~` card fence
92
+ included, which the document layer reads as another card. The type crosses
93
+ with `<` / `>` as entities and control characters as spaces; nothing reads it
94
+ back out of markdown.
95
+ - fix(content): **an inline unknown island keeps its line whole.**
96
+ `fix_html_comment_fences` inserted a newline after any `-->` carrying text
97
+ behind it, but only a comment opening a line — at most three spaces of indent
98
+ — starts an HTML block. One reached mid-line is inline HTML and swallows
99
+ nothing, so an inline island turned `ab` into `"a b"`. The repair fires for
100
+ the block-opening case alone.
101
+ - fix(core): **the payload ingresses refuse what no parse can produce.** The
102
+ wire `TryFrom`, the storage DTO and `push_card` / `insert_card` each admitted
103
+ a payload whose markdown does not read back: a duplicate field key, more
104
+ fields than `MAX_FIELD_COUNT`, a repeated `$` entry, a comment whose text
105
+ spans lines, and a composable card carrying `$quill` or `$seed`. The comment
106
+ is the one that fails quietly — `#` opens a single line, so a text of
107
+ `"hi\ninjected: pwned"` emits bare YAML after the first line and re-reads as
108
+ a field nobody wrote. The rest emit markdown the parser rejects, and a
109
+ composable `$quill` also trips the `debug_assert` in `from_main_and_cards`,
110
+ which the rebuild behind `compile_data` runs: a render panicked on a debug
111
+ build where it owed an error. `PayloadViolation` carries the verdict —
112
+ `FieldViolation`'s seam one level up, reading the item list rather than one
113
+ item — which the wire maps to `WireError::InvalidPayload` and the DTO to
114
+ `StorageError::Malformed`, so both boundaries share one message. The
115
+ `$quill` / `$seed` half is positional, since a `CardWire` is equally how the
116
+ main card is read back, so it sits at placement beside the `$kind` gate as
117
+ `EditError::RootOnlyEntry` (`edit::root_only_entry`). The DTO refuses a root
118
+ `$kind` other than `main` and synthesises an absent one, as the parser does.
119
+ Additive: `validate_payload`, `PayloadViolation`, `MetaKey::ALL`. Both error
120
+ enums are `#[non_exhaustive]`, so no compiling caller changes, and no
121
+ document the format calls readable is refused.
122
+ - fix(cli): **`render -f svg` / `-f png` writes every page.** The Typst backend
123
+ emits one artifact per page and the command wrote `artifacts.first()`,
124
+ discarding the rest with no warning, so a multi-page document produced a
125
+ single-page file that looked complete. A multi-page render now writes one
126
+ numbered file per page: `out.svg` becomes `out-1.svg`, `out-2.svg`, …. Page
127
+ one is numbered too, so no unnumbered file claims to be the whole document.
128
+ `--stdout` carries one artifact and refuses a multi-page render.
129
+ - fix(core): **a `!must_fill` marker on a mapping is refused at every ingress,
130
+ not just at parse.** `Card::store_fill`, the `CardWire` boundary and the
131
+ `@0.92.0` storage DTO took `fill: true` against an object and emitted
132
+ `x: a: 1`, which `Document::parse` then refuses — breaking the emit round
133
+ trip. The rule the parser enforces now sits beside the other field invariants
134
+ as `edit::validate_fill_targets`, raising `edit::fill_on_mapping`. A canonical
135
+ content object stays legal: emit projects it to its markdown scalar first,
136
+ which is the shape `!must_fill` emits against.
137
+ - fix(core): **a comment after a nested key that needs quoting keeps its
138
+ position.** A comment's position is its index among its mapping's children,
139
+ and the prescan matched only a bare `[A-Za-z_][A-Za-z0-9_]*` nested key. So
140
+ `"a b": 1`, which the emitter writes itself, was not counted, and every comment
141
+ after it in that mapping round-tripped one slot early. The prescan reads a
142
+ nested key in both spellings the emitter writes at depth: quoted, and plain
143
+ with characters the bare form excludes.
144
+ - fix(pdfform): **an array-bound text widget is multiline.** `resolve::coerce_text`
145
+ joins an array's elements with newlines unconditionally while the widget took
146
+ `multiline` from `ui`, defaulting to false: a viewer collapsed the `/V` at its
147
+ first line while the flattened SVG/PNG stacked every line, so the interactive
148
+ PDF and the raster disagreed.
149
+ - fix(core): **a version segment is plain digits, and a `$quill` `@` carries a
150
+ selector.** `u32::from_str` accepts a leading `+`, so `memo@+2.+1` parsed as
151
+ `Minor(2, 1)` and `version: "+1.0"` loaded, though `quill_ref_hint` promises
152
+ digits and neither spelling re-`Display`s to itself; and `memo@` yielded an
153
+ empty selector silently read as `latest`. An absent selector still means
154
+ latest.
155
+ - fix(core): **an unquoted numeric `version:` keeps its fraction.** The loader
156
+ accepts a YAML number by intent but converted it through `f64::to_string`,
157
+ which drops the fraction: `version: 1.0` became `"1"` and failed validation
158
+ with a hint naming the `'1.0'` the author had written. An unquoted `1.10` is
159
+ the YAML number `1.1` before the loader sees it, so VERSIONING.md's quote-it
160
+ rule now names that rather than the `x.0` case.
161
+ - fix(python): **a value with no JSON form raises instead of storing its
162
+ `repr`.** `py_to_json_at` fell through to `str()`, so a tuple stored
163
+ `"('a', 'b')"`, a `bytes` stored `"b'...'"` and a `set` stored `"{'x'}"` —
164
+ silently, surfacing as garbage at render or read-back. `datetime.date`,
165
+ `datetime.datetime` and `datetime.time` keep the stringified form the
166
+ fallback existed for; everything else is a `ValueError`, which is what the
167
+ WASM lane already refuses.
168
+ - fix(python): **the type stub declares `Diagnostic.args`**, the localization
169
+ channel the class exposes. Under a type checker `diag.args` was an attribute
170
+ error on a `@final` class.
171
+ - docs(python): **the error contract names both classes the binding raises.**
172
+ `errors.rs` claimed every raised exception is `QuillmarkError` carrying
173
+ diagnostics, while an argument the binding cannot convert raises `ValueError`
174
+ — the behavior the binding's own tests pin. Engine refusals carry diagnostics;
175
+ an unconvertible argument raises `ValueError` before the engine is called.
176
+ Stated in the module doc and in the error-handling guide.
177
+ - fix(wasm): **`new Document(ref)` refuses an invalid reference with the code
178
+ and hint `setQuillRef` attaches.** The same input was classified two ways by
179
+ one binding: `parse::invalid_quill_reference` plus the canonical grammar hint,
180
+ or a bare message. Both doors mint it through one helper now.
181
+ - fix(typst): **a literal `;` after emitted inline markup survives to the
182
+ page.** Typst's markup parser reads a semicolon directly after an embedded
183
+ code expression as that expression's terminator and renders nothing, so
184
+ ``Use `--force`; otherwise`` lowered to `#raw("--force"); otherwise` and
185
+ dropped the character. `continues_expr` guarded `(` and `.ident` for the same
186
+ reason; it guards `;` now, and the emitter writes the same `\` before it.
187
+ - fix(typst): **document text reaches the page as the characters it holds, not
188
+ Typst's substitutions for them.** `escape_markup` escaped `~`, whose lexer
189
+ shorthand is a non-breaking space, and left the rest of that class active:
190
+ `pages 3--5` rendered an en dash, `wait...` an ellipsis, `-5` a minus sign,
191
+ and `-?` an invisible soft hyphen, taking both authored characters off the
192
+ page. A mark decided it too, since Typst reads the text behind one as a fresh
193
+ token: `x-5` stayed literal but `**x**-5` lowered to `#strong[x]-5`, a minus
194
+ sign the content never held. Each shorthand's head is escaped now, which is
195
+ enough — what one leaves behind is too short to re-form it. **Smart quotes
196
+ stay.** `'` and `"` are an element with a set rule, not a lexer shorthand, so
197
+ a quill picks its own typography with `#set smartquote(enabled: false)`; the
198
+ emitter escaping them would settle that for every quill with no way back.
199
+ Documents holding a dash pair, an ellipsis or a signed number render
200
+ differently.
201
+ - fix(typst): **an island that renders as nothing no longer joins the text
202
+ either side of it.** An island type this build does not know, and an empty
203
+ table island, emit no markup, so the two text runs their slot separated abut
204
+ in the output — where the escapers, which run per text run, see one side of
205
+ the join at a time. `a/`, such an island, `/b` wrote `a//b`: a Typst comment
206
+ that swallowed the rest of the paragraph. The emitter guards that seam with
207
+ the same `\` it writes at a line anchor and an expression tail.
208
+ - fix(typst): **the caret one past a field's last character resolves to the last
209
+ glyph, not the paragraph's first.** `Scan::locate` admits the end position but
210
+ `forward_pos` matched runs half-open, so the most common caret position while
211
+ typing fell through to the segment's generated start. A position no run
212
+ contains now resolves against the nearest preceding run.
213
+ - fix(typst): **a vendored package whose manifest declares a non-semver
214
+ `version` or an unusable `entrypoint` path warns instead of failing the
215
+ session.** Both aborted `QuillWorld::new` even when the plate never imports
216
+ that package, where an unparseable manifest, an unusable asset path, and a
217
+ missing entrypoint already warn and carry on. A non-semver `version` skips the
218
+ package; an unusable `entrypoint` path keeps the files already loaded and
219
+ skips only the check that the entrypoint is among them.
220
+ - fix(wasm): **`RenderResult` crosses a diagnostic's `args` as the
221
+ `Record<string, unknown>` it declares.** It returns through tsify's ABI, whose
222
+ default serializer emits a `Map` for a map-typed field, where every
223
+ hand-serialized diagnostic path passes `serialize_maps_as_objects(true)`.
224
+ `RenderResult.warnings` puts the document's parse warnings ahead of the
225
+ render's own, and a `plate::unsupported_construct` parse warning carries
226
+ `args`: a quill declining a construct its body still holds renders
227
+ successfully, and `args.construct` read back `undefined`. `RenderResult` and
228
+ `Diagnostic` declare `hashmap_as_object`, and a `const` assertion holds each
229
+ to it.
230
+ - fix(content): **a table island whose `aligns` or a row is not an array
231
+ normalizes to one the store can reload.** `normalize_table_props` repaired a
232
+ non-array `header` but fell through for the other two, while `table_shape_error`
233
+ read them as width 0 — so an island op carrying `{"aligns": "bogus"}` was
234
+ accepted and serialized, and the exact bytes then failed to reopen with
235
+ `TableAlignsMismatch`. Normalizing a table now makes its shape check pass, as
236
+ `KnownIslandType::normalize_props` promises.
237
+ - fix(content): **a code block's `lang` is reduced to an identifier on the
238
+ storage lane and refused on the authored one.** `sanitize_lang` ran on import
239
+ only, so the storage decode and the `setKind` op wire took any string and
240
+ `emit_code` wrote it onto the fence header verbatim: a `lang` of
241
+ `"rust\ninjected line"` exported a content line the document never had, and a
242
+ backtick made the fence illegal. The storage decode sanitizes, so a blob
243
+ written that way still opens; the authored wire raises a shape error, since
244
+ the host is writing now and the repair would be silent.
245
+ - fix(core): **a variant-bearing enum refuses a non-string `default:` at load,
246
+ as a plain enum already does.** The scalar branch applied the render floor's
247
+ leniency to a schema literal, so `default: 1` against `values: ["1"]` loaded
248
+ clean and then selected nothing: `selected_member`, `resolve_variant_sourced`
249
+ and the absent-discriminant fallback all read the default through `as_str`, so
250
+ the field compiled to the blank world instead.
251
+ - fix(core): **a seeded variant container commits at its resting form.**
252
+ `seed_parts` runs every other field through the strict write, but
253
+ `seed_variant` pushed its assembled container straight through, so a `$seed`
254
+ overlay for a richtext cell rested as raw markdown and the next `conform`
255
+ rewrote bytes on a document nobody edited — the divergence the shared write
256
+ exists to prevent.
257
+
3
258
  ## v0.111.0 - 2026-08-30
4
259
 
5
260
  - feat(wasm): **`mapMarks(content, bundle)` answers where a `ChangeBundle`'s
package/README.md CHANGED
@@ -173,9 +173,9 @@ discriminates the two formats without exceptions as control flow:
173
173
  const doc = Document.tryFromJson(content) ?? Document.fromMarkdown(content);
174
174
  ```
175
175
 
176
- The `schema` value (`quillmark/document@0.93.0`) is the **model version**,
176
+ The `schema` value (`quillmark/document@0.112.0`) is the **model version**,
177
177
  not the running crate version. It is a hand-set constant, bumped only when
178
- the `Document` model itself changes, so every `0.93.x` patch release reads
178
+ the `Document` model itself changes, so every `0.112.x` patch release reads
179
179
  and writes that same value.
180
180
 
181
181
  - **Upgrading is safe.** A newer build always reads documents written by an
@@ -74,8 +74,9 @@ export interface Content {
74
74
 
75
75
  /** One `\n`-separated segment of `Content.text`, in order. `kind` is an open set:
76
76
  * an unknown role round-trips with opaque `attrs` and renders as a paragraph.
77
- * The open arm blocks discriminant narrowing, so read `level`/`lang` behind a
78
- * check of the arm you want. */
77
+ * Every role spells its payload in `attrs`, known or not, so promoting one moves
78
+ * no bytes. The open arm blocks discriminant narrowing, so read
79
+ * `attrs.level`/`attrs.lang` behind a check of the arm you want. */
79
80
  export type ContentLine = {
80
81
  containers: ContentContainer[];
81
82
  /** A within-block hard line break rather than a new block. Omitted (false) in the common case. */
@@ -85,11 +86,11 @@ export type ContentLine = {
85
86
  /** A line's block role, shared by `ContentLine` and the `setKind` op. */
86
87
  export type ContentLineKind =
87
88
  | { kind: "para" }
88
- | { kind: "heading"; level: number }
89
- | { kind: "code"; lang?: string }
89
+ | { kind: "heading"; attrs: { level: number } }
90
+ | { kind: "code"; attrs?: { lang?: string } }
90
91
  | { kind: "island" }
91
92
  | { kind: "rule" }
92
- | { kind: string; attrs: unknown };
93
+ | { kind: string; attrs?: unknown };
93
94
 
94
95
  /** An ancestor block a line nests inside, outermost first. Open like
95
96
  * `ContentLine.kind`: an unrecognized container round-trips with opaque `attrs`
@@ -118,22 +119,26 @@ export type ContentLineKind =
118
119
  * Content parsed from a stored document is the one shape that arrives without
119
120
  * it — storage omits a zero — and needs a cast. */
120
121
  export type ContentContainer =
121
- | { container: "list_item"; ordered: boolean; start: number; ordinal: number; instance: number }
122
+ | {
123
+ container: "list_item";
124
+ attrs: { ordered: boolean; start: number; ordinal: number };
125
+ instance: number;
126
+ }
122
127
  | { container: "quote"; instance: number }
123
- | { container: string; attrs: unknown; instance: number };
128
+ | { container: string; attrs?: unknown; instance: number };
124
129
 
125
130
  /** A mark over char range `[start, end)` into `Content.text`. The open `type`
126
131
  * arm blocks discriminant narrowing, so read a payload-carrying arm behind its
127
- * guard: `isLinkMark` (`url`) / `isAnchorMark` (`id`), from
132
+ * guard: `isLinkMark` (`attrs.url`) / `isAnchorMark` (`attrs.id`), from
128
133
  * `@quillmark/wasm/runtime`. An `anchor`'s `id` is a caller-supplied opaque
129
134
  * handle, unique per `Content` and invariant while the mark lives (positions
130
135
  * rebase, the id never does); it has no markdown projection and survives only
131
136
  * through the edit lane. */
132
137
  export type ContentMark = { start: number; end: number } & (
133
138
  | { type: "strong" | "emph" | "underline" | "strike" | "code" }
134
- | { type: "link"; url: string }
135
- | { type: "anchor"; id: string }
136
- | { type: string; attrs: unknown }
139
+ | { type: "link"; attrs: { url: string } }
140
+ | { type: "anchor"; attrs: { id: string } }
141
+ | { type: string; attrs?: unknown }
137
142
  );
138
143
 
139
144
  /** A cell in a `TableProps`. `marks` rides the prose `ContentMark` shape, but
@@ -560,7 +565,7 @@ export interface ContentHit {
560
565
  */
561
566
  field: string;
562
567
  /**
563
- * USV offset into the field\'s `Content`.
568
+ * USV offset into the field's `Content`.
564
569
  */
565
570
  pos: number;
566
571
  /**
@@ -577,12 +582,12 @@ export interface ContentHit {
577
582
  * (paragraph, heading, whole code fence) and per page each touches, a scalar
578
583
  * referenced at several plate sites surfaces each site, and tracked content
579
584
  * plus a `field:`-bound widget yields both. Group by `field`. The whole-field
580
- * highlight is the union of a page\'s `span`-bearing rects, so inter-paragraph
585
+ * highlight is the union of a page's `span`-bearing rects, so inter-paragraph
581
586
  * whitespace stays uncovered; `LiveSession.fieldBoxes(field)` owns that union.
582
587
  */
583
588
  export interface FieldRegion {
584
589
  /**
585
- * Canonical `DocPath` field address (e.g. `\"cards.indorsement[1].from\"`):
590
+ * Canonical `DocPath` field address (e.g. `"cards.indorsement[1].from"`):
586
591
  * the grammar `parseDocPath` reads and `Diagnostic.path` carries. Feed it
587
592
  * back to `fieldBoxes` / `locate`.
588
593
  */
@@ -596,7 +601,7 @@ export interface FieldRegion {
596
601
  */
597
602
  rect: [number, number, number, number];
598
603
  /**
599
- * The slice this box covers: USV `[start, end)` into the field\'s `Content`
604
+ * The slice this box covers: USV `[start, end)` into the field's `Content`
600
605
  * for one content segment, `undefined` for a scalar site or widget.
601
606
  */
602
607
  span?: [number, number];
@@ -611,7 +616,7 @@ export interface Diagnostic {
611
616
  message: string;
612
617
  location?: Location;
613
618
  /**
614
- * Document-model path anchor (e.g. `\"cards.indorsement[0].signature_block\"`),
619
+ * Document-model path anchor (e.g. `"cards.indorsement[0].signature_block"`),
615
620
  * set on schema validation diagnostics and `undefined` otherwise.
616
621
  */
617
622
  path?: string;
@@ -1072,8 +1077,14 @@ export class LiveSession {
1072
1077
  * `FieldRegion.rect`, so from a canvas click use
1073
1078
  * `x = clickPx.x / renderScale`, `y = pageHeightPt - clickPx.y / renderScale`.
1074
1079
  * Unlike `regions()`, *every* placement answers, not just the first.
1080
+ *
1081
+ * `tolPt` is how far off the ink a click still counts, in the same points,
1082
+ * and defaults to `0` — exact. Convert the pointer slack a surface wants
1083
+ * from CSS pixels at the scale it drew the page (`slackPx / renderScale`),
1084
+ * so it stays the same size under the cursor as the page zooms. The
1085
+ * nearest placement answers, so raising it only fills a miss.
1075
1086
  */
1076
- fieldAt(page: number, x: number, y: number): string | undefined;
1087
+ fieldAt(page: number, x: number, y: number, tol_pt?: number | null): string | undefined;
1077
1088
  /**
1078
1089
  * The whole-field highlight boxes for `field`: one union rect per page over
1079
1090
  * the field's `span`-bearing content segments, the union `regions()` leaves
@@ -1113,11 +1124,15 @@ export class LiveSession {
1113
1124
  /**
1114
1125
  * A point → **content position**: the field *and* a USV offset into its
1115
1126
  * `Content`, for placing a caret or mapping a selection into the content
1116
- * model, or `undefined` off all content ink. `x`/`y` are PDF points,
1117
- * bottom-left origin, as in `fieldAt`. The offset is cluster-exact and
1118
- * degrades to the containing segment's start on origin-less ink.
1127
+ * model, or `undefined` off all content ink. `x`/`y`/`tolPt` are PDF
1128
+ * points, bottom-left origin, as in `fieldAt`. The offset is cluster-exact
1129
+ * and degrades to the containing segment's start on origin-less ink.
1130
+ *
1131
+ * `tolPt` earns the most here: the leading between two lines lies inside a
1132
+ * paragraph and on no glyph, and under `tolPt` such a point takes the
1133
+ * nearer line.
1119
1134
  */
1120
- positionAt(page: number, x: number, y: number): ContentHit | undefined;
1135
+ positionAt(page: number, x: number, y: number, tol_pt?: number | null): ContentHit | undefined;
1121
1136
  /**
1122
1137
  * Schema-field geometry for this compiled session: each content field's
1123
1138
  * **first placement** (one region per page it touches) plus widget and
@@ -1430,13 +1445,13 @@ export interface InitOutput {
1430
1445
  readonly formatDocPath: (a: number, b: number) => void;
1431
1446
  readonly importMarkdown: (a: number, b: number, c: number) => void;
1432
1447
  readonly livesession_backendId: (a: number, b: number) => void;
1433
- readonly livesession_fieldAt: (a: number, b: number, c: number, d: number, e: number) => void;
1448
+ readonly livesession_fieldAt: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
1434
1449
  readonly livesession_fieldBoxes: (a: number, b: number, c: number, d: number) => void;
1435
1450
  readonly livesession_locate: (a: number, b: number, c: number, d: number) => number;
1436
1451
  readonly livesession_pageCount: (a: number) => number;
1437
1452
  readonly livesession_pageSize: (a: number, b: number, c: number) => void;
1438
1453
  readonly livesession_paint: (a: number, b: number, c: number, d: number, e: number) => void;
1439
- readonly livesession_positionAt: (a: number, b: number, c: number, d: number) => number;
1454
+ readonly livesession_positionAt: (a: number, b: number, c: number, d: number, e: number) => number;
1440
1455
  readonly livesession_regions: (a: number, b: number) => void;
1441
1456
  readonly livesession_render: (a: number, b: number, c: number) => void;
1442
1457
  readonly livesession_supportsCanvas: (a: number) => number;
@@ -1410,15 +1410,22 @@ export class LiveSession {
1410
1410
  * `FieldRegion.rect`, so from a canvas click use
1411
1411
  * `x = clickPx.x / renderScale`, `y = pageHeightPt - clickPx.y / renderScale`.
1412
1412
  * Unlike `regions()`, *every* placement answers, not just the first.
1413
+ *
1414
+ * `tolPt` is how far off the ink a click still counts, in the same points,
1415
+ * and defaults to `0` — exact. Convert the pointer slack a surface wants
1416
+ * from CSS pixels at the scale it drew the page (`slackPx / renderScale`),
1417
+ * so it stays the same size under the cursor as the page zooms. The
1418
+ * nearest placement answers, so raising it only fills a miss.
1413
1419
  * @param {number} page
1414
1420
  * @param {number} x
1415
1421
  * @param {number} y
1422
+ * @param {number | null} [tol_pt]
1416
1423
  * @returns {string | undefined}
1417
1424
  */
1418
- fieldAt(page, x, y) {
1425
+ fieldAt(page, x, y, tol_pt) {
1419
1426
  try {
1420
1427
  const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
1421
- wasm.livesession_fieldAt(retptr, this.__wbg_ptr, page, x, y);
1428
+ wasm.livesession_fieldAt(retptr, this.__wbg_ptr, page, x, y, isLikeNone(tol_pt) ? 0x100000001 : Math.fround(tol_pt));
1422
1429
  var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
1423
1430
  var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
1424
1431
  let v1;
@@ -1537,16 +1544,21 @@ export class LiveSession {
1537
1544
  /**
1538
1545
  * A point → **content position**: the field *and* a USV offset into its
1539
1546
  * `Content`, for placing a caret or mapping a selection into the content
1540
- * model, or `undefined` off all content ink. `x`/`y` are PDF points,
1541
- * bottom-left origin, as in `fieldAt`. The offset is cluster-exact and
1542
- * degrades to the containing segment's start on origin-less ink.
1547
+ * model, or `undefined` off all content ink. `x`/`y`/`tolPt` are PDF
1548
+ * points, bottom-left origin, as in `fieldAt`. The offset is cluster-exact
1549
+ * and degrades to the containing segment's start on origin-less ink.
1550
+ *
1551
+ * `tolPt` earns the most here: the leading between two lines lies inside a
1552
+ * paragraph and on no glyph, and under `tolPt` such a point takes the
1553
+ * nearer line.
1543
1554
  * @param {number} page
1544
1555
  * @param {number} x
1545
1556
  * @param {number} y
1557
+ * @param {number | null} [tol_pt]
1546
1558
  * @returns {ContentHit | undefined}
1547
1559
  */
1548
- positionAt(page, x, y) {
1549
- const ret = wasm.livesession_positionAt(this.__wbg_ptr, page, x, y);
1560
+ positionAt(page, x, y, tol_pt) {
1561
+ const ret = wasm.livesession_positionAt(this.__wbg_ptr, page, x, y, isLikeNone(tol_pt) ? 0x100000001 : Math.fround(tol_pt));
1550
1562
  return takeObject(ret);
1551
1563
  }
1552
1564
  /**
Binary file
@@ -64,13 +64,13 @@ export const exportMarkdown: (a: number, b: number) => void;
64
64
  export const formatDocPath: (a: number, b: number) => void;
65
65
  export const importMarkdown: (a: number, b: number, c: number) => void;
66
66
  export const livesession_backendId: (a: number, b: number) => void;
67
- export const livesession_fieldAt: (a: number, b: number, c: number, d: number, e: number) => void;
67
+ export const livesession_fieldAt: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
68
68
  export const livesession_fieldBoxes: (a: number, b: number, c: number, d: number) => void;
69
69
  export const livesession_locate: (a: number, b: number, c: number, d: number) => number;
70
70
  export const livesession_pageCount: (a: number) => number;
71
71
  export const livesession_pageSize: (a: number, b: number, c: number) => void;
72
72
  export const livesession_paint: (a: number, b: number, c: number, d: number, e: number) => void;
73
- export const livesession_positionAt: (a: number, b: number, c: number, d: number) => number;
73
+ export const livesession_positionAt: (a: number, b: number, c: number, d: number, e: number) => number;
74
74
  export const livesession_regions: (a: number, b: number) => void;
75
75
  export const livesession_render: (a: number, b: number, c: number) => void;
76
76
  export const livesession_supportsCanvas: (a: number) => number;
@@ -74,8 +74,9 @@ export interface Content {
74
74
 
75
75
  /** One `\n`-separated segment of `Content.text`, in order. `kind` is an open set:
76
76
  * an unknown role round-trips with opaque `attrs` and renders as a paragraph.
77
- * The open arm blocks discriminant narrowing, so read `level`/`lang` behind a
78
- * check of the arm you want. */
77
+ * Every role spells its payload in `attrs`, known or not, so promoting one moves
78
+ * no bytes. The open arm blocks discriminant narrowing, so read
79
+ * `attrs.level`/`attrs.lang` behind a check of the arm you want. */
79
80
  export type ContentLine = {
80
81
  containers: ContentContainer[];
81
82
  /** A within-block hard line break rather than a new block. Omitted (false) in the common case. */
@@ -85,11 +86,11 @@ export type ContentLine = {
85
86
  /** A line's block role, shared by `ContentLine` and the `setKind` op. */
86
87
  export type ContentLineKind =
87
88
  | { kind: "para" }
88
- | { kind: "heading"; level: number }
89
- | { kind: "code"; lang?: string }
89
+ | { kind: "heading"; attrs: { level: number } }
90
+ | { kind: "code"; attrs?: { lang?: string } }
90
91
  | { kind: "island" }
91
92
  | { kind: "rule" }
92
- | { kind: string; attrs: unknown };
93
+ | { kind: string; attrs?: unknown };
93
94
 
94
95
  /** An ancestor block a line nests inside, outermost first. Open like
95
96
  * `ContentLine.kind`: an unrecognized container round-trips with opaque `attrs`
@@ -118,22 +119,26 @@ export type ContentLineKind =
118
119
  * Content parsed from a stored document is the one shape that arrives without
119
120
  * it — storage omits a zero — and needs a cast. */
120
121
  export type ContentContainer =
121
- | { container: "list_item"; ordered: boolean; start: number; ordinal: number; instance: number }
122
+ | {
123
+ container: "list_item";
124
+ attrs: { ordered: boolean; start: number; ordinal: number };
125
+ instance: number;
126
+ }
122
127
  | { container: "quote"; instance: number }
123
- | { container: string; attrs: unknown; instance: number };
128
+ | { container: string; attrs?: unknown; instance: number };
124
129
 
125
130
  /** A mark over char range `[start, end)` into `Content.text`. The open `type`
126
131
  * arm blocks discriminant narrowing, so read a payload-carrying arm behind its
127
- * guard: `isLinkMark` (`url`) / `isAnchorMark` (`id`), from
132
+ * guard: `isLinkMark` (`attrs.url`) / `isAnchorMark` (`attrs.id`), from
128
133
  * `@quillmark/wasm/runtime`. An `anchor`'s `id` is a caller-supplied opaque
129
134
  * handle, unique per `Content` and invariant while the mark lives (positions
130
135
  * rebase, the id never does); it has no markdown projection and survives only
131
136
  * through the edit lane. */
132
137
  export type ContentMark = { start: number; end: number } & (
133
138
  | { type: "strong" | "emph" | "underline" | "strike" | "code" }
134
- | { type: "link"; url: string }
135
- | { type: "anchor"; id: string }
136
- | { type: string; attrs: unknown }
139
+ | { type: "link"; attrs: { url: string } }
140
+ | { type: "anchor"; attrs: { id: string } }
141
+ | { type: string; attrs?: unknown }
137
142
  );
138
143
 
139
144
  /** A cell in a `TableProps`. `marks` rides the prose `ContentMark` shape, but
@@ -560,7 +565,7 @@ export interface ContentHit {
560
565
  */
561
566
  field: string;
562
567
  /**
563
- * USV offset into the field\'s `Content`.
568
+ * USV offset into the field's `Content`.
564
569
  */
565
570
  pos: number;
566
571
  /**
@@ -577,12 +582,12 @@ export interface ContentHit {
577
582
  * (paragraph, heading, whole code fence) and per page each touches, a scalar
578
583
  * referenced at several plate sites surfaces each site, and tracked content
579
584
  * plus a `field:`-bound widget yields both. Group by `field`. The whole-field
580
- * highlight is the union of a page\'s `span`-bearing rects, so inter-paragraph
585
+ * highlight is the union of a page's `span`-bearing rects, so inter-paragraph
581
586
  * whitespace stays uncovered; `LiveSession.fieldBoxes(field)` owns that union.
582
587
  */
583
588
  export interface FieldRegion {
584
589
  /**
585
- * Canonical `DocPath` field address (e.g. `\"cards.indorsement[1].from\"`):
590
+ * Canonical `DocPath` field address (e.g. `"cards.indorsement[1].from"`):
586
591
  * the grammar `parseDocPath` reads and `Diagnostic.path` carries. Feed it
587
592
  * back to `fieldBoxes` / `locate`.
588
593
  */
@@ -596,7 +601,7 @@ export interface FieldRegion {
596
601
  */
597
602
  rect: [number, number, number, number];
598
603
  /**
599
- * The slice this box covers: USV `[start, end)` into the field\'s `Content`
604
+ * The slice this box covers: USV `[start, end)` into the field's `Content`
600
605
  * for one content segment, `undefined` for a scalar site or widget.
601
606
  */
602
607
  span?: [number, number];
@@ -611,7 +616,7 @@ export interface Diagnostic {
611
616
  message: string;
612
617
  location?: Location;
613
618
  /**
614
- * Document-model path anchor (e.g. `\"cards.indorsement[0].signature_block\"`),
619
+ * Document-model path anchor (e.g. `"cards.indorsement[0].signature_block"`),
615
620
  * set on schema validation diagnostics and `undefined` otherwise.
616
621
  */
617
622
  path?: string;
@@ -1072,8 +1077,14 @@ export class LiveSession {
1072
1077
  * `FieldRegion.rect`, so from a canvas click use
1073
1078
  * `x = clickPx.x / renderScale`, `y = pageHeightPt - clickPx.y / renderScale`.
1074
1079
  * Unlike `regions()`, *every* placement answers, not just the first.
1080
+ *
1081
+ * `tolPt` is how far off the ink a click still counts, in the same points,
1082
+ * and defaults to `0` — exact. Convert the pointer slack a surface wants
1083
+ * from CSS pixels at the scale it drew the page (`slackPx / renderScale`),
1084
+ * so it stays the same size under the cursor as the page zooms. The
1085
+ * nearest placement answers, so raising it only fills a miss.
1075
1086
  */
1076
- fieldAt(page: number, x: number, y: number): string | undefined;
1087
+ fieldAt(page: number, x: number, y: number, tol_pt?: number | null): string | undefined;
1077
1088
  /**
1078
1089
  * The whole-field highlight boxes for `field`: one union rect per page over
1079
1090
  * the field's `span`-bearing content segments, the union `regions()` leaves
@@ -1113,11 +1124,15 @@ export class LiveSession {
1113
1124
  /**
1114
1125
  * A point → **content position**: the field *and* a USV offset into its
1115
1126
  * `Content`, for placing a caret or mapping a selection into the content
1116
- * model, or `undefined` off all content ink. `x`/`y` are PDF points,
1117
- * bottom-left origin, as in `fieldAt`. The offset is cluster-exact and
1118
- * degrades to the containing segment's start on origin-less ink.
1127
+ * model, or `undefined` off all content ink. `x`/`y`/`tolPt` are PDF
1128
+ * points, bottom-left origin, as in `fieldAt`. The offset is cluster-exact
1129
+ * and degrades to the containing segment's start on origin-less ink.
1130
+ *
1131
+ * `tolPt` earns the most here: the leading between two lines lies inside a
1132
+ * paragraph and on no glyph, and under `tolPt` such a point takes the
1133
+ * nearer line.
1119
1134
  */
1120
- positionAt(page: number, x: number, y: number): ContentHit | undefined;
1135
+ positionAt(page: number, x: number, y: number, tol_pt?: number | null): ContentHit | undefined;
1121
1136
  /**
1122
1137
  * Schema-field geometry for this compiled session: each content field's
1123
1138
  * **first placement** (one region per page it touches) plus widget and
@@ -1430,13 +1445,13 @@ export interface InitOutput {
1430
1445
  readonly formatDocPath: (a: number, b: number) => void;
1431
1446
  readonly importMarkdown: (a: number, b: number, c: number) => void;
1432
1447
  readonly livesession_backendId: (a: number, b: number) => void;
1433
- readonly livesession_fieldAt: (a: number, b: number, c: number, d: number, e: number) => void;
1448
+ readonly livesession_fieldAt: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
1434
1449
  readonly livesession_fieldBoxes: (a: number, b: number, c: number, d: number) => void;
1435
1450
  readonly livesession_locate: (a: number, b: number, c: number, d: number) => number;
1436
1451
  readonly livesession_pageCount: (a: number) => number;
1437
1452
  readonly livesession_pageSize: (a: number, b: number, c: number) => void;
1438
1453
  readonly livesession_paint: (a: number, b: number, c: number, d: number, e: number) => void;
1439
- readonly livesession_positionAt: (a: number, b: number, c: number, d: number) => number;
1454
+ readonly livesession_positionAt: (a: number, b: number, c: number, d: number, e: number) => number;
1440
1455
  readonly livesession_regions: (a: number, b: number) => void;
1441
1456
  readonly livesession_render: (a: number, b: number, c: number) => void;
1442
1457
  readonly livesession_supportsCanvas: (a: number) => number;
@@ -1410,15 +1410,22 @@ export class LiveSession {
1410
1410
  * `FieldRegion.rect`, so from a canvas click use
1411
1411
  * `x = clickPx.x / renderScale`, `y = pageHeightPt - clickPx.y / renderScale`.
1412
1412
  * Unlike `regions()`, *every* placement answers, not just the first.
1413
+ *
1414
+ * `tolPt` is how far off the ink a click still counts, in the same points,
1415
+ * and defaults to `0` — exact. Convert the pointer slack a surface wants
1416
+ * from CSS pixels at the scale it drew the page (`slackPx / renderScale`),
1417
+ * so it stays the same size under the cursor as the page zooms. The
1418
+ * nearest placement answers, so raising it only fills a miss.
1413
1419
  * @param {number} page
1414
1420
  * @param {number} x
1415
1421
  * @param {number} y
1422
+ * @param {number | null} [tol_pt]
1416
1423
  * @returns {string | undefined}
1417
1424
  */
1418
- fieldAt(page, x, y) {
1425
+ fieldAt(page, x, y, tol_pt) {
1419
1426
  try {
1420
1427
  const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
1421
- wasm.livesession_fieldAt(retptr, this.__wbg_ptr, page, x, y);
1428
+ wasm.livesession_fieldAt(retptr, this.__wbg_ptr, page, x, y, isLikeNone(tol_pt) ? 0x100000001 : Math.fround(tol_pt));
1422
1429
  var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
1423
1430
  var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
1424
1431
  let v1;
@@ -1537,16 +1544,21 @@ export class LiveSession {
1537
1544
  /**
1538
1545
  * A point → **content position**: the field *and* a USV offset into its
1539
1546
  * `Content`, for placing a caret or mapping a selection into the content
1540
- * model, or `undefined` off all content ink. `x`/`y` are PDF points,
1541
- * bottom-left origin, as in `fieldAt`. The offset is cluster-exact and
1542
- * degrades to the containing segment's start on origin-less ink.
1547
+ * model, or `undefined` off all content ink. `x`/`y`/`tolPt` are PDF
1548
+ * points, bottom-left origin, as in `fieldAt`. The offset is cluster-exact
1549
+ * and degrades to the containing segment's start on origin-less ink.
1550
+ *
1551
+ * `tolPt` earns the most here: the leading between two lines lies inside a
1552
+ * paragraph and on no glyph, and under `tolPt` such a point takes the
1553
+ * nearer line.
1543
1554
  * @param {number} page
1544
1555
  * @param {number} x
1545
1556
  * @param {number} y
1557
+ * @param {number | null} [tol_pt]
1546
1558
  * @returns {ContentHit | undefined}
1547
1559
  */
1548
- positionAt(page, x, y) {
1549
- const ret = wasm.livesession_positionAt(this.__wbg_ptr, page, x, y);
1560
+ positionAt(page, x, y, tol_pt) {
1561
+ const ret = wasm.livesession_positionAt(this.__wbg_ptr, page, x, y, isLikeNone(tol_pt) ? 0x100000001 : Math.fround(tol_pt));
1550
1562
  return takeObject(ret);
1551
1563
  }
1552
1564
  /**
Binary file
@@ -64,13 +64,13 @@ export const exportMarkdown: (a: number, b: number) => void;
64
64
  export const formatDocPath: (a: number, b: number) => void;
65
65
  export const importMarkdown: (a: number, b: number, c: number) => void;
66
66
  export const livesession_backendId: (a: number, b: number) => void;
67
- export const livesession_fieldAt: (a: number, b: number, c: number, d: number, e: number) => void;
67
+ export const livesession_fieldAt: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
68
68
  export const livesession_fieldBoxes: (a: number, b: number, c: number, d: number) => void;
69
69
  export const livesession_locate: (a: number, b: number, c: number, d: number) => number;
70
70
  export const livesession_pageCount: (a: number) => number;
71
71
  export const livesession_pageSize: (a: number, b: number, c: number) => void;
72
72
  export const livesession_paint: (a: number, b: number, c: number, d: number, e: number) => void;
73
- export const livesession_positionAt: (a: number, b: number, c: number, d: number) => number;
73
+ export const livesession_positionAt: (a: number, b: number, c: number, d: number, e: number) => number;
74
74
  export const livesession_regions: (a: number, b: number) => void;
75
75
  export const livesession_render: (a: number, b: number, c: number) => void;
76
76
  export const livesession_supportsCanvas: (a: number) => number;
package/core/wasm.d.ts CHANGED
@@ -74,8 +74,9 @@ export interface Content {
74
74
 
75
75
  /** One `\n`-separated segment of `Content.text`, in order. `kind` is an open set:
76
76
  * an unknown role round-trips with opaque `attrs` and renders as a paragraph.
77
- * The open arm blocks discriminant narrowing, so read `level`/`lang` behind a
78
- * check of the arm you want. */
77
+ * Every role spells its payload in `attrs`, known or not, so promoting one moves
78
+ * no bytes. The open arm blocks discriminant narrowing, so read
79
+ * `attrs.level`/`attrs.lang` behind a check of the arm you want. */
79
80
  export type ContentLine = {
80
81
  containers: ContentContainer[];
81
82
  /** A within-block hard line break rather than a new block. Omitted (false) in the common case. */
@@ -85,11 +86,11 @@ export type ContentLine = {
85
86
  /** A line's block role, shared by `ContentLine` and the `setKind` op. */
86
87
  export type ContentLineKind =
87
88
  | { kind: "para" }
88
- | { kind: "heading"; level: number }
89
- | { kind: "code"; lang?: string }
89
+ | { kind: "heading"; attrs: { level: number } }
90
+ | { kind: "code"; attrs?: { lang?: string } }
90
91
  | { kind: "island" }
91
92
  | { kind: "rule" }
92
- | { kind: string; attrs: unknown };
93
+ | { kind: string; attrs?: unknown };
93
94
 
94
95
  /** An ancestor block a line nests inside, outermost first. Open like
95
96
  * `ContentLine.kind`: an unrecognized container round-trips with opaque `attrs`
@@ -118,22 +119,26 @@ export type ContentLineKind =
118
119
  * Content parsed from a stored document is the one shape that arrives without
119
120
  * it — storage omits a zero — and needs a cast. */
120
121
  export type ContentContainer =
121
- | { container: "list_item"; ordered: boolean; start: number; ordinal: number; instance: number }
122
+ | {
123
+ container: "list_item";
124
+ attrs: { ordered: boolean; start: number; ordinal: number };
125
+ instance: number;
126
+ }
122
127
  | { container: "quote"; instance: number }
123
- | { container: string; attrs: unknown; instance: number };
128
+ | { container: string; attrs?: unknown; instance: number };
124
129
 
125
130
  /** A mark over char range `[start, end)` into `Content.text`. The open `type`
126
131
  * arm blocks discriminant narrowing, so read a payload-carrying arm behind its
127
- * guard: `isLinkMark` (`url`) / `isAnchorMark` (`id`), from
132
+ * guard: `isLinkMark` (`attrs.url`) / `isAnchorMark` (`attrs.id`), from
128
133
  * `@quillmark/wasm/runtime`. An `anchor`'s `id` is a caller-supplied opaque
129
134
  * handle, unique per `Content` and invariant while the mark lives (positions
130
135
  * rebase, the id never does); it has no markdown projection and survives only
131
136
  * through the edit lane. */
132
137
  export type ContentMark = { start: number; end: number } & (
133
138
  | { type: "strong" | "emph" | "underline" | "strike" | "code" }
134
- | { type: "link"; url: string }
135
- | { type: "anchor"; id: string }
136
- | { type: string; attrs: unknown }
139
+ | { type: "link"; attrs: { url: string } }
140
+ | { type: "anchor"; attrs: { id: string } }
141
+ | { type: string; attrs?: unknown }
137
142
  );
138
143
 
139
144
  /** A cell in a `TableProps`. `marks` rides the prose `ContentMark` shape, but
@@ -503,7 +508,7 @@ export interface Diagnostic {
503
508
  message: string;
504
509
  location?: Location;
505
510
  /**
506
- * Document-model path anchor (e.g. `\"cards.indorsement[0].signature_block\"`),
511
+ * Document-model path anchor (e.g. `"cards.indorsement[0].signature_block"`),
507
512
  * set on schema validation diagnostics and `undefined` otherwise.
508
513
  */
509
514
  path?: string;
package/core/wasm_bg.wasm CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/wasm",
3
- "version": "0.111.0",
3
+ "version": "0.112.0",
4
4
  "description": "WebAssembly bindings for Quillmark, a schema-driven document engine",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -219,34 +219,32 @@ export declare function isImageIsland(
219
219
  island: ContentIsland
220
220
  ): island is ContentIsland & { type: 'image'; props: ImageProps };
221
221
 
222
- /** Narrow a {@link ContentMark} to the `link` arm (carries `url`). */
222
+ /** Narrow a {@link ContentMark} to the `link` arm (carries `attrs.url`). */
223
223
  export declare function isLinkMark(
224
224
  mark: ContentMark
225
- ): mark is ContentMark & { type: 'link'; url: string };
225
+ ): mark is ContentMark & { type: 'link'; attrs: { url: string } };
226
226
 
227
- /** Narrow a {@link ContentMark} to the `anchor` arm (carries `id`). */
227
+ /** Narrow a {@link ContentMark} to the `anchor` arm (carries `attrs.id`). */
228
228
  export declare function isAnchorMark(
229
229
  mark: ContentMark
230
- ): mark is ContentMark & { type: 'anchor'; id: string };
230
+ ): mark is ContentMark & { type: 'anchor'; attrs: { id: string } };
231
231
 
232
- /** Narrow a {@link ContentLine} to the `heading` arm (carries `level`). */
232
+ /** Narrow a {@link ContentLine} to the `heading` arm (carries `attrs.level`). */
233
233
  export declare function isHeadingLine(
234
234
  line: ContentLine
235
- ): line is ContentLine & { kind: 'heading'; level: number };
235
+ ): line is ContentLine & { kind: 'heading'; attrs: { level: number } };
236
236
 
237
- /** Narrow a {@link ContentLine} to the `code` arm (carries `lang`). */
237
+ /** Narrow a {@link ContentLine} to the `code` arm (carries `attrs.lang`). */
238
238
  export declare function isCodeLine(
239
239
  line: ContentLine
240
- ): line is ContentLine & { kind: 'code'; lang?: string };
240
+ ): line is ContentLine & { kind: 'code'; attrs?: { lang?: string } };
241
241
 
242
242
  /** Narrow a {@link ContentContainer} to the `list_item` arm (carries its shape). */
243
243
  export declare function isListItemContainer(
244
244
  container: ContentContainer
245
245
  ): container is ContentContainer & {
246
246
  container: 'list_item';
247
- ordered: boolean;
248
- start: number;
249
- ordinal: number;
247
+ attrs: { ordered: boolean; start: number; ordinal: number };
250
248
  instance: number;
251
249
  };
252
250
 
@@ -257,8 +255,9 @@ export declare function isListItemContainer(
257
255
  // built-in names by hand re-couples to a closed set.
258
256
  //
259
257
  // They classify unknown TAGS, not unknown payloads on known tags: a future
260
- // `kind: "footnote"` carrying a sibling `ref` loses `ref` at any consumer that
261
- // predates it, with or without these.
258
+ // `kind: "footnote"` carrying an `attrs.ref` loses `ref` at any consumer that
259
+ // predates it, with or without these. The spelling needs no classifying: a
260
+ // payload rides `attrs` whether or not this build knows the name.
262
261
 
263
262
  /** True when this build does not know `line.kind`: the open arm, carrying opaque `attrs`. */
264
263
  export declare function isUnknownLine(
@@ -612,13 +611,24 @@ export declare class LiveSession {
612
611
  * transform documented there: `x = clickPx.x / renderScale`,
613
612
  * `y = pageHeightPt - clickPx.y / renderScale`. Unlike {@link regions},
614
613
  * *every* placement answers, not just the first.
614
+ *
615
+ * `tolPt` is how far off the ink a click still counts, in the same points,
616
+ * and defaults to `0` — exact. It is pointer slack, so derive it from the
617
+ * scale the page was drawn at (`slackPx / renderScale`) rather than fixing a
618
+ * value in points, which shrinks under the cursor as the page zooms out. The
619
+ * nearest placement answers and containment is distance zero, so raising
620
+ * `tolPt` only ever fills a miss.
615
621
  */
616
- fieldAt(page: number, x: number, y: number): string | undefined;
622
+ fieldAt(page: number, x: number, y: number, tolPt?: number): string | undefined;
617
623
  /**
618
624
  * Fine-grained click → content position (caret placement). Same PDF-point
619
- * space as {@link fieldAt}; `undefined` off all content ink.
625
+ * space as {@link fieldAt}; `undefined` past `tolPt` from all content ink.
626
+ *
627
+ * `tolPt` buys the most here: the leading between two lines lies inside a
628
+ * paragraph and on no glyph, and under `tolPt` a point there takes the line
629
+ * it is nearer.
620
630
  */
621
- positionAt(page: number, x: number, y: number): ContentHit | undefined;
631
+ positionAt(page: number, x: number, y: number, tolPt?: number): ContentHit | undefined;
622
632
  /**
623
633
  * Content position → caret rect: reverse of {@link positionAt}. `field` is a
624
634
  * canonical `DocPath` address (`parseDocPath`-routable), as {@link regions} keys.
@@ -398,12 +398,12 @@ for (const name of /** @type {const} */ (['validate', 'resolve', 'conform'])) {
398
398
  // leaving `props` / the mark payload / `level` opaque at every consumer. These
399
399
  // are the checked narrowing path: on the true branch the payload's pinned shape
400
400
  // is asserted. Only the payload-carrying arms get a guard: an island always
401
- // carries `props`, a `link` mark carries `url`, an `anchor` mark carries `id`, a
402
- // `heading` line carries `level` and a `code` line `lang`, a `list_item`
403
- // container its shape; the payload-free arms (`strong`/`emph`/`underline`/
404
- // `strike`/`code` marks, `para`/`island`/`rule` lines, `quote`) narrow to
405
- // nothing. An unrecognized discriminant fails every guard and keeps its opaque
406
- // `attrs`/`props`.
401
+ // carries `props`, and a `link`/`anchor` mark, a `heading`/`code` line and a
402
+ // `list_item` container each carry their payload in `attrs`; the payload-free
403
+ // arms (`strong`/`emph`/`underline`/`strike`/`code` marks, `para`/`island`/
404
+ // `rule` lines, `quote`) omit `attrs` and narrow to nothing. An unrecognized
405
+ // discriminant fails every guard and carries the same `attrs` a known one
406
+ // would.
407
407
 
408
408
  /**
409
409
  * @param {import('../core/wasm.js').ContentIsland} island
@@ -423,7 +423,7 @@ export function isImageIsland(island) {
423
423
 
424
424
  /**
425
425
  * @param {import('../core/wasm.js').ContentMark} mark
426
- * @returns {mark is import('../core/wasm.js').ContentMark & { type: 'link'; url: string }}
426
+ * @returns {mark is import('../core/wasm.js').ContentMark & { type: 'link'; attrs: { url: string } }}
427
427
  */
428
428
  export function isLinkMark(mark) {
429
429
  return mark.type === 'link';
@@ -431,7 +431,7 @@ export function isLinkMark(mark) {
431
431
 
432
432
  /**
433
433
  * @param {import('../core/wasm.js').ContentMark} mark
434
- * @returns {mark is import('../core/wasm.js').ContentMark & { type: 'anchor'; id: string }}
434
+ * @returns {mark is import('../core/wasm.js').ContentMark & { type: 'anchor'; attrs: { id: string } }}
435
435
  */
436
436
  export function isAnchorMark(mark) {
437
437
  return mark.type === 'anchor';
@@ -439,7 +439,7 @@ export function isAnchorMark(mark) {
439
439
 
440
440
  /**
441
441
  * @param {import('../core/wasm.js').ContentLine} line
442
- * @returns {line is import('../core/wasm.js').ContentLine & { kind: 'heading'; level: number }}
442
+ * @returns {line is import('../core/wasm.js').ContentLine & { kind: 'heading'; attrs: { level: number } }}
443
443
  */
444
444
  export function isHeadingLine(line) {
445
445
  return line.kind === 'heading';
@@ -447,7 +447,7 @@ export function isHeadingLine(line) {
447
447
 
448
448
  /**
449
449
  * @param {import('../core/wasm.js').ContentLine} line
450
- * @returns {line is import('../core/wasm.js').ContentLine & { kind: 'code'; lang?: string }}
450
+ * @returns {line is import('../core/wasm.js').ContentLine & { kind: 'code'; attrs?: { lang?: string } }}
451
451
  */
452
452
  export function isCodeLine(line) {
453
453
  return line.kind === 'code';
@@ -455,7 +455,7 @@ export function isCodeLine(line) {
455
455
 
456
456
  /**
457
457
  * @param {import('../core/wasm.js').ContentContainer} container
458
- * @returns {container is import('../core/wasm.js').ContentContainer & { container: 'list_item'; ordered: boolean; start: number; ordinal: number; instance?: number }}
458
+ * @returns {container is import('../core/wasm.js').ContentContainer & { container: 'list_item'; attrs: { ordered: boolean; start: number; ordinal: number }; instance?: number }}
459
459
  */
460
460
  export function isListItemContainer(container) {
461
461
  return container.container === 'list_item';
@@ -517,12 +517,13 @@ export function isUnknownIsland(island) {
517
517
  // form cannot tell a list ending beside another from one list of two items, so
518
518
  // an omitted discriminator welds them and nothing reports it.
519
519
  //
520
- // WELD_KEYS is the rule `Container::same_weld` owns upstream: which fields two
521
- // adjacent runs must share for the markdown projection to read them as one, and
522
- // therefore for the canonical form to have to spend a discriminator. `start` is
523
- // not among them, since CommonMark reads only a list's first number. A table
524
- // rather than a switch, so `tests/known_names_drift.rs` can pin it against the
525
- // Rust predicate.
520
+ // WELD_KEYS is the rule `Container::same_weld` owns upstream: which `attrs`
521
+ // entries two adjacent runs must share for the markdown projection to read them
522
+ // as one, and therefore for the canonical form to have to spend a
523
+ // discriminator. `start` is not among them, since CommonMark reads only a
524
+ // list's first number — a subset, which is why a built-in needs an entry rather
525
+ // than the unknown branch's whole-bag compare. A table rather than a switch, so
526
+ // `tests/known_names_drift.rs` can pin it against the Rust predicate.
526
527
 
527
528
  const WELD_KEYS = { list_item: ['ordered'], quote: [] };
528
529
 
@@ -549,7 +550,7 @@ function weldsWith(a, b) {
549
550
  // `hasOwn`, so a tag colliding with an `Object.prototype` member reaches the
550
551
  // unknown branch rather than a function.
551
552
  if (!Object.hasOwn(WELD_KEYS, a.container)) return sameJson(a.attrs, b.attrs);
552
- return WELD_KEYS[a.container].every((k) => a[k] === b[k]);
553
+ return WELD_KEYS[a.container].every((k) => a.attrs?.[k] === b.attrs?.[k]);
553
554
  }
554
555
 
555
556
  /**
@@ -962,20 +963,22 @@ export class LiveSession {
962
963
  * @param {number} page
963
964
  * @param {number} x
964
965
  * @param {number} y
966
+ * @param {number} [tolPt]
965
967
  * @returns {string | undefined}
966
968
  */
967
- fieldAt(page, x, y) {
968
- return this.#inner.fieldAt(page, x, y);
969
+ fieldAt(page, x, y, tolPt) {
970
+ return this.#inner.fieldAt(page, x, y, tolPt);
969
971
  }
970
972
 
971
973
  /**
972
974
  * @param {number} page
973
975
  * @param {number} x
974
976
  * @param {number} y
977
+ * @param {number} [tolPt]
975
978
  * @returns {import('./runtime.d.ts').ContentHit | undefined}
976
979
  */
977
- positionAt(page, x, y) {
978
- return this.#inner.positionAt(page, x, y);
980
+ positionAt(page, x, y, tolPt) {
981
+ return this.#inner.positionAt(page, x, y, tolPt);
979
982
  }
980
983
 
981
984
  /**