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
@@ -14,12 +14,14 @@
14
14
  import { OrchestratorClient } from '../orchestrator-client.js';
15
15
  import { ShellStateManager } from '../state.js';
16
16
  import { createStatusBar } from '../components/status-bar.js';
17
+ import { createContextPanel } from '../components/context-panel.js';
17
18
  import { injectShellStyles } from '../styles.js';
18
19
  import { showFilePicker, showFolderPicker } from '../dialogs/file-picker.js';
19
20
  import { prompt, confirm } from '../dialogs/base-dialog.js';
20
21
  import { Drive } from '../drive.js';
21
22
  import { AiClient } from '../ai-client.js';
22
23
  import { showAiMenu, AI_COMMANDS, injectAiMenuStyles } from '../ai-menu.js';
24
+ import { canImportLinkedTableFromHost } from '../../tables/index.js';
23
25
 
24
26
  // =============================================================================
25
27
  // STUDIO
@@ -92,6 +94,16 @@ export async function createStudio(target, options = {}) {
92
94
  overflow: hidden;
93
95
  `;
94
96
 
97
+ // Create main row container (editor + context rail)
98
+ const mainContainer = document.createElement('div');
99
+ mainContainer.className = 'mrmd-studio__main';
100
+ mainContainer.style.cssText = `
101
+ display: flex;
102
+ flex: 1;
103
+ min-height: 0;
104
+ overflow: hidden;
105
+ `;
106
+
95
107
  // Create editor container
96
108
  const editorContainer = document.createElement('div');
97
109
  editorContainer.className = 'mrmd-studio__editor';
@@ -99,8 +111,19 @@ export async function createStudio(target, options = {}) {
99
111
  flex: 1;
100
112
  overflow: hidden;
101
113
  position: relative;
114
+ min-width: 0;
102
115
  `;
103
116
 
117
+ const contextPanelContainer = document.createElement('div');
118
+ contextPanelContainer.className = 'mrmd-studio__context';
119
+ contextPanelContainer.style.cssText = `
120
+ display: flex;
121
+ min-height: 0;
122
+ `;
123
+
124
+ mainContainer.appendChild(editorContainer);
125
+ mainContainer.appendChild(contextPanelContainer);
126
+
104
127
  // Create status bar container
105
128
  const statusBarContainer = document.createElement('div');
106
129
  statusBarContainer.className = 'mrmd-studio__statusbar';
@@ -108,9 +131,9 @@ export async function createStudio(target, options = {}) {
108
131
  // Assemble layout
109
132
  if (statusBarConfig.position === 'top') {
110
133
  studioEl.appendChild(statusBarContainer);
111
- studioEl.appendChild(editorContainer);
134
+ studioEl.appendChild(mainContainer);
112
135
  } else {
113
- studioEl.appendChild(editorContainer);
136
+ studioEl.appendChild(mainContainer);
114
137
  studioEl.appendChild(statusBarContainer);
115
138
  }
116
139
 
@@ -142,6 +165,16 @@ export async function createStudio(target, options = {}) {
142
165
  return () => eventHandlers.get(event).delete(handler);
143
166
  }
144
167
 
168
+ function getCurrentDocumentMarkdownPath(docName = currentDocName) {
169
+ if (!docName) return null;
170
+ if (docName.startsWith('/')) return null;
171
+ return docName.endsWith('.md') ? docName : `${docName}.md`;
172
+ }
173
+
174
+ function supportsLinkedTableImport() {
175
+ return canImportLinkedTableFromHost();
176
+ }
177
+
145
178
  /**
146
179
  * Detect if cursor is inside a code block and return block info
147
180
  * @param {EditorView} view
@@ -266,6 +299,7 @@ export async function createStudio(target, options = {}) {
266
299
  // Track current editor instance and preserved state
267
300
  let editor = null;
268
301
  let currentDocName = null;
302
+ let contextPanelComponent = null;
269
303
  let preservedEditorState = {
270
304
  theme: editorOptions.theme || null,
271
305
  dark: editorOptions.dark ?? null,
@@ -288,6 +322,9 @@ export async function createStudio(target, options = {}) {
288
322
  // Preserve theme across switches
289
323
  theme: preservedEditorState.theme,
290
324
  dark: preservedEditorState.dark,
325
+ // Linked-table host context
326
+ projectRoot: shellState.get('projectRoot') || editorOptions.projectRoot || null,
327
+ documentPath: getCurrentDocumentMarkdownPath(docName) || editorOptions.documentPath || null,
291
328
  };
292
329
 
293
330
  // Remove any sync options since we're providing ydoc directly
@@ -376,14 +413,17 @@ export async function createStudio(target, options = {}) {
376
413
  });
377
414
  }
378
415
 
379
- // Add Comment Syntax extension
380
- if (mrmd.default.commentSyntax?.createCommentSyntaxExtension) {
381
- const commentExtensions = mrmd.default.commentSyntax.createCommentSyntaxExtension({
382
- aiClient,
383
- juiceLevel: shellState.get('ai')?.juiceLevel || 0,
384
- });
416
+ // Configure built-in comment syntax with the AI client.
417
+ // The base editor already installs the extension, so only append
418
+ // updated facet config here.
419
+ if (mrmd.default.commentSyntax?.commentConfigFacet) {
385
420
  newEditor.view.dispatch({
386
- effects: mrmd.codemirror.StateEffect.appendConfig.of(commentExtensions),
421
+ effects: mrmd.codemirror.StateEffect.appendConfig.of(
422
+ mrmd.default.commentSyntax.commentConfigFacet.of({
423
+ aiClient,
424
+ juiceLevel: shellState.get('ai')?.juiceLevel || 0,
425
+ })
426
+ ),
387
427
  });
388
428
  }
389
429
  }
@@ -398,15 +438,47 @@ export async function createStudio(target, options = {}) {
398
438
  * @param {Object|null} codeBlock - Code block info if cursor is in a code block
399
439
  */
400
440
  async function executeAiCommand(cmd, targetEditor, codeBlock = null) {
401
- if (!aiClient || !mrmd.default.ai) return;
441
+ if (!mrmd.default.ai) return;
402
442
 
403
443
  const view = targetEditor.view;
444
+
445
+ if (cmd.action === 'insert-frontmatter-template') {
446
+ const handled = targetEditor.commands?.insertFrontmatterTemplate?.() || false;
447
+ if (handled) {
448
+ emit('aiCommandExecuted', { command: cmd.id, action: cmd.action });
449
+ } else {
450
+ emit('aiCommandError', { command: cmd.id, error: 'Could not insert frontmatter template' });
451
+ }
452
+ return;
453
+ }
454
+
455
+ if (!aiClient) return;
456
+
404
457
  const context = mrmd.default.ai.getAiContext(view);
405
458
  const juiceLevel = shellState.get('ai')?.juiceLevel || 0;
406
459
 
407
460
  // Detect language from code block, or fall back to python
408
461
  const detectedLanguage = codeBlock?.language || 'python';
409
462
 
463
+ // Resolve richer context from _assets/context/*.md when available
464
+ let resolvedContextText = context.documentContext;
465
+ try {
466
+ if (currentDocName) {
467
+ const resolved = await orchestratorClient.resolveContext({
468
+ doc: currentDocName,
469
+ content: context.documentContext,
470
+ cursorPos: context.cursorPos,
471
+ selection: { from: context.selectionFrom, to: context.selectionTo },
472
+ ensureExists: true,
473
+ });
474
+ if (resolved?.contextText) {
475
+ resolvedContextText = resolved.contextText;
476
+ }
477
+ }
478
+ } catch (error) {
479
+ console.warn('[Studio] Failed to resolve AI context, falling back to document only:', error);
480
+ }
481
+
410
482
  // Mark AI as active
411
483
  shellState._set('ai.active', true);
412
484
 
@@ -419,20 +491,20 @@ export async function createStudio(target, options = {}) {
419
491
  params = {
420
492
  text_before_cursor: context.textBeforeCursor,
421
493
  local_context: context.localContext,
422
- document_context: context.documentContext,
494
+ document_context: resolvedContextText,
423
495
  };
424
496
  } else if (cmd.program.includes('Fix') || cmd.program.includes('Correct')) {
425
497
  params = {
426
498
  text_to_fix: context.selectedText,
427
499
  local_context: context.localContext,
428
- document_context: context.documentContext,
500
+ document_context: resolvedContextText,
429
501
  };
430
502
  } else if (cmd.program.includes('Code')) {
431
503
  params = {
432
504
  code: context.selectedText,
433
505
  language: detectedLanguage,
434
506
  local_context: context.localContext,
435
- document_context: context.documentContext,
507
+ document_context: resolvedContextText,
436
508
  };
437
509
  } else if (cmd.program.includes('Synonym')) {
438
510
  params = {
@@ -440,7 +512,7 @@ export async function createStudio(target, options = {}) {
440
512
  local_context: context.localContext,
441
513
  };
442
514
  } else if (cmd.program.includes('Document')) {
443
- params = { document: context.documentContext };
515
+ params = { document: resolvedContextText };
444
516
  }
445
517
 
446
518
  await mrmd.default.ai.executeAiOperation(view, aiClient, {
@@ -544,12 +616,24 @@ export async function createStudio(target, options = {}) {
544
616
  path: normalizedName.endsWith('.md') ? normalizedName : `${normalizedName}.md`,
545
617
  root: filesResult.root,
546
618
  });
619
+ editor?.setLinkedTableHostContext?.({
620
+ projectRoot: shellState.get('projectRoot') || null,
621
+ documentPath: normalizedName.endsWith('.md') ? normalizedName : `${normalizedName}.md`,
622
+ });
547
623
 
548
624
  // Update status bar with new editor
549
625
  if (statusBarComponent) {
550
626
  statusBarComponent.setEditor(editor);
551
627
  }
552
628
 
629
+ if (contextPanelComponent) {
630
+ contextPanelComponent.setEditor(editor);
631
+ contextPanelComponent.setDocument(normalizedName);
632
+ contextPanelComponent.refresh().catch((e) => {
633
+ console.warn('[Studio] Failed to refresh context panel:', e);
634
+ });
635
+ }
636
+
553
637
  emit('fileOpened', { doc: normalizedName });
554
638
 
555
639
  } catch (e) {
@@ -593,6 +677,8 @@ export async function createStudio(target, options = {}) {
593
677
 
594
678
  // Shell action handlers
595
679
  const handlers = {
680
+ supportsLinkedTableImport,
681
+
596
682
  async onRename() {
597
683
  const file = shellState.get('file');
598
684
  if (!file) return;
@@ -670,6 +756,65 @@ export async function createStudio(target, options = {}) {
670
756
  });
671
757
  },
672
758
 
759
+ async onImportLinkedTable() {
760
+ const file = shellState.get('file');
761
+ const projectRoot = shellState.get('projectRoot');
762
+
763
+ if (!supportsLinkedTableImport()) {
764
+ await confirm({
765
+ title: 'Linked table import unavailable',
766
+ message: 'This build does not expose the Electron linked-table host API yet.',
767
+ confirmLabel: 'OK',
768
+ cancelLabel: '',
769
+ });
770
+ return;
771
+ }
772
+
773
+ if (!file || !editor) {
774
+ return;
775
+ }
776
+
777
+ if (!projectRoot || file.isOutsideProject || !file.path || file.path.startsWith('/')) {
778
+ await confirm({
779
+ title: 'Linked table import requires a project file',
780
+ message: 'Open a markdown document inside a project before importing a linked table.',
781
+ confirmLabel: 'OK',
782
+ cancelLabel: '',
783
+ });
784
+ return;
785
+ }
786
+
787
+ showFilePicker({
788
+ mode: 'open',
789
+ title: 'Import Linked Table',
790
+ orchestratorClient,
791
+ initialPath: projectRoot || '~',
792
+ allowOutsideProject: true,
793
+ onSelect: async (sourceFilePath) => {
794
+ try {
795
+ const result = await editor.importLinkedTableFromHost(sourceFilePath, {
796
+ projectRoot,
797
+ documentPath: file.path,
798
+ cacheFormat: 'csv',
799
+ });
800
+ emit('linkedTableImported', {
801
+ sourceFilePath,
802
+ tableId: result.tableId,
803
+ spec: result.spec,
804
+ });
805
+ } catch (error) {
806
+ console.error('[Studio] Linked table import failed:', error);
807
+ await confirm({
808
+ title: 'Linked table import failed',
809
+ message: error.message || String(error),
810
+ confirmLabel: 'OK',
811
+ cancelLabel: '',
812
+ });
813
+ }
814
+ },
815
+ });
816
+ },
817
+
673
818
  // NOTE: onChangeVenv, onChangeCwd, and onRestartRuntime are no longer used.
674
819
  // The new runtime model doesn't support changing venv/cwd on running runtimes.
675
820
  // Instead, users start new runtimes via the status bar menu and attach docs to them.
@@ -896,10 +1041,33 @@ export async function createStudio(target, options = {}) {
896
1041
  path: currentDocName.endsWith('.md') ? currentDocName : `${currentDocName}.md`,
897
1042
  root: result.root,
898
1043
  });
1044
+ editor?.setLinkedTableHostContext?.({
1045
+ projectRoot: shellState.get('projectRoot') || null,
1046
+ documentPath: currentDocName.endsWith('.md') ? currentDocName : `${currentDocName}.md`,
1047
+ });
899
1048
  } catch (e) {
900
1049
  console.warn('Could not set initial file context:', e);
901
1050
  }
902
1051
 
1052
+ // Create context panel
1053
+ contextPanelComponent = createContextPanel({
1054
+ container: contextPanelContainer,
1055
+ orchestratorClient,
1056
+ shellState,
1057
+ getCurrentDocument: () => currentDocName,
1058
+ getEditor: () => editor,
1059
+ getAiContext: mrmd.default.ai?.getAiContext,
1060
+ onOpenRaw: async (contextPath) => {
1061
+ const rawDoc = contextPath.replace(/\.md$/, '');
1062
+ await switchDocument(rawDoc);
1063
+ },
1064
+ });
1065
+ contextPanelComponent.setDocument(currentDocName);
1066
+ contextPanelComponent.setEditor(editor);
1067
+ contextPanelComponent.refresh().catch((e) => {
1068
+ console.warn('[Studio] Failed to initialize context panel:', e);
1069
+ });
1070
+
903
1071
  // Create studio object
904
1072
  const studio = {
905
1073
  /** Current editor instance (may change on file switch) */
@@ -938,6 +1106,29 @@ export async function createStudio(target, options = {}) {
938
1106
  await switchDocument(docName);
939
1107
  },
940
1108
 
1109
+ /**
1110
+ * Import a linked table into the current document.
1111
+ * If no source path is provided, opens the file picker flow.
1112
+ *
1113
+ * @param {string} [sourceFilePath]
1114
+ */
1115
+ async importLinkedTable(sourceFilePath) {
1116
+ if (sourceFilePath) {
1117
+ const file = shellState.get('file');
1118
+ const projectRoot = shellState.get('projectRoot');
1119
+ if (!editor || !file?.path || !projectRoot) {
1120
+ throw new Error('Linked table import requires an open project document');
1121
+ }
1122
+ return editor.importLinkedTableFromHost(sourceFilePath, {
1123
+ projectRoot,
1124
+ documentPath: file.path,
1125
+ cacheFormat: 'csv',
1126
+ });
1127
+ }
1128
+
1129
+ return handlers.onImportLinkedTable();
1130
+ },
1131
+
941
1132
  /**
942
1133
  * Save current file to a new location
943
1134
  * @param {string} targetPath - Target path
@@ -1018,6 +1209,7 @@ export async function createStudio(target, options = {}) {
1018
1209
 
1019
1210
  // Destroy status bar
1020
1211
  statusBarComponent?.destroy();
1212
+ contextPanelComponent?.destroy();
1021
1213
 
1022
1214
  // Destroy current editor
1023
1215
  if (editor) {
@@ -416,6 +416,75 @@ export class OrchestratorClient {
416
416
  return this.listRuntimeAttachments();
417
417
  }
418
418
 
419
+ // ===========================================================================
420
+ // Context Management
421
+ // ===========================================================================
422
+
423
+ /**
424
+ * Resolve markdown-managed AI context for a document.
425
+ * @param {Object} request
426
+ * @param {string} request.doc
427
+ * @param {string} [request.content]
428
+ * @param {number} [request.cursorPos]
429
+ * @param {{from: number, to: number}} [request.selection]
430
+ * @param {string[]} [request.codeSymbols]
431
+ * @param {boolean} [request.ensureExists=false]
432
+ */
433
+ async resolveContext(request) {
434
+ return this._fetch('/api/context/resolve', {
435
+ method: 'POST',
436
+ body: JSON.stringify(request),
437
+ });
438
+ }
439
+
440
+ /**
441
+ * Get context markdown for a document.
442
+ * @param {string} doc
443
+ */
444
+ async getContext(doc) {
445
+ return this._fetch(`/api/context/${encodeURIComponent(doc)}`);
446
+ }
447
+
448
+ /**
449
+ * Save context markdown for a document.
450
+ * @param {string} doc
451
+ * @param {string} content
452
+ */
453
+ async saveContext(doc, content) {
454
+ return this._fetch(`/api/context/${encodeURIComponent(doc)}`, {
455
+ method: 'PUT',
456
+ body: JSON.stringify({ content }),
457
+ });
458
+ }
459
+
460
+ /**
461
+ * Initialize context markdown for a document if missing.
462
+ * @param {string} doc
463
+ */
464
+ async initContext(doc) {
465
+ return this._fetch(`/api/context/init/${encodeURIComponent(doc)}`, {
466
+ method: 'POST',
467
+ });
468
+ }
469
+
470
+ /**
471
+ * Get project default context markdown.
472
+ */
473
+ async getDefaultContext() {
474
+ return this._fetch('/api/context');
475
+ }
476
+
477
+ /**
478
+ * Save project default context markdown.
479
+ * @param {string} content
480
+ */
481
+ async saveDefaultContext(content) {
482
+ return this._fetch('/api/context', {
483
+ method: 'PUT',
484
+ body: JSON.stringify({ content }),
485
+ });
486
+ }
487
+
419
488
  // ===========================================================================
420
489
  // Project & Runtime Management
421
490
  // ===========================================================================
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @fileoverview Spellcheck / autocorrect for prose in CodeMirror 6
3
+ *
4
+ * Enables the browser's native spellcheck on the CM6 content element, then
5
+ * disables it in places that are usually not natural-language prose:
6
+ * - fenced code blocks
7
+ * - inline code
8
+ * - markdown link URLs
9
+ * - quoted literals like 'foo/bar'
10
+ * - path-like tokens such as ./src/app, /usr/bin, src/utils/file.py
11
+ *
12
+ * Important note about "autocorrect":
13
+ * On desktop Chromium/Electron, what you mostly get is native spellcheck
14
+ * (underlines + suggestions), not aggressive iOS-style auto-replacement.
15
+ * The `autocorrect` attribute is mainly useful in Safari/iOS and is mostly
16
+ * ignored by Chromium. So in Electron this feature is best thought of as
17
+ * fast spellcheck with suggestions, not full mobile-style autocorrect.
18
+ */
19
+
20
+ import { EditorView, Decoration, ViewPlugin } from '@codemirror/view';
21
+ import { syntaxTree } from '@codemirror/language';
22
+
23
+ /**
24
+ * Enable browser-native spellcheck on the editor's contenteditable element.
25
+ */
26
+ const proseSpellcheck = EditorView.contentAttributes.of({
27
+ spellcheck: 'true',
28
+ autocorrect: 'on', // Safari / iOS; harmless elsewhere
29
+ autocapitalize: 'sentences', // mobile keyboards
30
+ });
31
+
32
+ /**
33
+ * Reused mark decoration for ranges where spellcheck should be disabled.
34
+ */
35
+ const noSpellcheckMark = Decoration.mark({
36
+ attributes: { spellcheck: 'false' },
37
+ });
38
+
39
+ /**
40
+ * Markdown syntax nodes where spellcheck should be disabled.
41
+ *
42
+ * Confirmed CM6 markdown node names:
43
+ * - FencedCode
44
+ * - InlineCode
45
+ * - URL
46
+ */
47
+ const SUPPRESSED_NODE_NAMES = new Set([
48
+ 'FencedCode',
49
+ 'InlineCode',
50
+ 'URL',
51
+ ]);
52
+
53
+ /**
54
+ * Regexes for non-prose tokens that often appear in markdown paragraphs but
55
+ * should not be spellchecked.
56
+ *
57
+ * These are intentionally conservative:
58
+ * - quoted literals: 'foo/bar', "snake_case"
59
+ * - unix/relative paths: ./src/app, /usr/bin, ~/work/project
60
+ * - slash-delimited paths/modules: src/widgets/theme.js
61
+ */
62
+ const NON_PROSE_PATTERNS = [
63
+ /'[^'\n]+'/g,
64
+ /"[^"\n]+"/g,
65
+ /(?:\.{1,2}\/|~\/|\/)[^\s'"`<>]+/g,
66
+ /(?:[A-Za-z0-9._-]+\/){1,}[A-Za-z0-9._-]+\/?/g,
67
+ ];
68
+
69
+ function addRegexRanges(docText, baseFrom, ranges) {
70
+ for (const pattern of NON_PROSE_PATTERNS) {
71
+ pattern.lastIndex = 0;
72
+ let match;
73
+ while ((match = pattern.exec(docText))) {
74
+ const from = baseFrom + match.index;
75
+ const to = from + match[0].length;
76
+ if (to > from) ranges.push({ from, to });
77
+
78
+ // Safety against zero-length regex matches
79
+ if (match[0].length === 0) {
80
+ pattern.lastIndex += 1;
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ function mergeRanges(ranges) {
87
+ if (ranges.length <= 1) return ranges;
88
+
89
+ const sorted = ranges
90
+ .filter((r) => r && r.to > r.from)
91
+ .sort((a, b) => (a.from - b.from) || (a.to - b.to));
92
+
93
+ if (sorted.length === 0) return [];
94
+
95
+ const merged = [sorted[0]];
96
+ for (let i = 1; i < sorted.length; i += 1) {
97
+ const current = sorted[i];
98
+ const last = merged[merged.length - 1];
99
+
100
+ if (current.from <= last.to) {
101
+ last.to = Math.max(last.to, current.to);
102
+ } else {
103
+ merged.push({ ...current });
104
+ }
105
+ }
106
+
107
+ return merged;
108
+ }
109
+
110
+ /**
111
+ * ViewPlugin that disables spellcheck in non-prose regions.
112
+ *
113
+ * Performance:
114
+ * We only inspect visible ranges instead of the whole document. That keeps
115
+ * the work small even on long notes and reduces the chance that spellcheck
116
+ * feels laggy.
117
+ */
118
+ const noSpellcheckInNonProse = ViewPlugin.fromClass(
119
+ class {
120
+ constructor(view) {
121
+ this.decorations = this.build(view);
122
+ }
123
+
124
+ update(update) {
125
+ if (update.docChanged || update.viewportChanged) {
126
+ this.decorations = this.build(update.view);
127
+ }
128
+ }
129
+
130
+ build(view) {
131
+ const collected = [];
132
+ const tree = syntaxTree(view.state);
133
+
134
+ for (const { from, to } of view.visibleRanges) {
135
+ tree.iterate({
136
+ from,
137
+ to,
138
+ enter(node) {
139
+ if (SUPPRESSED_NODE_NAMES.has(node.name) && node.from < node.to) {
140
+ collected.push({ from: node.from, to: node.to });
141
+ }
142
+ },
143
+ });
144
+
145
+ const text = view.state.doc.sliceString(from, to);
146
+ addRegexRanges(text, from, collected);
147
+ }
148
+
149
+ const merged = mergeRanges(collected);
150
+ return Decoration.set(
151
+ merged.map((r) => noSpellcheckMark.range(r.from, r.to)),
152
+ true,
153
+ );
154
+ }
155
+ },
156
+ { decorations: (v) => v.decorations },
157
+ );
158
+
159
+ /**
160
+ * Create spellcheck extensions.
161
+ *
162
+ * @returns {import('@codemirror/state').Extension[]}
163
+ */
164
+ export function createSpellcheckExtensions() {
165
+ return [proseSpellcheck, noSpellcheckInNonProse];
166
+ }