mrmd-editor 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +3 -1
  2. package/src/commands.js +112 -4
  3. package/src/comment-syntax.js +364 -39
  4. package/src/config/handlers.js +1 -2
  5. package/src/config/schema.js +46 -4
  6. package/src/document-template.js +2236 -0
  7. package/src/frontmatter-updater.js +204 -74
  8. package/src/grammar.js +758 -0
  9. package/src/index.js +1074 -55
  10. package/src/keymap.js +11 -2
  11. package/src/markdown/block-decorations.js +108 -5
  12. package/src/markdown/facets.js +37 -0
  13. package/src/markdown/html-inline.js +9 -5
  14. package/src/markdown/index.js +13 -3
  15. package/src/markdown/inline-commands.js +256 -0
  16. package/src/markdown/inline-model.js +578 -0
  17. package/src/markdown/inline-state.js +103 -0
  18. package/src/markdown/renderer.js +219 -12
  19. package/src/markdown/styles.js +290 -3
  20. package/src/markdown/widgets/alert-title.js +10 -8
  21. package/src/markdown/widgets/frontmatter.js +0 -6
  22. package/src/markdown/widgets/index.js +1 -0
  23. package/src/markdown/widgets/list-marker.js +29 -0
  24. package/src/markdown/wysiwyg.js +1158 -0
  25. package/src/mrp-types.js +2 -0
  26. package/src/output-widget.js +532 -18
  27. package/src/page-view-pagination.js +127 -0
  28. package/src/runtime-lsp.js +1757 -150
  29. package/src/section-controls/commands.js +617 -0
  30. package/src/section-controls/index.js +63 -0
  31. package/src/section-controls/plugin.js +165 -0
  32. package/src/section-controls/widgets.js +936 -0
  33. package/src/shell/ai-menu.js +11 -0
  34. package/src/shell/components/context-panel.js +572 -0
  35. package/src/shell/components/status-bar.js +10 -2
  36. package/src/shell/layouts/studio.js +206 -14
  37. package/src/shell/orchestrator-client.js +69 -0
  38. package/src/spellcheck.js +166 -0
  39. package/src/tables/README.md +97 -0
  40. package/src/tables/commands/insert-linked-table.js +122 -0
  41. package/src/tables/commands/open-table-workspace.js +43 -0
  42. package/src/tables/index.js +24 -0
  43. package/src/tables/jobs/client.js +158 -0
  44. package/src/tables/parsing/anchors.js +82 -0
  45. package/src/tables/parsing/linked-table-blocks.js +61 -0
  46. package/src/tables/state/linked-table-state.js +68 -0
  47. package/src/tables/widgets/linked-table-source-banner.js +77 -0
  48. package/src/tables/widgets/linked-table-widget.js +256 -0
  49. package/src/tables/workspace/controller.js +616 -0
  50. package/src/term-pty-client.js +51 -2
  51. package/src/term-widget.js +43 -3
  52. package/src/widgets/theme-utils.js +24 -16
  53. package/src/widgets/theme.js +1015 -1
  54. package/src/runtime-codelens/detector.js +0 -279
  55. package/src/runtime-codelens/index.js +0 -76
  56. package/src/runtime-codelens/plugin.js +0 -142
  57. package/src/runtime-codelens/styles.js +0 -184
  58. package/src/runtime-codelens/widgets.js +0 -216
@@ -0,0 +1,578 @@
1
+ /**
2
+ * Shared inline formatting model.
3
+ *
4
+ * This module provides a tolerant inline parser plus helpers for caret/selection
5
+ * semantics, segment rewriting, and serialization. It is intentionally more
6
+ * forgiving than CommonMark so editing can stay stable while the user is in the
7
+ * middle of typing malformed markdown.
8
+ */
9
+
10
+ export const INLINE_MARK_ORDER = ['underline', 'strike', 'italic', 'bold', 'code'];
11
+
12
+ export const INLINE_MARK_SPECS = {
13
+ underline: { mark: 'underline', open: '<u>', close: '</u>', kind: 'html' },
14
+ strike: { mark: 'strike', open: '~~', close: '~~', kind: 'markdown' },
15
+ bold: { mark: 'bold', open: '**', close: '**', kind: 'markdown' },
16
+ italic: { mark: 'italic', open: '*', close: '*', kind: 'markdown' },
17
+ code: { mark: 'code', open: '`', close: '`', kind: 'markdown' },
18
+ };
19
+
20
+ const INLINE_OPEN_ORDER = [
21
+ INLINE_MARK_SPECS.underline,
22
+ INLINE_MARK_SPECS.bold,
23
+ INLINE_MARK_SPECS.strike,
24
+ INLINE_MARK_SPECS.italic,
25
+ INLINE_MARK_SPECS.code,
26
+ ];
27
+
28
+ export function cloneMarkSet(value) {
29
+ return new Set(value ? Array.from(value) : []);
30
+ }
31
+
32
+ export function markSetsEqual(a, b) {
33
+ if (a.size !== b.size) return false;
34
+ for (const value of a) {
35
+ if (!b.has(value)) return false;
36
+ }
37
+ return true;
38
+ }
39
+
40
+ export function commonMarkSet(a, b) {
41
+ const out = new Set();
42
+ for (const value of a) {
43
+ if (b.has(value)) out.add(value);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ export function syntaxToMark(open, close = open) {
49
+ for (const spec of Object.values(INLINE_MARK_SPECS)) {
50
+ if (spec.open === open && spec.close === close) return spec.mark;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ export function markToSyntax(mark) {
56
+ return INLINE_MARK_SPECS[mark] || null;
57
+ }
58
+
59
+ export function inlineClassForMark(mark) {
60
+ switch (mark) {
61
+ case 'bold': return 'cm-md-bold';
62
+ case 'italic': return 'cm-md-italic';
63
+ case 'underline': return 'cm-md-underline';
64
+ case 'strike': return 'cm-md-strikethrough';
65
+ case 'code': return 'cm-md-inline-code';
66
+ default: return null;
67
+ }
68
+ }
69
+
70
+ function isEscapedMarkdownDelimiter(text, index) {
71
+ let backslashes = 0;
72
+ for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) backslashes++;
73
+ return (backslashes % 2) === 1;
74
+ }
75
+
76
+ function matchesSpecToken(text, index, token, spec) {
77
+ if (!text.startsWith(token, index)) return false;
78
+ if (spec.kind === 'markdown') {
79
+ return !isEscapedMarkdownDelimiter(text, index);
80
+ }
81
+ return true;
82
+ }
83
+
84
+ function matchOpenSpec(text, index) {
85
+ for (const spec of INLINE_OPEN_ORDER) {
86
+ if (!matchesSpecToken(text, index, spec.open, spec)) continue;
87
+ if (spec.mark === 'italic' && text.startsWith('**', index)) continue;
88
+ if (spec.mark === 'code' && text.startsWith('```', index)) continue;
89
+ return spec;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function parseInlineNodes(text, index = 0, endSpec = null, activeMarks = new Set()) {
95
+ const nodes = [];
96
+ let cursor = index;
97
+ let textStart = index;
98
+
99
+ const flushText = (to) => {
100
+ if (to <= textStart) return;
101
+ nodes.push({
102
+ type: 'text',
103
+ from: textStart,
104
+ to,
105
+ text: text.slice(textStart, to),
106
+ });
107
+ };
108
+
109
+ while (cursor < text.length) {
110
+ if (endSpec && matchesSpecToken(text, cursor, endSpec.close, endSpec)) {
111
+ flushText(cursor);
112
+ return {
113
+ nodes,
114
+ index: cursor + endSpec.close.length,
115
+ closeStart: cursor,
116
+ closed: true,
117
+ };
118
+ }
119
+
120
+ const spec = matchOpenSpec(text, cursor);
121
+ if (!spec || activeMarks.has(spec.mark)) {
122
+ cursor++;
123
+ continue;
124
+ }
125
+
126
+ if (spec.mark === 'code') {
127
+ let close = cursor + spec.open.length;
128
+ while (close <= text.length - spec.close.length) {
129
+ if (matchesSpecToken(text, close, spec.close, spec)) break;
130
+ close++;
131
+ }
132
+ if (close <= text.length - spec.close.length) {
133
+ flushText(cursor);
134
+ const contentFrom = cursor + spec.open.length;
135
+ const contentTo = close;
136
+ nodes.push({
137
+ type: 'mark',
138
+ mark: spec.mark,
139
+ from: cursor,
140
+ to: close + spec.close.length,
141
+ contentFrom,
142
+ contentTo,
143
+ children: contentFrom < contentTo
144
+ ? [{ type: 'text', from: contentFrom, to: contentTo, text: text.slice(contentFrom, contentTo) }]
145
+ : [],
146
+ });
147
+ cursor = close + spec.close.length;
148
+ textStart = cursor;
149
+ continue;
150
+ }
151
+ cursor++;
152
+ continue;
153
+ }
154
+
155
+ const nextActive = new Set(activeMarks);
156
+ nextActive.add(spec.mark);
157
+ const inner = parseInlineNodes(text, cursor + spec.open.length, spec, nextActive);
158
+ if (inner.closed && inner.closeStart >= cursor + spec.open.length) {
159
+ flushText(cursor);
160
+ nodes.push({
161
+ type: 'mark',
162
+ mark: spec.mark,
163
+ from: cursor,
164
+ to: inner.index,
165
+ contentFrom: cursor + spec.open.length,
166
+ contentTo: inner.closeStart,
167
+ children: inner.nodes,
168
+ });
169
+ cursor = inner.index;
170
+ textStart = cursor;
171
+ continue;
172
+ }
173
+
174
+ cursor++;
175
+ }
176
+
177
+ flushText(text.length);
178
+ return {
179
+ nodes,
180
+ index: text.length,
181
+ closeStart: -1,
182
+ closed: false,
183
+ };
184
+ }
185
+
186
+ function buildTextSegments(nodes, lineFrom, activeMarks = new Set(), out = []) {
187
+ for (const node of nodes) {
188
+ if (node.type === 'text') {
189
+ if (node.from < node.to) {
190
+ out.push({
191
+ from: lineFrom + node.from,
192
+ to: lineFrom + node.to,
193
+ text: node.text,
194
+ marks: cloneMarkSet(activeMarks),
195
+ });
196
+ }
197
+ continue;
198
+ }
199
+
200
+ const nextMarks = cloneMarkSet(activeMarks);
201
+ nextMarks.add(node.mark);
202
+ buildTextSegments(node.children || [], lineFrom, nextMarks, out);
203
+ }
204
+ return out;
205
+ }
206
+
207
+ function collectSpans(nodes, lineFrom, out = []) {
208
+ for (const node of nodes) {
209
+ if (node.type !== 'mark') continue;
210
+ out.push({
211
+ mark: node.mark,
212
+ from: lineFrom + node.from,
213
+ to: lineFrom + node.to,
214
+ contentFrom: lineFrom + node.contentFrom,
215
+ contentTo: lineFrom + node.contentTo,
216
+ openLength: node.contentFrom - node.from,
217
+ closeLength: node.to - node.contentTo,
218
+ children: node.children || [],
219
+ });
220
+ collectSpans(node.children || [], lineFrom, out);
221
+ }
222
+ return out;
223
+ }
224
+
225
+ export function mergeAdjacentSegments(segments) {
226
+ const out = [];
227
+ for (const segment of segments) {
228
+ if (!segment || segment.text.length === 0) continue;
229
+ const prev = out[out.length - 1];
230
+ if (prev && prev.to === segment.from && markSetsEqual(prev.marks, segment.marks)) {
231
+ prev.to = segment.to;
232
+ prev.text += segment.text;
233
+ continue;
234
+ }
235
+ out.push({
236
+ from: segment.from,
237
+ to: segment.to,
238
+ text: segment.text,
239
+ marks: cloneMarkSet(segment.marks),
240
+ });
241
+ }
242
+ return out;
243
+ }
244
+
245
+ export function cloneSegments(segments) {
246
+ return segments.map((segment) => ({
247
+ ...segment,
248
+ from: segment.from,
249
+ to: segment.to,
250
+ text: segment.text,
251
+ marks: cloneMarkSet(segment.marks),
252
+ }));
253
+ }
254
+
255
+ function stripBoundaryWhitespace(segment, side) {
256
+ if (!segment || segment.text.length === 0 || segment.marks.size === 0 || segment.marks.has('code')) {
257
+ return '';
258
+ }
259
+
260
+ if (side === 'leading') {
261
+ const match = segment.text.match(/^[ \t]+/);
262
+ if (!match) return '';
263
+ segment.text = segment.text.slice(match[0].length);
264
+ segment.from += match[0].length;
265
+ return match[0];
266
+ }
267
+
268
+ const match = segment.text.match(/[ \t]+$/);
269
+ if (!match) return '';
270
+ segment.text = segment.text.slice(0, -match[0].length);
271
+ segment.to -= match[0].length;
272
+ return match[0];
273
+ }
274
+
275
+ export function normalizeWhitespaceSegments(segments) {
276
+ const working = cloneSegments(segments).filter((segment) => segment.text.length > 0);
277
+ if (working.length === 0) return working;
278
+
279
+ const firstLeading = stripBoundaryWhitespace(working[0], 'leading');
280
+ if (firstLeading) {
281
+ working.unshift({
282
+ from: working[0].from - firstLeading.length,
283
+ to: working[0].from,
284
+ text: firstLeading,
285
+ marks: new Set(),
286
+ });
287
+ }
288
+
289
+ const lastTrailing = stripBoundaryWhitespace(working[working.length - 1], 'trailing');
290
+ if (lastTrailing) {
291
+ const last = working[working.length - 1];
292
+ working.push({
293
+ from: last.to,
294
+ to: last.to + lastTrailing.length,
295
+ text: lastTrailing,
296
+ marks: new Set(),
297
+ });
298
+ }
299
+
300
+ for (let i = 0; i < working.length - 1; i++) {
301
+ const left = working[i];
302
+ const right = working[i + 1];
303
+ if (!left || !right) continue;
304
+ if (left.text.length === 0 || right.text.length === 0) continue;
305
+ if (left.marks.has('code') || right.marks.has('code')) continue;
306
+
307
+ const trailing = stripBoundaryWhitespace(left, 'trailing');
308
+ const leading = stripBoundaryWhitespace(right, 'leading');
309
+ if (!trailing && !leading) continue;
310
+
311
+ const boundaryText = trailing + leading;
312
+ if (!boundaryText) continue;
313
+ const boundaryFrom = left.to;
314
+ const boundaryMarks = commonMarkSet(left.marks, right.marks);
315
+ working.splice(i + 1, 0, {
316
+ from: boundaryFrom,
317
+ to: boundaryFrom + boundaryText.length,
318
+ text: boundaryText,
319
+ marks: boundaryMarks,
320
+ });
321
+ i++;
322
+ }
323
+
324
+ return mergeAdjacentSegments(working.filter((segment) => segment.text.length > 0));
325
+ }
326
+
327
+ export function orderedMarks(marks) {
328
+ return INLINE_MARK_ORDER.filter((mark) => marks.has(mark));
329
+ }
330
+
331
+ export function getLineInlineModel(lineText, lineFrom = 0) {
332
+ const parsed = parseInlineNodes(lineText);
333
+ const segments = mergeAdjacentSegments(buildTextSegments(parsed.nodes, lineFrom));
334
+ const spans = collectSpans(parsed.nodes, lineFrom);
335
+ return {
336
+ nodes: parsed.nodes,
337
+ segments,
338
+ spans,
339
+ };
340
+ }
341
+
342
+ export function visitInlineSpans(spans, visitor) {
343
+ for (const span of spans) visitor(span);
344
+ }
345
+
346
+ export function findDelimitedRange(lineText, posInLine, open, close = open) {
347
+ const mark = syntaxToMark(open, close);
348
+ if (!mark) return null;
349
+ const model = getLineInlineModel(lineText, 0);
350
+ let found = null;
351
+
352
+ for (const span of model.spans) {
353
+ if (span.mark !== mark) continue;
354
+ if (posInLine < span.contentFrom || posInLine > span.contentTo) continue;
355
+ if (!found || ((span.contentTo - span.contentFrom) <= (found.contentTo - found.contentFrom))) {
356
+ found = {
357
+ start: span.from,
358
+ end: span.to,
359
+ contentStart: span.contentFrom,
360
+ contentEnd: span.contentTo,
361
+ };
362
+ }
363
+ }
364
+
365
+ return found;
366
+ }
367
+
368
+ export function findInnermostSpanContaining(spans, pos, mark = null) {
369
+ let found = null;
370
+ for (const span of spans) {
371
+ if (mark && span.mark !== mark) continue;
372
+ if (pos < span.contentFrom || pos > span.contentTo) continue;
373
+ if (!found || ((span.contentTo - span.contentFrom) <= (found.contentTo - found.contentFrom))) {
374
+ found = span;
375
+ }
376
+ }
377
+ return found;
378
+ }
379
+
380
+ export function getCaretInlineContext(state, pos) {
381
+ const line = state.doc.lineAt(pos);
382
+ const model = getLineInlineModel(line.text, line.from);
383
+ const marksAtCaret = new Set();
384
+ const spansAtCaret = [];
385
+ const atStartOf = [];
386
+ const atEndOf = [];
387
+ const insideEmptyOf = [];
388
+ const insideMiddleOf = [];
389
+
390
+ for (const span of model.spans) {
391
+ if (pos < span.contentFrom || pos > span.contentTo) continue;
392
+ marksAtCaret.add(span.mark);
393
+ spansAtCaret.push(span);
394
+ if (span.contentFrom === span.contentTo && pos === span.contentFrom) {
395
+ insideEmptyOf.push(span.mark);
396
+ } else if (pos === span.contentFrom) {
397
+ atStartOf.push(span.mark);
398
+ } else if (pos === span.contentTo) {
399
+ atEndOf.push(span.mark);
400
+ } else {
401
+ insideMiddleOf.push(span.mark);
402
+ }
403
+ }
404
+
405
+ return {
406
+ pos,
407
+ line,
408
+ model,
409
+ marksAtCaret,
410
+ spansAtCaret,
411
+ insideCode: marksAtCaret.has('code'),
412
+ boundary: { atStartOf, atEndOf, insideEmptyOf, insideMiddleOf },
413
+ };
414
+ }
415
+
416
+ export function getSelectionInlineContext(state, from, to) {
417
+ const start = Math.min(from, to);
418
+ const end = Math.max(from, to);
419
+ const doc = state.doc;
420
+ const startLine = doc.lineAt(start).number;
421
+ const endLine = doc.lineAt(Math.max(start, end)).number;
422
+ const lineInfos = [];
423
+ const coverage = new Map(INLINE_MARK_ORDER.map((mark) => [mark, 0]));
424
+ let selectedTextLength = 0;
425
+ let intersectsCode = false;
426
+
427
+ for (let lineNumber = startLine; lineNumber <= endLine; lineNumber++) {
428
+ const line = doc.line(lineNumber);
429
+ const model = getLineInlineModel(line.text, line.from);
430
+ const lineFrom = Math.max(start, line.from);
431
+ const lineTo = Math.min(end, line.to);
432
+ lineInfos.push({ line, model, from: lineFrom, to: lineTo });
433
+
434
+ for (const segment of model.segments) {
435
+ const overlapFrom = Math.max(lineFrom, segment.from);
436
+ const overlapTo = Math.min(lineTo, segment.to);
437
+ if (overlapFrom >= overlapTo) continue;
438
+ const overlapLen = overlapTo - overlapFrom;
439
+ selectedTextLength += overlapLen;
440
+ if (segment.marks.has('code')) intersectsCode = true;
441
+ for (const mark of segment.marks) {
442
+ coverage.set(mark, (coverage.get(mark) || 0) + overlapLen);
443
+ }
444
+ }
445
+ }
446
+
447
+ const fullyCoveredBy = new Set();
448
+ const partiallyCoveredBy = new Set();
449
+ for (const mark of INLINE_MARK_ORDER) {
450
+ const len = coverage.get(mark) || 0;
451
+ if (selectedTextLength > 0 && len === selectedTextLength) {
452
+ fullyCoveredBy.add(mark);
453
+ } else if (len > 0) {
454
+ partiallyCoveredBy.add(mark);
455
+ }
456
+ }
457
+
458
+ return {
459
+ from: start,
460
+ to: end,
461
+ empty: start === end,
462
+ selectedTextLength,
463
+ fullyCoveredBy,
464
+ partiallyCoveredBy,
465
+ mixedMarks: new Set([...partiallyCoveredBy].filter((mark) => !fullyCoveredBy.has(mark))),
466
+ intersectsCode,
467
+ lines: lineInfos,
468
+ };
469
+ }
470
+
471
+ export function splitSegmentsAtPositions(segments, positions) {
472
+ const sorted = Array.from(new Set(positions)).sort((a, b) => a - b);
473
+ let working = cloneSegments(segments);
474
+
475
+ for (const pos of sorted) {
476
+ const next = [];
477
+ for (const segment of working) {
478
+ if (pos <= segment.from || pos >= segment.to) {
479
+ next.push(segment);
480
+ continue;
481
+ }
482
+ const offset = pos - segment.from;
483
+ next.push({
484
+ from: segment.from,
485
+ to: pos,
486
+ text: segment.text.slice(0, offset),
487
+ marks: cloneMarkSet(segment.marks),
488
+ });
489
+ next.push({
490
+ from: pos,
491
+ to: segment.to,
492
+ text: segment.text.slice(offset),
493
+ marks: cloneMarkSet(segment.marks),
494
+ });
495
+ }
496
+ working = next;
497
+ }
498
+
499
+ return working;
500
+ }
501
+
502
+ export function serializeSegments(segments, trackPositions = [], options = {}) {
503
+ const sortedSegments = segments.filter((segment) => segment.text.length > 0);
504
+
505
+ const wantedPositions = Array.from(new Set(trackPositions)).sort((a, b) => a - b);
506
+ const tracked = new Map(wantedPositions.map((pos) => [pos, null]));
507
+ let trackIndex = 0;
508
+
509
+ let out = '';
510
+ let openMarks = [];
511
+ let insertedCaret = null;
512
+
513
+ const closeToCommonPrefix = (desired) => {
514
+ let prefix = 0;
515
+ while (
516
+ prefix < openMarks.length &&
517
+ prefix < desired.length &&
518
+ openMarks[prefix] === desired[prefix]
519
+ ) {
520
+ prefix++;
521
+ }
522
+ for (let i = openMarks.length - 1; i >= prefix; i--) {
523
+ out += INLINE_MARK_SPECS[openMarks[i]].close;
524
+ }
525
+ openMarks = openMarks.slice(0, prefix);
526
+ for (let i = prefix; i < desired.length; i++) {
527
+ out += INLINE_MARK_SPECS[desired[i]].open;
528
+ openMarks.push(desired[i]);
529
+ }
530
+ };
531
+
532
+ const mapBeforeBoundary = (boundaryPos) => {
533
+ while (trackIndex < wantedPositions.length && wantedPositions[trackIndex] < boundaryPos) {
534
+ if (tracked.get(wantedPositions[trackIndex]) == null) tracked.set(wantedPositions[trackIndex], out.length);
535
+ trackIndex++;
536
+ }
537
+ };
538
+
539
+ const mapAtBoundary = (boundaryPos) => {
540
+ while (trackIndex < wantedPositions.length && wantedPositions[trackIndex] === boundaryPos) {
541
+ if (tracked.get(wantedPositions[trackIndex]) == null) tracked.set(wantedPositions[trackIndex], out.length);
542
+ trackIndex++;
543
+ }
544
+ };
545
+
546
+ for (const segment of sortedSegments) {
547
+ mapBeforeBoundary(segment.from);
548
+ closeToCommonPrefix(orderedMarks(segment.marks));
549
+ mapAtBoundary(segment.from);
550
+
551
+ const startOut = out.length;
552
+ out += segment.text;
553
+
554
+ for (let i = trackIndex; i < wantedPositions.length; i++) {
555
+ const pos = wantedPositions[i];
556
+ if (pos <= segment.from || pos >= segment.to) break;
557
+ if (tracked.get(pos) == null) tracked.set(pos, startOut + (pos - segment.from));
558
+ }
559
+ while (trackIndex < wantedPositions.length && wantedPositions[trackIndex] < segment.to) trackIndex++;
560
+ mapAtBoundary(segment.to);
561
+
562
+ if (segment.trackCaretAfter) {
563
+ insertedCaret = out.length;
564
+ }
565
+ }
566
+
567
+ closeToCommonPrefix([]);
568
+ while (trackIndex < wantedPositions.length) {
569
+ if (tracked.get(wantedPositions[trackIndex]) == null) tracked.set(wantedPositions[trackIndex], out.length);
570
+ trackIndex++;
571
+ }
572
+
573
+ return {
574
+ text: out,
575
+ tracked,
576
+ insertedCaret,
577
+ };
578
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Inline editing state.
3
+ *
4
+ * Tracks pending caret splits for semantic inline formatting toggles. This lets
5
+ * the editor keep the caret in place when the user toggles a mark off in the
6
+ * middle of a span, and only materialize the split once actual text is typed.
7
+ */
8
+
9
+ import { EditorState, StateEffect, StateField } from '@codemirror/state';
10
+ import {
11
+ cloneMarkSet,
12
+ getLineInlineModel,
13
+ serializeSegments,
14
+ splitSegmentsAtPositions,
15
+ } from './inline-model.js';
16
+
17
+ export const setPendingInlineSplitEffect = StateEffect.define();
18
+ export const clearPendingInlineSplitEffect = StateEffect.define();
19
+
20
+ export const inlinePendingSplitField = StateField.define({
21
+ create() {
22
+ return null;
23
+ },
24
+ update(value, tr) {
25
+ let next = value;
26
+
27
+ for (const effect of tr.effects) {
28
+ if (effect.is(setPendingInlineSplitEffect)) next = effect.value;
29
+ if (effect.is(clearPendingInlineSplitEffect)) next = null;
30
+ }
31
+
32
+ if (next) {
33
+ const sel = tr.state.selection.main;
34
+ if (!sel.empty || sel.head !== next.pos) {
35
+ next = null;
36
+ } else if (tr.docChanged) {
37
+ next = null;
38
+ }
39
+ }
40
+
41
+ return next;
42
+ },
43
+ });
44
+
45
+ export function getPendingInlineSplit(state) {
46
+ return state.field(inlinePendingSplitField, false) || null;
47
+ }
48
+
49
+ function getSimpleInsertion(tr, pos) {
50
+ let change = null;
51
+ let count = 0;
52
+ tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
53
+ count++;
54
+ change = { fromA, toA, fromB, toB, inserted: inserted.toString() };
55
+ });
56
+
57
+ if (count !== 1 || !change) return null;
58
+ if (change.fromA !== pos || change.toA !== pos) return null;
59
+ if (!change.inserted || change.inserted.includes('\n')) return null;
60
+ return change.inserted;
61
+ }
62
+
63
+ export const inlinePendingSplitFilter = EditorState.transactionFilter.of((tr) => {
64
+ const pending = getPendingInlineSplit(tr.startState);
65
+ if (!pending || !tr.docChanged) return tr;
66
+
67
+ const startSel = tr.startState.selection.main;
68
+ if (!startSel.empty || startSel.head !== pending.pos) return tr;
69
+
70
+ const insertedText = getSimpleInsertion(tr, pending.pos);
71
+ if (!insertedText) return tr;
72
+
73
+ const line = tr.startState.doc.lineAt(pending.pos);
74
+ const model = getLineInlineModel(line.text, line.from);
75
+ const segments = splitSegmentsAtPositions(model.segments, [pending.pos]);
76
+ const insertAt = segments.findIndex((segment) => segment.from >= pending.pos);
77
+ const insertionIndex = insertAt === -1 ? segments.length : insertAt;
78
+ segments.splice(insertionIndex, 0, {
79
+ from: pending.pos,
80
+ to: pending.pos + insertedText.length,
81
+ text: insertedText,
82
+ marks: cloneMarkSet(pending.marks),
83
+ trackCaretAfter: true,
84
+ });
85
+
86
+ const rendered = serializeSegments(segments);
87
+ const caretPos = line.from + (rendered.insertedCaret ?? rendered.text.length);
88
+
89
+ return {
90
+ changes: { from: line.from, to: line.to, insert: rendered.text },
91
+ selection: { anchor: caretPos },
92
+ effects: clearPendingInlineSplitEffect.of(null),
93
+ userEvent: 'input.inline.pending-split',
94
+ scrollIntoView: true,
95
+ };
96
+ });
97
+
98
+ export function createInlineEditingExtensions() {
99
+ return [
100
+ inlinePendingSplitField,
101
+ inlinePendingSplitFilter,
102
+ ];
103
+ }