mrmd-editor 0.7.0 → 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 (61) 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/execution.js +69 -15
  8. package/src/frontmatter-updater.js +204 -74
  9. package/src/grammar.js +758 -0
  10. package/src/index.js +1120 -55
  11. package/src/keymap.js +11 -2
  12. package/src/markdown/block-decorations.js +108 -5
  13. package/src/markdown/facets.js +37 -0
  14. package/src/markdown/html-inline.js +9 -5
  15. package/src/markdown/index.js +13 -3
  16. package/src/markdown/inline-commands.js +256 -0
  17. package/src/markdown/inline-model.js +578 -0
  18. package/src/markdown/inline-state.js +103 -0
  19. package/src/markdown/renderer.js +219 -12
  20. package/src/markdown/styles.js +290 -3
  21. package/src/markdown/widgets/alert-title.js +10 -8
  22. package/src/markdown/widgets/frontmatter.js +0 -6
  23. package/src/markdown/widgets/index.js +1 -0
  24. package/src/markdown/widgets/list-marker.js +29 -0
  25. package/src/markdown/wysiwyg.js +1158 -0
  26. package/src/mrp-types.js +2 -0
  27. package/src/output-widget.js +532 -18
  28. package/src/page-view-pagination.js +127 -0
  29. package/src/runtime-lsp.js +1757 -150
  30. package/src/section-controls/commands.js +617 -0
  31. package/src/section-controls/index.js +63 -0
  32. package/src/section-controls/plugin.js +165 -0
  33. package/src/section-controls/widgets.js +936 -0
  34. package/src/shell/ai-menu.js +11 -0
  35. package/src/shell/components/context-panel.js +572 -0
  36. package/src/shell/components/status-bar.js +218 -8
  37. package/src/shell/dialogs/file-picker.js +211 -0
  38. package/src/shell/layouts/studio.js +229 -14
  39. package/src/shell/orchestrator-client.js +114 -0
  40. package/src/shell/styles.js +62 -0
  41. package/src/spellcheck.js +166 -0
  42. package/src/tables/README.md +97 -0
  43. package/src/tables/commands/insert-linked-table.js +122 -0
  44. package/src/tables/commands/open-table-workspace.js +43 -0
  45. package/src/tables/index.js +24 -0
  46. package/src/tables/jobs/client.js +158 -0
  47. package/src/tables/parsing/anchors.js +82 -0
  48. package/src/tables/parsing/linked-table-blocks.js +61 -0
  49. package/src/tables/state/linked-table-state.js +68 -0
  50. package/src/tables/widgets/linked-table-source-banner.js +77 -0
  51. package/src/tables/widgets/linked-table-widget.js +256 -0
  52. package/src/tables/workspace/controller.js +616 -0
  53. package/src/term-pty-client.js +111 -7
  54. package/src/term-widget.js +43 -3
  55. package/src/widgets/theme-utils.js +24 -16
  56. package/src/widgets/theme.js +1535 -1
  57. package/src/runtime-codelens/detector.js +0 -279
  58. package/src/runtime-codelens/index.js +0 -76
  59. package/src/runtime-codelens/plugin.js +0 -142
  60. package/src/runtime-codelens/styles.js +0 -184
  61. package/src/runtime-codelens/widgets.js +0 -216
package/src/grammar.js ADDED
@@ -0,0 +1,758 @@
1
+ /**
2
+ * @fileoverview LanguageTool-style prose diagnostics for CodeMirror 6
3
+ *
4
+ * This module provides a reusable CM6 linter extension that:
5
+ * - extracts visible prose fragments from markdown
6
+ * - suppresses code/URLs/path-like content inside those fragments
7
+ * - calls a host-provided async grammar checker
8
+ * - maps results back to document positions as diagnostics
9
+ *
10
+ * The editor package stays host-agnostic: Electron/server/browser shells provide
11
+ * the actual `check()` implementation.
12
+ */
13
+
14
+ import { Annotation, StateEffect, StateField } from '@codemirror/state';
15
+ import { EditorView, hoverTooltip, showTooltip, ViewPlugin, closeHoverTooltips } from '@codemirror/view';
16
+ import { syntaxTree } from '@codemirror/language';
17
+ import { linter, forEachDiagnostic } from '@codemirror/lint';
18
+
19
+ /**
20
+ * Annotation used to force a fresh grammar pass even when the document didn't
21
+ * change (for example after changing document grammar settings).
22
+ */
23
+ export const forceLanguageToolRefresh = Annotation.define();
24
+
25
+ const PROSE_BLOCK_NAMES = new Set([
26
+ 'Paragraph',
27
+ 'ATXHeading1',
28
+ 'ATXHeading2',
29
+ 'ATXHeading3',
30
+ 'ATXHeading4',
31
+ 'ATXHeading5',
32
+ 'ATXHeading6',
33
+ 'SetextHeading1',
34
+ 'SetextHeading2',
35
+ ]);
36
+
37
+ const SUPPRESSED_INLINE_NAMES = new Set([
38
+ 'InlineCode',
39
+ 'URL',
40
+ ]);
41
+
42
+ const NON_PROSE_PATTERNS = [
43
+ /'[^'\n]+'/g,
44
+ /"[^"\n]+"/g,
45
+ /(?:\.{1,2}\/|~\/|\/)[^\s'"`<>]+/g,
46
+ /(?:[A-Za-z0-9._-]+\/){1,}[A-Za-z0-9._-]+\/?/g,
47
+ ];
48
+
49
+ function normalizeStringList(value) {
50
+ const list = Array.isArray(value) ? value : [value];
51
+ const out = [];
52
+ const seen = new Set();
53
+ for (const item of list) {
54
+ const normalized = String(item || '').trim();
55
+ if (!normalized) continue;
56
+ const key = normalized.toLowerCase();
57
+ if (seen.has(key)) continue;
58
+ seen.add(key);
59
+ out.push(normalized);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ function addRegexSuppressions(text, baseFrom, ranges) {
65
+ for (const pattern of NON_PROSE_PATTERNS) {
66
+ pattern.lastIndex = 0;
67
+ let match;
68
+ while ((match = pattern.exec(text))) {
69
+ const from = baseFrom + match.index;
70
+ const to = from + match[0].length;
71
+ if (to > from) ranges.push({ from, to });
72
+ if (match[0].length === 0) pattern.lastIndex += 1;
73
+ }
74
+ }
75
+ }
76
+
77
+ function mergeRanges(ranges) {
78
+ if (!ranges || ranges.length <= 1) return ranges || [];
79
+
80
+ const sorted = ranges
81
+ .filter((r) => r && r.to > r.from)
82
+ .sort((a, b) => (a.from - b.from) || (a.to - b.to));
83
+
84
+ if (sorted.length === 0) return [];
85
+
86
+ const merged = [{ ...sorted[0] }];
87
+ for (let i = 1; i < sorted.length; i += 1) {
88
+ const current = sorted[i];
89
+ const last = merged[merged.length - 1];
90
+ if (current.from <= last.to) {
91
+ last.to = Math.max(last.to, current.to);
92
+ } else {
93
+ merged.push({ ...current });
94
+ }
95
+ }
96
+
97
+ return merged;
98
+ }
99
+
100
+ function replaceRangesWithSpaces(text, baseFrom, ranges) {
101
+ if (!ranges || ranges.length === 0) return text;
102
+ const chars = text.split('');
103
+ for (const range of ranges) {
104
+ const start = Math.max(0, range.from - baseFrom);
105
+ const end = Math.min(chars.length, range.to - baseFrom);
106
+ for (let i = start; i < end; i += 1) {
107
+ if (chars[i] !== '\n') chars[i] = ' ';
108
+ }
109
+ }
110
+ return chars.join('');
111
+ }
112
+
113
+ function hasNaturalLanguage(text) {
114
+ return /\p{L}{2,}/u.test(String(text || ''));
115
+ }
116
+
117
+ function intersectsVisibleRange(from, to, visibleRanges) {
118
+ return visibleRanges.some((r) => from < r.to && to > r.from);
119
+ }
120
+
121
+ /**
122
+ * Collect visible prose fragments from the markdown syntax tree.
123
+ *
124
+ * Each returned fragment preserves original offsets by replacing suppressed
125
+ * inline/code/path spans with spaces rather than removing them.
126
+ */
127
+ export function collectVisibleProseFragments(view, options = {}) {
128
+ const {
129
+ maxFragments = 12,
130
+ maxFragmentLength = 4000,
131
+ } = options;
132
+
133
+ const tree = syntaxTree(view.state);
134
+ const visibleRanges = view.visibleRanges || [{ from: 0, to: view.state.doc.length }];
135
+ const fragments = [];
136
+ const seen = new Set();
137
+
138
+ tree.iterate({
139
+ enter(node) {
140
+ if (!PROSE_BLOCK_NAMES.has(node.name)) return;
141
+ if (!intersectsVisibleRange(node.from, node.to, visibleRanges)) return;
142
+
143
+ const key = `${node.from}:${node.to}`;
144
+ if (seen.has(key)) return;
145
+ seen.add(key);
146
+
147
+ const rawText = view.state.doc.sliceString(node.from, node.to);
148
+ if (!rawText || rawText.length > maxFragmentLength) return;
149
+
150
+ const suppressions = [];
151
+ tree.iterate({
152
+ from: node.from,
153
+ to: node.to,
154
+ enter(inner) {
155
+ if (SUPPRESSED_INLINE_NAMES.has(inner.name) && inner.from < inner.to) {
156
+ suppressions.push({ from: inner.from, to: inner.to });
157
+ }
158
+ },
159
+ });
160
+ addRegexSuppressions(rawText, node.from, suppressions);
161
+ const mergedSuppressions = mergeRanges(suppressions);
162
+ const text = replaceRangesWithSpaces(rawText, node.from, mergedSuppressions);
163
+
164
+ if (!hasNaturalLanguage(text)) return;
165
+
166
+ fragments.push({
167
+ from: node.from,
168
+ to: node.to,
169
+ text,
170
+ });
171
+ },
172
+ });
173
+
174
+ fragments.sort((a, b) => a.from - b.from);
175
+ return fragments.slice(0, maxFragments);
176
+ }
177
+
178
+ function buildPayload(fragment, prefs = {}) {
179
+ const preferredVariants = normalizeStringList(prefs.preferredVariants || []);
180
+ const enabledRules = normalizeStringList(prefs.enabledRules || []);
181
+ const disabledRules = normalizeStringList(prefs.disabledRules || []);
182
+ const enabledCategories = normalizeStringList(prefs.enabledCategories || []);
183
+ const disabledCategories = normalizeStringList(prefs.disabledCategories || []);
184
+ const mode = String(prefs.mode || 'default').toLowerCase();
185
+
186
+ return {
187
+ text: fragment.text,
188
+ language: prefs.language || undefined,
189
+ motherTongue: prefs.motherTongue || undefined,
190
+ preferredVariants: preferredVariants.length > 0 ? preferredVariants.join(',') : undefined,
191
+ enabledRules,
192
+ disabledRules,
193
+ enabledCategories,
194
+ disabledCategories,
195
+ level: mode === 'picky' ? 'picky' : undefined,
196
+ };
197
+ }
198
+
199
+ function shouldIgnoreMatch(fragment, match, dictionaryWordsLower) {
200
+ const offset = Number(match?.offset || 0);
201
+ const length = Number(match?.length || 0);
202
+ if (length <= 0) return true;
203
+
204
+ const text = fragment.text.slice(offset, offset + length).trim().toLowerCase();
205
+ if (!text) return true;
206
+ if (dictionaryWordsLower.has(text)) return true;
207
+
208
+ const ruleId = String(match?.rule?.id || '').toUpperCase();
209
+ if (ruleId === 'WHITESPACE_RULE') return true;
210
+
211
+ return false;
212
+ }
213
+
214
+ const languageToolTheme = EditorView.baseTheme({
215
+ // Underline styles for grammar ranges
216
+ '.cm-lintRange-warning': {
217
+ backgroundImage: 'linear-gradient(to right, color-mix(in srgb, var(--widget-warning, #f59e0b) 88%, transparent) 45%, transparent 0%)',
218
+ backgroundPosition: 'left bottom',
219
+ backgroundSize: '6px 2px',
220
+ backgroundRepeat: 'repeat-x',
221
+ },
222
+ '.cm-lintRange-error': {
223
+ backgroundImage: 'linear-gradient(to right, color-mix(in srgb, var(--widget-danger, #ef4444) 88%, transparent) 45%, transparent 0%)',
224
+ backgroundPosition: 'left bottom',
225
+ backgroundSize: '6px 2px',
226
+ backgroundRepeat: 'repeat-x',
227
+ },
228
+ // Custom grammar hover tooltip (matches runtime hover popover style)
229
+ '.mrmd-grammar-hover': {
230
+ background: 'var(--widget-surface-elevated, var(--editor-background, #1e1e1e))',
231
+ border: '1px solid var(--widget-border, rgba(255, 255, 255, 0.12))',
232
+ borderRadius: 'var(--widget-border-radius, 6px)',
233
+ padding: '8px 12px',
234
+ maxWidth: '460px',
235
+ maxHeight: 'min(52vh, 440px)',
236
+ overflow: 'auto',
237
+ fontSize: '13px',
238
+ lineHeight: '1.45',
239
+ color: 'var(--widget-text, var(--editor-foreground, #e1e1e1))',
240
+ boxShadow: 'var(--mrmd-shadow-md, 0 6px 18px rgba(0, 0, 0, 0.3))',
241
+ userSelect: 'text',
242
+ pointerEvents: 'auto',
243
+ },
244
+ '.mrmd-grammar-hover-sticky': {
245
+ borderColor: 'var(--widget-border-focus, var(--mrmd-accent, #58a6ff))',
246
+ },
247
+ '.mrmd-grammar-hover-content': {
248
+ display: 'flex',
249
+ flexDirection: 'column',
250
+ gap: '6px',
251
+ },
252
+ '.mrmd-grammar-hover-header': {
253
+ display: 'flex',
254
+ alignItems: 'center',
255
+ justifyContent: 'space-between',
256
+ gap: '10px',
257
+ },
258
+ '.mrmd-grammar-hover-source': {
259
+ fontWeight: '600',
260
+ fontSize: '11px',
261
+ color: 'var(--widget-text-muted, #9ca3af)',
262
+ textTransform: 'uppercase',
263
+ letterSpacing: '0.05em',
264
+ },
265
+ '.mrmd-grammar-hover-rule': {
266
+ fontSize: '10px',
267
+ color: 'var(--widget-text-muted, #64748b)',
268
+ fontFamily: 'var(--widget-font-mono, monospace)',
269
+ },
270
+ '.mrmd-grammar-hover-message': {
271
+ color: 'var(--widget-text, var(--editor-foreground, #e1e1e1))',
272
+ whiteSpace: 'pre-wrap',
273
+ },
274
+ '.mrmd-grammar-hover-matched': {
275
+ display: 'inline-block',
276
+ background: 'rgba(245, 158, 11, 0.15)',
277
+ color: 'var(--widget-warning, #f59e0b)',
278
+ borderRadius: '3px',
279
+ padding: '1px 5px',
280
+ fontFamily: 'var(--widget-font-mono, monospace)',
281
+ fontSize: '12px',
282
+ },
283
+ '.mrmd-grammar-hover-suggestions': {
284
+ display: 'flex',
285
+ flexWrap: 'wrap',
286
+ gap: '5px',
287
+ paddingTop: '4px',
288
+ },
289
+ '.mrmd-grammar-hover-suggestion-btn': {
290
+ appearance: 'none',
291
+ border: '1px solid var(--widget-border, rgba(255,255,255,0.12))',
292
+ background: 'var(--widget-surface, rgba(255,255,255,0.04))',
293
+ color: 'var(--widget-text, var(--editor-foreground, #e5e7eb))',
294
+ borderRadius: '6px',
295
+ padding: '4px 10px',
296
+ cursor: 'pointer',
297
+ fontSize: '12px',
298
+ lineHeight: '1.2',
299
+ fontFamily: 'inherit',
300
+ transition: 'background 0.1s, border-color 0.1s',
301
+ },
302
+ '.mrmd-grammar-hover-suggestion-btn:hover': {
303
+ background: 'var(--widget-surface-hover, rgba(255,255,255,0.08))',
304
+ borderColor: 'var(--widget-border-focus, var(--mrmd-accent, #58a6ff))',
305
+ },
306
+ '.mrmd-grammar-hover-suggestion-btn:active': {
307
+ transform: 'translateY(1px)',
308
+ },
309
+ '.mrmd-grammar-hover-actions': {
310
+ display: 'flex',
311
+ gap: '8px',
312
+ borderTop: '1px solid var(--widget-border, rgba(255,255,255,0.08))',
313
+ paddingTop: '6px',
314
+ marginTop: '2px',
315
+ },
316
+ '.mrmd-grammar-hover-action-btn': {
317
+ appearance: 'none',
318
+ border: 'none',
319
+ background: 'transparent',
320
+ color: 'var(--widget-text-muted, #9ca3af)',
321
+ cursor: 'pointer',
322
+ fontSize: '11px',
323
+ padding: '2px 0',
324
+ fontFamily: 'inherit',
325
+ transition: 'color 0.1s',
326
+ },
327
+ '.mrmd-grammar-hover-action-btn:hover': {
328
+ color: 'var(--widget-text, #e5e7eb)',
329
+ },
330
+ });
331
+
332
+ function matchToDiagnostic(fragment, match) {
333
+ const offset = Number(match?.offset || 0);
334
+ const length = Number(match?.length || 0);
335
+ const from = fragment.from + offset;
336
+ const to = from + Math.max(length, 1);
337
+ const replacements = Array.isArray(match?.replacements) ? match.replacements : [];
338
+ const actions = replacements.slice(0, 5).map((replacement) => ({
339
+ name: replacement.value,
340
+ apply(view, actionFrom, actionTo) {
341
+ view.dispatch({
342
+ changes: { from: actionFrom, to: actionTo, insert: replacement.value },
343
+ });
344
+ },
345
+ }));
346
+
347
+ const ruleId = String(match?.rule?.id || '');
348
+ const ruleIdLabel = ruleId ? ` [${ruleId}]` : '';
349
+ const message = `${match.message || 'Grammar suggestion'}${ruleIdLabel}`;
350
+ const matchedText = fragment.text.slice(offset, offset + length);
351
+
352
+ return {
353
+ from,
354
+ to,
355
+ severity: 'warning',
356
+ source: 'languagetool',
357
+ message,
358
+ actions,
359
+ // Custom fields for the grammar hover / context menu
360
+ ruleId,
361
+ matchedText,
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Collect LanguageTool diagnostics near a document position, sorted by
367
+ * proximity (intersects position > same line > visible range > document order).
368
+ *
369
+ * @param {import('@codemirror/view').EditorView} view
370
+ * @param {number} pos - document offset
371
+ * @returns {Array<{diagnostic: Object, from: number, to: number, intersectsSelection: boolean, onCurrentLine: boolean, inVisibleRange: boolean}>}
372
+ */
373
+ function collectLanguageToolCandidates(view, pos) {
374
+ if (!view?.state || pos == null) return [];
375
+
376
+ const cursorLine = view.state.doc.lineAt(pos);
377
+ const candidates = [];
378
+ forEachDiagnostic(view.state, (diagnostic, from, to) => {
379
+ if (diagnostic?.source !== 'languagetool') return;
380
+ if (!Array.isArray(diagnostic.actions) || diagnostic.actions.length === 0) return;
381
+ const intersectsSelection = from <= pos && to >= pos;
382
+ const onCurrentLine = from < cursorLine.to && to > cursorLine.from;
383
+ const inVisibleRange = view.visibleRanges?.some?.((range) => from < range.to && to > range.from) ?? true;
384
+ candidates.push({ diagnostic, from, to, intersectsSelection, onCurrentLine, inVisibleRange });
385
+ });
386
+
387
+ candidates.sort((a, b) => {
388
+ if (a.intersectsSelection !== b.intersectsSelection) return a.intersectsSelection ? -1 : 1;
389
+ if (a.onCurrentLine !== b.onCurrentLine) return a.onCurrentLine ? -1 : 1;
390
+ if (a.inVisibleRange !== b.inVisibleRange) return a.inVisibleRange ? -1 : 1;
391
+ return a.from - b.from;
392
+ });
393
+
394
+ return candidates;
395
+ }
396
+
397
+ /**
398
+ * Find all LanguageTool diagnostics that overlap a document position.
399
+ * @param {import('@codemirror/view').EditorView} view
400
+ * @param {number} pos
401
+ * @returns {Array<{diagnostic: Object, from: number, to: number}>}
402
+ */
403
+ function findLanguageToolDiagnosticsAt(view, pos) {
404
+ const results = [];
405
+ forEachDiagnostic(view.state, (diagnostic, from, to) => {
406
+ if (diagnostic?.source !== 'languagetool') return;
407
+ if (pos >= from && pos <= to) {
408
+ results.push({ diagnostic, from, to });
409
+ }
410
+ });
411
+ return results;
412
+ }
413
+
414
+ /**
415
+ * Build the branded grammar hover tooltip DOM.
416
+ * Matches the runtime hover popover style with grammar-specific content.
417
+ *
418
+ * @param {import('@codemirror/view').EditorView} view
419
+ * @param {Array<{diagnostic: Object, from: number, to: number}>} hits
420
+ * @param {Object} callbacks - { onIgnoreRule, onAddToDictionary }
421
+ * @param {Object} [opts] - { sticky }
422
+ * @returns {HTMLElement}
423
+ */
424
+ function buildGrammarHoverDOM(view, hits, callbacks, opts = {}) {
425
+ const { sticky = false } = opts;
426
+
427
+ const dom = document.createElement('div');
428
+ dom.className = `mrmd-grammar-hover${sticky ? ' mrmd-grammar-hover-sticky' : ''}`;
429
+
430
+ for (const hit of hits) {
431
+ const d = hit.diagnostic;
432
+ const section = document.createElement('div');
433
+ section.className = 'mrmd-grammar-hover-content';
434
+
435
+ // Header: source + rule ID
436
+ const header = document.createElement('div');
437
+ header.className = 'mrmd-grammar-hover-header';
438
+ const sourceEl = document.createElement('span');
439
+ sourceEl.className = 'mrmd-grammar-hover-source';
440
+ sourceEl.textContent = 'Grammar';
441
+ header.appendChild(sourceEl);
442
+ if (d.ruleId) {
443
+ const ruleEl = document.createElement('span');
444
+ ruleEl.className = 'mrmd-grammar-hover-rule';
445
+ ruleEl.textContent = d.ruleId;
446
+ header.appendChild(ruleEl);
447
+ }
448
+ section.appendChild(header);
449
+
450
+ // Message
451
+ const msgEl = document.createElement('div');
452
+ msgEl.className = 'mrmd-grammar-hover-message';
453
+ // Strip the [RULE_ID] suffix from the displayed message (it's in the header)
454
+ const cleanMsg = d.ruleId
455
+ ? d.message.replace(` [${d.ruleId}]`, '')
456
+ : d.message;
457
+ msgEl.textContent = cleanMsg;
458
+ section.appendChild(msgEl);
459
+
460
+ // Matched text
461
+ if (d.matchedText) {
462
+ const matchEl = document.createElement('span');
463
+ matchEl.className = 'mrmd-grammar-hover-matched';
464
+ matchEl.textContent = d.matchedText;
465
+ section.appendChild(matchEl);
466
+ }
467
+
468
+ // Suggestion buttons
469
+ if (Array.isArray(d.actions) && d.actions.length > 0) {
470
+ const suggestionsEl = document.createElement('div');
471
+ suggestionsEl.className = 'mrmd-grammar-hover-suggestions';
472
+ for (const action of d.actions) {
473
+ const btn = document.createElement('button');
474
+ btn.type = 'button';
475
+ btn.className = 'mrmd-grammar-hover-suggestion-btn';
476
+ btn.textContent = action.name;
477
+ btn.addEventListener('mousedown', (e) => e.stopPropagation());
478
+ btn.addEventListener('click', (e) => {
479
+ e.preventDefault();
480
+ e.stopPropagation();
481
+ action.apply(view, hit.from, hit.to);
482
+ });
483
+ suggestionsEl.appendChild(btn);
484
+ }
485
+ section.appendChild(suggestionsEl);
486
+ }
487
+
488
+ // Action row: Ignore rule | Add to dictionary
489
+ const hasIgnore = d.ruleId && typeof callbacks.onIgnoreRule === 'function';
490
+ const hasDict = d.matchedText && typeof callbacks.onAddToDictionary === 'function';
491
+ if (hasIgnore || hasDict) {
492
+ const actionsEl = document.createElement('div');
493
+ actionsEl.className = 'mrmd-grammar-hover-actions';
494
+
495
+ if (hasIgnore) {
496
+ const ignoreBtn = document.createElement('button');
497
+ ignoreBtn.type = 'button';
498
+ ignoreBtn.className = 'mrmd-grammar-hover-action-btn';
499
+ ignoreBtn.textContent = 'Ignore rule';
500
+ ignoreBtn.title = `Disable rule ${d.ruleId}`;
501
+ ignoreBtn.addEventListener('mousedown', (e) => e.stopPropagation());
502
+ ignoreBtn.addEventListener('click', (e) => {
503
+ e.preventDefault();
504
+ e.stopPropagation();
505
+ callbacks.onIgnoreRule(d.ruleId, view);
506
+ });
507
+ actionsEl.appendChild(ignoreBtn);
508
+ }
509
+
510
+ if (hasDict) {
511
+ const dictBtn = document.createElement('button');
512
+ dictBtn.type = 'button';
513
+ dictBtn.className = 'mrmd-grammar-hover-action-btn';
514
+ dictBtn.textContent = `Add "${d.matchedText}" to dictionary`;
515
+ dictBtn.addEventListener('mousedown', (e) => e.stopPropagation());
516
+ dictBtn.addEventListener('click', (e) => {
517
+ e.preventDefault();
518
+ e.stopPropagation();
519
+ callbacks.onAddToDictionary(d.matchedText, view);
520
+ });
521
+ actionsEl.appendChild(dictBtn);
522
+ }
523
+
524
+ section.appendChild(actionsEl);
525
+ }
526
+
527
+ dom.appendChild(section);
528
+ }
529
+
530
+ return dom;
531
+ }
532
+
533
+ /**
534
+ * Create a reusable LanguageTool-backed CM6 diagnostics extension.
535
+ *
536
+ * Returns an array of extensions: themed underlines, a custom branded hover
537
+ * tooltip (with click-to-pin, suggestions, ignore-rule, add-to-dictionary),
538
+ * and the CM6 linter that produces diagnostics.
539
+ *
540
+ * @param {Object} options
541
+ * @param {(payload: Object) => Promise<Object>} options.check - async LT check function
542
+ * @param {() => Object | Promise<Object>} [options.getPreferences] - returns effective prefs
543
+ * @param {() => string[] | Promise<string[]>} [options.getDictionary] - custom dictionary words
544
+ * @param {number} [options.debounceMs=700] - lint debounce
545
+ * @param {number} [options.maxDiagnostics=50] - cap rendered diagnostics
546
+ * @param {number} [options.maxFragments=12] - cap visible prose fragments checked
547
+ * @param {number} [options.maxFragmentLength=4000] - skip giant fragments
548
+ * @param {(ruleId: string, view: EditorView) => void} [options.onIgnoreRule] - callback when user ignores a rule
549
+ * @param {(word: string, view: EditorView) => void} [options.onAddToDictionary] - callback when user adds a word
550
+ * @returns {import('@codemirror/state').Extension}
551
+ */
552
+ export function createLanguageToolDiagnosticsExtension(options = {}) {
553
+ const {
554
+ check,
555
+ getPreferences = () => ({}),
556
+ getDictionary = () => [],
557
+ debounceMs = 700,
558
+ maxDiagnostics = 50,
559
+ maxFragments = 12,
560
+ maxFragmentLength = 4000,
561
+ onIgnoreRule,
562
+ onAddToDictionary,
563
+ } = options;
564
+
565
+ if (typeof check !== 'function') {
566
+ throw new Error('createLanguageToolDiagnosticsExtension requires a check(payload) function');
567
+ }
568
+
569
+ const callbacks = { onIgnoreRule, onAddToDictionary };
570
+
571
+ // -- Pinned (sticky) grammar tooltip state --
572
+ const setPinnedTooltip = StateEffect.define();
573
+ const clearPinnedTooltip = StateEffect.define();
574
+
575
+ const pinnedTooltipField = StateField.define({
576
+ create() { return null; },
577
+ update(value, tr) {
578
+ if (tr.docChanged) return null;
579
+ for (const effect of tr.effects) {
580
+ if (effect.is(setPinnedTooltip)) return effect.value;
581
+ if (effect.is(clearPinnedTooltip)) return null;
582
+ }
583
+ return value;
584
+ },
585
+ provide: (f) => showTooltip.from(f),
586
+ });
587
+
588
+ // Close pinned tooltip on click outside
589
+ const pinnedClosePlugin = ViewPlugin.fromClass(
590
+ class {
591
+ constructor(view) {
592
+ this.view = view;
593
+ this.onMouseDownCapture = (event) => {
594
+ const pinned = view.state.field(pinnedTooltipField, false);
595
+ if (!pinned) return;
596
+ if (event.target instanceof Element && event.target.closest('.mrmd-grammar-hover')) return;
597
+ view.dispatch({ effects: clearPinnedTooltip.of(null) });
598
+ };
599
+ view.dom.ownerDocument.addEventListener('mousedown', this.onMouseDownCapture, true);
600
+ }
601
+ destroy() {
602
+ this.view.dom.ownerDocument.removeEventListener('mousedown', this.onMouseDownCapture, true);
603
+ }
604
+ },
605
+ );
606
+
607
+ // -- Custom grammar hover tooltip --
608
+ function createTooltipDescriptor(view, hits, pos, end, sticky = false) {
609
+ return {
610
+ pos,
611
+ end,
612
+ above: false,
613
+ arrow: true,
614
+ create() {
615
+ const dom = buildGrammarHoverDOM(view, hits, callbacks, { sticky });
616
+
617
+ if (!sticky) {
618
+ // Click to pin
619
+ dom.addEventListener('mousedown', (event) => {
620
+ if (event.button !== 0) return;
621
+ if (event.target instanceof Element &&
622
+ (event.target.closest('.mrmd-grammar-hover-suggestion-btn') ||
623
+ event.target.closest('.mrmd-grammar-hover-action-btn'))) return;
624
+
625
+ const stickyTooltip = createTooltipDescriptor(view, hits, pos, end, true);
626
+ view.dispatch({
627
+ effects: [
628
+ setPinnedTooltip.of(stickyTooltip),
629
+ closeHoverTooltips,
630
+ ],
631
+ });
632
+ });
633
+ }
634
+
635
+ return {
636
+ dom,
637
+ offset: { x: 0, y: -8 },
638
+ overlap: true,
639
+ };
640
+ },
641
+ };
642
+ }
643
+
644
+ const grammarHover = hoverTooltip((view, pos, side) => {
645
+ const hits = findLanguageToolDiagnosticsAt(view, pos);
646
+ if (hits.length === 0) return null;
647
+
648
+ const minFrom = Math.min(...hits.map(h => h.from));
649
+ const maxTo = Math.max(...hits.map(h => h.to));
650
+ return createTooltipDescriptor(view, hits, minFrom, maxTo, false);
651
+ }, {
652
+ hoverTime: 350,
653
+ });
654
+
655
+ // -- Linter (produces diagnostics / underlines) --
656
+ const grammarLinter = linter(async (view) => {
657
+ const prefs = await Promise.resolve(getPreferences(view));
658
+ if (prefs?.enabled === false) return [];
659
+
660
+ const fragments = collectVisibleProseFragments(view, {
661
+ maxFragments,
662
+ maxFragmentLength,
663
+ });
664
+ if (fragments.length === 0) return [];
665
+
666
+ const dictionary = normalizeStringList(await Promise.resolve(getDictionary(view)));
667
+ const dictionaryWordsLower = new Set(dictionary.map((word) => word.toLowerCase()));
668
+
669
+ try {
670
+ const results = await Promise.all(
671
+ fragments.map(async (fragment) => {
672
+ const payload = buildPayload(fragment, prefs || {});
673
+ const response = await check(payload);
674
+ const matches = Array.isArray(response?.matches) ? response.matches : [];
675
+ return matches
676
+ .filter((match) => !shouldIgnoreMatch(fragment, match, dictionaryWordsLower))
677
+ .map((match) => matchToDiagnostic(fragment, match));
678
+ })
679
+ );
680
+
681
+ return results.flat().slice(0, maxDiagnostics);
682
+ } catch (error) {
683
+ console.warn('[grammar] LanguageTool check failed:', error?.message || error);
684
+ return [];
685
+ }
686
+ }, {
687
+ delay: debounceMs,
688
+ // Suppress built-in lint tooltip for LanguageTool diagnostics (we use our own)
689
+ tooltipFilter: (diagnostics) => diagnostics.filter((d) => d.source !== 'languagetool'),
690
+ needsRefresh(update) {
691
+ return update.docChanged
692
+ || update.viewportChanged
693
+ || update.transactions.some((tr) => tr.annotation(forceLanguageToolRefresh));
694
+ },
695
+ });
696
+
697
+ return [
698
+ languageToolTheme,
699
+ pinnedTooltipField,
700
+ pinnedClosePlugin,
701
+ grammarHover,
702
+ grammarLinter,
703
+ ];
704
+ }
705
+
706
+ /**
707
+ * Force a grammar re-check for extensions created by this module.
708
+ * Useful after changing document grammar settings from host UI.
709
+ */
710
+ export function refreshLanguageToolDiagnostics(view) {
711
+ if (!view) return;
712
+ view.dispatch({
713
+ annotations: forceLanguageToolRefresh.of(true),
714
+ });
715
+ }
716
+
717
+ /**
718
+ * Get the best LanguageTool diagnostic near the given position and return
719
+ * a serialisable menu descriptor with the diagnostic message and suggested
720
+ * replacements. Used by the Electron context-menu handler.
721
+ *
722
+ * @param {import('@codemirror/view').EditorView} view
723
+ * @param {number} pos - document offset (e.g. from posAtCoords)
724
+ * @returns {{ from: number, to: number, message: string, source: string, suggestions: Array<{index: number, label: string}> } | null}
725
+ */
726
+ export function getLanguageToolSuggestionMenu(view, pos) {
727
+ const candidates = collectLanguageToolCandidates(view, pos);
728
+ if (candidates.length === 0) return null;
729
+
730
+ const best = candidates[0];
731
+ return {
732
+ from: best.from,
733
+ to: best.to,
734
+ message: best.diagnostic.message,
735
+ source: best.diagnostic.source || 'languagetool',
736
+ ruleId: best.diagnostic.ruleId || '',
737
+ matchedText: best.diagnostic.matchedText || '',
738
+ suggestions: best.diagnostic.actions.map((action, index) => ({
739
+ index,
740
+ label: action.name,
741
+ })),
742
+ };
743
+ }
744
+
745
+ export function applyLanguageToolSuggestionAt(view, pos, actionIndex = 0) {
746
+ const candidates = collectLanguageToolCandidates(view, pos);
747
+ if (candidates.length === 0) return false;
748
+ const best = candidates[0];
749
+ const action = best.diagnostic.actions[actionIndex] || best.diagnostic.actions[0];
750
+ if (!action?.apply) return false;
751
+ action.apply(view, best.from, best.to);
752
+ return true;
753
+ }
754
+
755
+ export function applyFirstLanguageToolSuggestion(view) {
756
+ if (!view?.state) return false;
757
+ return applyLanguageToolSuggestionAt(view, view.state.selection.main.head, 0);
758
+ }