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.
- package/package.json +3 -1
- package/src/commands.js +112 -4
- package/src/comment-syntax.js +364 -39
- package/src/config/handlers.js +1 -2
- package/src/config/schema.js +46 -4
- package/src/document-template.js +2236 -0
- package/src/execution.js +69 -15
- package/src/frontmatter-updater.js +204 -74
- package/src/grammar.js +758 -0
- package/src/index.js +1120 -55
- package/src/keymap.js +11 -2
- package/src/markdown/block-decorations.js +108 -5
- package/src/markdown/facets.js +37 -0
- package/src/markdown/html-inline.js +9 -5
- package/src/markdown/index.js +13 -3
- package/src/markdown/inline-commands.js +256 -0
- package/src/markdown/inline-model.js +578 -0
- package/src/markdown/inline-state.js +103 -0
- package/src/markdown/renderer.js +219 -12
- package/src/markdown/styles.js +290 -3
- package/src/markdown/widgets/alert-title.js +10 -8
- package/src/markdown/widgets/frontmatter.js +0 -6
- package/src/markdown/widgets/index.js +1 -0
- package/src/markdown/widgets/list-marker.js +29 -0
- package/src/markdown/wysiwyg.js +1158 -0
- package/src/mrp-types.js +2 -0
- package/src/output-widget.js +532 -18
- package/src/page-view-pagination.js +127 -0
- package/src/runtime-lsp.js +1757 -150
- package/src/section-controls/commands.js +617 -0
- package/src/section-controls/index.js +63 -0
- package/src/section-controls/plugin.js +165 -0
- package/src/section-controls/widgets.js +936 -0
- package/src/shell/ai-menu.js +11 -0
- package/src/shell/components/context-panel.js +572 -0
- package/src/shell/components/status-bar.js +218 -8
- package/src/shell/dialogs/file-picker.js +211 -0
- package/src/shell/layouts/studio.js +229 -14
- package/src/shell/orchestrator-client.js +114 -0
- package/src/shell/styles.js +62 -0
- package/src/spellcheck.js +166 -0
- package/src/tables/README.md +97 -0
- package/src/tables/commands/insert-linked-table.js +122 -0
- package/src/tables/commands/open-table-workspace.js +43 -0
- package/src/tables/index.js +24 -0
- package/src/tables/jobs/client.js +158 -0
- package/src/tables/parsing/anchors.js +82 -0
- package/src/tables/parsing/linked-table-blocks.js +61 -0
- package/src/tables/state/linked-table-state.js +68 -0
- package/src/tables/widgets/linked-table-source-banner.js +77 -0
- package/src/tables/widgets/linked-table-widget.js +256 -0
- package/src/tables/workspace/controller.js +616 -0
- package/src/term-pty-client.js +111 -7
- package/src/term-widget.js +43 -3
- package/src/widgets/theme-utils.js +24 -16
- package/src/widgets/theme.js +1535 -1
- package/src/runtime-codelens/detector.js +0 -279
- package/src/runtime-codelens/index.js +0 -76
- package/src/runtime-codelens/plugin.js +0 -142
- package/src/runtime-codelens/styles.js +0 -184
- 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
|
+
}
|