|
|
|
|
@ -1,71 +1,111 @@
@@ -1,71 +1,111 @@
|
|
|
|
|
import { linter, type Diagnostic } from '@codemirror/lint' |
|
|
|
|
import type { Extension } from '@codemirror/state' |
|
|
|
|
import { Annotation, EditorSelection, type Extension } from '@codemirror/state' |
|
|
|
|
import type { EditorView } from '@codemirror/view' |
|
|
|
|
import { languageToolCheck, type LanguageToolMatch } from '@/lib/languagetool-client' |
|
|
|
|
|
|
|
|
|
/** Local LanguageTool is slow on cold JVM; keep payloads bounded (LT has ~20–30k limits anyway). */ |
|
|
|
|
const MAX_CHECK_CHARS = 28_000 |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Attach to a transaction so {@link languageToolLintExtension}'s `needsRefresh` schedules a new |
|
|
|
|
* lint pass (e.g. grammar language changed). `forceLinting()` is a no-op once the last run finished. |
|
|
|
|
*/ |
|
|
|
|
export const advancedLabGrammarLangRerun = Annotation.define<boolean>() |
|
|
|
|
|
|
|
|
|
/** Request an immediate grammar re-check (after language change, etc.). */ |
|
|
|
|
export function requestAdvancedLabGrammarLint(view: EditorView): void { |
|
|
|
|
view.dispatch({ annotations: advancedLabGrammarLangRerun.of(true) }) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function matchToDiagnostic(docLen: number, m: LanguageToolMatch): Diagnostic | null { |
|
|
|
|
const from = Math.max(0, Math.min(m.offset, docLen)) |
|
|
|
|
const to = Math.max(from, Math.min(m.offset + m.length, docLen)) |
|
|
|
|
if (to <= from) return null |
|
|
|
|
const fix = m.replacements?.[0]?.value |
|
|
|
|
const message = m.message + (m.rule?.id ? ` (${m.rule.id})` : '') |
|
|
|
|
|
|
|
|
|
if (!fix) { |
|
|
|
|
return { from, to, severity: 'info', message } |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Do not use {@link Diagnostic.actions} for Apply: CodeMirror resolves actions via |
|
|
|
|
* `findDiagnostic(..., diagnostic)` by **object identity**. Async LT refreshes replace |
|
|
|
|
* diagnostics with new objects, so the tooltip's stale reference never matches and Apply |
|
|
|
|
* never runs. A custom control in `renderMessage` uses stable closure `from`/`to`/`fix`. |
|
|
|
|
*/ |
|
|
|
|
return { |
|
|
|
|
from, |
|
|
|
|
to, |
|
|
|
|
severity: 'info', |
|
|
|
|
message: m.message + (m.rule?.id ? ` (${m.rule.id})` : ''), |
|
|
|
|
actions: fix |
|
|
|
|
? [ |
|
|
|
|
{ |
|
|
|
|
name: 'Apply', |
|
|
|
|
apply(view) { |
|
|
|
|
view.dispatch({ changes: { from, to, insert: fix } }) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
] |
|
|
|
|
: undefined |
|
|
|
|
message, |
|
|
|
|
renderMessage(view: EditorView) { |
|
|
|
|
const wrap = document.createElement('span') |
|
|
|
|
wrap.style.display = 'inline-flex' |
|
|
|
|
wrap.style.alignItems = 'baseline' |
|
|
|
|
wrap.style.gap = '0.4em' |
|
|
|
|
|
|
|
|
|
const btn = document.createElement('button') |
|
|
|
|
btn.type = 'button' |
|
|
|
|
btn.className = 'cm-diagnosticAction' |
|
|
|
|
btn.textContent = 'Apply' |
|
|
|
|
btn.addEventListener('click', (e) => { |
|
|
|
|
e.preventDefault() |
|
|
|
|
e.stopPropagation() |
|
|
|
|
const len = view.state.doc.length |
|
|
|
|
const f = Math.max(0, Math.min(from, len)) |
|
|
|
|
const t = Math.max(f, Math.min(to, len)) |
|
|
|
|
view.dispatch({ |
|
|
|
|
changes: { from: f, to: t, insert: fix }, |
|
|
|
|
selection: EditorSelection.cursor(f + fix.length) |
|
|
|
|
}) |
|
|
|
|
view.focus() |
|
|
|
|
}) |
|
|
|
|
wrap.appendChild(btn) |
|
|
|
|
|
|
|
|
|
const text = document.createElement('span') |
|
|
|
|
text.textContent = message |
|
|
|
|
wrap.appendChild(text) |
|
|
|
|
|
|
|
|
|
return wrap |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Async grammar/style lint for CodeMirror using LanguageTool `/v2/check`. |
|
|
|
|
* Per-editor state (debounce / abort) lives in the closure so promises always settle and stale fetches are cancelled. |
|
|
|
|
* `getLanguage` is read on each lint pass so the UI can change language without remounting the editor. |
|
|
|
|
*/ |
|
|
|
|
export function languageToolLintExtension(language: string, debounceMs: number): Extension { |
|
|
|
|
export function languageToolLintExtension(getLanguage: () => string, debounceMs: number): Extension { |
|
|
|
|
let requestSeq = 0 |
|
|
|
|
let inFlight: AbortController | null = null |
|
|
|
|
|
|
|
|
|
return linter((view) => { |
|
|
|
|
return new Promise<Diagnostic[]>((resolve) => { |
|
|
|
|
let settled = false |
|
|
|
|
const finish = (diags: Diagnostic[]) => { |
|
|
|
|
if (settled) return |
|
|
|
|
settled = true |
|
|
|
|
resolve(diags) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const text = view.state.doc.toString() |
|
|
|
|
if (text.length < 3) { |
|
|
|
|
finish([]) |
|
|
|
|
return |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const seq = ++requestSeq |
|
|
|
|
inFlight?.abort() |
|
|
|
|
inFlight = null |
|
|
|
|
return linter( |
|
|
|
|
(view) => { |
|
|
|
|
return new Promise<Diagnostic[]>((resolve) => { |
|
|
|
|
let settled = false |
|
|
|
|
const finish = (diags: Diagnostic[]) => { |
|
|
|
|
if (settled) return |
|
|
|
|
settled = true |
|
|
|
|
resolve(diags) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
window.setTimeout(() => { |
|
|
|
|
if (seq !== requestSeq) { |
|
|
|
|
const text = view.state.doc.toString() |
|
|
|
|
if (text.length < 3) { |
|
|
|
|
finish([]) |
|
|
|
|
return |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const seq = ++requestSeq |
|
|
|
|
inFlight?.abort() |
|
|
|
|
inFlight = null |
|
|
|
|
|
|
|
|
|
const toSend = text.length > MAX_CHECK_CHARS ? text.slice(0, MAX_CHECK_CHARS) : text |
|
|
|
|
const ac = new AbortController() |
|
|
|
|
inFlight = ac |
|
|
|
|
|
|
|
|
|
void languageToolCheck(toSend, language, ac.signal) |
|
|
|
|
void languageToolCheck(toSend, getLanguage(), ac.signal) |
|
|
|
|
.then((res) => { |
|
|
|
|
if (seq !== requestSeq) { |
|
|
|
|
finish([]) |
|
|
|
|
@ -82,7 +122,12 @@ export function languageToolLintExtension(language: string, debounceMs: number):
@@ -82,7 +122,12 @@ export function languageToolLintExtension(language: string, debounceMs: number):
|
|
|
|
|
.catch(() => { |
|
|
|
|
finish([]) |
|
|
|
|
}) |
|
|
|
|
}, debounceMs) |
|
|
|
|
}) |
|
|
|
|
}) |
|
|
|
|
}) |
|
|
|
|
}, |
|
|
|
|
{ |
|
|
|
|
delay: debounceMs, |
|
|
|
|
needsRefresh: (update) => |
|
|
|
|
update.transactions.some((tr) => tr.annotation(advancedLabGrammarLangRerun) === true) |
|
|
|
|
} |
|
|
|
|
) |
|
|
|
|
} |
|
|
|
|
|