@quillmark/svelte 0.10.0 → 0.11.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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/THEMING.md +4 -2
  3. package/dist/core/codec/blocks.js +21 -1
  4. package/dist/core/codec/breaks.d.ts +10 -2
  5. package/dist/core/codec/breaks.js +65 -9
  6. package/dist/core/codec/code.d.ts +6 -4
  7. package/dist/core/codec/code.js +7 -4
  8. package/dist/core/codec/decode.js +91 -62
  9. package/dist/core/codec/encode.d.ts +5 -8
  10. package/dist/core/codec/encode.js +55 -85
  11. package/dist/core/codec/field.js +43 -24
  12. package/dist/core/codec/index.d.ts +2 -1
  13. package/dist/core/codec/index.js +6 -3
  14. package/dist/core/codec/islands.d.ts +13 -9
  15. package/dist/core/codec/islands.js +75 -49
  16. package/dist/core/codec/keymap.js +2 -1
  17. package/dist/core/codec/marks.d.ts +17 -13
  18. package/dist/core/codec/marks.js +39 -42
  19. package/dist/core/codec/positions.d.ts +4 -1
  20. package/dist/core/codec/positions.js +5 -1
  21. package/dist/core/codec/prose.css +10 -0
  22. package/dist/core/codec/reconcile.d.ts +6 -0
  23. package/dist/core/codec/reconcile.js +19 -0
  24. package/dist/core/codec/schema.d.ts +5 -8
  25. package/dist/core/codec/schema.js +30 -156
  26. package/dist/core/codec/table-view.js +77 -26
  27. package/dist/core/codec/table.d.ts +6 -5
  28. package/dist/core/codec/table.js +14 -31
  29. package/dist/core/codec/urls.d.ts +20 -0
  30. package/dist/core/codec/urls.js +45 -0
  31. package/dist/preset/recipes.css +1 -1
  32. package/dist/preset/scale.css +7 -4
  33. package/dist/preview/Preview.svelte +6 -0
  34. package/dist/preview/bridge.d.ts +8 -1
  35. package/dist/preview/bridge.js +31 -25
  36. package/dist/preview/controller.js +13 -13
  37. package/dist/preview/geometry.d.ts +12 -1
  38. package/dist/preview/geometry.js +14 -1
  39. package/dist/preview/paint.js +6 -4
  40. package/dist/preview/strings.d.ts +0 -2
  41. package/dist/preview/strings.js +5 -6
  42. package/dist/visual/ArrayField.svelte +11 -13
  43. package/dist/visual/Card.svelte +5 -8
  44. package/dist/visual/DateField.svelte +2 -2
  45. package/dist/visual/Field.svelte +7 -3
  46. package/dist/visual/FieldHint.svelte +1 -1
  47. package/dist/visual/ObjectField.svelte +6 -1
  48. package/dist/visual/VisualEditor.svelte +1 -1
  49. package/dist/visual/VisualEditorInner.svelte +73 -18
  50. package/dist/visual/controls.css +8 -9
  51. package/dist/visual/ext.d.ts +12 -13
  52. package/dist/visual/ext.js +22 -19
  53. package/dist/visual/index.js +2 -3
  54. package/dist/visual/links.d.ts +4 -0
  55. package/dist/visual/links.js +15 -4
  56. package/dist/visual/strings.d.ts +7 -4
  57. package/dist/visual/strings.js +1 -0
  58. package/dist/visual/structure.d.ts +9 -1
  59. package/dist/visual/structure.js +26 -4
  60. package/package.json +2 -2
package/README.md CHANGED
@@ -40,7 +40,7 @@ const doc = quill.seedDocument();
40
40
 
41
41
  // An EXISTING document: parsed back from what you stored.
42
42
  const doc = Document.fromMarkdown(markdown); // canonical Quillmark markdown
43
- const doc = Document.fromJson(json); // the versioned storage DTO (`doc.toJson()`)
43
+ const doc = Document.fromStored(json); // the versioned storage DTO (`doc.toStored()`)
44
44
 
45
45
  const session = await new Engine().open(quill, doc);
46
46
  // free() on teardown the handles you MINTED: this quill, this doc, this session.
package/THEMING.md CHANGED
@@ -24,6 +24,8 @@ Nothing to import and nothing to set. The package pulls its own stylesheet, and
24
24
 
25
25
  **Placing and sizing**: each surface carries a stable root class (`.qm-editor`, `.qm-preview`), so you can place it from your own stylesheet without a wrapper. Each also takes a `class` prop that merges onto the same element, which is the better handle when you have one to give.
26
26
 
27
+ A surface's width is the one you give it. Each contains its own inline size, so what a document holds — a table is as wide as its columns — scrolls inside the mount rather than widening the column the mount stands in.
28
+
27
29
  **Taking the column back**: our selectors compute at specificity zero (below), so `padding: 0` or a `background` of your own on `.qm-editor` wins outright. Nothing is locked.
28
30
 
29
31
  ## Make it yours
@@ -121,7 +123,7 @@ import '@quillmark/svelte/preset';
121
123
  ```html
122
124
  <body class="qm-page">
123
125
  <p class="qm-label">quill</p>
124
- <pre class="qm-readout">specimen@1.0.0</pre>
126
+ <pre class="qm-readout">showcase@1.0.0</pre>
125
127
  </body>
126
128
  ```
127
129
 
@@ -191,7 +193,7 @@ A blanket `:focus-visible` of yours outranks ours, including on the controls tha
191
193
 
192
194
  ## What is deliberately not public
193
195
 
194
- The three root classes and `.qm-pane` are the whole class contract on the surfaces. Every other `qm-*` class inside them is internal and renames without notice: a class contract over the interior would freeze the DOM shape the surfaces are free to re-cut, and the dials already reach the values a restyle wants. The preset's classes are a contract of their own: they land on _your_ DOM, so they freeze nothing of ours.
196
+ The two root classes and `.qm-pane` are the whole class contract on the surfaces. Every other `qm-*` class inside them is internal and renames without notice: a class contract over the interior would freeze the DOM shape the surfaces are free to re-cut, and the dials already reach the values a restyle wants. The preset's classes are a contract of their own: they land on _your_ DOM, so they freeze nothing of ours.
195
197
 
196
198
  The derived surface scale (surface / border / ink rungs, the blur radius, the popover's translucency ratio, the recede-opacity ladder, the two leading rungs, the overlay ring widths, the scroll tail) is internal on the same terms, and declared **on** each root element, so setting a rung from an ancestor does nothing. A dial appears when a real consumer needs one; every dial is one more thing a reader has to hold.
197
199
 
@@ -1,6 +1,7 @@
1
1
  import { Selection } from 'prosemirror-state';
2
2
  import { chainCommands, splitBlock } from 'prosemirror-commands';
3
3
  import { canJoin, findWrapping } from 'prosemirror-transform';
4
+ import { takesLineBreak } from './schema.js';
4
5
  /** The head of a textblock: where a shorthand fires, and where a pick lands once its
5
6
  * run is consumed. */
6
7
  function atHead(state) {
@@ -33,10 +34,29 @@ function retype(name, attrs) {
33
34
  const { $from } = state.selection;
34
35
  if (!type || !atHead(state) || !fits($from, type))
35
36
  return false;
36
- dispatch?.(state.tr.setBlockType($from.pos, $from.pos, type, attrs));
37
+ const tr = state.tr;
38
+ spaceBreaks(tr, $from, type);
39
+ dispatch?.(tr.setBlockType($from.pos, $from.pos, type, attrs));
37
40
  return true;
38
41
  };
39
42
  }
43
+ /** Replace every `hard_break` in the caret's block with a space, ahead of a retype into
44
+ * a type that holds none. `setBlockType` clears what the new type cannot hold by
45
+ * deleting it, which joins the text either side with nothing and glues the words; a
46
+ * space is what the break becomes at every other door (`breaks.ts`, `decode.ts`). A
47
+ * break and a space are both one position wide, so an earlier replacement leaves every
48
+ * later one addressed. */
49
+ function spaceBreaks(tr, $from, type) {
50
+ const br = tr.doc.type.schema.nodes.hard_break;
51
+ if (!br || takesLineBreak(type))
52
+ return;
53
+ let at = $from.start();
54
+ $from.parent.forEach((child) => {
55
+ if (child.type === br)
56
+ tr.replaceWith(at, at + child.nodeSize, type.schema.text(' ', type.allowedMarks(child.marks)));
57
+ at += child.nodeSize;
58
+ });
59
+ }
40
60
  /** `wrappingInputRule`'s body: wrap the block range at `pos`, then join a preceding
41
61
  * sibling of the same type — the one boundary the wrap itself opens (`lists.ts`
42
62
  * §cleanup: command-local, never a global pass). */
@@ -1,10 +1,18 @@
1
1
  import type { Schema } from 'prosemirror-model';
2
- import { Plugin } from 'prosemirror-state';
2
+ import { Plugin, type Command } from 'prosemirror-state';
3
3
  /**
4
- * Rewrite every `\n` a transaction leaves in a non-`code` textblock to a `hard_break`.
4
+ * Rewrite every `\n` a transaction leaves in a non-`code` textblock: to a `hard_break`
5
+ * where the block takes one, to a space where it does not.
5
6
  *
6
7
  * Reads the schema rather than a flag: the inline schemas declare neither node, and
7
8
  * nothing puts a `\n` in one — a paste parses its whitespace away and the keys type
8
9
  * characters.
9
10
  */
10
11
  export declare function linebreakPlugin(schema: Schema): Plugin;
12
+ /**
13
+ * The line-break link of the body's key chains: `{}` for the inline schemas, which
14
+ * declare no break node. A `richtext(inline)` field is one line by construction
15
+ * (`Content::is_inline`), so there the key is swallowed beside Enter (`field.ts`)
16
+ * rather than answered here.
17
+ */
18
+ export declare function breakKeymap(schema: Schema): Record<string, Command>;
@@ -1,6 +1,8 @@
1
- import { Plugin } from 'prosemirror-state';
1
+ import { Plugin, TextSelection } from 'prosemirror-state';
2
+ import { takesLineBreak } from './schema.js';
2
3
  /**
3
- * Rewrite every `\n` a transaction leaves in a non-`code` textblock to a `hard_break`.
4
+ * Rewrite every `\n` a transaction leaves in a non-`code` textblock: to a `hard_break`
5
+ * where the block takes one, to a space where it does not.
4
6
  *
5
7
  * Reads the schema rather than a flag: the inline schemas declare neither node, and
6
8
  * nothing puts a `\n` in one — a paste parses its whitespace away and the keys type
@@ -13,25 +15,79 @@ export function linebreakPlugin(schema) {
13
15
  if (!br || !trs.some((tr) => tr.docChanged))
14
16
  return null;
15
17
  const at = [];
16
- state.doc.descendants((node, pos) => {
18
+ state.doc.descendants((node, pos, parent) => {
17
19
  // A code block's newlines are its content (`whitespace: 'pre'`).
18
20
  if (node.type.spec.code)
19
21
  return false;
20
- if (!node.isText)
22
+ if (!node.isText || !parent)
21
23
  return true;
24
+ // A space carries the marks the `\n` it stands in for was under, the way
25
+ // PM's own `clearIncompatible` fills one: without them a bolded break
26
+ // leaves an unbolded space, and one mark becomes two in the store.
27
+ const fill = takesLineBreak(parent.type)
28
+ ? br.create()
29
+ : schema.text(' ', parent.type.allowedMarks(node.marks));
22
30
  const text = node.text ?? '';
23
31
  for (let i = text.indexOf('\n'); i >= 0; i = text.indexOf('\n', i + 1))
24
- at.push(pos + i);
32
+ at.push({ pos: pos + i, fill });
25
33
  return true;
26
34
  });
27
35
  if (!at.length)
28
36
  return null;
29
- // A break and the `\n` it replaces are both one position wide, so an earlier
30
- // replacement leaves every later one addressed.
37
+ // A break, a space and the `\n` either replaces are all one position wide, so
38
+ // an earlier replacement leaves every later one addressed.
31
39
  const tr = state.tr;
32
- for (const pos of at)
33
- tr.replaceWith(pos, pos + 1, br.create());
40
+ for (const it of at)
41
+ tr.replaceWith(it.pos, it.pos + 1, it.fill);
34
42
  return tr;
35
43
  }
36
44
  });
37
45
  }
46
+ /**
47
+ * Shift-Enter: a line break inside the caret's own block — the `continues` line a hard
48
+ * break is, never a second block.
49
+ *
50
+ * A selection that is not a text range is swallowed outright: an island is the subject of
51
+ * the next command, never a thing armed for replacement (`atoms.ts`). Otherwise the
52
+ * caret's own block decides. It **declines** in a code block, where the newline Enter
53
+ * takes already *is* that line and the code link binds the key one surface in
54
+ * (`code.ts`); it **swallows** the key where the block holds no break (`takesLineBreak`),
55
+ * a heading being the one of those; everywhere else it inserts the break.
56
+ *
57
+ * **Swallowing is the guard, not a spelling of declining.** A key the leaf declines is
58
+ * the browser's, a contenteditable answers this one with a `<br>`, and a heading that
59
+ * merely declined would take the space a `<br>` parses to.
60
+ *
61
+ * The break takes no marks and the caret keeps them: `decode` mints an unmarked break,
62
+ * so an inherited mark would leave the leaf holding a shape a re-hydrate swaps out
63
+ * (CODEC §Encode, one spelling of each shape), while dropping the marks from the
64
+ * *caret* would end a bold run at a line break the author only meant to wrap at.
65
+ */
66
+ function insertBreak(schema) {
67
+ const br = schema.nodes.hard_break;
68
+ return (state, dispatch) => {
69
+ if (!(state.selection instanceof TextSelection))
70
+ return true;
71
+ const { parent } = state.selection.$from;
72
+ if (parent.type.spec.code)
73
+ return false;
74
+ if (!takesLineBreak(parent.type))
75
+ return true;
76
+ if (dispatch) {
77
+ const marks = state.storedMarks ?? state.selection.$from.marks();
78
+ dispatch(state.tr.replaceSelectionWith(br.create(), false).setStoredMarks(marks).scrollIntoView());
79
+ }
80
+ return true;
81
+ };
82
+ }
83
+ /**
84
+ * The line-break link of the body's key chains: `{}` for the inline schemas, which
85
+ * declare no break node. A `richtext(inline)` field is one line by construction
86
+ * (`Content::is_inline`), so there the key is swallowed beside Enter (`field.ts`)
87
+ * rather than answered here.
88
+ */
89
+ export function breakKeymap(schema) {
90
+ if (!schema.nodes.hard_break)
91
+ return {};
92
+ return { 'Shift-Enter': insertBreak(schema) };
93
+ }
@@ -6,9 +6,11 @@ import { type Command } from 'prosemirror-state';
6
6
  *
7
7
  * `Enter` is upstream's `newlineInCode`, and it is load-bearing rather than
8
8
  * cosmetic: inside a `list_item > code_block` the list link's `splitListItem`
9
- * otherwise splits the item in two, each half holding a code block. Every command
10
- * here declines away from a code block — inside one for the three keys about its
11
- * text, against one for the two about its edge — so the list links keep the keys
12
- * elsewhere.
9
+ * otherwise splits the item in two, each half holding a code block. `Shift-Enter` is
10
+ * the same command because it is the same edit: the line break the key means
11
+ * everywhere else (`breaks.ts`) is, inside a fence, the newline Enter already takes.
12
+ * Every command here declines away from a code block — inside one for the four keys
13
+ * about its text, against one for the two about its edge — so the list links keep the
14
+ * keys elsewhere.
13
15
  */
14
16
  export declare function codeKeymap(schema: Schema): Record<string, Command>;
@@ -158,10 +158,12 @@ function retypeAtProseEdge(side) {
158
158
  *
159
159
  * `Enter` is upstream's `newlineInCode`, and it is load-bearing rather than
160
160
  * cosmetic: inside a `list_item > code_block` the list link's `splitListItem`
161
- * otherwise splits the item in two, each half holding a code block. Every command
162
- * here declines away from a code block — inside one for the three keys about its
163
- * text, against one for the two about its edge — so the list links keep the keys
164
- * elsewhere.
161
+ * otherwise splits the item in two, each half holding a code block. `Shift-Enter` is
162
+ * the same command because it is the same edit: the line break the key means
163
+ * everywhere else (`breaks.ts`) is, inside a fence, the newline Enter already takes.
164
+ * Every command here declines away from a code block — inside one for the four keys
165
+ * about its text, against one for the two about its edge — so the list links keep the
166
+ * keys elsewhere.
165
167
  */
166
168
  export function codeKeymap(schema) {
167
169
  if (!schema.nodes.code_block)
@@ -170,6 +172,7 @@ export function codeKeymap(schema) {
170
172
  Tab: indentInCode,
171
173
  'Shift-Tab': outdentInCode,
172
174
  Enter: newlineInCode,
175
+ 'Shift-Enter': newlineInCode,
173
176
  Backspace: retypeAtProseEdge(-1),
174
177
  Delete: retypeAtProseEdge(1)
175
178
  };
@@ -10,8 +10,7 @@
10
10
  import { DOMSerializer } from 'prosemirror-model';
11
11
  import { ISLAND_SLOT } from './islands.js';
12
12
  import { descriptorOf, markKey, pmMarkFromContent } from './marks.js';
13
- import { hasMarks, isInlineSchema } from './schema.js';
14
- import { isAnchorMark, isCodeLine, isHeadingLine, isListItemContainer } from '@quillmark/wasm';
13
+ import { hasMarks, isInlineSchema, takesLineBreak } from './schema.js';
15
14
  /** Code points of `s` (USV units): the iteration granularity the content speaks. */
16
15
  export function codePoints(s) {
17
16
  return Array.from(s);
@@ -50,7 +49,7 @@ export function decode(rt, schema) {
50
49
  // plaintext field that acquired one anyway — an older build's popover, a hand-
51
50
  // written document — is exactly the content that must open and then heal on the
52
51
  // next commit, not throw here.
53
- const marks = hasMarks(schema) ? rt.marks.filter((m) => !isAnchorMark(m)) : [];
52
+ const marks = hasMarks(schema) ? rt.marks.filter((m) => m.type !== 'anchor') : [];
54
53
  const cursor = { i: 0, rt };
55
54
  if (isInlineSchema(schema)) {
56
55
  return decodeInline(rt, schema, lineTexts, starts, marks, cursor);
@@ -96,22 +95,21 @@ function groupBlocks(schema, leaves, depth, marks, cursor) {
96
95
  continue;
97
96
  }
98
97
  const here = path[depth];
99
- // `ContentContainer` is an open set: a bare `here.container === 'x'` does not
100
- // narrow, so the two arms this schema builds are claimed by the boundary's
101
- // guard and a literal check, and everything else is an unknown the inert
102
- // wrapper carries (CODEC §Open sets); no branch degrades silently.
103
- if (isListItemContainer(here)) {
98
+ if (here.container === 'list_item') {
104
99
  // Gather the maximal run of sibling `list_item` leaves at this depth
105
100
  // (same ordered/start), then split it into items by `ordinal`.
106
- const { ordered, start, instance } = here;
101
+ const { ordered, start } = here.attrs;
102
+ const instance = instanceOf(here);
107
103
  let j = i + 1;
108
104
  while (j < leaves.length) {
109
105
  const c = atDepth(leaves[j], depth);
110
- if (!c || !isListItemContainer(c) || c.ordered !== ordered || c.start !== start)
106
+ if (!c || c.container !== 'list_item')
107
+ break;
108
+ if (c.attrs.ordered !== ordered || c.attrs.start !== start)
111
109
  break;
112
110
  // `instance` is the boundary between two adjacent lists; the normalizer
113
111
  // numbers a run's `ordinal`s gaplessly from 0, so a reset carries none.
114
- if (c.instance !== instance)
112
+ if (instanceOf(c) !== instance)
115
113
  break;
116
114
  j++;
117
115
  }
@@ -131,9 +129,11 @@ function groupBlocks(schema, leaves, depth, marks, cursor) {
131
129
  i = j;
132
130
  continue;
133
131
  }
134
- // quote and every unknown container share one shape: a wrapper over the run
135
- // of leaves carrying the identical container here (identity by `containerKey`,
136
- // so two adjacent unknowns differing only in `attrs` stay two wrappers).
132
+ // The quote, and the whole of what is left: `satisfies` is the exhaustiveness
133
+ // check, so a container added upstream is a compile error here rather than a
134
+ // blockquote it is not. A wrapper over the run of leaves carrying the identical
135
+ // container here, identity by `containerKey`.
136
+ here.container;
137
137
  const key = containerKey(here);
138
138
  let j = i + 1;
139
139
  while (j < leaves.length) {
@@ -143,9 +143,7 @@ function groupBlocks(schema, leaves, depth, marks, cursor) {
143
143
  j++;
144
144
  }
145
145
  const inner = groupBlocks(schema, leaves.slice(i, j), depth + 1, marks, cursor);
146
- out.push(here.container === 'quote'
147
- ? schema.nodes.blockquote.create(null, inner)
148
- : schema.nodes.unknown_container.create({ container: here.container, attrs: 'attrs' in here ? here.attrs : null }, inner));
146
+ out.push(schema.nodes.blockquote.create(null, inner));
149
147
  i = j;
150
148
  }
151
149
  return out;
@@ -154,61 +152,73 @@ function atDepth(leaf, depth) {
154
152
  return leaf.containers[depth];
155
153
  }
156
154
  /** The `ordinal` of a leaf's `list_item` at `depth`: only called inside a run the
157
- * guard has already established, so a miss is unreachable. */
155
+ * discriminant has already established, so a miss is unreachable. */
158
156
  function ordinalAt(leaf, depth) {
159
157
  const c = atDepth(leaf, depth);
160
- return c && isListItemContainer(c) ? c.ordinal : -1;
158
+ return c?.container === 'list_item' ? c.attrs.ordinal : -1;
159
+ }
160
+ /** A container's `instance`, absent meaning the zero a canonical read omits. It is what
161
+ * tells one container from an adjacent sibling of identical shape, which contiguity
162
+ * alone reads as one, so absent and `0` have to answer alike. */
163
+ function instanceOf(c) {
164
+ return c.instance ?? 0;
161
165
  }
162
- /** Identity of a container for run gathering: its name, its `instance` and its payload,
163
- * NUL-joined as `markKey` joins a mark's, so no `attrs` content can forge a name
164
- * boundary. `instance` is what tells one container from an adjacent sibling of identical
165
- * shape, which contiguity alone reads as one. Every arm but `list_item` (which has its
166
- * own run rule) keys here. */
166
+ /** Identity of a container for run gathering: its name and its `instance`, NUL-joined
167
+ * as `markKey` joins a mark's. `list_item` carries its own run rule, so the quote is
168
+ * what keys here. */
167
169
  function containerKey(c) {
168
- const id = `${c.container}\u0000${c.instance}`;
169
- return 'attrs' in c ? `${id}\u0000${JSON.stringify(c.attrs)}` : id;
170
+ return `${c.container}\u0000${instanceOf(c)}`;
170
171
  }
171
- /** A single leaf block node (para/heading/code/rule/island, or an unknown kind
172
- * carried on a paragraph) from its segments. `kind` is an open set, so the two
173
- * payload-carrying arms read through the boundary's guards; the payload-free arms
174
- * compare literally, and anything left is unknown. */
172
+ /** A single leaf block node from its segments. Exhaustive over the closed line
173
+ * vocabulary, so a kind added upstream is a compile error rather than a silent
174
+ * flattening to `para` that the next commit would store. */
175
175
  function makeLeaf(schema, leaf, marks, cursor) {
176
176
  const line = leaf.line;
177
- if (line.kind === 'rule')
178
- return schema.nodes.horizontal_rule.create();
179
- if (line.kind === 'island') {
180
- return schema.nodes.island_block.create(islandAttrs(cursor));
181
- }
182
- if (isCodeLine(line)) {
183
- // One code_block: the segments' texts joined by literal `\n`, no marks.
184
- const text = leaf.segments.map((s) => s.text).join('\n');
185
- const content = text.length ? [schema.text(text)] : [];
186
- return schema.nodes.code_block.create({ lang: line.lang ?? null }, content);
187
- }
188
- // para / heading / unknown: inline content, `hard_break` between continued segments.
189
- const inline = [];
190
- leaf.segments.forEach((seg, idx) => {
191
- if (idx > 0)
192
- inline.push(schema.nodes.hard_break.create());
193
- inline.push(...buildInline(schema, seg.text, seg.startUSV, marks, cursor, false));
194
- });
195
- if (isHeadingLine(line)) {
196
- return schema.nodes.heading.create({ level: line.level }, inline);
177
+ switch (line.kind) {
178
+ case 'rule':
179
+ return schema.nodes.horizontal_rule.create();
180
+ case 'island': {
181
+ const attrs = islandAttrs(cursor);
182
+ return attrs ? schema.nodes.island_block.create(attrs) : schema.nodes.paragraph.create();
183
+ }
184
+ case 'code': {
185
+ // One code_block: the segments' texts joined by literal `\n`, no marks.
186
+ const text = leaf.segments.map((s) => s.text).join('\n');
187
+ const content = text.length ? [schema.text(text)] : [];
188
+ return schema.nodes.code_block.create({ lang: line.attrs?.lang ?? null }, content);
189
+ }
190
+ case 'para':
191
+ case 'heading': {
192
+ // Inline content, the segment boundary a `hard_break` in a paragraph and a space
193
+ // in a heading, which takes none (`schema.ts` §`takesLineBreak`). A stored heading
194
+ // carries no continuation — the store clears one after a block of a single line —
195
+ // so the space is what keeps a hand-built content decodable.
196
+ const type = line.kind === 'heading' ? schema.nodes.heading : schema.nodes.paragraph;
197
+ const inline = [];
198
+ leaf.segments.forEach((seg, idx) => {
199
+ if (idx > 0)
200
+ inline.push(takesLineBreak(type) ? schema.nodes.hard_break.create() : schema.text(' '));
201
+ inline.push(...buildInline(schema, seg.text, seg.startUSV, marks, cursor, false));
202
+ });
203
+ return line.kind === 'heading'
204
+ ? type.create({ level: line.attrs.level }, inline)
205
+ : type.create(null, inline);
206
+ }
207
+ default: {
208
+ const unreached = line;
209
+ return unreached;
210
+ }
197
211
  }
198
- // An unknown kind renders as a paragraph and rides on it, so it re-encodes
199
- // verbatim rather than flattening to `para` on the field's first edit.
200
- const unknown = line.kind === 'para' ? null : { kind: line.kind, attrs: 'attrs' in line ? line.attrs : null };
201
- return schema.nodes.paragraph.create({ unknown }, inline);
202
212
  }
203
213
  /** Consume the next island entry as PM node attrs (text-order matched to slots):
204
214
  * the whole entry, `loss` included, which an island edit has to write back
205
- * (`islands.ts`). A slot with no entry behind it is a malformed content; the empty
206
- * attrs keep the decode total rather than throwing on a read. */
215
+ * (`islands.ts`). `null` where a slot has no entry behind it, which is a malformed
216
+ * content: the slot draws an empty paragraph or nothing at all, so the decode stays
217
+ * total without minting a node whose `type` the vocabulary would refuse on the way
218
+ * back. */
207
219
  function islandAttrs(cursor) {
208
220
  const isl = cursor.rt.islands[cursor.i++];
209
- if (!isl)
210
- return { id: '', islandType: '', props: null, loss: 'unrepresentable' };
211
- return { id: isl.id, islandType: isl.type, props: isl.props, loss: isl.loss };
221
+ return isl ? { id: isl.id, islandType: isl.type, props: isl.props, loss: isl.loss } : null;
212
222
  }
213
223
  /**
214
224
  * Inline nodes for one segment: split text into maximal runs of a constant mark
@@ -227,20 +237,39 @@ function buildInline(schema, segText, segStartUSV, marks, cursor, stripIslands)
227
237
  out.push(schema.text(runText, runMarks));
228
238
  runText = '';
229
239
  };
240
+ // A mark set changes only where a mark starts or ends. `marks` is the field's whole
241
+ // list, so resolving one per code point costs the text against every mark in the
242
+ // document.
243
+ const segEndUSV = segStartUSV + cps.length;
244
+ const spanning = marks.filter((m) => m.start < segEndUSV && m.end > segStartUSV);
245
+ const edges = new Set();
246
+ for (const m of spanning) {
247
+ if (m.start > segStartUSV)
248
+ edges.add(m.start);
249
+ if (m.end < segEndUSV)
250
+ edges.add(m.end);
251
+ }
252
+ let active = [];
253
+ let key = '';
254
+ let unresolved = true;
230
255
  for (let k = 0; k < cps.length; k++) {
231
256
  const cp = cps[k];
232
257
  const pos = segStartUSV + k;
233
258
  if (cp === ISLAND_SLOT) {
234
259
  flush();
235
260
  const attrs = islandAttrs(cursor);
236
- if (!stripIslands && schema.nodes.island_inline) {
261
+ if (attrs && !stripIslands && schema.nodes.island_inline) {
237
262
  out.push(schema.nodes.island_inline.create(attrs));
238
263
  }
239
264
  runKey = '\0slot'; // force a fresh run after a slot
265
+ unresolved = true; // the slot's own position is skipped, edge or not
240
266
  continue;
241
267
  }
242
- const active = marks.filter((m) => m.start <= pos && pos < m.end);
243
- const key = markSetKey(active);
268
+ if (unresolved || edges.has(pos)) {
269
+ active = spanning.filter((m) => m.start <= pos && pos < m.end);
270
+ key = markSetKey(active);
271
+ unresolved = false;
272
+ }
244
273
  if (key !== runKey && runText.length)
245
274
  flush();
246
275
  if (key !== runKey) {
@@ -21,8 +21,6 @@ export interface Scan {
21
21
  marks: ContentMark[];
22
22
  islands: ContentIsland[];
23
23
  runs: PosRun[];
24
- /** Total PM position at the end of the document content (doc.content.size). */
25
- pmEnd: number;
26
24
  /** Total USV length of `text`. */
27
25
  usvEnd: number;
28
26
  }
@@ -32,15 +30,14 @@ export interface Scan {
32
30
  * source both `pmToContent` and the position map draw from.
33
31
  */
34
32
  export declare function scanDoc(doc: PMNode): Scan;
33
+ /** The content projection a scan carries (drops the position runs): what a caller
34
+ * holding a `Scan` reads instead of walking the document a second time. */
35
+ export declare function scanContent(s: Scan): Content;
35
36
  /** `pmToContent`: the pure inverse of decode (drops the position runs). */
36
37
  export declare function pmToContent(doc: PMNode): Content;
37
38
  interface LowerOpts {
38
- /** Anchor decoration positions before the edit (post-nothing); see field.ts. */
39
- oldAnchors?: {
40
- id: string;
41
- pos: number;
42
- }[];
43
- /** Anchor decoration positions after the edit, in final (new) USV coords. */
39
+ /** Anchor decoration positions after the edit, in final (new) USV coords. The
40
+ * pre-edit set is `oldRt`'s own, so only the projection's is passed in. */
44
41
  newAnchors?: {
45
42
  id: string;
46
43
  pos: number;