@quillmark/svelte 0.0.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 (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +58 -0
  3. package/README.md +241 -0
  4. package/THEMING.md +153 -0
  5. package/dist/core/address.d.ts +56 -0
  6. package/dist/core/address.js +95 -0
  7. package/dist/core/bloom.d.ts +37 -0
  8. package/dist/core/bloom.js +116 -0
  9. package/dist/core/codec/anchor.d.ts +19 -0
  10. package/dist/core/codec/anchor.js +36 -0
  11. package/dist/core/codec/code.d.ts +12 -0
  12. package/dist/core/codec/code.js +118 -0
  13. package/dist/core/codec/decode.d.ts +24 -0
  14. package/dist/core/codec/decode.js +270 -0
  15. package/dist/core/codec/encode.d.ts +71 -0
  16. package/dist/core/codec/encode.js +480 -0
  17. package/dist/core/codec/field.d.ts +171 -0
  18. package/dist/core/codec/field.js +493 -0
  19. package/dist/core/codec/index.d.ts +22 -0
  20. package/dist/core/codec/index.js +43 -0
  21. package/dist/core/codec/inputrules.d.ts +7 -0
  22. package/dist/core/codec/inputrules.js +168 -0
  23. package/dist/core/codec/islands.d.ts +36 -0
  24. package/dist/core/codec/islands.js +82 -0
  25. package/dist/core/codec/keymap.d.ts +13 -0
  26. package/dist/core/codec/keymap.js +29 -0
  27. package/dist/core/codec/lists.d.ts +22 -0
  28. package/dist/core/codec/lists.js +104 -0
  29. package/dist/core/codec/marks.d.ts +37 -0
  30. package/dist/core/codec/marks.js +74 -0
  31. package/dist/core/codec/positions.d.ts +17 -0
  32. package/dist/core/codec/positions.js +108 -0
  33. package/dist/core/codec/prose.css +458 -0
  34. package/dist/core/codec/reconcile.d.ts +26 -0
  35. package/dist/core/codec/reconcile.js +64 -0
  36. package/dist/core/codec/schema.d.ts +13 -0
  37. package/dist/core/codec/schema.js +155 -0
  38. package/dist/core/codec/slash.d.ts +79 -0
  39. package/dist/core/codec/slash.js +0 -0
  40. package/dist/core/codec/table-view.d.ts +50 -0
  41. package/dist/core/codec/table-view.js +928 -0
  42. package/dist/core/codec/table.d.ts +85 -0
  43. package/dist/core/codec/table.js +224 -0
  44. package/dist/core/errors.d.ts +73 -0
  45. package/dist/core/errors.js +24 -0
  46. package/dist/core/index.d.ts +5 -0
  47. package/dist/core/index.js +19 -0
  48. package/dist/core/lifecycle.d.ts +7 -0
  49. package/dist/core/lifecycle.js +21 -0
  50. package/dist/core/rebind.svelte.d.ts +18 -0
  51. package/dist/core/rebind.svelte.js +44 -0
  52. package/dist/core/teardown.d.ts +20 -0
  53. package/dist/core/teardown.js +37 -0
  54. package/dist/core/theme.css +276 -0
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +9 -0
  57. package/dist/preset/index.css +10 -0
  58. package/dist/preset/recipes.css +188 -0
  59. package/dist/preset/scale.css +97 -0
  60. package/dist/preview/Preview.svelte +118 -0
  61. package/dist/preview/Preview.svelte.d.ts +39 -0
  62. package/dist/preview/bridge.d.ts +13 -0
  63. package/dist/preview/bridge.js +88 -0
  64. package/dist/preview/controller.d.ts +40 -0
  65. package/dist/preview/controller.js +141 -0
  66. package/dist/preview/geometry.d.ts +37 -0
  67. package/dist/preview/geometry.js +45 -0
  68. package/dist/preview/index.d.ts +5 -0
  69. package/dist/preview/index.js +14 -0
  70. package/dist/preview/overlay.d.ts +10 -0
  71. package/dist/preview/overlay.js +82 -0
  72. package/dist/preview/paint.d.ts +17 -0
  73. package/dist/preview/paint.js +262 -0
  74. package/dist/preview/strings.d.ts +14 -0
  75. package/dist/preview/strings.js +22 -0
  76. package/dist/visual/ArrayField.svelte +362 -0
  77. package/dist/visual/ArrayField.svelte.d.ts +24 -0
  78. package/dist/visual/BooleanField.svelte +88 -0
  79. package/dist/visual/BooleanField.svelte.d.ts +20 -0
  80. package/dist/visual/Card.svelte +849 -0
  81. package/dist/visual/Card.svelte.d.ts +43 -0
  82. package/dist/visual/CardControls.svelte +92 -0
  83. package/dist/visual/CardControls.svelte.d.ts +11 -0
  84. package/dist/visual/DateField.svelte +203 -0
  85. package/dist/visual/DateField.svelte.d.ts +28 -0
  86. package/dist/visual/DiagnosticList.svelte +50 -0
  87. package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
  88. package/dist/visual/EnumField.svelte +169 -0
  89. package/dist/visual/EnumField.svelte.d.ts +25 -0
  90. package/dist/visual/Field.svelte +270 -0
  91. package/dist/visual/Field.svelte.d.ts +37 -0
  92. package/dist/visual/FieldHint.svelte +185 -0
  93. package/dist/visual/FieldHint.svelte.d.ts +13 -0
  94. package/dist/visual/FieldLabel.svelte +141 -0
  95. package/dist/visual/FieldLabel.svelte.d.ts +19 -0
  96. package/dist/visual/FormatPopover.svelte +396 -0
  97. package/dist/visual/FormatPopover.svelte.d.ts +9 -0
  98. package/dist/visual/NumberField.svelte +72 -0
  99. package/dist/visual/NumberField.svelte.d.ts +19 -0
  100. package/dist/visual/ObjectField.svelte +131 -0
  101. package/dist/visual/ObjectField.svelte.d.ts +17 -0
  102. package/dist/visual/ProseArrayElement.svelte +98 -0
  103. package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
  104. package/dist/visual/ProseField.svelte +213 -0
  105. package/dist/visual/ProseField.svelte.d.ts +50 -0
  106. package/dist/visual/SlashMenu.svelte +126 -0
  107. package/dist/visual/SlashMenu.svelte.d.ts +14 -0
  108. package/dist/visual/TextField.svelte +63 -0
  109. package/dist/visual/TextField.svelte.d.ts +23 -0
  110. package/dist/visual/TipsCard.svelte +191 -0
  111. package/dist/visual/TipsCard.svelte.d.ts +13 -0
  112. package/dist/visual/VisualEditor.svelte +96 -0
  113. package/dist/visual/VisualEditor.svelte.d.ts +14 -0
  114. package/dist/visual/VisualEditorInner.svelte +1032 -0
  115. package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
  116. package/dist/visual/controls.css +272 -0
  117. package/dist/visual/diagnostics.d.ts +52 -0
  118. package/dist/visual/diagnostics.js +83 -0
  119. package/dist/visual/domid.d.ts +21 -0
  120. package/dist/visual/domid.js +36 -0
  121. package/dist/visual/ext.d.ts +19 -0
  122. package/dist/visual/ext.js +29 -0
  123. package/dist/visual/icons/Icon.svelte +39 -0
  124. package/dist/visual/icons/Icon.svelte.d.ts +12 -0
  125. package/dist/visual/icons/nodes.d.ts +103 -0
  126. package/dist/visual/icons/nodes.js +55 -0
  127. package/dist/visual/index.d.ts +9 -0
  128. package/dist/visual/index.js +24 -0
  129. package/dist/visual/motion.d.ts +18 -0
  130. package/dist/visual/motion.js +53 -0
  131. package/dist/visual/props.d.ts +77 -0
  132. package/dist/visual/props.js +1 -0
  133. package/dist/visual/signals.d.ts +59 -0
  134. package/dist/visual/signals.js +1 -0
  135. package/dist/visual/strings.d.ts +123 -0
  136. package/dist/visual/strings.js +80 -0
  137. package/dist/visual/structure.d.ts +224 -0
  138. package/dist/visual/structure.js +270 -0
  139. package/dist/visual/synced.svelte.d.ts +15 -0
  140. package/dist/visual/synced.svelte.js +34 -0
  141. package/dist/visual/tips.d.ts +24 -0
  142. package/dist/visual/tips.js +50 -0
  143. package/package.json +102 -0
@@ -0,0 +1,480 @@
1
+ import { mapPos, isAnchorMark } from '@quillmark/wasm';
2
+ import { codePoints, usvLength } from './decode.js';
3
+ import { ISLAND_SLOT, islandEntryFromNode } from './islands.js';
4
+ import { contentDescriptorFromPM, descriptorOf, markKey } from './marks.js';
5
+ import { valueEqual } from './reconcile.js';
6
+ /** Append `s` to the accumulated text, advancing the running USV length. */
7
+ function appendText(acc, s) {
8
+ acc.text += s;
9
+ acc.usvEnd += usvLength(s);
10
+ }
11
+ /**
12
+ * Walk a PM document once, producing the content projection AND the position-map
13
+ * runs (they share the traversal so they can never disagree). This is the single
14
+ * source both `pmToContent` and the position map draw from.
15
+ */
16
+ export function scanDoc(doc) {
17
+ const acc = {
18
+ text: '',
19
+ lines: [],
20
+ marks: [],
21
+ islands: [],
22
+ runs: [],
23
+ pmEnd: doc.content.size,
24
+ usvEnd: 0,
25
+ rawMarks: [],
26
+ lastContentEndPm: 0
27
+ };
28
+ scanBlocks(acc, doc, 0, []);
29
+ acc.marks = mergeMarks(acc.rawMarks);
30
+ return acc;
31
+ }
32
+ /** `pmToContent`: the pure inverse of decode (drops the position runs). */
33
+ export function pmToContent(doc) {
34
+ const { text, lines, marks, islands } = scanDoc(doc);
35
+ return { text, lines, marks, islands };
36
+ }
37
+ /** Walk a block-content fragment; `contentStart` is the PM pos before its first child. */
38
+ function scanBlocks(acc, parent, contentStart, containers) {
39
+ let pm = contentStart;
40
+ parent.forEach((child) => {
41
+ scanBlock(acc, child, pm, containers);
42
+ pm += child.nodeSize;
43
+ });
44
+ }
45
+ function scanBlock(acc, node, nodePos, containers) {
46
+ const name = node.type.name;
47
+ const contentStart = nodePos + 1;
48
+ switch (name) {
49
+ case 'paragraph':
50
+ case 'heading': {
51
+ // One kind value per block: the opening line and its hard-break
52
+ // continuations read the SAME object, so they cannot disagree.
53
+ const kind = textblockKind(node);
54
+ beginLine(acc, contentStart, containers, kind);
55
+ scanInline(acc, node, contentStart, containers, kind);
56
+ acc.lastContentEndPm = contentStart + node.content.size;
57
+ break;
58
+ }
59
+ case 'code_block': {
60
+ const lang = node.attrs.lang ?? undefined;
61
+ const kind = lang != null ? { kind: 'code', lang } : { kind: 'code' };
62
+ beginLine(acc, contentStart, containers, kind);
63
+ const codeText = node.textContent;
64
+ if (codeText.length)
65
+ emitText(acc, codeText, contentStart, []);
66
+ // Internal `\n`s already sit in the text (and its run); add the extra
67
+ // content lines they imply as `continues` code lines.
68
+ const extra = codeText.split('\n').length - 1;
69
+ for (let i = 0; i < extra; i++)
70
+ acc.lines.push({ containers, continues: true, ...kind });
71
+ acc.lastContentEndPm = contentStart + node.content.size;
72
+ break;
73
+ }
74
+ case 'horizontal_rule':
75
+ beginLine(acc, nodePos, containers, { kind: 'rule' });
76
+ acc.lastContentEndPm = nodePos + 1;
77
+ break;
78
+ case 'island_block':
79
+ beginLine(acc, nodePos, containers, { kind: 'island' });
80
+ acc.runs.push({ kind: 'atom', pmStart: nodePos, usvStart: acc.usvEnd });
81
+ appendText(acc, ISLAND_SLOT);
82
+ acc.islands.push(islandEntryFromNode(node.attrs));
83
+ acc.lastContentEndPm = nodePos + 1;
84
+ break;
85
+ case 'blockquote':
86
+ scanBlocks(acc, node, contentStart, [...containers, { container: 'quote' }]);
87
+ break;
88
+ case 'unknown_container':
89
+ scanBlocks(acc, node, contentStart, [
90
+ ...containers,
91
+ { container: node.attrs.container, attrs: node.attrs.attrs }
92
+ ]);
93
+ break;
94
+ case 'bullet_list':
95
+ case 'ordered_list': {
96
+ const ordered = name === 'ordered_list';
97
+ const start = ordered ? node.attrs.start : 1;
98
+ let itemPos = contentStart;
99
+ node.forEach((item, _off, index) => {
100
+ const container = {
101
+ container: 'list_item',
102
+ ordered,
103
+ start,
104
+ ordinal: index
105
+ };
106
+ scanBlocks(acc, item, itemPos + 1, [...containers, container]);
107
+ itemPos += item.nodeSize;
108
+ });
109
+ break;
110
+ }
111
+ default:
112
+ // Unknown block type: treat as an empty paragraph line (defensive).
113
+ beginLine(acc, contentStart, containers, { kind: 'para' });
114
+ acc.lastContentEndPm = contentStart;
115
+ }
116
+ }
117
+ /** A textblock's line kind: a heading's `level`, or (for a paragraph) `para`, or
118
+ * the unknown kind it carries (schema.ts) re-emitted verbatim. */
119
+ function textblockKind(node) {
120
+ if (node.type.name === 'heading')
121
+ return { kind: 'heading', level: node.attrs.level };
122
+ const u = node.attrs.unknown;
123
+ return u ? { kind: u.kind, attrs: u.attrs } : { kind: 'para' };
124
+ }
125
+ /** Open a new content line: emit the boundary `\n` (except the first line) + record. */
126
+ function beginLine(acc, thisContentStart, containers, kind) {
127
+ if (acc.lines.length > 0) {
128
+ acc.runs.push({
129
+ kind: 'nl',
130
+ pmStart: acc.lastContentEndPm,
131
+ pmEnd: thisContentStart,
132
+ usvStart: acc.usvEnd
133
+ });
134
+ appendText(acc, '\n');
135
+ }
136
+ acc.lines.push({ containers, ...kind });
137
+ }
138
+ /** Walk a textblock's inline content; emit text/atom/hard-break runs + marks. */
139
+ function scanInline(acc, block, contentStart, containers, kind) {
140
+ let pm = contentStart;
141
+ block.forEach((child) => {
142
+ if (child.isText) {
143
+ emitText(acc, child.text ?? '', pm, child.marks);
144
+ }
145
+ else if (child.type.name === 'hard_break') {
146
+ // A within-block hard break: the content `\n` (a `continues` line).
147
+ acc.runs.push({ kind: 'nl', pmStart: pm, pmEnd: pm + 1, usvStart: acc.usvEnd });
148
+ appendText(acc, '\n');
149
+ acc.lines.push({ containers, continues: true, ...kind });
150
+ }
151
+ else if (child.type.name === 'island_inline') {
152
+ acc.runs.push({ kind: 'atom', pmStart: pm, usvStart: acc.usvEnd });
153
+ appendText(acc, ISLAND_SLOT);
154
+ acc.islands.push(islandEntryFromNode(child.attrs));
155
+ }
156
+ pm += child.nodeSize;
157
+ });
158
+ }
159
+ /** Append a text node's chars: one position run + a content mark per formatting/unknown mark. */
160
+ function emitText(acc, s, pmStart, marks) {
161
+ if (!s.length)
162
+ return;
163
+ const usvStart = acc.usvEnd;
164
+ acc.runs.push({ kind: 'text', pmStart, usvStart, s });
165
+ appendText(acc, s);
166
+ for (const mark of marks) {
167
+ acc.rawMarks.push({
168
+ start: usvStart,
169
+ end: acc.usvEnd,
170
+ ...contentDescriptorFromPM(mark)
171
+ });
172
+ }
173
+ }
174
+ /**
175
+ * Merge adjacent/overlapping same-descriptor marks into maximal ranges: the
176
+ * per-text-node marks the walk emits, folded into the content's normalized form.
177
+ * Same two primitives the mark diff runs on (`groupFormatting` + `union`), so the
178
+ * projection and the diff group and merge by one rule.
179
+ */
180
+ function mergeMarks(raw) {
181
+ const out = [];
182
+ for (const g of groupFormatting(raw, (m) => ({ start: m.start, end: m.end })).values()) {
183
+ for (const iv of union(g.intervals))
184
+ out.push({ ...iv, ...g.descriptor });
185
+ }
186
+ // Stable, content-like order: by start, then end.
187
+ return out.sort((a, b) => a.start - b.start || a.end - b.end);
188
+ }
189
+ /** The edit `oldRt` → `newRt`: diffs the text once. */
190
+ export function contentEdit(oldRt, newRt) {
191
+ const delta = diffText(oldRt.text, newRt.text);
192
+ return delta ? { oldRt, newRt, delta } : { oldRt, newRt };
193
+ }
194
+ /** Lower an edit to a `ChangeBundle`: the old→new content diff, as ops. */
195
+ export function lower(edit, opts = {}) {
196
+ const { oldRt, newRt } = edit;
197
+ const { delta, islandOps, insertedAt } = splitIslands(edit);
198
+ const lineOps = diffLines(oldRt, newRt);
199
+ const markOps = diffMarks(oldRt, newRt, delta, insertedAt, opts);
200
+ const bundle = {};
201
+ if (delta)
202
+ bundle.delta = delta;
203
+ if (islandOps.length)
204
+ bundle.islandOps = islandOps;
205
+ if (lineOps.length)
206
+ bundle.lineOps = lineOps;
207
+ if (markOps.length)
208
+ bundle.markOps = markOps;
209
+ return bundle;
210
+ }
211
+ /** A minimal single-splice text delta over USV code points, or `undefined` if equal. */
212
+ function diffText(oldText, newText) {
213
+ if (oldText === newText)
214
+ return undefined;
215
+ const a = codePoints(oldText);
216
+ const b = codePoints(newText);
217
+ let p = 0;
218
+ const min = Math.min(a.length, b.length);
219
+ while (p < min && a[p] === b[p])
220
+ p++;
221
+ let s = 0;
222
+ while (s < min - p && a[a.length - 1 - s] === b[b.length - 1 - s])
223
+ s++;
224
+ const ops = [];
225
+ if (p > 0)
226
+ ops.push({ retain: p });
227
+ const del = a.length - p - s;
228
+ if (del > 0)
229
+ ops.push({ delete: del });
230
+ const ins = b.slice(p, b.length - s).join('');
231
+ if (ins.length > 0)
232
+ ops.push({ insert: ins });
233
+ if (s > 0)
234
+ ops.push({ retain: s });
235
+ return { ops };
236
+ }
237
+ /** Per-line `setContainers` / `setKind` / `setContinues` when line metadata changed; else none. */
238
+ function diffLines(oldRt, newRt) {
239
+ if (lineMetaEqual(oldRt.lines, newRt.lines))
240
+ return [];
241
+ const ops = [];
242
+ // Force every new line's metadata. Redundant ops are safe no-ops (verified),
243
+ // so this is correct regardless of how the delta's split/join inheritance left
244
+ // the intermediate metadata. `continues` is a boundary property of the `\n`
245
+ // preceding a line: line 0 has no predecessor, never carries it, and the op
246
+ // rejects it there, so it's set only for lines ≥ 1.
247
+ for (let i = 0; i < newRt.lines.length; i++) {
248
+ const l = newRt.lines[i];
249
+ ops.push({ op: 'setContainers', line: i, containers: l.containers });
250
+ ops.push(kindOp(i, l));
251
+ if (i > 0)
252
+ ops.push({ op: 'setContinues', line: i, continues: !!l.continues });
253
+ }
254
+ return ops;
255
+ }
256
+ /** A line minus its `containers` / `continues` envelope: exactly `setKind`'s
257
+ * payload, which the boundary names `ContentLineKind`. Lifting it whole is what
258
+ * keeps this arm-agnostic: `kind` is an OPEN set, so an arm-by-arm switch would
259
+ * have to guess at the unknown arm's payload and would drift on every arm
260
+ * upstream adds. */
261
+ function kindPart(l) {
262
+ const { containers: _containers, continues: _continues, ...kind } = l;
263
+ return kind;
264
+ }
265
+ function kindOp(line, l) {
266
+ return { op: 'setKind', line, ...kindPart(l) };
267
+ }
268
+ function lineMetaEqual(a, b) {
269
+ if (a.length !== b.length)
270
+ return false;
271
+ for (let i = 0; i < a.length; i++) {
272
+ if (!!a[i].continues !== !!b[i].continues)
273
+ return false;
274
+ if (kindKey(a[i]) !== kindKey(b[i]))
275
+ return false;
276
+ if (JSON.stringify(a[i].containers) !== JSON.stringify(b[i].containers))
277
+ return false;
278
+ }
279
+ return true;
280
+ }
281
+ /** A line kind's comparison key: key-order-insensitive, because the two sides come
282
+ * from different producers (a WASM read and this scan). */
283
+ function kindKey(l) {
284
+ return JSON.stringify(Object.entries(kindPart(l)).sort(([x], [y]) => (x < y ? -1 : 1)));
285
+ }
286
+ /**
287
+ * Split the edit's islands off the text splice. `applyChange` throws
288
+ * `IslandSlotInInsert` on a `delta` that types a slot, so a slot NEVER rides the
289
+ * delta: every slot inside the splice's inserted region is stripped from it and
290
+ * placed by `{ op: 'insert', at }`, which carries the backing entry in the same
291
+ * op. Every other island kept its slot, so a changed payload is `{ op: 'set' }`,
292
+ * addressed by the id both sides carry. A DELETED island needs no op at all: the
293
+ * delta that removes its slot drops the entry.
294
+ *
295
+ * `at` is the slot's position in the FINAL text: inserts apply left to right, so
296
+ * by the time the k-th lands, the k slots before it are back in place.
297
+ *
298
+ * The pairing is positional for `insert` and by id for `set`, which is what each
299
+ * addresses by. An id the store does not carry emits nothing rather than a `set`
300
+ * that would throw `UnknownIslandId`: nothing mints or renames an island id, so
301
+ * it is unreachable.
302
+ */
303
+ function splitIslands(edit) {
304
+ const { oldRt, newRt, delta } = edit;
305
+ const slots = slotPositions(newRt.text);
306
+ const { start, end } = insertedRegion(delta);
307
+ const oldById = new Map(oldRt.islands.map((isl) => [isl.id, isl]));
308
+ const islandOps = [];
309
+ const insertedAt = [];
310
+ newRt.islands.forEach((isl, i) => {
311
+ const at = slots[i];
312
+ if (at !== undefined && at >= start && at < end) {
313
+ islandOps.push({ op: 'insert', at, ...isl });
314
+ insertedAt.push(at);
315
+ }
316
+ else {
317
+ const before = oldById.get(isl.id);
318
+ if (before && !valueEqual(before, isl))
319
+ islandOps.push({ op: 'set', ...isl });
320
+ }
321
+ });
322
+ return {
323
+ delta: insertedAt.length && delta ? withoutSlots(delta) : delta,
324
+ islandOps,
325
+ insertedAt
326
+ };
327
+ }
328
+ /** The USV positions of `text`'s island slots, in document order: the i-th is the
329
+ * i-th island's, since the scan appends an entry per slot it writes. */
330
+ function slotPositions(text) {
331
+ const out = [];
332
+ let usv = 0;
333
+ for (const cp of text) {
334
+ if (cp === ISLAND_SLOT)
335
+ out.push(usv);
336
+ usv++;
337
+ }
338
+ return out;
339
+ }
340
+ /** The splice's inserted region `[start, end)` in FINAL coords. `diffText` emits
341
+ * one `[retain?][delete?][insert?][retain?]`, so the leading retain is where the
342
+ * insert lands and its length is how far it reaches. */
343
+ function insertedRegion(delta) {
344
+ if (!delta)
345
+ return { start: 0, end: 0 };
346
+ const lead = delta.ops[0];
347
+ const start = lead && 'retain' in lead ? lead.retain : 0;
348
+ const insert = delta.ops.find((op) => 'insert' in op);
349
+ return { start, end: start + (insert ? usvLength(insert.insert) : 0) };
350
+ }
351
+ /** The delta with every island slot removed from its insert, or `undefined` when
352
+ * that leaves nothing but retains. Retain/delete arithmetic reads the OLD text,
353
+ * so dropping inserted characters leaves the rest of the splice intact. */
354
+ function withoutSlots(delta) {
355
+ const ops = [];
356
+ let splices = false;
357
+ for (const op of delta.ops) {
358
+ if ('insert' in op) {
359
+ const kept = op.insert.split(ISLAND_SLOT).join('');
360
+ if (kept.length) {
361
+ ops.push({ insert: kept });
362
+ splices = true;
363
+ }
364
+ }
365
+ else {
366
+ if ('delete' in op)
367
+ splices = true;
368
+ ops.push(op);
369
+ }
370
+ }
371
+ return splices ? { ops } : undefined;
372
+ }
373
+ /**
374
+ * A post-delta position carried through the bundle's island inserts, which move
375
+ * text exactly as the delta does and rebase marks by the same rule (start-assoc
376
+ * `after`, end-assoc `before`; an anchor is a point and holds `before`). Ascending
377
+ * `at`s apply left to right, so each comparison reads the RUNNING position.
378
+ */
379
+ function shiftPastIslands(pos, insertedAt, assoc) {
380
+ let p = pos;
381
+ for (const at of insertedAt)
382
+ if (assoc === 'after' ? p >= at : p > at)
383
+ p++;
384
+ return p;
385
+ }
386
+ /** Formatting/unknown marks → add/remove ops; anchors → add/removeAnchor by id. */
387
+ function diffMarks(oldRt, newRt, delta, insertedAt, opts) {
388
+ const ops = [];
389
+ // Rebase old marks through BOTH text-moving channels to final coords, exactly
390
+ // as `applyChange` does internally (start assoc `after`, end assoc `before`).
391
+ const rebase = (pos, assoc) => shiftPastIslands(delta ? mapPos(delta, pos, assoc) : pos, insertedAt, assoc);
392
+ // Group both sides by descriptor key (excluding anchors).
393
+ const oldGroups = groupFormatting(oldRt.marks, (m) => ({
394
+ start: rebase(m.start, 'after'),
395
+ end: rebase(m.end, 'before')
396
+ }));
397
+ const newGroups = groupFormatting(newRt.marks, (m) => ({ start: m.start, end: m.end }));
398
+ const keys = new Set([...oldGroups.keys(), ...newGroups.keys()]);
399
+ for (const key of keys) {
400
+ const oldG = oldGroups.get(key);
401
+ const newG = newGroups.get(key);
402
+ const descriptor = (newG ?? oldG).descriptor;
403
+ const oldCov = union((oldG?.intervals ?? []).filter((iv) => iv.end > iv.start));
404
+ const newCov = union((newG?.intervals ?? []).filter((iv) => iv.end > iv.start));
405
+ for (const iv of subtract(oldCov, newCov))
406
+ ops.push({ op: 'remove', start: iv.start, end: iv.end, ...descriptor });
407
+ for (const iv of subtract(newCov, oldCov))
408
+ ops.push({ op: 'add', start: iv.start, end: iv.end, ...descriptor });
409
+ }
410
+ // Anchors: diff the decoration sets by id (positions already in final coords).
411
+ if (opts.oldAnchors || opts.newAnchors) {
412
+ const oldA = new Map((opts.oldAnchors ?? []).map((a) => [
413
+ a.id,
414
+ shiftPastIslands(delta ? mapPos(delta, a.pos, 'after') : a.pos, insertedAt, 'before')
415
+ ]));
416
+ const newA = new Map((opts.newAnchors ?? []).map((a) => [a.id, a.pos]));
417
+ for (const [id, pos] of newA) {
418
+ if (!oldA.has(id) || oldA.get(id) !== pos) {
419
+ if (oldA.has(id))
420
+ ops.push({ op: 'removeAnchor', id });
421
+ ops.push({ op: 'add', start: pos, end: pos, type: 'anchor', id });
422
+ }
423
+ }
424
+ for (const id of oldA.keys())
425
+ if (!newA.has(id))
426
+ ops.push({ op: 'removeAnchor', id });
427
+ }
428
+ return ops;
429
+ }
430
+ /** Group non-anchor marks by descriptor key, mapping each to an interval. */
431
+ function groupFormatting(marks, toInterval) {
432
+ const groups = new Map();
433
+ for (const m of marks) {
434
+ if (isAnchorMark(m))
435
+ continue;
436
+ const descriptor = descriptorOf(m);
437
+ const key = markKey(descriptor);
438
+ let g = groups.get(key);
439
+ if (!g) {
440
+ g = { descriptor, intervals: [] };
441
+ groups.set(key, g);
442
+ }
443
+ g.intervals.push(toInterval(m));
444
+ }
445
+ return groups;
446
+ }
447
+ /** Union a set of intervals into sorted, disjoint, maximal intervals. */
448
+ function union(intervals) {
449
+ if (!intervals.length)
450
+ return [];
451
+ const sorted = [...intervals].sort((a, b) => a.start - b.start || a.end - b.end);
452
+ const out = [{ ...sorted[0] }];
453
+ for (let i = 1; i < sorted.length; i++) {
454
+ const last = out[out.length - 1];
455
+ if (sorted[i].start <= last.end)
456
+ last.end = Math.max(last.end, sorted[i].end);
457
+ else
458
+ out.push({ ...sorted[i] });
459
+ }
460
+ return out;
461
+ }
462
+ /** Interval-set subtraction `a \ b` (both assumed unioned/sorted). */
463
+ function subtract(a, b) {
464
+ const out = [];
465
+ for (const iv of a) {
466
+ let cursor = iv.start;
467
+ for (const cut of b) {
468
+ if (cut.end <= cursor || cut.start >= iv.end)
469
+ continue;
470
+ if (cut.start > cursor)
471
+ out.push({ start: cursor, end: Math.min(cut.start, iv.end) });
472
+ cursor = Math.max(cursor, cut.end);
473
+ if (cursor >= iv.end)
474
+ break;
475
+ }
476
+ if (cursor < iv.end)
477
+ out.push({ start: cursor, end: iv.end });
478
+ }
479
+ return out.filter((iv) => iv.end > iv.start);
480
+ }
@@ -0,0 +1,171 @@
1
+ import type { Schema } from 'prosemirror-model';
2
+ import { Plugin } from 'prosemirror-state';
3
+ import { EditorView } from 'prosemirror-view';
4
+ import type { Document, Content, Addr, Quill } from '@quillmark/wasm';
5
+ import type { EditorErrorHandler } from '../errors.js';
6
+ import { type TableChromeStrings } from './table-view.js';
7
+ import { type SlashItem, type SlashState, type SlashStrings } from './slash.js';
8
+ /** Options for {@link createField}. */
9
+ export interface CreateFieldOpts {
10
+ doc: Document;
11
+ /** The schema the leaf reads through: `quill.reader(doc).getContent` decodes by
12
+ * declared type, which is the only thing that says whether a stored string is
13
+ * markdown or literal text. */
14
+ quill: Quill;
15
+ addr: Addr;
16
+ container: HTMLElement;
17
+ /** Constrained single-textblock schema (a `richtext(inline)` field). */
18
+ inline?: boolean;
19
+ /** Inline + marks/islands stripped (a `plaintext` field). Implies `inline`. */
20
+ plaintext?: boolean;
21
+ /** Suppress the markdown-shorthand input rules. */
22
+ noInputRules?: boolean;
23
+ /** The island chrome's wording, read LIVE (per render) so a consumer swapping
24
+ * locale mid-session re-renders rather than freezing it at mount. Absent leaves
25
+ * the package's English; an inline leaf never asks (it holds no island). */
26
+ tableStrings?: () => TableChromeStrings;
27
+ /** The slash menu's wording, read live for the same reason. */
28
+ slashStrings?: () => SlashStrings;
29
+ /**
30
+ * The slash menu's live state, or `undefined` when it is closed: the channel the
31
+ * chrome draws from (`visual/SlashMenu.svelte`).
32
+ *
33
+ * Its PRESENCE is what mounts the menu at all. The trigger and its keys are the
34
+ * leaf's, but a surface only a keyboard can reach (claiming Enter, Escape and the
35
+ * arrows with nothing on screen) is worse than no surface, so the door exists
36
+ * exactly where something can draw it. A constrained inline leaf never has one:
37
+ * it holds no island and no block to convert.
38
+ */
39
+ onSlash?(state: SlashState | undefined): void;
40
+ /** Accessible name → `aria-label` on the `contenteditable`. For a leaf NOTHING
41
+ * else names: an array element (the field label plus its 1-based index), the
42
+ * card body (no visible label at all). A leaf with a field label takes
43
+ * `labelledBy` instead: `for` cannot reach a `contenteditable`, which is not a
44
+ * labelable element, so the association runs the other way. */
45
+ label?: string;
46
+ /** The field label's own id → `aria-labelledby`, which supersedes `label`. */
47
+ labelledBy?: string;
48
+ /** The parked `description` → `aria-describedby`; announced after the name. */
49
+ describedBy?: string;
50
+ /** Ghost text shown on the empty leaf: the resolved `default:` a field ghosts,
51
+ * or a body's invitation. The INITIAL value; {@link
52
+ * FieldController.setPlaceholder} moves it after mount. Empty/absent
53
+ * shows no ghost. */
54
+ placeholder?: string;
55
+ onFocus?(addr: Addr): void;
56
+ /** Fired with the new USV caret after an edit or a selection move. */
57
+ onCaretMove?(addr: Addr, pos: number): void;
58
+ /**
59
+ * Fired after an edit COMMITTED to the document: the leaf's change signal, and
60
+ * the reason it is not `onCaretMove`. That one also fires on a bare selection
61
+ * move, so a host driving a recompile off it recompiles on every arrow key.
62
+ *
63
+ * Fires for every commit regardless of which branch it took (`applyChange`, the
64
+ * first-edit `overwrite`, the fallback `overwrite`) and for an anchor mutation,
65
+ * which changes no text. A commit that failed outright (`commit-lost`) does not
66
+ * fire it: nothing landed.
67
+ */
68
+ onChange?(addr: Addr): void;
69
+ /** Recovered failures on the commit path ({@link EditorErrorHandler}). */
70
+ onError?: EditorErrorHandler;
71
+ }
72
+ /** The prose-leaf handle (VISUAL_EDITOR §Surface). */
73
+ export interface FieldController {
74
+ /** The mounted `container`: the leaf's element, for a caller that has to reach the
75
+ * DOM the view sits in (the arrival wash, `core/bloom.ts`) rather than re-find it. */
76
+ readonly el: HTMLElement;
77
+ /** Place the caret at USV `pos` (preview onCaretPick → usvToPM → here). */
78
+ setCaret(pos: number): void;
79
+ /** External content change → re-hydrate this leaf (gated by reconcile). */
80
+ applyExternal(): void;
81
+ /**
82
+ * Move the empty-leaf ghost after mount: a card retyped to another kind takes
83
+ * its new kind's wording without remounting, which it must not do (the leaf key
84
+ * is the card's session id, so a remount would cost the caret).
85
+ *
86
+ * Refreshes the decoration WITHOUT a transaction: a placeholder is chrome, and
87
+ * a transaction would fire `onCaretMove` at a moment the caret did not move.
88
+ */
89
+ setPlaceholder(text: string | undefined): void;
90
+ focus(): void;
91
+ /** The current stored content for this addr (for tests / reconcile). */
92
+ getContent(): Content;
93
+ /**
94
+ * Insert an identity anchor `id` at USV `pos`: the seam that gives
95
+ * `anchor` the toggle the six formatting marks have. Zero-width; it folds into the
96
+ * plugin's position set and commits through the mark-diff `anchor` op, so it
97
+ * survives later edits like any anchor. The id is caller-supplied and must be
98
+ * unique + invariant (the 0.97 anchor-id policy); a duplicate id is a no-op.
99
+ */
100
+ insertAnchor(id: string, pos: number): void;
101
+ /** Remove the identity anchor `id` (the inverse of {@link insertAnchor}); a no-op if absent. */
102
+ removeAnchor(id: string): void;
103
+ /** Ids of the anchors within USV range `[from, to]`: a selection's anchor state. */
104
+ anchorsInRange(from: number, to: number): string[];
105
+ /**
106
+ * Move the slash menu's cursor onto item `id`: what a POINTER entering an item
107
+ * calls. The keyboard cursor and the pointer's highlight are one state, so the
108
+ * chrome moves the one the keys already drive rather than painting a second.
109
+ */
110
+ slashFocus(id: string): void;
111
+ /** Run slash item `id` (a click on one): the same path Enter takes. A no-op with
112
+ * no menu open, which is the state a stale click lands in. */
113
+ slashPick(id: string): void;
114
+ /** The current PM selection as a USV `{ from, to }` range (the boundary currency). */
115
+ selectionRange(): {
116
+ from: number;
117
+ to: number;
118
+ };
119
+ destroy(): void;
120
+ }
121
+ /**
122
+ * The ProseMirror handles a chrome surface reaches a mounted leaf through. Not part
123
+ * of {@link FieldController}: they are PM internals, and a consumer's contract is the
124
+ * controller's verbs. The format popover is the one caller and it needs both:
125
+ * `view` to name the leaf's own document, `focusedView` to act on wherever the caret
126
+ * actually is, which inside a table island is a NESTED cell view (`table-view.ts`).
127
+ * The two being different is exactly what tells the popover to withhold its
128
+ * `anchor` button: an anchor is the FIELD's coordinate space, and a cell is not in it.
129
+ */
130
+ export interface LeafViews {
131
+ view: EditorView;
132
+ focusedView(): EditorView;
133
+ /** Every view nested inside this leaf, in mount order: one per table cell. */
134
+ nestedViews(): EditorView[];
135
+ }
136
+ /** The canonical empty `Content`: one empty `para` line. The zero value a prose
137
+ * leaf (or an array's prose element) seeds from. */
138
+ export declare function emptyContent(): Content;
139
+ export declare function createField(opts: CreateFieldOpts): FieldController;
140
+ /**
141
+ * The prose-leaf plugin stack (VISUAL_EDITOR §Surface): shared by
142
+ * {@link createField} and the array-element inline editor (`ProseArrayElement`),
143
+ * so the two never fork the keymap/plugin ordering. History first, then any
144
+ * leaf-specific plugins (`afterHistory`: the addressed leaf passes its
145
+ * anchor-position plugin; a by-value array element passes none), the
146
+ * markdown-shorthand input rules, then the field keymap over the base keymap, and
147
+ * last the two that answer to a caret or a selection BESIDE a block: the gap cursor
148
+ * and {@link pastAtomPlugin}.
149
+ *
150
+ * A `plaintext` field carries no marks (decode strips them, the keymap suppresses
151
+ * Mod-b/i/u), so it also skips the input rules: `inlineSchema` still declares the
152
+ * mark types, so a `**bold**` rule would apply a strong mark AND eat the literal
153
+ * delimiters. The inline schema has no block nodes, so those rules are the ONLY
154
+ * ones it would add: skip all.
155
+ */
156
+ export declare function proseLeafPlugins(schema: Schema, opts: {
157
+ inline: boolean;
158
+ plaintext: boolean;
159
+ /** The slash menu's model and its report channel; absent mounts no menu and
160
+ * leaves Enter/Escape/the arrows to the links below (`keymap.ts`). */
161
+ slash?: {
162
+ items: () => SlashItem[];
163
+ onState: (state: SlashState | undefined) => void;
164
+ };
165
+ noInputRules?: boolean;
166
+ /** Read live, not captured: the ghost can move after mount
167
+ * ({@link FieldController.setPlaceholder}), and a re-hydration rebuilds this
168
+ * stack, so the plugin must ask rather than hold. */
169
+ placeholder?: () => string | undefined;
170
+ afterHistory?: Plugin[];
171
+ }): Plugin[];