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
package/src/keymap.js CHANGED
@@ -18,7 +18,7 @@ import { commandRegistry } from './commands.js';
18
18
  */
19
19
  export const defaultKeybindings = {
20
20
  // Execution
21
- 'Mod-Enter': 'runCell',
21
+ 'Mod-Enter': 'runCellOrInsertPageBreak',
22
22
  'Shift-Enter': 'runCellAndAdvance',
23
23
  'Mod-Shift-Enter': 'runAllCells',
24
24
 
@@ -42,6 +42,12 @@ export const defaultKeybindings = {
42
42
  // Code intelligence
43
43
  'F12': 'viewSource',
44
44
 
45
+ // Grammar
46
+ 'Alt-Enter': 'applyFirstGrammarSuggestion',
47
+
48
+ // View modes
49
+ 'Mod-Alt-Shift-w': 'toggleWysiwygMode',
50
+
45
51
  // Enter accepts completion if active, otherwise inserts newline
46
52
  'Enter': 'acceptCompletionOrNewline',
47
53
  };
@@ -84,7 +90,7 @@ function acceptCompletionOrNewline(editor) {
84
90
  *
85
91
  * // Custom bindings
86
92
  * createKeymap(editor, {
87
- * 'Mod-Enter': 'runCell',
93
+ * 'Mod-Enter': 'runCellOrInsertPageBreak',
88
94
  * 'Shift-Enter': 'runCellAndAdvance',
89
95
  * 'F5': 'runAllCells',
90
96
  * })
@@ -163,6 +169,7 @@ export function mergeKeybindings(userBindings, defaults = defaultKeybindings) {
163
169
  export function listCommands() {
164
170
  return [
165
171
  { name: 'runCell', description: 'Run current cell and stay in place' },
172
+ { name: 'runCellOrInsertPageBreak', description: 'Run current cell, or insert a page break when editing prose' },
166
173
  { name: 'runCellAndAdvance', description: 'Run current cell and move to next (create if needed)' },
167
174
  { name: 'runAllCells', description: 'Run all cells in document' },
168
175
  { name: 'runAllAbove', description: 'Run all cells above and including current' },
@@ -177,5 +184,7 @@ export function listCommands() {
177
184
  { name: 'indent', description: 'Indent current line or selection' },
178
185
  { name: 'dedent', description: 'Dedent current line or selection' },
179
186
  { name: 'viewSource', description: 'View source code for symbol under cursor' },
187
+ { name: 'applyFirstGrammarSuggestion', description: 'Apply the first grammar suggestion near the cursor' },
188
+ { name: 'insertFrontmatterTemplate', description: 'Insert or augment document frontmatter with a scholarly template' },
180
189
  ];
181
190
  }
@@ -18,6 +18,7 @@
18
18
  import { StateField } from '@codemirror/state';
19
19
  import { EditorView, Decoration, WidgetType, ViewPlugin } from '@codemirror/view';
20
20
  import { syntaxTree } from '@codemirror/language';
21
+ import { sourceModeFacet, wysiwygModeFacet } from './facets.js';
21
22
 
22
23
  // =============================================================================
23
24
  // Line Height Tracking for Accurate Spacing
@@ -67,6 +68,20 @@ import {
67
68
  import {
68
69
  FrontmatterWidget,
69
70
  } from './widgets/frontmatter.js';
71
+ import {
72
+ LinkedTableWidget,
73
+ } from '../tables/widgets/linked-table-widget.js';
74
+ import {
75
+ LinkedTableSourceBannerWidget,
76
+ } from '../tables/widgets/linked-table-source-banner.js';
77
+ import {
78
+ findLinkedTableBlocksInState,
79
+ getLinkedTableBlockRange,
80
+ isRangeInsideLinkedTable,
81
+ } from '../tables/parsing/linked-table-blocks.js';
82
+ import {
83
+ linkedTableMarkdownState,
84
+ } from '../tables/state/linked-table-state.js';
70
85
 
71
86
  // =============================================================================
72
87
  // Height Cache for Stable Layout
@@ -195,6 +210,35 @@ class TableWidgetWithHeightCache extends TableWidget {
195
210
  }
196
211
  }
197
212
 
213
+ /**
214
+ * LinkedTableWidget wrapper that caches its rendered height for stable layout.
215
+ */
216
+ class LinkedTableWidgetWithHeightCache extends LinkedTableWidget {
217
+ constructor(block, parsedTable, contentHash, options = {}) {
218
+ super(block, parsedTable, contentHash, options);
219
+ this.contentHash = contentHash;
220
+ }
221
+
222
+ eq(other) {
223
+ return super.eq(other) && other.contentHash === this.contentHash;
224
+ }
225
+
226
+ toDOM(view) {
227
+ const dom = super.toDOM(view);
228
+ const contentHash = this.contentHash;
229
+
230
+ requestAnimationFrame(() => {
231
+ const line = dom.closest('.cm-line');
232
+ const height = line ? line.offsetHeight : dom.offsetHeight;
233
+ if (height > 0) {
234
+ cacheWidgetHeight(contentHash, height);
235
+ }
236
+ });
237
+
238
+ return dom;
239
+ }
240
+ }
241
+
198
242
  /**
199
243
  * DisplayMathWidget wrapper that caches its rendered height for stable layout.
200
244
  */
@@ -440,11 +484,70 @@ function buildBlockDecorations(state) {
440
484
  const cursorLine = doc.lineAt(cursorPos).number;
441
485
  const decorations = [];
442
486
 
443
- // Find and process tables
487
+ // Mode flags
488
+ const isSourceMode = state.facet(sourceModeFacet);
489
+ const isWysiwygMode = state.facet(wysiwygModeFacet);
490
+ const revealedLinkedTables = state.field(linkedTableMarkdownState, false) || new Set();
491
+
492
+ // Find and process linked tables first
493
+ const linkedTableBlocks = findLinkedTableBlocksInState(state);
494
+
495
+ for (const block of linkedTableBlocks) {
496
+ const blockRange = getLinkedTableBlockRange(block);
497
+ const contentHash = 'linked-table-' + hashContent(doc.sliceString(blockRange.from, blockRange.to));
498
+ const showLinkedSource = isSourceMode || revealedLinkedTables.has(block.spec.id);
499
+
500
+ if (!showLinkedSource) {
501
+ const parsed = parseTable(block.tableLines || []);
502
+ if (parsed && parsed.rows.length > 0) {
503
+ decorations.push(
504
+ Decoration.replace({
505
+ widget: new LinkedTableWidgetWithHeightCache(block, parsed, contentHash),
506
+ }).range(blockRange.from, blockRange.to)
507
+ );
508
+ }
509
+ } else {
510
+ if (!isSourceMode && revealedLinkedTables.has(block.spec.id)) {
511
+ decorations.push(
512
+ Decoration.widget({
513
+ widget: new LinkedTableSourceBannerWidget(block),
514
+ side: -1,
515
+ block: true,
516
+ }).range(block.headerFrom)
517
+ );
518
+ }
519
+
520
+ const cachedHeight = getCachedHeight(contentHash);
521
+ if (cachedHeight) {
522
+ const lineCount = block.endLine - block.startLine + 1;
523
+ const lineHeight = getLineHeight();
524
+ const rawHeight = lineCount * lineHeight;
525
+ const padding = cachedHeight - rawHeight;
526
+
527
+ if (padding > 0) {
528
+ const lastLine = doc.line(block.endLine);
529
+ decorations.push(
530
+ Decoration.line({
531
+ attributes: {
532
+ class: 'cm-block-spacer-line',
533
+ style: `padding-bottom: ${padding}px`
534
+ }
535
+ }).range(lastLine.from)
536
+ );
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
+ // Find and process plain tables
444
543
  const tableRanges = findTableRanges(state);
445
544
 
446
545
  for (const range of tableRanges) {
447
- const cursorInTable = cursorLine >= range.startLine && cursorLine <= range.endLine;
546
+ if (isRangeInsideLinkedTable(range, linkedTableBlocks)) {
547
+ continue;
548
+ }
549
+
550
+ const cursorInTable = isSourceMode || (!isWysiwygMode && cursorLine >= range.startLine && cursorLine <= range.endLine);
448
551
 
449
552
  // Collect lines for both rendering and height calculation
450
553
  const lines = [];
@@ -496,7 +599,7 @@ function buildBlockDecorations(state) {
496
599
  const mathRanges = findDisplayMathRanges(state);
497
600
 
498
601
  for (const range of mathRanges) {
499
- const cursorInMath = cursorLine >= range.startLine && cursorLine <= range.endLine;
602
+ const cursorInMath = isSourceMode || (!isWysiwygMode && cursorLine >= range.startLine && cursorLine <= range.endLine);
500
603
  const contentHash = 'math-' + hashContent(range.content);
501
604
 
502
605
  if (!cursorInMath) {
@@ -536,7 +639,7 @@ function buildBlockDecorations(state) {
536
639
  const fmRange = findFrontmatterRange(state);
537
640
 
538
641
  if (fmRange) {
539
- const cursorInFrontmatter = cursorLine >= fmRange.startLine && cursorLine <= fmRange.endLine;
642
+ const cursorInFrontmatter = isSourceMode || (!isWysiwygMode && cursorLine >= fmRange.startLine && cursorLine <= fmRange.endLine);
540
643
  const contentHash = 'fm-' + hashContent(fmRange.content);
541
644
 
542
645
  if (!cursorInFrontmatter) {
@@ -592,7 +695,7 @@ export const blockDecorations = StateField.define({
592
695
  // Rebuild on any change that could affect block elements
593
696
  // For efficiency, we could map positions and only rebuild affected ranges,
594
697
  // but for now, full rebuild is acceptable
595
- if (tr.docChanged || tr.selection) {
698
+ if (tr.docChanged || tr.selection || tr.reconfigured) {
596
699
  return buildBlockDecorations(tr.state);
597
700
  }
598
701
  return decorations;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Markdown Rendering Facets
3
+ *
4
+ * Shared facets used by both the ViewPlugin (renderer.js) and
5
+ * the StateField (block-decorations.js). Kept in a separate file
6
+ * to avoid circular dependencies between those modules.
7
+ *
8
+ * @module markdown/facets
9
+ */
10
+
11
+ import { Facet } from '@codemirror/state';
12
+
13
+ /**
14
+ * Facet to toggle "source mode" — when true, all markdown syntax is shown
15
+ * as if the cursor were on every line. No rendering/hiding of markers,
16
+ * no widget replacement of syntax.
17
+ *
18
+ * Usage:
19
+ * sourceModeFacet.of(true) // enable source mode
20
+ * sourceModeFacet.of(false) // normal rendering mode
21
+ *
22
+ * @type {Facet<boolean, boolean>}
23
+ */
24
+ export const sourceModeFacet = Facet.define({
25
+ combine: (values) => values.some(v => v),
26
+ });
27
+
28
+ /**
29
+ * Facet to toggle WYSIWYG mode — when true, markdown is rendered everywhere,
30
+ * including the active line/block, and editing is routed through a protected,
31
+ * syntax-safe interaction layer.
32
+ *
33
+ * @type {Facet<boolean, boolean>}
34
+ */
35
+ export const wysiwygModeFacet = Facet.define({
36
+ combine: (values) => values.some(v => v),
37
+ });
@@ -16,8 +16,12 @@ import { WidgetType } from '@codemirror/view';
16
16
  /**
17
17
  * Regex to match HTML tags (opening, closing, self-closing, and comments)
18
18
  * Matches: <tag>, </tag>, <tag />, <tag attr="value">, <!-- comment -->
19
+ *
20
+ * MRMD special comments use <!--! ... !--> and are intentionally excluded
21
+ * here so they can be handled by the comment-syntax extension instead of
22
+ * being rendered away as invisible HTML comments.
19
23
  */
20
- const HTML_TAG_REGEX = /<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s+[^>]*)?\/?>|<!--[\s\S]*?-->/g;
24
+ const HTML_TAG_REGEX = /<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s+[^>]*)?\/?>|<!--(?!\!)[\s\S]*?-->/g;
21
25
 
22
26
  /**
23
27
  * Regex to match complete HTML elements (opening + content + closing)
@@ -26,8 +30,8 @@ const HTML_TAG_REGEX = /<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s+[^>]*)?\/?>|<!--[\s\S]*?--
26
30
  const HTML_ELEMENT_PATTERNS = [
27
31
  // Self-closing tags
28
32
  /<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)(?:\s+[^>]*)?\/?\s*>/gi,
29
- // HTML comments
30
- /<!--[\s\S]*?-->/g,
33
+ // HTML comments (excluding MRMD special comments: <!--! ... !-->)
34
+ /<!--(?!\!)[\s\S]*?-->/g,
31
35
  // Inline elements with content (non-greedy, handles simple nesting)
32
36
  /<(span|strong|em|b|i|u|s|mark|small|sub|sup|kbd|code|abbr|cite|dfn|q|time|var|samp|data|ruby|rt|rp|bdi|bdo|ins|del)(?:\s+[^>]*)?>[\s\S]*?<\/\1>/gi,
33
37
  ];
@@ -87,8 +91,8 @@ export function extractHtmlElements(text) {
87
91
  }
88
92
  }
89
93
 
90
- // Match HTML comments
91
- const comments = /<!--[\s\S]*?-->/g;
94
+ // Match HTML comments, but leave MRMD special comments to comment-syntax
95
+ const comments = /<!--(?!\!)[\s\S]*?-->/g;
92
96
  while ((match = comments.exec(text)) !== null) {
93
97
  const key = `${match.index}-${match.index + match[0].length}`;
94
98
  if (!seen.has(key)) {
@@ -28,6 +28,8 @@
28
28
 
29
29
  import { markdownRenderer } from './renderer.js';
30
30
  import { blockDecorations, lineHeightTracker } from './block-decorations.js';
31
+ import { createWysiwygExtensions } from './wysiwyg.js';
32
+ import { createInlineEditingExtensions } from './inline-state.js';
31
33
  import { markdownStyles, injectMarkdownStyles } from './styles.js';
32
34
 
33
35
  /**
@@ -49,20 +51,28 @@ export function markdown() {
49
51
  console.log('[Markdown] Creating extensions, blockDecorations:', blockDecorations, 'markdownRenderer:', markdownRenderer);
50
52
 
51
53
  return [
52
- lineHeightTracker, // ViewPlugin: updates line height cache (must come first!)
53
- blockDecorations, // StateField: tables, display math
54
- markdownRenderer, // ViewPlugin: everything else
54
+ lineHeightTracker, // ViewPlugin: updates line height cache (must come first!)
55
+ ...createInlineEditingExtensions(),
56
+ blockDecorations, // StateField: tables, display math
57
+ markdownRenderer, // ViewPlugin: everything else
58
+ ...createWysiwygExtensions(),
55
59
  ];
56
60
  }
57
61
 
58
62
  // Export individual pieces for advanced use
59
63
  export { markdownRenderer, assetResolverFacet } from './renderer.js';
64
+ export { sourceModeFacet, wysiwygModeFacet } from './facets.js';
65
+ export { createWysiwygExtensions, toggleInlineFormat, findDelimitedRange, findFencedCodeAt } from './wysiwyg.js';
66
+ export { createInlineEditingExtensions, getPendingInlineSplit } from './inline-state.js';
67
+ export { toggleInlineMark, toggleInlineMarkFromSyntax, getActiveInlineMarks, getSelectionFormattingState } from './inline-commands.js';
68
+ export { getLineInlineModel, getCaretInlineContext, getSelectionInlineContext, inlineClassForMark, syntaxToMark, markToSyntax } from './inline-model.js';
60
69
  export { blockDecorations, lineHeightTracker, cacheWidgetHeight, getCachedHeight, clearHeightCache } from './block-decorations.js';
61
70
  export { markdownStyles, injectMarkdownStyles } from './styles.js';
62
71
 
63
72
  // Widget exports
64
73
  export {
65
74
  TaskCheckboxWidget,
75
+ ListMarkerWidget,
66
76
  ImageWidget,
67
77
  ImagePlaceholder,
68
78
  parseImageMarkdown,
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Semantic inline formatting commands.
3
+ */
4
+
5
+ import { ChangeSet } from '@codemirror/state';
6
+ import {
7
+ cloneMarkSet,
8
+ findInnermostSpanContaining,
9
+ getCaretInlineContext,
10
+ getSelectionInlineContext,
11
+ markToSyntax,
12
+ normalizeWhitespaceSegments,
13
+ serializeSegments,
14
+ splitSegmentsAtPositions,
15
+ syntaxToMark,
16
+ } from './inline-model.js';
17
+ import {
18
+ clearPendingInlineSplitEffect,
19
+ getPendingInlineSplit,
20
+ setPendingInlineSplitEffect,
21
+ } from './inline-state.js';
22
+
23
+ function clearPendingEffects(state) {
24
+ return getPendingInlineSplit(state) ? [clearPendingInlineSplitEffect.of(null)] : [];
25
+ }
26
+
27
+ function addMarkToSet(marks, mark) {
28
+ const next = cloneMarkSet(marks);
29
+ next.add(mark);
30
+ return next;
31
+ }
32
+
33
+ function removeMarkFromSet(marks, mark) {
34
+ const next = cloneMarkSet(marks);
35
+ next.delete(mark);
36
+ return next;
37
+ }
38
+
39
+ function toggleMarkAtCaret(view, mark) {
40
+ const state = view.state;
41
+ const pos = state.selection.main.head;
42
+ const pending = getPendingInlineSplit(state);
43
+
44
+ if (pending && pending.pos === pos && pending.mark === mark) {
45
+ view.dispatch({
46
+ effects: clearPendingInlineSplitEffect.of(null),
47
+ userEvent: 'input.inline.pending-cancel',
48
+ });
49
+ return true;
50
+ }
51
+
52
+ const ctx = getCaretInlineContext(state, pos);
53
+ const sameSpan = findInnermostSpanContaining(ctx.model.spans, pos, mark);
54
+ const effects = clearPendingEffects(state);
55
+
56
+ if (ctx.insideCode && mark !== 'code') {
57
+ if (effects.length > 0) {
58
+ view.dispatch({ effects, userEvent: 'input.inline.noop' });
59
+ }
60
+ return true;
61
+ }
62
+
63
+ if (sameSpan) {
64
+ if (sameSpan.contentFrom === sameSpan.contentTo && pos === sameSpan.contentFrom) {
65
+ view.dispatch({
66
+ changes: { from: sameSpan.from, to: sameSpan.to, insert: '' },
67
+ selection: { anchor: sameSpan.from },
68
+ effects,
69
+ userEvent: 'input.inline.remove-empty',
70
+ scrollIntoView: true,
71
+ });
72
+ return true;
73
+ }
74
+
75
+ if (pos === sameSpan.contentFrom) {
76
+ view.dispatch({
77
+ selection: { anchor: sameSpan.from },
78
+ effects,
79
+ userEvent: 'input.inline.exit-left',
80
+ });
81
+ return true;
82
+ }
83
+
84
+ if (pos === sameSpan.contentTo) {
85
+ view.dispatch({
86
+ selection: { anchor: sameSpan.to },
87
+ effects,
88
+ userEvent: 'input.inline.exit-right',
89
+ });
90
+ return true;
91
+ }
92
+
93
+ view.dispatch({
94
+ selection: { anchor: pos },
95
+ effects: [
96
+ ...effects,
97
+ setPendingInlineSplitEffect.of({
98
+ pos,
99
+ mark,
100
+ marks: Array.from(removeMarkFromSet(ctx.marksAtCaret, mark)),
101
+ }),
102
+ ],
103
+ userEvent: 'input.inline.pending-split',
104
+ });
105
+ return true;
106
+ }
107
+
108
+ const syntax = markToSyntax(mark);
109
+ if (!syntax) return false;
110
+
111
+ view.dispatch({
112
+ changes: { from: pos, insert: syntax.open + syntax.close },
113
+ selection: { anchor: pos + syntax.open.length },
114
+ effects,
115
+ userEvent: 'input.inline.start',
116
+ scrollIntoView: true,
117
+ });
118
+ return true;
119
+ }
120
+
121
+ function applyMarkToSegment(segment, mark, action) {
122
+ if (mark !== 'code' && segment.marks.has('code')) return segment;
123
+
124
+ const next = {
125
+ ...segment,
126
+ marks: cloneMarkSet(segment.marks),
127
+ };
128
+
129
+ if (action === 'add') {
130
+ next.marks.add(mark);
131
+ } else {
132
+ next.marks.delete(mark);
133
+ }
134
+
135
+ return next;
136
+ }
137
+
138
+ function toggleMarkOnSelection(view, mark) {
139
+ const state = view.state;
140
+ const selection = state.selection.main;
141
+ const ctx = getSelectionInlineContext(state, selection.from, selection.to);
142
+ if (ctx.empty || ctx.selectedTextLength === 0) {
143
+ return toggleMarkAtCaret(view, mark);
144
+ }
145
+
146
+ const action = ctx.fullyCoveredBy.has(mark) ? 'remove' : 'add';
147
+ const changes = [];
148
+ const trackedOffsets = new Map();
149
+ const originalAnchor = selection.anchor;
150
+ const originalHead = selection.head;
151
+
152
+ for (const info of ctx.lines) {
153
+ if (info.from === info.to) continue;
154
+
155
+ let segments = splitSegmentsAtPositions(info.model.segments, [info.from, info.to]);
156
+ let changed = false;
157
+
158
+ segments = segments.map((segment) => {
159
+ if (segment.to <= info.from || segment.from >= info.to) return segment;
160
+ const next = applyMarkToSegment(segment, mark, action);
161
+ if (next !== segment) changed = true;
162
+ return next;
163
+ });
164
+
165
+ const normalized = normalizeWhitespaceSegments(segments);
166
+ const trackedPositions = [];
167
+ if (originalAnchor >= info.line.from && originalAnchor <= info.line.to) trackedPositions.push(originalAnchor);
168
+ if (originalHead >= info.line.from && originalHead <= info.line.to) trackedPositions.push(originalHead);
169
+ const rendered = serializeSegments(normalized, trackedPositions);
170
+
171
+ for (const [oldPos, newPos] of rendered.tracked.entries()) {
172
+ trackedOffsets.set(oldPos, { lineFrom: info.line.from, offset: newPos });
173
+ }
174
+
175
+ if (changed || rendered.text !== info.line.text) {
176
+ changes.push({ from: info.line.from, to: info.line.to, insert: rendered.text });
177
+ }
178
+ }
179
+
180
+ if (changes.length === 0) {
181
+ const effects = clearPendingEffects(state);
182
+ if (effects.length > 0) view.dispatch({ effects, userEvent: 'input.inline.noop' });
183
+ return true;
184
+ }
185
+
186
+ const changeSet = ChangeSet.of(changes, state.doc.length);
187
+ const mappedAnchor = trackedOffsets.has(originalAnchor)
188
+ ? changeSet.mapPos(trackedOffsets.get(originalAnchor).lineFrom, 1) + trackedOffsets.get(originalAnchor).offset
189
+ : changeSet.mapPos(originalAnchor, -1);
190
+ const mappedHead = trackedOffsets.has(originalHead)
191
+ ? changeSet.mapPos(trackedOffsets.get(originalHead).lineFrom, 1) + trackedOffsets.get(originalHead).offset
192
+ : changeSet.mapPos(originalHead, 1);
193
+
194
+ view.dispatch({
195
+ changes,
196
+ selection: { anchor: mappedAnchor, head: mappedHead },
197
+ effects: clearPendingEffects(state),
198
+ userEvent: `input.inline.${action}`,
199
+ scrollIntoView: true,
200
+ });
201
+ return true;
202
+ }
203
+
204
+ export function toggleInlineMark(view, mark) {
205
+ if (!view?.state) return false;
206
+ const selection = view.state.selection.main;
207
+ if (selection.empty) return toggleMarkAtCaret(view, mark);
208
+ return toggleMarkOnSelection(view, mark);
209
+ }
210
+
211
+ export function toggleInlineMarkFromSyntax(view, open, close = open) {
212
+ const mark = syntaxToMark(open, close);
213
+ if (!mark) return false;
214
+ return toggleInlineMark(view, mark);
215
+ }
216
+
217
+ export function getActiveInlineMarks(view) {
218
+ const state = view.state;
219
+ const pending = getPendingInlineSplit(state);
220
+ const selection = state.selection.main;
221
+
222
+ if (selection.empty && pending && pending.pos === selection.head) {
223
+ return new Set(pending.marks);
224
+ }
225
+
226
+ if (selection.empty) {
227
+ return cloneMarkSet(getCaretInlineContext(state, selection.head).marksAtCaret);
228
+ }
229
+
230
+ return cloneMarkSet(getSelectionInlineContext(state, selection.from, selection.to).fullyCoveredBy);
231
+ }
232
+
233
+ export function getSelectionFormattingState(view) {
234
+ const active = getActiveInlineMarks(view);
235
+ const selection = view.state.selection.main;
236
+ const mixed = selection.empty
237
+ ? new Set()
238
+ : getSelectionInlineContext(view.state, selection.from, selection.to).mixedMarks;
239
+
240
+ return {
241
+ bold: active.has('bold'),
242
+ italic: active.has('italic'),
243
+ underline: active.has('underline'),
244
+ strike: active.has('strike'),
245
+ strikethrough: active.has('strike'),
246
+ code: active.has('code'),
247
+ mixed: {
248
+ bold: mixed.has('bold'),
249
+ italic: mixed.has('italic'),
250
+ underline: mixed.has('underline'),
251
+ strike: mixed.has('strike'),
252
+ strikethrough: mixed.has('strike'),
253
+ code: mixed.has('code'),
254
+ },
255
+ };
256
+ }