@blankdotpage/cake 0.1.7 → 0.1.10

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 (147) hide show
  1. package/dist/cake/clipboard.d.ts +1 -0
  2. package/dist/cake/clipboard.d.ts.map +1 -0
  3. package/dist/cake/clipboard.js +391 -0
  4. package/dist/cake/core/mapping/cursor-source-map.d.ts +1 -0
  5. package/dist/cake/core/mapping/cursor-source-map.d.ts.map +1 -0
  6. package/dist/cake/core/mapping/cursor-source-map.js +146 -0
  7. package/dist/cake/core/runtime.d.ts +3 -0
  8. package/dist/cake/core/runtime.d.ts.map +1 -0
  9. package/dist/cake/core/runtime.js +1758 -0
  10. package/dist/cake/core/types.d.ts +1 -0
  11. package/dist/cake/core/types.d.ts.map +1 -0
  12. package/dist/cake/core/types.js +1 -0
  13. package/dist/cake/dom/dom-map.d.ts +1 -0
  14. package/dist/cake/dom/dom-map.d.ts.map +1 -0
  15. package/dist/cake/dom/dom-map.js +151 -0
  16. package/dist/cake/dom/dom-selection.d.ts +1 -0
  17. package/dist/cake/dom/dom-selection.d.ts.map +1 -0
  18. package/dist/cake/dom/dom-selection.js +216 -0
  19. package/dist/cake/dom/render.d.ts +1 -0
  20. package/dist/cake/dom/render.d.ts.map +1 -0
  21. package/dist/cake/dom/render.js +470 -0
  22. package/dist/cake/dom/types.d.ts +1 -0
  23. package/dist/cake/dom/types.d.ts.map +1 -0
  24. package/dist/cake/dom/types.js +1 -0
  25. package/dist/cake/engine/cake-engine.d.ts +1 -0
  26. package/dist/cake/engine/cake-engine.d.ts.map +1 -0
  27. package/dist/cake/engine/cake-engine.js +3589 -0
  28. package/dist/cake/engine/selection/selection-geometry-dom.d.ts +1 -0
  29. package/dist/cake/engine/selection/selection-geometry-dom.d.ts.map +1 -0
  30. package/dist/cake/engine/selection/selection-geometry-dom.js +302 -0
  31. package/dist/cake/engine/selection/selection-geometry.d.ts +1 -0
  32. package/dist/cake/engine/selection/selection-geometry.d.ts.map +1 -0
  33. package/dist/cake/engine/selection/selection-geometry.js +158 -0
  34. package/dist/cake/engine/selection/selection-layout-dom.d.ts +1 -0
  35. package/dist/cake/engine/selection/selection-layout-dom.d.ts.map +1 -0
  36. package/dist/cake/engine/selection/selection-layout-dom.js +781 -0
  37. package/dist/cake/engine/selection/selection-layout.d.ts +1 -0
  38. package/dist/cake/engine/selection/selection-layout.d.ts.map +1 -0
  39. package/dist/cake/engine/selection/selection-layout.js +128 -0
  40. package/dist/cake/engine/selection/selection-navigation.d.ts +1 -0
  41. package/dist/cake/engine/selection/selection-navigation.d.ts.map +1 -0
  42. package/dist/cake/engine/selection/selection-navigation.js +229 -0
  43. package/dist/cake/engine/selection/visible-text.d.ts +1 -0
  44. package/dist/cake/engine/selection/visible-text.d.ts.map +1 -0
  45. package/dist/cake/engine/selection/visible-text.js +66 -0
  46. package/dist/cake/extensions/blockquote/blockquote.d.ts +1 -0
  47. package/dist/cake/extensions/blockquote/blockquote.d.ts.map +1 -0
  48. package/dist/cake/extensions/blockquote/blockquote.js +177 -0
  49. package/dist/cake/extensions/blockquote/index.d.ts +2 -0
  50. package/dist/cake/extensions/blockquote/index.d.ts.map +1 -0
  51. package/dist/cake/extensions/blockquote/index.js +1 -0
  52. package/dist/cake/extensions/bold/bold.d.ts +1 -0
  53. package/dist/cake/extensions/bold/bold.d.ts.map +1 -0
  54. package/dist/cake/extensions/bold/bold.js +113 -0
  55. package/dist/cake/extensions/bold/index.d.ts +2 -0
  56. package/dist/cake/extensions/bold/index.d.ts.map +1 -0
  57. package/dist/cake/extensions/bold/index.js +1 -0
  58. package/dist/cake/extensions/bundles.d.ts +1 -0
  59. package/dist/cake/extensions/bundles.d.ts.map +1 -0
  60. package/dist/cake/extensions/bundles.js +12 -0
  61. package/dist/cake/extensions/combined-emphasis/combined-emphasis.d.ts +1 -0
  62. package/dist/cake/extensions/combined-emphasis/combined-emphasis.d.ts.map +1 -0
  63. package/dist/cake/extensions/combined-emphasis/combined-emphasis.js +42 -0
  64. package/dist/cake/extensions/combined-emphasis/index.d.ts +2 -0
  65. package/dist/cake/extensions/combined-emphasis/index.d.ts.map +1 -0
  66. package/dist/cake/extensions/combined-emphasis/index.js +1 -0
  67. package/dist/cake/extensions/heading/heading.d.ts +1 -0
  68. package/dist/cake/extensions/heading/heading.d.ts.map +1 -0
  69. package/dist/cake/extensions/heading/heading.js +337 -0
  70. package/dist/cake/extensions/heading/index.d.ts +2 -0
  71. package/dist/cake/extensions/heading/index.d.ts.map +1 -0
  72. package/dist/cake/extensions/heading/index.js +1 -0
  73. package/dist/cake/extensions/image/image.d.ts +1 -0
  74. package/dist/cake/extensions/image/image.d.ts.map +1 -0
  75. package/dist/cake/extensions/image/image.js +120 -0
  76. package/dist/cake/extensions/image/index.d.ts +2 -0
  77. package/dist/cake/extensions/image/index.d.ts.map +1 -0
  78. package/dist/cake/extensions/image/index.js +1 -0
  79. package/dist/cake/extensions/index.d.ts +2 -2
  80. package/dist/cake/extensions/index.d.ts.map +1 -0
  81. package/dist/cake/extensions/index.js +25 -0
  82. package/dist/cake/extensions/italic/index.d.ts +2 -0
  83. package/dist/cake/extensions/italic/index.d.ts.map +1 -0
  84. package/dist/cake/extensions/italic/index.js +1 -0
  85. package/dist/cake/extensions/italic/italic.d.ts +1 -0
  86. package/dist/cake/extensions/italic/italic.d.ts.map +1 -0
  87. package/dist/cake/extensions/italic/italic.js +91 -0
  88. package/dist/cake/extensions/link/index.d.ts +2 -0
  89. package/dist/cake/extensions/link/index.d.ts.map +1 -0
  90. package/dist/cake/extensions/link/index.js +1 -0
  91. package/dist/cake/extensions/link/link-popover.d.ts +1 -0
  92. package/dist/cake/extensions/link/link-popover.d.ts.map +1 -0
  93. package/dist/cake/extensions/link/link-popover.js +205 -0
  94. package/dist/cake/extensions/link/link.d.ts +1 -0
  95. package/dist/cake/extensions/link/link.d.ts.map +1 -0
  96. package/dist/cake/extensions/link/link.js +202 -0
  97. package/dist/cake/extensions/list/index.d.ts +2 -0
  98. package/dist/cake/extensions/list/index.d.ts.map +1 -0
  99. package/dist/cake/extensions/list/index.js +1 -0
  100. package/dist/cake/extensions/list/list-ast.d.ts +1 -0
  101. package/dist/cake/extensions/list/list-ast.d.ts.map +1 -0
  102. package/dist/cake/extensions/list/list-ast.js +248 -0
  103. package/dist/cake/extensions/list/list.d.ts +1 -0
  104. package/dist/cake/extensions/list/list.d.ts.map +1 -0
  105. package/dist/cake/extensions/list/list.js +859 -0
  106. package/dist/cake/extensions/scrollbar/index.d.ts +1 -0
  107. package/dist/cake/extensions/scrollbar/index.d.ts.map +1 -0
  108. package/dist/cake/extensions/scrollbar/index.js +216 -0
  109. package/dist/cake/extensions/strikethrough/index.d.ts +2 -0
  110. package/dist/cake/extensions/strikethrough/index.d.ts.map +1 -0
  111. package/dist/cake/extensions/strikethrough/index.js +1 -0
  112. package/dist/cake/extensions/strikethrough/strikethrough.d.ts +1 -0
  113. package/dist/cake/extensions/strikethrough/strikethrough.d.ts.map +1 -0
  114. package/dist/cake/extensions/strikethrough/strikethrough.js +84 -0
  115. package/dist/cake/extensions/types.d.ts +1 -0
  116. package/dist/cake/extensions/types.d.ts.map +1 -0
  117. package/dist/cake/extensions/types.js +1 -0
  118. package/dist/cake/index.d.ts +1 -0
  119. package/dist/cake/index.d.ts.map +1 -0
  120. package/dist/cake/index.js +1 -0
  121. package/dist/cake/react/CakeEditor.d.ts +1 -0
  122. package/dist/cake/react/CakeEditor.d.ts.map +1 -0
  123. package/dist/cake/react/CakeEditor.js +233 -0
  124. package/dist/cake/shared/platform.d.ts +1 -0
  125. package/dist/cake/shared/platform.d.ts.map +1 -0
  126. package/dist/cake/shared/platform.js +19 -0
  127. package/dist/cake/shared/segmenter.d.ts +1 -0
  128. package/dist/cake/shared/segmenter.d.ts.map +1 -0
  129. package/dist/cake/shared/segmenter.js +46 -0
  130. package/dist/cake/shared/url.d.ts +1 -0
  131. package/dist/cake/shared/url.d.ts.map +1 -0
  132. package/dist/cake/shared/url.js +37 -0
  133. package/dist/cake/shared/word-break.d.ts +1 -0
  134. package/dist/cake/shared/word-break.d.ts.map +1 -0
  135. package/dist/cake/shared/word-break.js +178 -0
  136. package/dist/cake/test/harness.d.ts +1 -0
  137. package/dist/cake/test/harness.d.ts.map +1 -0
  138. package/dist/cake/test/harness.js +504 -0
  139. package/dist/codemirror/markdown-commands.d.ts +1 -0
  140. package/dist/codemirror/markdown-commands.d.ts.map +1 -0
  141. package/dist/codemirror/markdown-commands.js +532 -0
  142. package/dist/index.d.ts +1 -0
  143. package/dist/index.d.ts.map +1 -0
  144. package/dist/index.js +3 -11740
  145. package/package.json +8 -6
  146. package/dist/cake/extensions/pipe-link/pipe-link.d.ts +0 -1
  147. package/dist/index.cjs +0 -11740
@@ -0,0 +1,1758 @@
1
+ import { CursorSourceBuilder, } from "./mapping/cursor-source-map";
2
+ import { graphemeSegments } from "../shared/segmenter";
3
+ /** Type guard to check if a command is a structural edit */
4
+ export function isStructuralEdit(command) {
5
+ return (command.type === "insert" ||
6
+ command.type === "delete-backward" ||
7
+ command.type === "delete-forward" ||
8
+ command.type === "insert-line-break" ||
9
+ command.type === "insert-hard-line-break" ||
10
+ command.type === "exit-block-wrapper");
11
+ }
12
+ /** Type guard to check if a command can be applied directly by the engine */
13
+ export function isApplyEditCommand(command) {
14
+ return (command.type === "insert" ||
15
+ command.type === "insert-line-break" ||
16
+ command.type === "insert-hard-line-break" ||
17
+ command.type === "delete-backward" ||
18
+ command.type === "delete-forward");
19
+ }
20
+ /**
21
+ * Define an extension with typed custom commands.
22
+ *
23
+ * @example
24
+ * type MyCommand = { type: "my-command"; value: number };
25
+ * export const myExtension = defineExtension<MyCommand>({
26
+ * name: "my-extension",
27
+ * onEdit(command, state) {
28
+ * if (command.type === "my-command") {
29
+ * // command is narrowed to MyCommand here
30
+ * console.log(command.value);
31
+ * }
32
+ * return null;
33
+ * },
34
+ * });
35
+ */
36
+ export function defineExtension(extension) {
37
+ return extension;
38
+ }
39
+ const defaultSelection = { start: 0, end: 0, affinity: "forward" };
40
+ export function createRuntime(extensions) {
41
+ const toggleMarkerToKind = new Map();
42
+ for (const extension of extensions) {
43
+ const toggle = extension.toggleInline;
44
+ if (!toggle) {
45
+ continue;
46
+ }
47
+ for (const marker of toggle.markers) {
48
+ toggleMarkerToKind.set(marker, toggle.kind);
49
+ }
50
+ }
51
+ const inclusiveAtEndByKind = new Map();
52
+ for (const extension of extensions) {
53
+ const specs = extension.inlineWrapperAffinity;
54
+ if (!specs) {
55
+ continue;
56
+ }
57
+ for (const spec of specs) {
58
+ if (!inclusiveAtEndByKind.has(spec.kind)) {
59
+ inclusiveAtEndByKind.set(spec.kind, spec.inclusive);
60
+ }
61
+ }
62
+ }
63
+ const isInclusiveAtEnd = (kind) => inclusiveAtEndByKind.get(kind) ?? true;
64
+ const context = {
65
+ parseInline: (source, start, end) => parseInlineRange(source, start, end),
66
+ serializeInline: (inline) => serializeInline(inline),
67
+ serializeBlock: (block) => serializeBlock(block),
68
+ };
69
+ function parseBlockAt(source, start) {
70
+ for (const extension of extensions) {
71
+ if (!extension.parseBlock) {
72
+ continue;
73
+ }
74
+ const result = extension.parseBlock(source, start, context);
75
+ if (result) {
76
+ return result;
77
+ }
78
+ }
79
+ return parseLiteralBlock(source, start, context);
80
+ }
81
+ function parseInlineRange(source, start, end) {
82
+ const inlines = [];
83
+ let pos = start;
84
+ while (pos < end) {
85
+ let matched = false;
86
+ for (const extension of extensions) {
87
+ if (!extension.parseInline) {
88
+ continue;
89
+ }
90
+ const result = extension.parseInline(source, pos, end, context);
91
+ if (result) {
92
+ inlines.push(result.inline);
93
+ pos = result.nextPos;
94
+ matched = true;
95
+ break;
96
+ }
97
+ }
98
+ if (!matched) {
99
+ const literal = parseLiteralInline(source, pos, end);
100
+ inlines.push(literal.inline);
101
+ pos = literal.nextPos;
102
+ }
103
+ }
104
+ return inlines;
105
+ }
106
+ function parse(source) {
107
+ const blocks = [];
108
+ let pos = 0;
109
+ while (pos < source.length) {
110
+ const result = parseBlockAt(source, pos);
111
+ blocks.push(result.block);
112
+ pos = result.nextPos;
113
+ if (source[pos] === "\n") {
114
+ pos += 1;
115
+ }
116
+ }
117
+ if (source.length === 0) {
118
+ blocks.push({ type: "paragraph", content: [] });
119
+ }
120
+ if (source.endsWith("\n")) {
121
+ blocks.push({ type: "paragraph", content: [] });
122
+ }
123
+ return { type: "doc", blocks };
124
+ }
125
+ function serialize(doc) {
126
+ const builder = new CursorSourceBuilder();
127
+ const blocks = doc.blocks;
128
+ blocks.forEach((block, index) => {
129
+ const serialized = serializeBlock(block);
130
+ builder.appendSerialized(serialized);
131
+ if (index < blocks.length - 1) {
132
+ builder.appendText("\n");
133
+ }
134
+ });
135
+ return builder.build();
136
+ }
137
+ function serializeBlock(block) {
138
+ for (const extension of extensions) {
139
+ if (!extension.serializeBlock) {
140
+ continue;
141
+ }
142
+ const result = extension.serializeBlock(block, context);
143
+ if (result) {
144
+ return result;
145
+ }
146
+ }
147
+ if (block.type === "paragraph") {
148
+ return serializeParagraph(block, serializeInline);
149
+ }
150
+ if (block.type === "block-wrapper") {
151
+ return serializeBlockWrapper(block, serializeBlock);
152
+ }
153
+ return { source: "", map: new CursorSourceBuilder().build().map };
154
+ }
155
+ function serializeInline(inline) {
156
+ for (const extension of extensions) {
157
+ if (!extension.serializeInline) {
158
+ continue;
159
+ }
160
+ const result = extension.serializeInline(inline, context);
161
+ if (result) {
162
+ return result;
163
+ }
164
+ }
165
+ if (inline.type === "text") {
166
+ const builder = new CursorSourceBuilder();
167
+ builder.appendText(inline.text);
168
+ return builder.build();
169
+ }
170
+ if (inline.type === "inline-wrapper") {
171
+ return serializeInlineWrapper(inline, serializeInline);
172
+ }
173
+ return { source: "", map: new CursorSourceBuilder().build().map };
174
+ }
175
+ function normalize(doc) {
176
+ return {
177
+ type: "doc",
178
+ blocks: doc.blocks
179
+ .map((block) => normalizeBlock(block))
180
+ .filter((block) => block !== null),
181
+ };
182
+ }
183
+ function normalizeBlock(block) {
184
+ let next = block;
185
+ for (const extension of extensions) {
186
+ if (extension.normalizeBlock) {
187
+ const result = extension.normalizeBlock(next);
188
+ if (result === null) {
189
+ return null;
190
+ }
191
+ next = result;
192
+ }
193
+ }
194
+ if (next.type === "paragraph") {
195
+ return {
196
+ ...next,
197
+ content: next.content
198
+ .map((inline) => normalizeInline(inline))
199
+ .filter((inline) => inline !== null),
200
+ };
201
+ }
202
+ if (next.type === "block-wrapper") {
203
+ return {
204
+ ...next,
205
+ blocks: next.blocks
206
+ .map((child) => normalizeBlock(child))
207
+ .filter((child) => child !== null),
208
+ };
209
+ }
210
+ return next;
211
+ }
212
+ function applyInlineNormalizers(inline) {
213
+ let next = inline;
214
+ for (const extension of extensions) {
215
+ if (!extension.normalizeInline) {
216
+ continue;
217
+ }
218
+ const result = extension.normalizeInline(next);
219
+ if (result === null) {
220
+ return null;
221
+ }
222
+ next = result;
223
+ }
224
+ return next;
225
+ }
226
+ function normalizeInline(inline) {
227
+ const pre = applyInlineNormalizers(inline);
228
+ if (!pre) {
229
+ return null;
230
+ }
231
+ let next = pre;
232
+ if (next.type === "inline-wrapper") {
233
+ next = {
234
+ ...next,
235
+ children: next.children
236
+ .map((child) => normalizeInline(child))
237
+ .filter((child) => child !== null),
238
+ };
239
+ }
240
+ return applyInlineNormalizers(next);
241
+ }
242
+ function createState(source, selection = defaultSelection) {
243
+ const doc = parse(source);
244
+ const normalized = normalize(doc);
245
+ const serialized = serialize(normalized);
246
+ return {
247
+ source: serialized.source,
248
+ selection,
249
+ map: serialized.map,
250
+ doc: normalized,
251
+ runtime: runtime,
252
+ };
253
+ }
254
+ function createStateFromDoc(doc, selection = defaultSelection) {
255
+ const normalized = normalize(doc);
256
+ const serialized = serialize(normalized);
257
+ return {
258
+ source: serialized.source,
259
+ selection,
260
+ map: serialized.map,
261
+ doc: normalized,
262
+ runtime: runtime,
263
+ };
264
+ }
265
+ function applyEdit(command, state) {
266
+ // Extensions can either:
267
+ // - fully handle the edit by returning {source, selection}, or
268
+ // - delegate by returning another EditCommand, which will be applied by the
269
+ // runtime after re-running extension middleware.
270
+ //
271
+ // This keeps editing logic composable while still allowing escape hatches.
272
+ // If an extension delegates in a loop, this will loop as well.
273
+ while (true) {
274
+ let delegated = false;
275
+ for (const extension of extensions) {
276
+ if (!extension.onEdit) {
277
+ continue;
278
+ }
279
+ const result = extension.onEdit(command, state);
280
+ if (!result) {
281
+ continue;
282
+ }
283
+ if ("source" in result) {
284
+ return createState(result.source, result.selection);
285
+ }
286
+ command = result;
287
+ delegated = true;
288
+ break;
289
+ }
290
+ if (!delegated) {
291
+ break;
292
+ }
293
+ }
294
+ const selection = normalizeSelection(state.selection);
295
+ if (isStructuralEdit(command)) {
296
+ const structural = applyStructuralEdit(command, state.doc, selection);
297
+ if (!structural) {
298
+ // Structural edits can refuse to operate across certain doc-tree
299
+ // boundaries (e.g. headings are represented as block-wrappers, so a
300
+ // backspace at the start of the following paragraph crosses parents).
301
+ //
302
+ // When that happens, fall back to deleting in source space so
303
+ // Backspace/Delete still behave reasonably, then reparse.
304
+ if (command.type === "delete-backward" ||
305
+ command.type === "delete-forward") {
306
+ const cursorLength = state.map.cursorLength;
307
+ const cursorStart = Math.max(0, Math.min(cursorLength, Math.min(selection.start, selection.end)));
308
+ const cursorEnd = Math.max(0, Math.min(cursorLength, Math.max(selection.start, selection.end)));
309
+ if (cursorStart === cursorEnd) {
310
+ if (command.type === "delete-backward" && cursorStart === 0) {
311
+ return state;
312
+ }
313
+ if (command.type === "delete-forward" &&
314
+ cursorStart === cursorLength) {
315
+ return state;
316
+ }
317
+ }
318
+ const range = cursorStart === cursorEnd
319
+ ? command.type === "delete-backward"
320
+ ? { start: cursorStart - 1, end: cursorStart }
321
+ : { start: cursorStart, end: cursorStart + 1 }
322
+ : { start: cursorStart, end: cursorEnd };
323
+ const fullDocDelete = range.start === 0 && range.end === cursorLength;
324
+ const from = fullDocDelete
325
+ ? 0
326
+ : state.map.cursorToSource(range.start, "backward");
327
+ const to = fullDocDelete
328
+ ? state.source.length
329
+ : state.map.cursorToSource(range.end, "forward");
330
+ const fromClamped = Math.max(0, Math.min(from, state.source.length));
331
+ const toClamped = Math.max(fromClamped, Math.min(to, state.source.length));
332
+ const nextSource = state.source.slice(0, fromClamped) + state.source.slice(toClamped);
333
+ const next = createState(nextSource);
334
+ const caretCursor = next.map.sourceToCursor(fromClamped, "forward");
335
+ return {
336
+ ...next,
337
+ selection: {
338
+ start: caretCursor.cursorOffset,
339
+ end: caretCursor.cursorOffset,
340
+ affinity: caretCursor.affinity,
341
+ },
342
+ };
343
+ }
344
+ // Fallback for insert commands when structural edit fails
345
+ // (e.g., when selection spans across heading boundaries)
346
+ if (command.type === "insert" || command.type === "insert-line-break") {
347
+ const cursorLength = state.map.cursorLength;
348
+ const cursorStart = Math.max(0, Math.min(cursorLength, Math.min(selection.start, selection.end)));
349
+ const cursorEnd = Math.max(0, Math.min(cursorLength, Math.max(selection.start, selection.end)));
350
+ const range = { start: cursorStart, end: cursorEnd };
351
+ const fullDocReplace = range.start === 0 && range.end === cursorLength;
352
+ const from = fullDocReplace
353
+ ? 0
354
+ : state.map.cursorToSource(range.start, "backward");
355
+ const to = fullDocReplace
356
+ ? state.source.length
357
+ : state.map.cursorToSource(range.end, "forward");
358
+ const fromClamped = Math.max(0, Math.min(from, state.source.length));
359
+ const toClamped = Math.max(fromClamped, Math.min(to, state.source.length));
360
+ const insertText = command.type === "insert" ? command.text : "\n";
361
+ const nextSource = state.source.slice(0, fromClamped) +
362
+ insertText +
363
+ state.source.slice(toClamped);
364
+ const next = createState(nextSource);
365
+ const caretSource = fromClamped + insertText.length;
366
+ const caretCursor = next.map.sourceToCursor(caretSource, "forward");
367
+ return {
368
+ ...next,
369
+ selection: {
370
+ start: caretCursor.cursorOffset,
371
+ end: caretCursor.cursorOffset,
372
+ affinity: caretCursor.affinity,
373
+ },
374
+ };
375
+ }
376
+ return state;
377
+ }
378
+ // Structural edits operate directly on the current doc tree. To support
379
+ // markdown-first behavior while typing, we reparse from the resulting
380
+ // source and then remap the caret through source space so it stays stable
381
+ // even when marker characters become source-only.
382
+ const interim = createStateFromDoc(structural.doc);
383
+ const interimAffinity = structural.nextAffinity ?? "forward";
384
+ const caretSource = interim.map.cursorToSource(structural.nextCursor, interimAffinity);
385
+ const next = createState(interim.source);
386
+ const caretCursor = next.map.sourceToCursor(caretSource, interimAffinity);
387
+ return {
388
+ ...next,
389
+ selection: {
390
+ start: caretCursor.cursorOffset,
391
+ end: caretCursor.cursorOffset,
392
+ affinity: caretCursor.affinity,
393
+ },
394
+ };
395
+ }
396
+ // Indent and outdent are handled by extensions
397
+ // Runtime does nothing by default
398
+ if (command.type === "indent" || command.type === "outdent") {
399
+ return state;
400
+ }
401
+ // List toggle commands are handled by extensions
402
+ if (command.type === "toggle-bullet-list" ||
403
+ command.type === "toggle-numbered-list") {
404
+ return state;
405
+ }
406
+ if (command.type === "toggle-inline") {
407
+ return applyInlineToggle(state, command.marker);
408
+ }
409
+ return state;
410
+ }
411
+ function applyStructuralEdit(command, doc, selection) {
412
+ const lines = flattenDocToLines(doc);
413
+ if (lines.length === 0) {
414
+ return null;
415
+ }
416
+ const docCursorLength = cursorLengthForLines(lines);
417
+ const cursorStart = Math.max(0, Math.min(docCursorLength, Math.min(selection.start, selection.end)));
418
+ const cursorEnd = Math.max(0, Math.min(docCursorLength, Math.max(selection.start, selection.end)));
419
+ const affinity = selection.affinity ?? "forward";
420
+ if (command.type === "delete-backward" &&
421
+ cursorStart === 0 &&
422
+ cursorEnd === 0) {
423
+ return {
424
+ doc,
425
+ nextCursor: 0,
426
+ nextAffinity: affinity === "forward" ? "backward" : "backward",
427
+ };
428
+ }
429
+ if (command.type === "delete-forward" &&
430
+ cursorStart === docCursorLength &&
431
+ cursorEnd === docCursorLength) {
432
+ return {
433
+ doc,
434
+ nextCursor: docCursorLength,
435
+ nextAffinity: affinity === "backward" ? "forward" : "forward",
436
+ };
437
+ }
438
+ const replaceText = command.type === "insert"
439
+ ? command.text
440
+ : command.type === "insert-line-break" ||
441
+ command.type === "insert-hard-line-break"
442
+ ? "\n"
443
+ : command.type === "exit-block-wrapper"
444
+ ? "\n"
445
+ : "";
446
+ const range = command.type === "delete-backward" && cursorStart === cursorEnd
447
+ ? cursorStart === 0
448
+ ? { start: 0, end: 0 }
449
+ : { start: cursorStart - 1, end: cursorStart }
450
+ : command.type === "delete-forward" && cursorStart === cursorEnd
451
+ ? cursorStart === docCursorLength
452
+ ? { start: cursorStart, end: cursorStart }
453
+ : { start: cursorStart, end: cursorStart + 1 }
454
+ : { start: cursorStart, end: cursorEnd };
455
+ const shouldReplacePlaceholder = command.type === "insert" &&
456
+ replaceText.length > 0 &&
457
+ range.start === range.end &&
458
+ graphemeAtCursor(lines, range.start) === "\u200B";
459
+ const effectiveRange = shouldReplacePlaceholder
460
+ ? { start: range.start, end: Math.min(docCursorLength, range.start + 1) }
461
+ : range;
462
+ const startLoc = resolveCursorToLine(lines, effectiveRange.start);
463
+ const endLoc = resolveCursorToLine(lines, effectiveRange.end);
464
+ const startLine = lines[startLoc.lineIndex];
465
+ const endLine = lines[endLoc.lineIndex];
466
+ if (!startLine || !endLine) {
467
+ return null;
468
+ }
469
+ if (!pathsEqual(startLine.parentPath, endLine.parentPath) ||
470
+ startLine.indexInParent > endLine.indexInParent) {
471
+ return null;
472
+ }
473
+ const parentPath = startLine.parentPath;
474
+ const parentBlocks = getBlocksAtPath(doc.blocks, parentPath);
475
+ const startIndex = startLine.indexInParent;
476
+ const endIndex = endLine.indexInParent;
477
+ const startBlock = parentBlocks[startIndex];
478
+ const endBlock = parentBlocks[endIndex];
479
+ if (!startBlock || !endBlock) {
480
+ return null;
481
+ }
482
+ const getNearestWrapperAtPath = (rootBlocks, leafPath) => {
483
+ for (let depth = leafPath.length - 1; depth >= 1; depth -= 1) {
484
+ const prefix = leafPath.slice(0, depth);
485
+ const block = getBlockAtPath(rootBlocks, prefix);
486
+ if (block && block.type === "block-wrapper") {
487
+ return { block, path: prefix };
488
+ }
489
+ }
490
+ return null;
491
+ };
492
+ // Atomic block handling (generic: works for any block-atom kind).
493
+ //
494
+ // These blocks have no editable text content, but the caret can land on
495
+ // their line start/end boundaries. We still need to support basic editing
496
+ // semantics around them (Enter to create a new paragraph after, Backspace
497
+ // to delete/move across).
498
+ if (cursorStart === cursorEnd) {
499
+ // Enter at an atomic block inserts a new empty paragraph after it.
500
+ if (command.type === "insert-line-break" &&
501
+ startBlock.type === "block-atom") {
502
+ const nextParentBlocks = [
503
+ ...parentBlocks.slice(0, startIndex + 1),
504
+ { type: "paragraph", content: [] },
505
+ ...parentBlocks.slice(startIndex + 1),
506
+ ];
507
+ const nextDoc = {
508
+ ...doc,
509
+ blocks: updateBlocksAtPath(doc.blocks, parentPath, () => nextParentBlocks),
510
+ };
511
+ const nextLines = flattenDocToLines(nextDoc);
512
+ const lineStarts = getLineStartOffsets(nextLines);
513
+ const nextLineIndex = Math.min(nextLines.length - 1, startLoc.lineIndex + 1);
514
+ return {
515
+ doc: nextDoc,
516
+ nextCursor: lineStarts[nextLineIndex] ?? 0,
517
+ nextAffinity: "forward",
518
+ };
519
+ }
520
+ // Backspace on an atomic block deletes the block.
521
+ if (command.type === "delete-backward" &&
522
+ startBlock.type === "block-atom") {
523
+ const nextParentBlocks = parentBlocks.filter((_, i) => i !== startIndex);
524
+ const ensured = nextParentBlocks.length > 0
525
+ ? nextParentBlocks
526
+ : [{ type: "paragraph", content: [] }];
527
+ const nextDoc = {
528
+ ...doc,
529
+ blocks: updateBlocksAtPath(doc.blocks, parentPath, () => ensured),
530
+ };
531
+ const nextLines = flattenDocToLines(nextDoc);
532
+ const lineStarts = getLineStartOffsets(nextLines);
533
+ const nextLineIndex = Math.min(nextLines.length - 1, startLoc.lineIndex);
534
+ return {
535
+ doc: nextDoc,
536
+ nextCursor: lineStarts[nextLineIndex] ?? 0,
537
+ nextAffinity: "forward",
538
+ };
539
+ }
540
+ // Backspace at the start of a paragraph immediately after an atomic block
541
+ // swaps the paragraph above the atomic block (so the paragraph "moves up"
542
+ // and the atomic block "moves down").
543
+ if (command.type === "delete-backward" &&
544
+ startBlock.type === "paragraph" &&
545
+ startLoc.offsetInLine === 0 &&
546
+ startLoc.lineIndex > 0) {
547
+ const prevLine = lines[startLoc.lineIndex - 1];
548
+ if (prevLine &&
549
+ prevLine.block.type === "block-atom" &&
550
+ pathsEqual(prevLine.parentPath, startLine.parentPath) &&
551
+ prevLine.indexInParent === startLine.indexInParent - 1) {
552
+ const imageIndex = prevLine.indexInParent;
553
+ const paragraphIndex = startLine.indexInParent;
554
+ const nextParentBlocks = parentBlocks.slice();
555
+ const temp = nextParentBlocks[imageIndex];
556
+ nextParentBlocks[imageIndex] = nextParentBlocks[paragraphIndex];
557
+ nextParentBlocks[paragraphIndex] = temp;
558
+ const nextDoc = {
559
+ ...doc,
560
+ blocks: updateBlocksAtPath(doc.blocks, parentPath, () => nextParentBlocks),
561
+ };
562
+ const nextLines = flattenDocToLines(nextDoc);
563
+ const lineStarts = getLineStartOffsets(nextLines);
564
+ const nextLineIndex = Math.max(0, startLoc.lineIndex - 1);
565
+ return {
566
+ doc: nextDoc,
567
+ nextCursor: lineStarts[nextLineIndex] ?? 0,
568
+ nextAffinity: "forward",
569
+ };
570
+ }
571
+ }
572
+ }
573
+ if (startBlock.type !== "paragraph" || endBlock.type !== "paragraph") {
574
+ return null;
575
+ }
576
+ const startRuns = paragraphToRuns(startBlock);
577
+ const endRuns = endIndex === startIndex ? startRuns : paragraphToRuns(endBlock);
578
+ const [beforeRuns] = splitRunsAt(startRuns, startLoc.offsetInLine);
579
+ const [, afterRuns] = splitRunsAt(endRuns, endLoc.offsetInLine);
580
+ // Generic "exit block-wrapper" behavior:
581
+ // Split the nearest enclosing wrapper's single paragraph into:
582
+ // - wrapper paragraph: content before the caret
583
+ // - new paragraph after the wrapper: content after the caret
584
+ //
585
+ // This is useful for wrapper kinds that conceptually should not span
586
+ // multiple lines (e.g. headings), while keeping the core syntax-agnostic.
587
+ if (command.type === "exit-block-wrapper" &&
588
+ effectiveRange.start === effectiveRange.end &&
589
+ startLoc.lineIndex === endLoc.lineIndex) {
590
+ const wrapperInfo = getNearestWrapperAtPath(doc.blocks, startLine.path);
591
+ if (wrapperInfo &&
592
+ wrapperInfo.block.blocks.length === 1 &&
593
+ wrapperInfo.block.blocks[0]?.type === "paragraph") {
594
+ const wrapperParentPath = wrapperInfo.path.slice(0, -1);
595
+ const wrapperIndexInParent = wrapperInfo.path[wrapperInfo.path.length - 1] ?? 0;
596
+ const wrapperParentBlocks = getBlocksAtPath(doc.blocks, wrapperParentPath);
597
+ const nextWrapper = {
598
+ ...wrapperInfo.block,
599
+ blocks: [
600
+ {
601
+ type: "paragraph",
602
+ content: runsToInlines(normalizeRuns(beforeRuns)),
603
+ },
604
+ ],
605
+ };
606
+ const nextParagraph = {
607
+ type: "paragraph",
608
+ content: runsToInlines(normalizeRuns(afterRuns)),
609
+ };
610
+ const nextParentBlocks = [
611
+ ...wrapperParentBlocks.slice(0, wrapperIndexInParent),
612
+ nextWrapper,
613
+ nextParagraph,
614
+ ...wrapperParentBlocks.slice(wrapperIndexInParent + 1),
615
+ ];
616
+ const nextDoc = {
617
+ ...doc,
618
+ blocks: updateBlocksAtPath(doc.blocks, wrapperParentPath, () => nextParentBlocks),
619
+ };
620
+ const nextLines = flattenDocToLines(nextDoc);
621
+ const lineStarts = getLineStartOffsets(nextLines);
622
+ const insertedPath = [...wrapperParentPath, wrapperIndexInParent + 1];
623
+ const insertedLineIndex = nextLines.findIndex((line) => pathsEqual(line.path, insertedPath));
624
+ const nextCursor = insertedLineIndex >= 0 ? (lineStarts[insertedLineIndex] ?? 0) : 0;
625
+ return {
626
+ doc: nextDoc,
627
+ nextCursor,
628
+ nextAffinity: "forward",
629
+ };
630
+ }
631
+ }
632
+ const hasSelectedText = effectiveRange.start !== effectiveRange.end;
633
+ const baseMarks = hasSelectedText
634
+ ? commonMarksAcrossSelection(lines, effectiveRange.start, effectiveRange.end, doc)
635
+ : marksAtCursor(startRuns, startLoc.offsetInLine, affinity);
636
+ const insertDoc = parse(replaceText);
637
+ const insertLines = flattenDocToLines(insertDoc);
638
+ const insertBlocks = insertDoc.blocks;
639
+ const insertCursorLength = cursorLengthForLines(insertLines);
640
+ const replacementBlocks = buildReplacementBlocks({
641
+ baseMarks,
642
+ beforeRuns,
643
+ afterRuns,
644
+ insertBlocks,
645
+ });
646
+ const nextParentBlocks = [
647
+ ...parentBlocks.slice(0, startIndex),
648
+ ...replacementBlocks,
649
+ ...parentBlocks.slice(endIndex + 1),
650
+ ];
651
+ const nextDoc = {
652
+ ...doc,
653
+ blocks: updateBlocksAtPath(doc.blocks, parentPath, () => nextParentBlocks),
654
+ };
655
+ const nextDocCursorLength = cursorLengthForLines(flattenDocToLines(nextDoc));
656
+ const nextCursor = Math.max(0, Math.min(nextDocCursorLength, effectiveRange.start + insertCursorLength));
657
+ const nextLines = flattenDocToLines(nextDoc);
658
+ const around = marksAroundCursor(nextDoc, nextCursor);
659
+ const fallbackAffinity = command.type === "delete-backward"
660
+ ? "backward"
661
+ : command.type === "delete-forward"
662
+ ? "forward"
663
+ : command.type === "insert"
664
+ ? effectiveRange.start === effectiveRange.end
665
+ ? affinity
666
+ : "forward"
667
+ : "forward";
668
+ let nextAffinity = command.type === "insert"
669
+ ? preferredTypingAffinityAtGap(around.left, around.right, fallbackAffinity)
670
+ : preferredAffinityAtGap(around.left, around.right, fallbackAffinity);
671
+ // If the cursor is at the start of a non-first line (i.e., right after a
672
+ // serialized newline), keep affinity "forward" so selection anchors in the
673
+ // following line, not at the end of the previous one.
674
+ const nextLoc = resolveCursorToLine(nextLines, nextCursor);
675
+ const lineStarts = getLineStartOffsets(nextLines);
676
+ if (nextLoc.lineIndex > 0 &&
677
+ nextLoc.offsetInLine === 0 &&
678
+ nextCursor === (lineStarts[nextLoc.lineIndex] ?? 0)) {
679
+ nextAffinity = "forward";
680
+ }
681
+ return {
682
+ doc: nextDoc,
683
+ nextCursor,
684
+ nextAffinity,
685
+ };
686
+ }
687
+ function cursorLengthForLines(lines) {
688
+ let length = 0;
689
+ for (const line of lines) {
690
+ length += line.cursorLength;
691
+ if (line.hasNewline) {
692
+ length += 1;
693
+ }
694
+ }
695
+ return length;
696
+ }
697
+ function flattenDocToLines(doc) {
698
+ const entries = [];
699
+ const visit = (blocks, prefix) => {
700
+ blocks.forEach((block, index) => {
701
+ const path = [...prefix, index];
702
+ if (block.type === "block-wrapper") {
703
+ visit(block.blocks, path);
704
+ return;
705
+ }
706
+ entries.push({ path, block });
707
+ });
708
+ };
709
+ visit(doc.blocks, []);
710
+ if (entries.length === 0) {
711
+ return [
712
+ {
713
+ path: [0],
714
+ parentPath: [],
715
+ indexInParent: 0,
716
+ block: { type: "paragraph", content: [] },
717
+ text: "",
718
+ cursorLength: 0,
719
+ hasNewline: false,
720
+ },
721
+ ];
722
+ }
723
+ return entries.map((entry, i) => {
724
+ const parentPath = entry.path.slice(0, -1);
725
+ const indexInParent = entry.path[entry.path.length - 1] ?? 0;
726
+ const text = blockVisibleText(entry.block);
727
+ return {
728
+ path: entry.path,
729
+ parentPath,
730
+ indexInParent,
731
+ block: entry.block,
732
+ text,
733
+ cursorLength: graphemeSegments(text).length,
734
+ hasNewline: i < entries.length - 1,
735
+ };
736
+ });
737
+ }
738
+ function resolveCursorToLine(lines, cursorOffset) {
739
+ const total = cursorLengthForLines(lines);
740
+ const clamped = Math.max(0, Math.min(cursorOffset, total));
741
+ let start = 0;
742
+ for (let i = 0; i < lines.length; i += 1) {
743
+ const line = lines[i];
744
+ const end = start + line.cursorLength;
745
+ if (clamped <= end || i === lines.length - 1) {
746
+ return {
747
+ lineIndex: i,
748
+ offsetInLine: Math.max(0, Math.min(clamped - start, line.cursorLength)),
749
+ };
750
+ }
751
+ start = end + (line.hasNewline ? 1 : 0);
752
+ if (line.hasNewline && clamped === start) {
753
+ return {
754
+ lineIndex: Math.min(lines.length - 1, i + 1),
755
+ offsetInLine: 0,
756
+ };
757
+ }
758
+ }
759
+ return {
760
+ lineIndex: lines.length - 1,
761
+ offsetInLine: lines[lines.length - 1]?.cursorLength ?? 0,
762
+ };
763
+ }
764
+ function getLineStartOffsets(lines) {
765
+ const offsets = [];
766
+ let current = 0;
767
+ for (const line of lines) {
768
+ offsets.push(current);
769
+ current += line.cursorLength + (line.hasNewline ? 1 : 0);
770
+ }
771
+ return offsets;
772
+ }
773
+ function graphemeAtCursor(lines, cursorOffset) {
774
+ const loc = resolveCursorToLine(lines, cursorOffset);
775
+ const line = lines[loc.lineIndex];
776
+ if (!line) {
777
+ return null;
778
+ }
779
+ if (loc.offsetInLine >= line.cursorLength) {
780
+ return null;
781
+ }
782
+ const segments = Array.from(graphemeSegments(line.text));
783
+ return segments[loc.offsetInLine]?.segment ?? null;
784
+ }
785
+ function blockVisibleText(block) {
786
+ if (block.type === "paragraph") {
787
+ return block.content.map(inlineVisibleText).join("");
788
+ }
789
+ if (block.type === "block-atom") {
790
+ return "";
791
+ }
792
+ if (block.type === "block-wrapper") {
793
+ return block.blocks.map(blockVisibleText).join("\n");
794
+ }
795
+ return "";
796
+ }
797
+ function inlineVisibleText(inline) {
798
+ if (inline.type === "text") {
799
+ return inline.text;
800
+ }
801
+ if (inline.type === "inline-wrapper") {
802
+ return inline.children.map(inlineVisibleText).join("");
803
+ }
804
+ if (inline.type === "inline-atom") {
805
+ return " ";
806
+ }
807
+ return "";
808
+ }
809
+ function stableStringify(value) {
810
+ if (value === null || value === undefined) {
811
+ return "";
812
+ }
813
+ if (typeof value !== "object") {
814
+ return JSON.stringify(value);
815
+ }
816
+ if (Array.isArray(value)) {
817
+ return `[${value.map(stableStringify).join(",")}]`;
818
+ }
819
+ const record = value;
820
+ const keys = Object.keys(record).sort();
821
+ const parts = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
822
+ return `{${parts.join(",")}}`;
823
+ }
824
+ function markKey(kind, data) {
825
+ const suffix = data ? stableStringify(data) : "";
826
+ return `${kind}:${suffix}`;
827
+ }
828
+ function paragraphToRuns(paragraph) {
829
+ const runs = [];
830
+ const stack = [];
831
+ const pushText = (text) => {
832
+ if (!text) {
833
+ return;
834
+ }
835
+ const marks = stack.slice();
836
+ const last = runs[runs.length - 1];
837
+ if (last && marksEqual(last.marks, marks)) {
838
+ last.text += text;
839
+ return;
840
+ }
841
+ runs.push({ text, marks });
842
+ };
843
+ const walk = (inline) => {
844
+ if (inline.type === "text") {
845
+ pushText(inline.text);
846
+ return;
847
+ }
848
+ if (inline.type === "inline-atom") {
849
+ pushText(" ");
850
+ return;
851
+ }
852
+ if (inline.type === "inline-wrapper") {
853
+ const data = inline.data;
854
+ const mark = {
855
+ kind: inline.kind,
856
+ data,
857
+ key: markKey(inline.kind, data),
858
+ };
859
+ stack.push(mark);
860
+ for (const child of inline.children) {
861
+ walk(child);
862
+ }
863
+ stack.pop();
864
+ }
865
+ };
866
+ for (const inline of paragraph.content) {
867
+ walk(inline);
868
+ }
869
+ return runs;
870
+ }
871
+ function marksEqual(a, b) {
872
+ if (a.length !== b.length) {
873
+ return false;
874
+ }
875
+ for (let i = 0; i < a.length; i += 1) {
876
+ if (a[i]?.key !== b[i]?.key) {
877
+ return false;
878
+ }
879
+ }
880
+ return true;
881
+ }
882
+ function sliceRuns(runs, startCursor, endCursor) {
883
+ const [left, rest] = splitRunsAt(runs, startCursor);
884
+ const [selected, right] = splitRunsAt(rest, Math.max(0, endCursor - startCursor));
885
+ return { before: left, selected, after: right };
886
+ }
887
+ function splitRunsAt(runs, cursorOffset) {
888
+ const left = [];
889
+ const right = [];
890
+ let remaining = Math.max(0, cursorOffset);
891
+ for (let i = 0; i < runs.length; i += 1) {
892
+ const run = runs[i];
893
+ const segs = Array.from(graphemeSegments(run.text));
894
+ const runLen = segs.length;
895
+ if (remaining === 0) {
896
+ right.push(run, ...runs.slice(i + 1));
897
+ return [left, right];
898
+ }
899
+ if (remaining >= runLen) {
900
+ left.push(run);
901
+ remaining -= runLen;
902
+ continue;
903
+ }
904
+ const leftText = segs
905
+ .slice(0, remaining)
906
+ .map((s) => s.segment)
907
+ .join("");
908
+ const rightText = segs
909
+ .slice(remaining)
910
+ .map((s) => s.segment)
911
+ .join("");
912
+ if (leftText) {
913
+ left.push({ ...run, text: leftText });
914
+ }
915
+ if (rightText) {
916
+ right.push({ ...run, text: rightText });
917
+ }
918
+ right.push(...runs.slice(i + 1));
919
+ return [left, right];
920
+ }
921
+ return [left, right];
922
+ }
923
+ function commonMarksPrefix(runs) {
924
+ if (runs.length === 0) {
925
+ return [];
926
+ }
927
+ let prefix = runs[0]?.marks ?? [];
928
+ for (let i = 1; i < runs.length; i += 1) {
929
+ const marks = runs[i]?.marks ?? [];
930
+ const max = Math.min(prefix.length, marks.length);
931
+ let j = 0;
932
+ for (; j < max; j += 1) {
933
+ if (prefix[j]?.key !== marks[j]?.key) {
934
+ break;
935
+ }
936
+ }
937
+ prefix = prefix.slice(0, j);
938
+ if (prefix.length === 0) {
939
+ return [];
940
+ }
941
+ }
942
+ return prefix;
943
+ }
944
+ function marksAtCursor(runs, cursorOffset, affinity) {
945
+ const left = cursorOffset > 0 ? marksAtGraphemeIndex(runs, cursorOffset - 1) : null;
946
+ const right = marksAtGraphemeIndex(runs, cursorOffset);
947
+ if (affinity === "backward") {
948
+ return left ?? [];
949
+ }
950
+ return right ?? [];
951
+ }
952
+ function marksAtGraphemeIndex(runs, index) {
953
+ if (index < 0) {
954
+ return null;
955
+ }
956
+ let remaining = index;
957
+ for (const run of runs) {
958
+ const segs = Array.from(graphemeSegments(run.text));
959
+ if (remaining < segs.length) {
960
+ return run.marks;
961
+ }
962
+ remaining -= segs.length;
963
+ }
964
+ return null;
965
+ }
966
+ function marksAroundCursor(doc, cursorOffset) {
967
+ const lines = flattenDocToLines(doc);
968
+ const loc = resolveCursorToLine(lines, cursorOffset);
969
+ const line = lines[loc.lineIndex];
970
+ if (!line) {
971
+ return { left: [], right: [] };
972
+ }
973
+ const block = getBlockAtPath(doc.blocks, line.path);
974
+ if (!block || block.type !== "paragraph") {
975
+ return { left: [], right: [] };
976
+ }
977
+ const runs = paragraphToRuns(block);
978
+ const left = loc.offsetInLine > 0
979
+ ? marksAtGraphemeIndex(runs, loc.offsetInLine - 1)
980
+ : null;
981
+ const right = marksAtGraphemeIndex(runs, loc.offsetInLine);
982
+ return { left: left ?? [], right: right ?? [] };
983
+ }
984
+ function preferredAffinityAtGap(left, right, fallback) {
985
+ if (isMarksPrefix(left, right) && right.length > left.length) {
986
+ return "forward";
987
+ }
988
+ if (isMarksPrefix(right, left) && left.length > right.length) {
989
+ return "backward";
990
+ }
991
+ return fallback;
992
+ }
993
+ function preferredTypingAffinityAtGap(left, right, fallback) {
994
+ // For typing, treat non-inclusive wrappers (e.g. links) as "exited" at the
995
+ // end boundary so new characters don't extend them (v1 parity).
996
+ if (isMarksPrefix(right, left) && left.length > right.length) {
997
+ const extras = left.slice(right.length);
998
+ if (extras.some((mark) => !isInclusiveAtEnd(mark.kind))) {
999
+ return "forward";
1000
+ }
1001
+ }
1002
+ return preferredAffinityAtGap(left, right, fallback);
1003
+ }
1004
+ function isMarksPrefix(prefix, full) {
1005
+ if (prefix.length > full.length) {
1006
+ return false;
1007
+ }
1008
+ for (let i = 0; i < prefix.length; i += 1) {
1009
+ if (prefix[i]?.key !== full[i]?.key) {
1010
+ return false;
1011
+ }
1012
+ }
1013
+ return true;
1014
+ }
1015
+ function normalizeRuns(runs) {
1016
+ const next = [];
1017
+ for (const run of runs) {
1018
+ if (!run.text) {
1019
+ continue;
1020
+ }
1021
+ const prev = next[next.length - 1];
1022
+ if (prev && marksEqual(prev.marks, run.marks)) {
1023
+ prev.text += run.text;
1024
+ continue;
1025
+ }
1026
+ next.push(run);
1027
+ }
1028
+ return next;
1029
+ }
1030
+ function runsToInlines(runs) {
1031
+ const root = [];
1032
+ const wrapperStack = [];
1033
+ const currentChildren = () => wrapperStack.length === 0
1034
+ ? root
1035
+ : (wrapperStack[wrapperStack.length - 1]?.children ?? root);
1036
+ const closeTo = (depth) => {
1037
+ while (wrapperStack.length > depth) {
1038
+ wrapperStack.pop();
1039
+ }
1040
+ };
1041
+ const openFrom = (marks, start) => {
1042
+ for (let i = start; i < marks.length; i += 1) {
1043
+ const mark = marks[i];
1044
+ if (!mark) {
1045
+ continue;
1046
+ }
1047
+ const wrapper = {
1048
+ type: "inline-wrapper",
1049
+ kind: mark.kind,
1050
+ data: mark.data,
1051
+ children: [],
1052
+ };
1053
+ currentChildren().push(wrapper);
1054
+ wrapperStack.push({
1055
+ mark,
1056
+ children: wrapper.children,
1057
+ });
1058
+ }
1059
+ };
1060
+ let openMarks = [];
1061
+ for (const run of runs) {
1062
+ const marks = run.marks;
1063
+ const max = Math.min(openMarks.length, marks.length);
1064
+ let common = 0;
1065
+ for (; common < max; common += 1) {
1066
+ if (openMarks[common]?.key !== marks[common]?.key) {
1067
+ break;
1068
+ }
1069
+ }
1070
+ closeTo(common);
1071
+ openMarks = openMarks.slice(0, common);
1072
+ openFrom(marks, common);
1073
+ openMarks = marks;
1074
+ if (run.text) {
1075
+ currentChildren().push({ type: "text", text: run.text });
1076
+ }
1077
+ }
1078
+ closeTo(0);
1079
+ return root;
1080
+ }
1081
+ function buildReplacementBlocks(params) {
1082
+ const { baseMarks, beforeRuns, afterRuns, insertBlocks } = params;
1083
+ const firstParagraphIndex = insertBlocks.findIndex((b) => b.type === "paragraph");
1084
+ const lastParagraphIndex = (() => {
1085
+ for (let i = insertBlocks.length - 1; i >= 0; i -= 1) {
1086
+ if (insertBlocks[i]?.type === "paragraph") {
1087
+ return i;
1088
+ }
1089
+ }
1090
+ return -1;
1091
+ })();
1092
+ if (firstParagraphIndex === -1 || lastParagraphIndex === -1) {
1093
+ const mergedRuns = normalizeRuns([...beforeRuns, ...afterRuns]);
1094
+ return [
1095
+ { type: "paragraph", content: runsToInlines(mergedRuns) },
1096
+ ...insertBlocks,
1097
+ ];
1098
+ }
1099
+ const blocks = [];
1100
+ insertBlocks.forEach((block, index) => {
1101
+ if (block.type !== "paragraph") {
1102
+ blocks.push(block);
1103
+ return;
1104
+ }
1105
+ const insertRuns = paragraphToRuns(block).map((run) => ({
1106
+ ...run,
1107
+ marks: [...baseMarks, ...run.marks],
1108
+ }));
1109
+ if (index === firstParagraphIndex && index === lastParagraphIndex) {
1110
+ const mergedRuns = normalizeRuns([
1111
+ ...beforeRuns,
1112
+ ...insertRuns,
1113
+ ...afterRuns,
1114
+ ]);
1115
+ blocks.push({ ...block, content: runsToInlines(mergedRuns) });
1116
+ return;
1117
+ }
1118
+ if (index === firstParagraphIndex) {
1119
+ const mergedRuns = normalizeRuns([...beforeRuns, ...insertRuns]);
1120
+ blocks.push({ ...block, content: runsToInlines(mergedRuns) });
1121
+ return;
1122
+ }
1123
+ if (index === lastParagraphIndex) {
1124
+ const mergedRuns = normalizeRuns([...insertRuns, ...afterRuns]);
1125
+ blocks.push({ ...block, content: runsToInlines(mergedRuns) });
1126
+ return;
1127
+ }
1128
+ blocks.push({
1129
+ ...block,
1130
+ content: runsToInlines(normalizeRuns(insertRuns)),
1131
+ });
1132
+ });
1133
+ return blocks;
1134
+ }
1135
+ function commonMarksAcrossSelection(lines, startCursor, endCursor, doc) {
1136
+ if (startCursor === endCursor) {
1137
+ return [];
1138
+ }
1139
+ const startLoc = resolveCursorToLine(lines, startCursor);
1140
+ const endLoc = resolveCursorToLine(lines, endCursor);
1141
+ const slices = [];
1142
+ for (let lineIndex = startLoc.lineIndex; lineIndex <= endLoc.lineIndex; lineIndex += 1) {
1143
+ const line = lines[lineIndex];
1144
+ if (!line) {
1145
+ continue;
1146
+ }
1147
+ const block = getBlockAtPath(doc.blocks, line.path);
1148
+ if (!block || block.type !== "paragraph") {
1149
+ continue;
1150
+ }
1151
+ const runs = paragraphToRuns(block);
1152
+ const startInLine = lineIndex === startLoc.lineIndex ? startLoc.offsetInLine : 0;
1153
+ const endInLine = lineIndex === endLoc.lineIndex
1154
+ ? endLoc.offsetInLine
1155
+ : line.cursorLength;
1156
+ slices.push(...sliceRuns(runs, startInLine, endInLine).selected);
1157
+ }
1158
+ return commonMarksPrefix(slices);
1159
+ }
1160
+ function getBlockAtPath(blocks, path) {
1161
+ let current = null;
1162
+ let currentBlocks = blocks;
1163
+ for (let depth = 0; depth < path.length; depth += 1) {
1164
+ const index = path[depth] ?? 0;
1165
+ current = currentBlocks[index] ?? null;
1166
+ if (!current) {
1167
+ return null;
1168
+ }
1169
+ if (current.type === "block-wrapper") {
1170
+ currentBlocks = current.blocks;
1171
+ }
1172
+ else if (depth < path.length - 1) {
1173
+ return null;
1174
+ }
1175
+ }
1176
+ return current;
1177
+ }
1178
+ function getBlocksAtPath(blocks, path) {
1179
+ let current = blocks;
1180
+ for (const index of path) {
1181
+ const block = current[index];
1182
+ if (!block || block.type !== "block-wrapper") {
1183
+ return current;
1184
+ }
1185
+ current = block.blocks;
1186
+ }
1187
+ return current;
1188
+ }
1189
+ function updateBlocksAtPath(blocks, path, updater) {
1190
+ if (path.length === 0) {
1191
+ return updater(blocks);
1192
+ }
1193
+ const [head, ...rest] = path;
1194
+ const target = blocks[head];
1195
+ if (!target || target.type !== "block-wrapper") {
1196
+ return blocks;
1197
+ }
1198
+ const nextChildBlocks = updateBlocksAtPath(target.blocks, rest, updater);
1199
+ return blocks.map((block, index) => index === head ? { ...target, blocks: nextChildBlocks } : block);
1200
+ }
1201
+ function pathsEqual(a, b) {
1202
+ if (a.length !== b.length) {
1203
+ return false;
1204
+ }
1205
+ for (let i = 0; i < a.length; i += 1) {
1206
+ if (a[i] !== b[i]) {
1207
+ return false;
1208
+ }
1209
+ }
1210
+ return true;
1211
+ }
1212
+ function applyInlineToggle(state, marker) {
1213
+ const selection = normalizeSelection(state.selection);
1214
+ const source = state.source;
1215
+ const map = state.map;
1216
+ if (selection.start === selection.end) {
1217
+ const caret = selection.start;
1218
+ const markerLen = marker.length;
1219
+ // When the caret is at the end boundary of an inline wrapper, toggling the
1220
+ // wrapper should "exit" it (so the next character types outside). This is
1221
+ // best expressed in cursor space by flipping affinity to "forward" when we
1222
+ // are leaving a wrapper of the requested kind.
1223
+ const markerKind = toggleMarkerToKind.get(marker) ?? null;
1224
+ if (markerKind) {
1225
+ const around = marksAroundCursor(state.doc, caret);
1226
+ if (isMarksPrefix(around.right, around.left) &&
1227
+ around.left.length > around.right.length) {
1228
+ const exiting = around.left.slice(around.right.length);
1229
+ if (exiting.some((mark) => mark.kind === markerKind)) {
1230
+ if (exiting.length > 1) {
1231
+ const placeholder = "\u200B";
1232
+ const insertAtForward = map.cursorToSource(caret, "forward");
1233
+ const insertAtBackward = map.cursorToSource(caret, "backward");
1234
+ const between = source.slice(insertAtBackward, insertAtForward);
1235
+ const markerIndex = between.indexOf(marker);
1236
+ if (markerIndex !== -1) {
1237
+ const insertAt = insertAtBackward + markerIndex + markerLen;
1238
+ const nextSource = source.slice(0, insertAt) +
1239
+ placeholder +
1240
+ source.slice(insertAt);
1241
+ const next = createState(nextSource);
1242
+ const placeholderStart = insertAt;
1243
+ const startCursor = next.map.sourceToCursor(placeholderStart, "forward");
1244
+ return {
1245
+ ...next,
1246
+ selection: {
1247
+ start: startCursor.cursorOffset,
1248
+ end: startCursor.cursorOffset,
1249
+ affinity: "forward",
1250
+ },
1251
+ };
1252
+ }
1253
+ }
1254
+ return {
1255
+ ...state,
1256
+ selection: {
1257
+ start: caret,
1258
+ end: caret,
1259
+ affinity: "forward",
1260
+ },
1261
+ };
1262
+ }
1263
+ }
1264
+ }
1265
+ // Otherwise, insert an empty marker pair with a zero-width placeholder
1266
+ // selected so the next typed character replaces it.
1267
+ //
1268
+ // If the caret is already positioned before an existing placeholder (e.g.
1269
+ // Cmd+B then Cmd+I), wrap the existing placeholder rather than inserting
1270
+ // a second one so typing produces combined emphasis (***text***).
1271
+ const placeholder = "\u200B";
1272
+ const insertAtForward = map.cursorToSource(caret, "forward");
1273
+ const insertAtBackward = map.cursorToSource(caret, "backward");
1274
+ const placeholderPos = (() => {
1275
+ const candidates = [insertAtForward, insertAtBackward];
1276
+ for (const candidate of candidates) {
1277
+ if (source[candidate] === placeholder) {
1278
+ return candidate;
1279
+ }
1280
+ if (candidate > 0 && source[candidate - 1] === placeholder) {
1281
+ return candidate - 1;
1282
+ }
1283
+ }
1284
+ return null;
1285
+ })();
1286
+ const insertAt = placeholderPos ??
1287
+ map.cursorToSource(caret, selection.affinity ?? "forward");
1288
+ const nextSource = placeholderPos !== null
1289
+ ? source.slice(0, insertAt) +
1290
+ marker +
1291
+ placeholder +
1292
+ marker +
1293
+ source.slice(insertAt + placeholder.length)
1294
+ : source.slice(0, insertAt) +
1295
+ marker +
1296
+ placeholder +
1297
+ marker +
1298
+ source.slice(insertAt);
1299
+ const next = createState(nextSource);
1300
+ const placeholderStart = insertAt + markerLen;
1301
+ const startCursor = next.map.sourceToCursor(placeholderStart, "forward");
1302
+ return {
1303
+ ...next,
1304
+ selection: {
1305
+ start: startCursor.cursorOffset,
1306
+ end: startCursor.cursorOffset,
1307
+ affinity: "forward",
1308
+ },
1309
+ };
1310
+ }
1311
+ const cursorStart = Math.min(selection.start, selection.end);
1312
+ const cursorEnd = Math.max(selection.start, selection.end);
1313
+ const from = map.cursorToSource(cursorStart, "forward");
1314
+ const to = map.cursorToSource(cursorEnd, "backward");
1315
+ const selectedText = source.slice(from, to);
1316
+ const markerLen = marker.length;
1317
+ const markerKind = toggleMarkerToKind.get(marker) ?? null;
1318
+ const linesForSelection = flattenDocToLines(state.doc);
1319
+ const commonMarks = markerKind
1320
+ ? commonMarksAcrossSelection(linesForSelection, cursorStart, cursorEnd, state.doc)
1321
+ : [];
1322
+ const hasCommonMark = markerKind !== null && commonMarks.some((mark) => mark.kind === markerKind);
1323
+ const canUnwrap = markerKind ? hasCommonMark : true;
1324
+ const startLoc = resolveCursorToLine(linesForSelection, cursorStart);
1325
+ const endLoc = resolveCursorToLine(linesForSelection, cursorEnd);
1326
+ if (markerKind &&
1327
+ (startLoc.lineIndex !== endLoc.lineIndex || selectedText.includes("\n"))) {
1328
+ const edits = [];
1329
+ const segments = (() => {
1330
+ if (!selectedText.includes("\n")) {
1331
+ const lineOffsets = getLineStartOffsets(linesForSelection);
1332
+ const byCursorLines = [];
1333
+ for (let lineIndex = startLoc.lineIndex; lineIndex <= endLoc.lineIndex; lineIndex += 1) {
1334
+ const line = linesForSelection[lineIndex];
1335
+ if (!line) {
1336
+ continue;
1337
+ }
1338
+ const lineStart = lineOffsets[lineIndex] ?? 0;
1339
+ const startInLine = lineIndex === startLoc.lineIndex ? startLoc.offsetInLine : 0;
1340
+ const endInLine = lineIndex === endLoc.lineIndex
1341
+ ? endLoc.offsetInLine
1342
+ : line.cursorLength;
1343
+ if (startInLine === endInLine) {
1344
+ continue;
1345
+ }
1346
+ const segmentStartCursor = lineStart + startInLine;
1347
+ const segmentEndCursor = lineStart + endInLine;
1348
+ const segmentFrom = map.cursorToSource(segmentStartCursor, "forward");
1349
+ const segmentTo = map.cursorToSource(segmentEndCursor, "backward");
1350
+ if (segmentFrom === segmentTo) {
1351
+ continue;
1352
+ }
1353
+ byCursorLines.push({ from: segmentFrom, to: segmentTo });
1354
+ }
1355
+ return byCursorLines;
1356
+ }
1357
+ const byNewlines = [];
1358
+ let sliceOffset = 0;
1359
+ while (sliceOffset <= selectedText.length) {
1360
+ const newlineIndex = selectedText.indexOf("\n", sliceOffset);
1361
+ const segmentEndOffset = newlineIndex === -1 ? selectedText.length : newlineIndex;
1362
+ const segmentFrom = from + sliceOffset;
1363
+ const segmentTo = from + segmentEndOffset;
1364
+ if (segmentFrom !== segmentTo) {
1365
+ byNewlines.push({ from: segmentFrom, to: segmentTo });
1366
+ }
1367
+ if (newlineIndex === -1) {
1368
+ break;
1369
+ }
1370
+ sliceOffset = newlineIndex + 1;
1371
+ }
1372
+ return byNewlines;
1373
+ })();
1374
+ for (const segment of segments) {
1375
+ const segmentFrom = segment.from;
1376
+ const segmentTo = segment.to;
1377
+ if (canUnwrap) {
1378
+ if (segmentFrom >= markerLen &&
1379
+ source.slice(segmentFrom - markerLen, segmentFrom) === marker) {
1380
+ edits.push({
1381
+ from: segmentFrom - markerLen,
1382
+ to: segmentFrom,
1383
+ insert: "",
1384
+ });
1385
+ }
1386
+ if (source.slice(segmentTo, segmentTo + markerLen) === marker) {
1387
+ edits.push({
1388
+ from: segmentTo,
1389
+ to: segmentTo + markerLen,
1390
+ insert: "",
1391
+ });
1392
+ }
1393
+ }
1394
+ else {
1395
+ edits.push({ from: segmentFrom, to: segmentFrom, insert: marker });
1396
+ edits.push({ from: segmentTo, to: segmentTo, insert: marker });
1397
+ }
1398
+ }
1399
+ if (edits.length === 0) {
1400
+ return state;
1401
+ }
1402
+ edits.sort((a, b) => b.from - a.from);
1403
+ let newSource = source;
1404
+ for (const edit of edits) {
1405
+ newSource =
1406
+ newSource.slice(0, edit.from) +
1407
+ edit.insert +
1408
+ newSource.slice(edit.to);
1409
+ }
1410
+ const next = createState(newSource);
1411
+ return {
1412
+ ...next,
1413
+ selection: {
1414
+ start: cursorStart,
1415
+ end: cursorEnd,
1416
+ affinity: selection.affinity ?? "forward",
1417
+ },
1418
+ };
1419
+ }
1420
+ const isSelectionWrappedByAdjacentMarkers = markerLen > 0 &&
1421
+ from >= markerLen &&
1422
+ source.slice(from - markerLen, from) === marker &&
1423
+ source.slice(to, to + markerLen) === marker;
1424
+ const isWrappedBySelectionText = selectedText.startsWith(marker) &&
1425
+ selectedText.endsWith(marker) &&
1426
+ selectedText.length >= markerLen * 2;
1427
+ const isWrapped = canUnwrap &&
1428
+ (isSelectionWrappedByAdjacentMarkers ||
1429
+ (markerKind ? isWrappedBySelectionText : isWrappedBySelectionText));
1430
+ let newSource;
1431
+ if (isWrapped) {
1432
+ // Unwrap
1433
+ if (isSelectionWrappedByAdjacentMarkers) {
1434
+ newSource =
1435
+ source.slice(0, from - markerLen) +
1436
+ selectedText +
1437
+ source.slice(to + markerLen);
1438
+ }
1439
+ else {
1440
+ const unwrapped = selectedText.slice(markerLen, -markerLen);
1441
+ newSource = source.slice(0, from) + unwrapped + source.slice(to);
1442
+ }
1443
+ }
1444
+ else {
1445
+ // Wrap
1446
+ const wrapped = marker + selectedText + marker;
1447
+ newSource = source.slice(0, from) + wrapped + source.slice(to);
1448
+ }
1449
+ const next = createState(newSource);
1450
+ return {
1451
+ ...next,
1452
+ selection: {
1453
+ start: cursorStart,
1454
+ end: cursorEnd,
1455
+ affinity: selection.affinity ?? "forward",
1456
+ },
1457
+ };
1458
+ }
1459
+ function updateSelection(state, selection, options) {
1460
+ const normalized = normalizeSelection(selection);
1461
+ const cursorLength = state.map.cursorLength;
1462
+ const start = Math.max(0, Math.min(cursorLength, normalized.start));
1463
+ const end = Math.max(0, Math.min(cursorLength, normalized.end));
1464
+ const kind = options?.kind ?? "programmatic";
1465
+ let affinity = normalized.affinity ?? "forward";
1466
+ if (start === end) {
1467
+ const around = marksAroundCursor(state.doc, start);
1468
+ if (kind === "keyboard") {
1469
+ affinity = preferredTypingAffinityAtGap(around.left, around.right, affinity);
1470
+ }
1471
+ else if (kind === "dom") {
1472
+ // Keep DOM-provided affinity unless it would keep the caret inside a
1473
+ // non-inclusive wrapper at its end boundary (v1 parity for links).
1474
+ if (affinity === "backward") {
1475
+ if (isMarksPrefix(around.right, around.left) &&
1476
+ around.left.length > around.right.length) {
1477
+ const extras = around.left.slice(around.right.length);
1478
+ if (extras.some((mark) => !isInclusiveAtEnd(mark.kind))) {
1479
+ affinity = "forward";
1480
+ }
1481
+ }
1482
+ }
1483
+ }
1484
+ }
1485
+ return {
1486
+ ...state,
1487
+ selection: { start, end, affinity },
1488
+ };
1489
+ }
1490
+ function serializeSelection(state, selection) {
1491
+ const normalized = normalizeSelection(selection);
1492
+ const lines = flattenDocToLines(state.doc);
1493
+ const docCursorLength = cursorLengthForLines(lines);
1494
+ const cursorStart = Math.max(0, Math.min(docCursorLength, Math.min(normalized.start, normalized.end)));
1495
+ const cursorEnd = Math.max(0, Math.min(docCursorLength, Math.max(normalized.start, normalized.end)));
1496
+ if (cursorStart === cursorEnd) {
1497
+ return "";
1498
+ }
1499
+ const startLoc = resolveCursorToLine(lines, cursorStart);
1500
+ const endLoc = resolveCursorToLine(lines, cursorEnd);
1501
+ const blocks = [];
1502
+ for (let lineIndex = startLoc.lineIndex; lineIndex <= endLoc.lineIndex; lineIndex += 1) {
1503
+ const line = lines[lineIndex];
1504
+ if (!line) {
1505
+ continue;
1506
+ }
1507
+ const block = getBlockAtPath(state.doc.blocks, line.path);
1508
+ if (!block || block.type !== "paragraph") {
1509
+ continue;
1510
+ }
1511
+ const runs = paragraphToRuns(block);
1512
+ const startInLine = lineIndex === startLoc.lineIndex ? startLoc.offsetInLine : 0;
1513
+ const endInLine = lineIndex === endLoc.lineIndex
1514
+ ? endLoc.offsetInLine
1515
+ : line.cursorLength;
1516
+ const selectedRuns = sliceRuns(runs, startInLine, endInLine).selected;
1517
+ const content = runsToInlines(normalizeRuns(selectedRuns));
1518
+ const paragraph = { type: "paragraph", content };
1519
+ // Check if this line is inside a block-wrapper (e.g., heading)
1520
+ if (line.path.length > 1) {
1521
+ const wrapperPath = line.path.slice(0, -1);
1522
+ const wrapper = getBlockAtPath(state.doc.blocks, wrapperPath);
1523
+ if (wrapper && wrapper.type === "block-wrapper") {
1524
+ blocks.push({
1525
+ type: "block-wrapper",
1526
+ kind: wrapper.kind,
1527
+ data: wrapper.data,
1528
+ blocks: [paragraph],
1529
+ });
1530
+ continue;
1531
+ }
1532
+ }
1533
+ blocks.push(paragraph);
1534
+ }
1535
+ const sliceDoc = {
1536
+ type: "doc",
1537
+ blocks: blocks.length > 0 ? blocks : [{ type: "paragraph", content: [] }],
1538
+ };
1539
+ return serialize(normalize(sliceDoc)).source;
1540
+ }
1541
+ function escapeHtml(text) {
1542
+ return text
1543
+ .replaceAll("&", "&amp;")
1544
+ .replaceAll("<", "&lt;")
1545
+ .replaceAll(">", "&gt;")
1546
+ .replaceAll('"', "&quot;")
1547
+ .replaceAll("'", "&#39;");
1548
+ }
1549
+ function runsToHtml(runs) {
1550
+ let html = "";
1551
+ for (const run of runs) {
1552
+ let content = escapeHtml(run.text);
1553
+ // Apply marks in reverse order so outer marks wrap inner marks
1554
+ const sortedMarks = [...run.marks].reverse();
1555
+ for (const mark of sortedMarks) {
1556
+ if (mark.kind === "bold") {
1557
+ content = `<strong>${content}</strong>`;
1558
+ }
1559
+ else if (mark.kind === "italic") {
1560
+ content = `<em>${content}</em>`;
1561
+ }
1562
+ else if (mark.kind === "strikethrough") {
1563
+ content = `<s>${content}</s>`;
1564
+ }
1565
+ else if (mark.kind === "link") {
1566
+ const url = mark.data?.url ?? "";
1567
+ content = `<a href="${escapeHtml(url)}">${content}</a>`;
1568
+ }
1569
+ }
1570
+ html += content;
1571
+ }
1572
+ return html;
1573
+ }
1574
+ function serializeSelectionToHtml(state, selection) {
1575
+ const normalized = normalizeSelection(selection);
1576
+ const lines = flattenDocToLines(state.doc);
1577
+ const docCursorLength = cursorLengthForLines(lines);
1578
+ const cursorStart = Math.max(0, Math.min(docCursorLength, Math.min(normalized.start, normalized.end)));
1579
+ const cursorEnd = Math.max(0, Math.min(docCursorLength, Math.max(normalized.start, normalized.end)));
1580
+ if (cursorStart === cursorEnd) {
1581
+ return "";
1582
+ }
1583
+ const startLoc = resolveCursorToLine(lines, cursorStart);
1584
+ const endLoc = resolveCursorToLine(lines, cursorEnd);
1585
+ let html = "";
1586
+ let activeList = null;
1587
+ const closeList = () => {
1588
+ if (activeList) {
1589
+ html += `</${activeList.type}>`;
1590
+ activeList = null;
1591
+ }
1592
+ };
1593
+ const openList = (type, indent) => {
1594
+ if (activeList && activeList.type === type && activeList.indent === indent) {
1595
+ return;
1596
+ }
1597
+ closeList();
1598
+ html += `<${type}>`;
1599
+ activeList = { type, indent };
1600
+ };
1601
+ for (let lineIndex = startLoc.lineIndex; lineIndex <= endLoc.lineIndex; lineIndex += 1) {
1602
+ const line = lines[lineIndex];
1603
+ if (!line) {
1604
+ continue;
1605
+ }
1606
+ const block = getBlockAtPath(state.doc.blocks, line.path);
1607
+ if (!block || block.type !== "paragraph") {
1608
+ continue;
1609
+ }
1610
+ const runs = paragraphToRuns(block);
1611
+ const startInLine = lineIndex === startLoc.lineIndex ? startLoc.offsetInLine : 0;
1612
+ const endInLine = lineIndex === endLoc.lineIndex ? endLoc.offsetInLine : line.cursorLength;
1613
+ const selectedRuns = sliceRuns(runs, startInLine, endInLine).selected;
1614
+ // Check if this line is inside a block-wrapper (heading or list)
1615
+ let wrapperKind = null;
1616
+ let wrapperData;
1617
+ if (line.path.length > 1) {
1618
+ const wrapperPath = line.path.slice(0, -1);
1619
+ const wrapper = getBlockAtPath(state.doc.blocks, wrapperPath);
1620
+ if (wrapper && wrapper.type === "block-wrapper") {
1621
+ wrapperKind = wrapper.kind;
1622
+ wrapperData = wrapper.data;
1623
+ }
1624
+ }
1625
+ // Extract plain text to check for list patterns
1626
+ const plainText = runs.map((r) => r.text).join("");
1627
+ const listMatch = plainText.match(/^(\s*)([-*+]|\d+\.)( )(.*)$/);
1628
+ // Determine the HTML content - strip list prefix if it's a list line
1629
+ let lineHtml;
1630
+ if (listMatch && !wrapperKind) {
1631
+ // For list lines, only include the content after the prefix
1632
+ const prefixLength = listMatch[1].length + listMatch[2].length + listMatch[3].length;
1633
+ const contentRuns = sliceRuns(runs, prefixLength, runs.reduce((sum, r) => sum + r.text.length, 0)).selected;
1634
+ lineHtml = runsToHtml(normalizeRuns(contentRuns));
1635
+ }
1636
+ else {
1637
+ lineHtml = runsToHtml(normalizeRuns(selectedRuns));
1638
+ }
1639
+ if (wrapperKind === "heading") {
1640
+ closeList();
1641
+ const level = Math.min(wrapperData?.level ?? 1, 6);
1642
+ html += `<h${level} style="margin:0">${lineHtml}</h${level}>`;
1643
+ }
1644
+ else if (wrapperKind === "bullet-list") {
1645
+ openList("ul", 0);
1646
+ html += `<li>${lineHtml}</li>`;
1647
+ }
1648
+ else if (wrapperKind === "numbered-list") {
1649
+ openList("ol", 0);
1650
+ html += `<li>${lineHtml}</li>`;
1651
+ }
1652
+ else if (wrapperKind === "blockquote") {
1653
+ closeList();
1654
+ html += `<blockquote>${lineHtml}</blockquote>`;
1655
+ }
1656
+ else if (listMatch) {
1657
+ // Plain paragraph with list markers (cake v3 list model)
1658
+ const isNumbered = /^\d+\.$/.test(listMatch[2]);
1659
+ const indent = Math.floor(listMatch[1].length / 2);
1660
+ openList(isNumbered ? "ol" : "ul", indent);
1661
+ html += `<li>${lineHtml}</li>`;
1662
+ }
1663
+ else {
1664
+ closeList();
1665
+ html += `<div>${lineHtml}</div>`;
1666
+ }
1667
+ }
1668
+ closeList();
1669
+ if (!html) {
1670
+ return "";
1671
+ }
1672
+ return `<div>${html}</div>`;
1673
+ }
1674
+ const runtime = {
1675
+ extensions,
1676
+ parse,
1677
+ serialize,
1678
+ createState,
1679
+ updateSelection,
1680
+ serializeSelection,
1681
+ serializeSelectionToHtml,
1682
+ applyEdit,
1683
+ };
1684
+ return runtime;
1685
+ }
1686
+ function parseLiteralBlock(source, start, context) {
1687
+ let end = source.indexOf("\n", start);
1688
+ if (end === -1) {
1689
+ end = source.length;
1690
+ }
1691
+ const content = context.parseInline(source, start, end);
1692
+ return { block: { type: "paragraph", content }, nextPos: end };
1693
+ }
1694
+ function parseLiteralInline(source, start, end) {
1695
+ // Fast path for ASCII characters (most common case)
1696
+ const code = source.charCodeAt(start);
1697
+ if (code < 0x80) {
1698
+ // Single ASCII character
1699
+ const text = source[start] ?? "";
1700
+ return { inline: { type: "text", text }, nextPos: start + 1 };
1701
+ }
1702
+ // For non-ASCII, check if it's a surrogate pair (emoji, etc.)
1703
+ if (code >= 0xd800 && code <= 0xdbff) {
1704
+ // High surrogate - combine with low surrogate
1705
+ const lowCode = source.charCodeAt(start + 1);
1706
+ if (lowCode >= 0xdc00 && lowCode <= 0xdfff) {
1707
+ // Valid surrogate pair - but might be part of a larger grapheme cluster (like emoji with skin tone)
1708
+ // Fall back to segmenter for these cases
1709
+ const segment = graphemeSegments(source.slice(start, Math.min(start + 10, end)))[0];
1710
+ const text = segment ? segment.segment : source.slice(start, start + 2);
1711
+ return { inline: { type: "text", text }, nextPos: start + text.length };
1712
+ }
1713
+ }
1714
+ // Other multi-byte UTF-8 characters (most are single grapheme clusters)
1715
+ // Use a small window for segmenter to avoid processing entire remaining text
1716
+ const segment = graphemeSegments(source.slice(start, Math.min(start + 10, end)))[0];
1717
+ const text = segment ? segment.segment : (source[start] ?? "");
1718
+ return { inline: { type: "text", text }, nextPos: start + text.length };
1719
+ }
1720
+ function serializeParagraph(block, serializeInline) {
1721
+ const builder = new CursorSourceBuilder();
1722
+ for (const inline of block.content) {
1723
+ const serialized = serializeInline(inline);
1724
+ builder.appendSerialized(serialized);
1725
+ }
1726
+ return builder.build();
1727
+ }
1728
+ function serializeInlineWrapper(inline, serializeInline) {
1729
+ const builder = new CursorSourceBuilder();
1730
+ for (const child of inline.children) {
1731
+ const serialized = serializeInline(child);
1732
+ builder.appendSerialized(serialized);
1733
+ }
1734
+ return builder.build();
1735
+ }
1736
+ function serializeBlockWrapper(block, serializeBlock) {
1737
+ const builder = new CursorSourceBuilder();
1738
+ block.blocks.forEach((child, index) => {
1739
+ const serialized = serializeBlock(child);
1740
+ builder.appendSerialized(serialized);
1741
+ if (index < block.blocks.length - 1) {
1742
+ builder.appendText("\n");
1743
+ }
1744
+ });
1745
+ return builder.build();
1746
+ }
1747
+ function normalizeSelection(selection) {
1748
+ if (selection.start <= selection.end) {
1749
+ return selection;
1750
+ }
1751
+ const isRange = selection.start !== selection.end;
1752
+ return {
1753
+ ...selection,
1754
+ start: selection.end,
1755
+ end: selection.start,
1756
+ affinity: isRange ? "backward" : selection.affinity,
1757
+ };
1758
+ }