@quillmark/svelte 0.10.1 → 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 (58) hide show
  1. package/README.md +1 -1
  2. package/THEMING.md +2 -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/reconcile.d.ts +6 -0
  22. package/dist/core/codec/reconcile.js +19 -0
  23. package/dist/core/codec/schema.d.ts +5 -8
  24. package/dist/core/codec/schema.js +30 -156
  25. package/dist/core/codec/table-view.js +77 -26
  26. package/dist/core/codec/table.d.ts +6 -5
  27. package/dist/core/codec/table.js +14 -31
  28. package/dist/core/codec/urls.d.ts +20 -0
  29. package/dist/core/codec/urls.js +45 -0
  30. package/dist/preset/recipes.css +1 -1
  31. package/dist/preset/scale.css +7 -4
  32. package/dist/preview/bridge.d.ts +8 -1
  33. package/dist/preview/bridge.js +31 -25
  34. package/dist/preview/controller.js +13 -13
  35. package/dist/preview/geometry.d.ts +12 -1
  36. package/dist/preview/geometry.js +14 -1
  37. package/dist/preview/paint.js +6 -4
  38. package/dist/preview/strings.d.ts +0 -2
  39. package/dist/preview/strings.js +5 -6
  40. package/dist/visual/ArrayField.svelte +11 -13
  41. package/dist/visual/Card.svelte +5 -8
  42. package/dist/visual/DateField.svelte +2 -2
  43. package/dist/visual/Field.svelte +7 -3
  44. package/dist/visual/FieldHint.svelte +1 -1
  45. package/dist/visual/ObjectField.svelte +6 -1
  46. package/dist/visual/VisualEditor.svelte +1 -1
  47. package/dist/visual/VisualEditorInner.svelte +65 -18
  48. package/dist/visual/controls.css +8 -9
  49. package/dist/visual/ext.d.ts +12 -13
  50. package/dist/visual/ext.js +22 -19
  51. package/dist/visual/index.js +2 -3
  52. package/dist/visual/links.d.ts +4 -0
  53. package/dist/visual/links.js +15 -4
  54. package/dist/visual/strings.d.ts +7 -4
  55. package/dist/visual/strings.js +1 -0
  56. package/dist/visual/structure.d.ts +9 -1
  57. package/dist/visual/structure.js +26 -4
  58. package/package.json +2 -2
@@ -1,9 +1,8 @@
1
- import { isAnchorMark } from '@quillmark/wasm';
2
1
  import { core } from '../lifecycle.js';
3
2
  import { codePoints, usvLength } from './decode.js';
4
3
  import { ISLAND_SLOT, islandEntryFromNode } from './islands.js';
5
- import { contentDescriptorFromPM, descriptorOf, markKey } from './marks.js';
6
- import { valueEqual } from './reconcile.js';
4
+ import { anchorsFromContent, contentDescriptorFromPM, descriptorOf, markKey } from './marks.js';
5
+ import { canonicalJson, valueEqual } from './reconcile.js';
7
6
  /** Append `s` to the accumulated text, advancing the running USV length. */
8
7
  function appendText(acc, s) {
9
8
  acc.text += s;
@@ -21,7 +20,6 @@ export function scanDoc(doc) {
21
20
  marks: [],
22
21
  islands: [],
23
22
  runs: [],
24
- pmEnd: doc.content.size,
25
23
  usvEnd: 0,
26
24
  rawMarks: [],
27
25
  lastContentEndPm: 0
@@ -30,10 +28,15 @@ export function scanDoc(doc) {
30
28
  acc.marks = mergeMarks(acc.rawMarks);
31
29
  return acc;
32
30
  }
31
+ /** The content projection a scan carries (drops the position runs): what a caller
32
+ * holding a `Scan` reads instead of walking the document a second time. */
33
+ export function scanContent(s) {
34
+ const { text, lines, marks, islands } = s;
35
+ return { text, lines, marks, islands };
36
+ }
33
37
  /** `pmToContent`: the pure inverse of decode (drops the position runs). */
34
38
  export function pmToContent(doc) {
35
- const { text, lines, marks, islands } = scanDoc(doc);
36
- return { text, lines, marks, islands };
39
+ return scanContent(scanDoc(doc));
37
40
  }
38
41
  /** Walk a block-content fragment; `contentStart` is the PM pos before its first child.
39
42
  * Two container siblings of one run shape read as a single container — contiguity plus
@@ -54,7 +57,7 @@ function scanBlocks(acc, parent, contentStart, containers) {
54
57
  }
55
58
  /** A block's container run shape, `ordinal` and `instance` aside; null where it opens
56
59
  * no container. A list's `start` is not in it — the store welds two runs differing only
57
- * there — and an unknown's `attrs` is, as `containerKey` reads it on the way back. */
60
+ * there. */
58
61
  function runShape(node) {
59
62
  switch (node.type.name) {
60
63
  case 'blockquote':
@@ -63,8 +66,6 @@ function runShape(node) {
63
66
  return 'list\u0000bullet';
64
67
  case 'ordered_list':
65
68
  return 'list\u0000ordered';
66
- case 'unknown_container':
67
- return `unknown\u0000${node.attrs.container}\u0000${JSON.stringify(node.attrs.attrs)}`;
68
69
  default:
69
70
  return null;
70
71
  }
@@ -85,7 +86,7 @@ function scanBlock(acc, node, nodePos, containers, instance) {
85
86
  }
86
87
  case 'code_block': {
87
88
  const lang = node.attrs.lang ?? undefined;
88
- const kind = lang != null ? { kind: 'code', lang } : { kind: 'code' };
89
+ const kind = lang != null ? { kind: 'code', attrs: { lang } } : { kind: 'code' };
89
90
  beginLine(acc, contentStart, containers, kind);
90
91
  const codeText = node.textContent;
91
92
  if (codeText.length)
@@ -110,12 +111,9 @@ function scanBlock(acc, node, nodePos, containers, instance) {
110
111
  acc.lastContentEndPm = nodePos + 1;
111
112
  break;
112
113
  case 'blockquote':
113
- scanBlocks(acc, node, contentStart, [...containers, { container: 'quote', instance }]);
114
- break;
115
- case 'unknown_container':
116
114
  scanBlocks(acc, node, contentStart, [
117
115
  ...containers,
118
- { container: node.attrs.container, attrs: node.attrs.attrs, instance }
116
+ withInstance({ container: 'quote' }, instance)
119
117
  ]);
120
118
  break;
121
119
  case 'bullet_list':
@@ -124,13 +122,7 @@ function scanBlock(acc, node, nodePos, containers, instance) {
124
122
  const start = ordered ? node.attrs.start : 1;
125
123
  let itemPos = contentStart;
126
124
  node.forEach((item, _off, index) => {
127
- const container = {
128
- container: 'list_item',
129
- ordered,
130
- start,
131
- ordinal: index,
132
- instance
133
- };
125
+ const container = withInstance({ container: 'list_item', attrs: { ordered, start, ordinal: index } }, instance);
134
126
  scanBlocks(acc, item, itemPos + 1, [...containers, container]);
135
127
  itemPos += item.nodeSize;
136
128
  });
@@ -142,13 +134,17 @@ function scanBlock(acc, node, nodePos, containers, instance) {
142
134
  acc.lastContentEndPm = contentStart;
143
135
  }
144
136
  }
145
- /** A textblock's line kind: a heading's `level`, or (for a paragraph) `para`, or
146
- * the unknown kind it carries (schema.ts) re-emitted verbatim. */
137
+ /** A textblock's line kind: a heading's `level`, or (for a paragraph) `para`. */
147
138
  function textblockKind(node) {
148
139
  if (node.type.name === 'heading')
149
- return { kind: 'heading', level: node.attrs.level };
150
- const u = node.attrs.unknown;
151
- return u ? { kind: u.kind, attrs: u.attrs } : { kind: 'para' };
140
+ return { kind: 'heading', attrs: { level: node.attrs.level } };
141
+ return { kind: 'para' };
142
+ }
143
+ /** A container with its run `instance`, spelled only where it is not the zero a
144
+ * canonical read omits: the projection is compared against such a read
145
+ * (`lineMetaEqual`), so the two have to spell one container one way. */
146
+ function withInstance(container, instance) {
147
+ return instance === 0 ? container : { ...container, instance };
152
148
  }
153
149
  /** Open a new content line: emit the boundary `\n` (except the first line) + record. */
154
150
  function beginLine(acc, thisContentStart, containers, kind) {
@@ -201,11 +197,7 @@ function emitText(acc, s, pmStart, marks) {
201
197
  acc.runs.push({ kind: 'text', pmStart, usvStart, s });
202
198
  appendText(acc, s);
203
199
  for (const mark of marks) {
204
- acc.rawMarks.push({
205
- start: usvStart,
206
- end: acc.usvEnd,
207
- ...contentDescriptorFromPM(mark)
208
- });
200
+ acc.rawMarks.push({ start: usvStart, end: acc.usvEnd, ...contentDescriptorFromPM(mark) });
209
201
  }
210
202
  }
211
203
  /**
@@ -216,7 +208,7 @@ function emitText(acc, s, pmStart, marks) {
216
208
  */
217
209
  function mergeMarks(raw) {
218
210
  const out = [];
219
- for (const g of groupFormatting(raw, (m) => ({ start: m.start, end: m.end })).values()) {
211
+ for (const g of groupFormatting(raw).values()) {
220
212
  for (const iv of union(g.intervals))
221
213
  out.push({ ...iv, ...g.descriptor });
222
214
  }
@@ -231,9 +223,10 @@ export function contentEdit(oldRt, newRt) {
231
223
  /** Lower an edit to a `ChangeBundle`: the old→new content diff, as ops. */
232
224
  export function lower(edit, opts = {}) {
233
225
  const { oldRt, newRt } = edit;
234
- const { delta, islandOps, insertedAt } = splitIslands(edit);
226
+ const { delta, islandOps } = splitIslands(edit);
235
227
  const lineOps = diffLines(oldRt, newRt, edit.delta);
236
- const markOps = diffMarks(oldRt, newRt, delta, insertedAt, opts);
228
+ // The text-moving channels first: the mark diff reads them back off the bundle,
229
+ // so what it rebases against is what `applyChange` will be handed.
237
230
  const bundle = {};
238
231
  if (delta)
239
232
  bundle.delta = delta;
@@ -241,6 +234,7 @@ export function lower(edit, opts = {}) {
241
234
  bundle.islandOps = islandOps;
242
235
  if (lineOps.length)
243
236
  bundle.lineOps = lineOps;
237
+ const markOps = diffMarks(oldRt, newRt, bundle, opts);
244
238
  if (markOps.length)
245
239
  bundle.markOps = markOps;
246
240
  return bundle;
@@ -321,10 +315,8 @@ function diffLines(oldRt, newRt, delta) {
321
315
  return ops;
322
316
  }
323
317
  /** A line minus its `containers` / `continues` envelope: exactly `setKind`'s
324
- * payload, which the boundary names `ContentLineKind`. Lifting it whole is what
325
- * keeps this arm-agnostic: `kind` is an open set, so an arm-by-arm switch would
326
- * have to guess at the unknown arm's payload and would drift on every arm
327
- * upstream adds. */
318
+ * payload, which the boundary names `ContentLineKind`. Lifting it whole is what keeps
319
+ * this arm-agnostic, so an arm added upstream reaches the op by construction. */
328
320
  function kindPart(l) {
329
321
  const { containers: _containers, continues: _continues, ...kind } = l;
330
322
  return kind;
@@ -340,15 +332,16 @@ function lineMetaEqual(a, b) {
340
332
  return false;
341
333
  if (kindKey(a[i]) !== kindKey(b[i]))
342
334
  return false;
343
- if (JSON.stringify(a[i].containers) !== JSON.stringify(b[i].containers))
335
+ if (!valueEqual(a[i].containers, b[i].containers))
344
336
  return false;
345
337
  }
346
338
  return true;
347
339
  }
348
- /** A line kind's comparison key: key-order-insensitive, because the two sides come
349
- * from different producers (a WASM read and this scan). */
340
+ /** A line kind's comparison key: key-order-insensitive at every depth, because the two
341
+ * sides come from different producers (a WASM read and this scan) and a kind carries a
342
+ * nested `attrs` bag. */
350
343
  function kindKey(l) {
351
- return JSON.stringify(Object.entries(kindPart(l)).sort(([x], [y]) => (x < y ? -1 : 1)));
344
+ return canonicalJson(kindPart(l));
352
345
  }
353
346
  /**
354
347
  * Split the edit's islands off the text splice. `applyChange` throws
@@ -373,12 +366,12 @@ function splitIslands(edit) {
373
366
  const { start, end } = insertedRegion(delta);
374
367
  const oldById = new Map(oldRt.islands.map((isl) => [isl.id, isl]));
375
368
  const islandOps = [];
376
- const insertedAt = [];
369
+ let inserts = 0;
377
370
  newRt.islands.forEach((isl, i) => {
378
371
  const at = slots[i];
379
372
  if (at !== undefined && at >= start && at < end) {
380
373
  islandOps.push({ op: 'insert', at, ...isl });
381
- insertedAt.push(at);
374
+ inserts++;
382
375
  }
383
376
  else {
384
377
  const before = oldById.get(isl.id);
@@ -386,11 +379,7 @@ function splitIslands(edit) {
386
379
  islandOps.push({ op: 'set', ...isl });
387
380
  }
388
381
  });
389
- return {
390
- delta: insertedAt.length && delta ? withoutSlots(delta) : delta,
391
- islandOps,
392
- insertedAt
393
- };
382
+ return { delta: inserts && delta ? withoutSlots(delta) : delta, islandOps };
394
383
  }
395
384
  /** The USV positions of `text`'s island slots, in document order: the i-th is the
396
385
  * i-th island's, since the scan appends an entry per slot it writes. */
@@ -437,33 +426,17 @@ function withoutSlots(delta) {
437
426
  }
438
427
  return splices ? { ops } : undefined;
439
428
  }
440
- /**
441
- * A post-delta position carried through the bundle's island inserts, which move
442
- * text exactly as the delta does and rebase marks by the same rule (start-assoc
443
- * `after`, end-assoc `before`; an anchor is a point and holds `before`). Ascending
444
- * `at`s apply left to right, so each comparison reads the running position.
445
- */
446
- function shiftPastIslands(pos, insertedAt, assoc) {
447
- let p = pos;
448
- for (const at of insertedAt)
449
- if (assoc === 'after' ? p >= at : p > at)
450
- p++;
451
- return p;
452
- }
453
- /** Formatting/unknown marks → add/remove ops; anchors → add/removeAnchor by id. */
454
- function diffMarks(oldRt, newRt, delta, insertedAt, opts) {
429
+ /** Formatting marks → add/remove ops; anchors → add/removeAnchor by id. A `MarkOp`'s
430
+ * `add` / `remove` is a `ContentMark` under an op, so a descriptor spreads in whole and
431
+ * the wire's `attrs` spelling is the one a checker admits. */
432
+ function diffMarks(oldRt, newRt, moved, opts) {
455
433
  const ops = [];
456
- // Read the gate once per diff rather than once per mark.
457
- const { mapPos } = core();
458
- // Rebase old marks through both text-moving channels to final coords, exactly
459
- // as `applyChange` does internally (start assoc `after`, end assoc `before`).
460
- const rebase = (pos, assoc) => shiftPastIslands(delta ? mapPos(delta, pos, assoc) : pos, insertedAt, assoc);
434
+ // The store's answer for where `moved`'s text channels leave the field's marks;
435
+ // `markOps` are the difference from it.
436
+ const rebased = core().mapMarks(oldRt, moved);
461
437
  // Group both sides by descriptor key (excluding anchors).
462
- const oldGroups = groupFormatting(oldRt.marks, (m) => ({
463
- start: rebase(m.start, 'after'),
464
- end: rebase(m.end, 'before')
465
- }));
466
- const newGroups = groupFormatting(newRt.marks, (m) => ({ start: m.start, end: m.end }));
438
+ const oldGroups = groupFormatting(rebased);
439
+ const newGroups = groupFormatting(newRt.marks);
467
440
  const keys = new Set([...oldGroups.keys(), ...newGroups.keys()]);
468
441
  for (const key of keys) {
469
442
  const oldG = oldGroups.get(key);
@@ -477,17 +450,14 @@ function diffMarks(oldRt, newRt, delta, insertedAt, opts) {
477
450
  ops.push({ op: 'add', start: iv.start, end: iv.end, ...descriptor });
478
451
  }
479
452
  // Anchors: diff the decoration sets by id (positions already in final coords).
480
- if (opts.oldAnchors || opts.newAnchors) {
481
- const oldA = new Map((opts.oldAnchors ?? []).map((a) => [
482
- a.id,
483
- shiftPastIslands(delta ? mapPos(delta, a.pos, 'after') : a.pos, insertedAt, 'before')
484
- ]));
485
- const newA = new Map((opts.newAnchors ?? []).map((a) => [a.id, a.pos]));
453
+ if (opts.newAnchors) {
454
+ const oldA = new Map(anchorsFromContent({ marks: rebased }).map((a) => [a.id, a.pos]));
455
+ const newA = new Map(opts.newAnchors.map((a) => [a.id, a.pos]));
486
456
  for (const [id, pos] of newA) {
487
457
  if (!oldA.has(id) || oldA.get(id) !== pos) {
488
458
  if (oldA.has(id))
489
459
  ops.push({ op: 'removeAnchor', id });
490
- ops.push({ op: 'add', start: pos, end: pos, type: 'anchor', id });
460
+ ops.push({ op: 'add', start: pos, end: pos, type: 'anchor', attrs: { id } });
491
461
  }
492
462
  }
493
463
  for (const id of oldA.keys())
@@ -496,11 +466,11 @@ function diffMarks(oldRt, newRt, delta, insertedAt, opts) {
496
466
  }
497
467
  return ops;
498
468
  }
499
- /** Group non-anchor marks by descriptor key, mapping each to an interval. */
500
- function groupFormatting(marks, toInterval) {
469
+ /** Group non-anchor marks by descriptor key. */
470
+ function groupFormatting(marks) {
501
471
  const groups = new Map();
502
472
  for (const m of marks) {
503
- if (isAnchorMark(m))
473
+ if (m.type === 'anchor')
504
474
  continue;
505
475
  const descriptor = descriptorOf(m);
506
476
  const key = markKey(descriptor);
@@ -509,7 +479,7 @@ function groupFormatting(marks, toInterval) {
509
479
  g = { descriptor, intervals: [] };
510
480
  groups.set(key, g);
511
481
  }
512
- g.intervals.push(toInterval(m));
482
+ g.intervals.push({ start: m.start, end: m.end });
513
483
  }
514
484
  return groups;
515
485
  }
@@ -20,8 +20,8 @@ import { EditorState, NodeSelection, Plugin, PluginKey, Selection, TextSelection
20
20
  import { Decoration, DecorationSet, EditorView } from 'prosemirror-view';
21
21
  import { reportError, errorMessage } from '../errors.js';
22
22
  import { decode } from './decode.js';
23
- import { usvToPM, pmToUsv, buildLineIndex } from './positions.js';
24
- import { lower, pmToContent, contentEdit } from './encode.js';
23
+ import { usvToPM, pmToUsv, buildLineIndex, lineIndexOf } from './positions.js';
24
+ import { lower, pmToContent, scanContent, scanDoc, contentEdit } from './encode.js';
25
25
  import { islandPastePlugin } from './islands.js';
26
26
  import { anchorsFromContent } from './marks.js';
27
27
  import { createReconciler } from './reconcile.js';
@@ -33,7 +33,11 @@ import { DEFAULT_TABLE_STRINGS, tableNodeView } from './table-view.js';
33
33
  import { focusSlashItem, runSlashItem, slashPlugin } from './slash.js';
34
34
  const anchorKey = new PluginKey('quill-anchors');
35
35
  /** Plugin-held anchor positions (PM coords), mapped through every edit's `StepMap`
36
- * and mutated by an {@link AnchorEdit} meta (an insert/remove at a selection). */
36
+ * and mutated by an {@link AnchorEdit} meta (an insert/remove at a selection).
37
+ *
38
+ * The `-1` is the store's rule for a point, not PM's default: `applyChange` rebases a
39
+ * zero-width mark `before`, so text typed at an anchor's own position lands after it
40
+ * and the mark diff has no op to spend reconciling this set with the store. */
37
41
  function anchorPlugin(seed) {
38
42
  return new Plugin({
39
43
  key: anchorKey,
@@ -41,7 +45,7 @@ function anchorPlugin(seed) {
41
45
  init: () => seed,
42
46
  apply: (tr, anchors) => {
43
47
  let next = tr.docChanged
44
- ? anchors.map((a) => ({ id: a.id, pos: tr.mapping.map(a.pos) }))
48
+ ? anchors.map((a) => ({ id: a.id, pos: tr.mapping.map(a.pos, -1) }))
45
49
  : anchors;
46
50
  const edit = tr.getMeta(anchorKey);
47
51
  if (edit?.op === 'add')
@@ -134,8 +138,9 @@ export function createField(opts) {
134
138
  // The menu's report channel, and the whole of what a leaf holds of it: the
135
139
  // vocabulary is the codec's own constant (`slash.ts`), not a wording to derive.
136
140
  const slash = inline ? undefined : opts.onSlash;
137
- const state = buildState(reconciler.last);
138
- index = buildLineIndex(state.doc);
141
+ const seeded = buildState(reconciler.last);
142
+ const state = seeded.state;
143
+ index = seeded.index;
139
144
  // The clearance PM's reveal keeps around the caret, in this leaf's own line box.
140
145
  // Its default is 5px, which is a caret visible and unusable: the next line typed
141
146
  // lands past the fold, the reason the preview's own scroll clears a target by the
@@ -173,13 +178,19 @@ export function createField(opts) {
173
178
  const oldRt = reconciler.last;
174
179
  const next = view.state.apply(tr);
175
180
  view.updateState(next); // (a) optimistic
176
- index = buildLineIndex(next.doc);
181
+ // One walk per structural change: the index and the projection the commit
182
+ // diffs both come off it. A selection-only transaction leaves the document
183
+ // it was built from, so the index over it still holds.
184
+ const scan = tr.docChanged ? scanDoc(next.doc) : undefined;
185
+ if (scan)
186
+ index = lineIndexOf(scan);
177
187
  // Commit a content edit or an anchor mutation. An anchor insert/remove is
178
188
  // zero-width, so `docChanged` is false: the `anchorKey` meta is what
179
189
  // routes it through the same commit path (the diff emits the anchor op).
180
190
  if (tr.docChanged || tr.getMeta(anchorKey)) {
181
191
  // (d) the change signal, only for what actually landed.
182
- if (commitEdit(oldRt, next.doc))
192
+ const newRt = scan ? scanContent(scan) : pmToContent(next.doc);
193
+ if (commitEdit(oldRt, newRt))
183
194
  opts.onChange?.(addr);
184
195
  }
185
196
  // The caret, for both structural and selection-only changes: a host
@@ -195,6 +206,8 @@ export function createField(opts) {
195
206
  }
196
207
  }
197
208
  });
209
+ /** The state, and the index over the document it was built from: the seed reads one
210
+ * and every caller wants the same one, and `buildLineIndex` is a whole walk. */
198
211
  function buildState(rt) {
199
212
  const pmDoc = decode(rt, schema);
200
213
  const anchors = plaintext ? [] : anchorsFromContent(rt);
@@ -204,7 +217,7 @@ export function createField(opts) {
204
217
  id: a.id,
205
218
  pos: usvToPM(seedIndex, a.pos)
206
219
  }));
207
- return EditorState.create({
220
+ const built = EditorState.create({
208
221
  doc: pmDoc,
209
222
  plugins: proseLeafPlugins(schema, {
210
223
  inline,
@@ -216,15 +229,17 @@ export function createField(opts) {
216
229
  afterHistory: [anchorPlugin(seededAnchors)]
217
230
  })
218
231
  });
232
+ return { state: built, index: seedIndex };
219
233
  }
220
234
  // (b)+(c): lower the edit to ops and commit, or `overwrite` a field not yet at
221
235
  // content rest. Keep the optimistic PM on throw.
222
236
  // Returns whether anything landed: the caller's change signal, which must not
223
237
  // be a property of which branch the commit took.
224
- function commitEdit(oldRt, newDoc) {
225
- // The projection and its text splice, each computed once per keystroke: the gate
226
- // below, both overwrite fallbacks, and `lower` all read this one `edit`.
227
- const edit = contentEdit(oldRt, pmToContent(newDoc));
238
+ function commitEdit(oldRt, newRt) {
239
+ // The text splice, computed once per keystroke: the gate below, both overwrite
240
+ // fallbacks, and `lower` all read this one `edit`. The projection is the
241
+ // caller's, off the same walk that rebuilt the index.
242
+ const edit = contentEdit(oldRt, newRt);
228
243
  try {
229
244
  // A field not yet at content rest is the only edit that overwrites by choice:
230
245
  // `applyChange` throws on an absent declared field (verified) and mis-reads
@@ -235,11 +250,10 @@ export function createField(opts) {
235
250
  doc.overwrite(addr, edit.newRt); // brings the field to content rest
236
251
  }
237
252
  else {
238
- // Pre-edit anchors are the stored content's anchors (USV); post-edit
239
- // anchors are the plugin's positions (mapped through the tr) as USV.
240
- const oldAnchors = plaintext ? [] : anchorsFromContent(oldRt);
241
- const newAnchors = plaintext ? [] : readAnchorsUsv(newDoc);
242
- doc.applyChange(addr, lower(edit, { oldAnchors, newAnchors }));
253
+ // Post-edit anchors are the plugin's positions (mapped through the tr) as
254
+ // USV; the pre-edit set is `oldRt`'s own, which `lower` reads for itself.
255
+ const newAnchors = plaintext ? [] : readAnchorsUsv();
256
+ doc.applyChange(addr, lower(edit, { newAnchors }));
243
257
  }
244
258
  reconciler.commit(readLeaf(reader, addr));
245
259
  return true;
@@ -275,8 +289,9 @@ export function createField(opts) {
275
289
  }
276
290
  /** The anchors the plugin currently holds (PM coords); `[]` before the first apply. */
277
291
  const heldAnchors = () => anchorKey.getState(view.state) ?? [];
278
- /** Anchor positions in the current (new) doc, as USV. */
279
- function readAnchorsUsv(newDoc) {
292
+ /** The plugin's anchor positions as USV, against `index` — which `dispatchTransaction`
293
+ * rebuilt over the new document before the commit this reads for. */
294
+ function readAnchorsUsv() {
280
295
  return heldAnchors().map((a) => ({ id: a.id, pos: pmToUsv(index, a.pos) }));
281
296
  }
282
297
  const controller = {
@@ -303,11 +318,11 @@ export function createField(opts) {
303
318
  return; // own edit / no change
304
319
  const caretUsv = pmToUsv(index, view.state.selection.head);
305
320
  const fresh = buildState(current);
306
- view.updateState(fresh);
307
- index = buildLineIndex(fresh.doc);
321
+ view.updateState(fresh.state);
322
+ index = fresh.index;
308
323
  // Best-effort caret continuity across an external change: keep the USV.
309
324
  const pm = usvToPM(index, caretUsv);
310
- view.dispatch(view.state.tr.setSelection(Selection.near(fresh.doc.resolve(pm))));
325
+ view.dispatch(view.state.tr.setSelection(Selection.near(fresh.state.doc.resolve(pm))));
311
326
  reconciler.commit(current);
312
327
  },
313
328
  setPlaceholder(text) {
@@ -511,8 +526,12 @@ function editorKeymap(schema, inline, slash) {
511
526
  if (schema.marks.underline)
512
527
  map['Mod-u'] = toggleMark(schema.marks.underline);
513
528
  if (inline) {
514
- // One textblock only: swallow Enter so no second block is attempted.
529
+ // One textblock only: swallow Enter so no second block is attempted. One *line*
530
+ // only too (`Content::is_inline`), so Shift-Enter goes with it: the schema
531
+ // declares no break node, and a declined key is the browser's, whose native
532
+ // `<br>` is the one thing that could put a second line in a field that holds one.
515
533
  map['Enter'] = () => true;
534
+ map['Shift-Enter'] = () => true;
516
535
  }
517
536
  else {
518
537
  // Each binding falls through to `baseKeymap` when no link claims the key.
@@ -9,7 +9,8 @@ export { DEFAULT_TABLE_STRINGS } from './table-view.js';
9
9
  export type { TableChromeStrings } from './table-view.js';
10
10
  export { DEFAULT_SLASH_STRINGS } from './slash.js';
11
11
  export type { SlashState, SlashStrings } from './slash.js';
12
- export { blockSchema, inlineSchema, plaintextSchema, hasMarks, rendersHref } from './schema.js';
12
+ export { blockSchema, inlineSchema, plaintextSchema, hasMarks } from './schema.js';
13
+ export { rendersHref, storableUrl } from './urls.js';
13
14
  export { decode, renderContent, usvLength } from './decode.js';
14
15
  export { pmToContent, contentEdit, lower } from './encode.js';
15
16
  export type { ContentEdit } from './encode.js';
@@ -21,9 +21,12 @@ export { DEFAULT_TABLE_STRINGS } from './table-view.js';
21
21
  // the pixels (`visual/SlashMenu.svelte` is the only off-barrel caller).
22
22
  export { DEFAULT_SLASH_STRINGS } from './slash.js';
23
23
  // Schemas (the decode/encode target; the VisualEditor mounts them). `hasMarks` is
24
- // what a mark surface asks before offering itself over a leaf, and `rendersHref` the
25
- // link gate the prompt re-asks on submit (`visual/links.ts`).
26
- export { blockSchema, inlineSchema, plaintextSchema, hasMarks, rendersHref } from './schema.js';
24
+ // what a mark surface asks before offering itself over a leaf.
25
+ export { blockSchema, inlineSchema, plaintextSchema, hasMarks } from './schema.js';
26
+ // The url edge: `rendersHref` is the link gate the prompt re-asks on submit, and
27
+ // `storableUrl` the encode the store's own refusal requires of the value it takes
28
+ // (`visual/links.ts`).
29
+ export { rendersHref, storableUrl } from './urls.js';
27
30
  // Decode / encode / positions (tests + VisualEditor).
28
31
  //
29
32
  // The barrel carries what has an off-barrel caller, and nothing else. A symbol
@@ -8,22 +8,26 @@ export declare const islandBlockSpec: NodeSpec;
8
8
  /** Inline island node (an image): a `U+FFFC` slot inside a `para` line. */
9
9
  export declare const islandInlineSpec: NodeSpec;
10
10
  /** A PM island node's attributes: the content entry, spelled the way a node spec
11
- * spells it (`islandType`, because `type` on a PM node is the node type). `loss`
12
- * reads its class off the entry rather than restating the union, which the public
13
- * entry point does not name (DOCUMENT_MODEL §Stability seams). */
11
+ * spells it (`islandType`, because `type` on a PM node is the node type). `islandType`
12
+ * and `loss` read their sets off the entry rather than restating them, the public entry
13
+ * point naming neither (DOCUMENT_MODEL §Stability seams). */
14
14
  export interface IslandNodeAttrs {
15
15
  id: string;
16
- islandType: string;
16
+ islandType: ContentIsland['type'];
17
17
  props: unknown;
18
18
  loss: ContentIsland['loss'];
19
19
  }
20
- /** Build a content island entry from a PM island node's attrs (block or inline). */
20
+ /** Build a content island entry from a PM island node's attrs (block or inline). The
21
+ * cast pairs a closed `type` with a `props` that crossed the DOM as opaque JSON. The
22
+ * store is lenient about a table's shape — it fills what it can and keeps the rest —
23
+ * so a payload the arm does not hold is stored rather than refused, and `undefined` at
24
+ * its reader is what the surface draws the placeholder for (§`tablePropsOfNode`). */
21
25
  export declare function islandEntryFromNode(attrs: IslandNodeAttrs): ContentIsland;
22
26
  /** A node's `TableProps`, or `undefined` for any other island and for a payload of the
23
- * wrong shape: the boundary's own guard over the entry the node carries, so the open
24
- * `type` arm narrows once here rather than at each reader. The type says which reader,
25
- * the shape says whether it can read; every caller draws the placeholder for
26
- * `undefined` (`table-view.ts` §`render`). */
27
+ * wrong shape: the boundary's own guard over the entry the node carries, so a `props`
28
+ * that crossed the DOM is read once here rather than at each reader. The type says
29
+ * which reader, the shape says whether it can read; every caller draws the placeholder
30
+ * for `undefined` (`table-view.ts` §`render`). */
27
31
  export declare function tablePropsOfNode(node: PMNode): TableProps | undefined;
28
32
  /**
29
33
  * A minter over one document: the positional `isl-{n}` sequence continued past the