|
@@ -1,12 +1,11 @@
|
|
|
import { addMisspelledWords, misspelledWordsField } from './misspelled-words'
|
|
import { addMisspelledWords, misspelledWordsField } from './misspelled-words'
|
|
|
import { ignoredWordsField, resetSpellChecker } from './ignored-words'
|
|
import { ignoredWordsField, resetSpellChecker } from './ignored-words'
|
|
|
-import { LineTracker } from './line-tracker'
|
|
|
|
|
import { cacheField, addWordToCache, WordCacheValue } from './cache'
|
|
import { cacheField, addWordToCache, WordCacheValue } from './cache'
|
|
|
import { WORD_REGEX } from './helpers'
|
|
import { WORD_REGEX } from './helpers'
|
|
|
import OError from '@overleaf/o-error'
|
|
import OError from '@overleaf/o-error'
|
|
|
import { spellCheckRequest } from './backend'
|
|
import { spellCheckRequest } from './backend'
|
|
|
import { EditorView, ViewUpdate } from '@codemirror/view'
|
|
import { EditorView, ViewUpdate } from '@codemirror/view'
|
|
|
-import { Line, Range, RangeValue } from '@codemirror/state'
|
|
|
|
|
|
|
+import { ChangeSet, Line, Range, RangeValue } from '@codemirror/state'
|
|
|
import { IgnoredWords } from '../../../dictionary/ignored-words'
|
|
import { IgnoredWords } from '../../../dictionary/ignored-words'
|
|
|
import {
|
|
import {
|
|
|
getNormalTextSpansFromLine,
|
|
getNormalTextSpansFromLine,
|
|
@@ -15,10 +14,6 @@ import {
|
|
|
import { waitForParser } from '../wait-for-parser'
|
|
import { waitForParser } from '../wait-for-parser'
|
|
|
import { debugConsole } from '@/utils/debugging'
|
|
import { debugConsole } from '@/utils/debugging'
|
|
|
|
|
|
|
|
-const _log = (...args: any) => {
|
|
|
|
|
- debugConsole.debug('[SpellChecker]: ', ...args)
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
/*
|
|
/*
|
|
|
* Spellchecker, handles updates, schedules spelling checks
|
|
* Spellchecker, handles updates, schedules spelling checks
|
|
|
*/
|
|
*/
|
|
@@ -26,43 +21,41 @@ export class SpellChecker {
|
|
|
private abortController?: AbortController | null = null
|
|
private abortController?: AbortController | null = null
|
|
|
private timeout: number | null = null
|
|
private timeout: number | null = null
|
|
|
private firstCheck = true
|
|
private firstCheck = true
|
|
|
- private lineTracker: LineTracker | null = null
|
|
|
|
|
private waitingForParser = false
|
|
private waitingForParser = false
|
|
|
private firstCheckPending = false
|
|
private firstCheckPending = false
|
|
|
|
|
+ private trackedChanges: ChangeSet
|
|
|
|
|
|
|
|
// eslint-disable-next-line no-useless-constructor
|
|
// eslint-disable-next-line no-useless-constructor
|
|
|
constructor(private readonly language: string) {
|
|
constructor(private readonly language: string) {
|
|
|
this.language = language
|
|
this.language = language
|
|
|
|
|
+ this.trackedChanges = ChangeSet.empty(0)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
destroy() {
|
|
destroy() {
|
|
|
- _log('destroy')
|
|
|
|
|
this._clearPendingSpellCheck()
|
|
this._clearPendingSpellCheck()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
_abortRequest() {
|
|
_abortRequest() {
|
|
|
if (this.abortController) {
|
|
if (this.abortController) {
|
|
|
- _log('abort request')
|
|
|
|
|
this.abortController.abort()
|
|
this.abortController.abort()
|
|
|
this.abortController = null
|
|
this.abortController = null
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
handleUpdate(update: ViewUpdate) {
|
|
handleUpdate(update: ViewUpdate) {
|
|
|
- if (!this.lineTracker) {
|
|
|
|
|
- this.lineTracker = new LineTracker(update.state.doc)
|
|
|
|
|
- }
|
|
|
|
|
if (update.docChanged) {
|
|
if (update.docChanged) {
|
|
|
- this.lineTracker.applyUpdate(update)
|
|
|
|
|
|
|
+ this.trackedChanges = this.trackedChanges.compose(update.changes)
|
|
|
this.scheduleSpellCheck(update.view)
|
|
this.scheduleSpellCheck(update.view)
|
|
|
} else if (update.viewportChanged) {
|
|
} else if (update.viewportChanged) {
|
|
|
|
|
+ this.trackedChanges = ChangeSet.empty(0)
|
|
|
this.scheduleSpellCheck(update.view)
|
|
this.scheduleSpellCheck(update.view)
|
|
|
} else if (
|
|
} else if (
|
|
|
update.transactions.some(tr => {
|
|
update.transactions.some(tr => {
|
|
|
return tr.effects.some(effect => effect.is(resetSpellChecker))
|
|
return tr.effects.some(effect => effect.is(resetSpellChecker))
|
|
|
})
|
|
})
|
|
|
) {
|
|
) {
|
|
|
- this.lineTracker.resetAllLines()
|
|
|
|
|
|
|
+ // for tests
|
|
|
|
|
+ this.trackedChanges = ChangeSet.empty(0)
|
|
|
this.spellCheckAsap(update.view)
|
|
this.spellCheckAsap(update.view)
|
|
|
}
|
|
}
|
|
|
// At the point that the spellchecker is initialized, the editor may not
|
|
// At the point that the spellchecker is initialized, the editor may not
|
|
@@ -78,21 +71,15 @@ export class SpellChecker {
|
|
|
update.state.facet(EditorView.editable)
|
|
update.state.facet(EditorView.editable)
|
|
|
) {
|
|
) {
|
|
|
this.firstCheckPending = true
|
|
this.firstCheckPending = true
|
|
|
- _log('Scheduling initial spellcheck')
|
|
|
|
|
this.spellCheckAsap(update.view)
|
|
this.spellCheckAsap(update.view)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
_performSpellCheck(view: EditorView) {
|
|
_performSpellCheck(view: EditorView) {
|
|
|
- _log('Begin ---------------->')
|
|
|
|
|
const wordsToCheck = this.getWordsToCheck(view)
|
|
const wordsToCheck = this.getWordsToCheck(view)
|
|
|
if (wordsToCheck.length === 0) {
|
|
if (wordsToCheck.length === 0) {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
- _log(
|
|
|
|
|
- '- words to check',
|
|
|
|
|
- wordsToCheck.map(w => w.text)
|
|
|
|
|
- )
|
|
|
|
|
const cache = view.state.field(cacheField)
|
|
const cache = view.state.field(cacheField)
|
|
|
const { knownMisspelledWords, unknownWords } = cache.checkWords(
|
|
const { knownMisspelledWords, unknownWords } = cache.checkWords(
|
|
|
this.language,
|
|
this.language,
|
|
@@ -101,7 +88,8 @@ export class SpellChecker {
|
|
|
const processResult = (
|
|
const processResult = (
|
|
|
misspellings: { index: number; suggestions: string[] }[]
|
|
misspellings: { index: number; suggestions: string[] }[]
|
|
|
) => {
|
|
) => {
|
|
|
- this.lineTracker?.clearChangedLinesForWords(wordsToCheck)
|
|
|
|
|
|
|
+ this.trackedChanges = ChangeSet.empty(0)
|
|
|
|
|
+
|
|
|
if (this.firstCheck) {
|
|
if (this.firstCheck) {
|
|
|
this.firstCheck = false
|
|
this.firstCheck = false
|
|
|
this.firstCheckPending = false
|
|
this.firstCheckPending = false
|
|
@@ -111,25 +99,13 @@ export class SpellChecker {
|
|
|
unknownWords,
|
|
unknownWords,
|
|
|
misspellings
|
|
misspellings
|
|
|
)
|
|
)
|
|
|
- _log('- result', result)
|
|
|
|
|
window.setTimeout(() => {
|
|
window.setTimeout(() => {
|
|
|
view.dispatch({
|
|
view.dispatch({
|
|
|
effects: compileEffects(result),
|
|
effects: compileEffects(result),
|
|
|
})
|
|
})
|
|
|
}, 0)
|
|
}, 0)
|
|
|
- _log('<---------------- End')
|
|
|
|
|
}
|
|
}
|
|
|
- _log('- before spellcheck request')
|
|
|
|
|
- _log(
|
|
|
|
|
- ' - unknownWords',
|
|
|
|
|
- unknownWords.map(w => w.text)
|
|
|
|
|
- )
|
|
|
|
|
- _log(
|
|
|
|
|
- ' - knownMisspelledWords',
|
|
|
|
|
- knownMisspelledWords.map(w => w.text)
|
|
|
|
|
- )
|
|
|
|
|
if (unknownWords.length === 0) {
|
|
if (unknownWords.length === 0) {
|
|
|
- _log('- skip request')
|
|
|
|
|
processResult([])
|
|
processResult([])
|
|
|
} else {
|
|
} else {
|
|
|
this._abortRequest()
|
|
this._abortRequest()
|
|
@@ -141,7 +117,7 @@ export class SpellChecker {
|
|
|
})
|
|
})
|
|
|
.catch(error => {
|
|
.catch(error => {
|
|
|
this.abortController = null
|
|
this.abortController = null
|
|
|
- _log('>> error in spellcheck request', error)
|
|
|
|
|
|
|
+ debugConsole.error(error)
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -152,10 +128,7 @@ export class SpellChecker {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
this.waitingForParser = true
|
|
this.waitingForParser = true
|
|
|
- waitForParser(
|
|
|
|
|
- view,
|
|
|
|
|
- view => viewportRangeToCheck(this.firstCheck, view).to
|
|
|
|
|
- ).then(() => {
|
|
|
|
|
|
|
+ waitForParser(view, view => view.viewport.to).then(() => {
|
|
|
this.waitingForParser = false
|
|
this.waitingForParser = false
|
|
|
this._performSpellCheck(view)
|
|
this._performSpellCheck(view)
|
|
|
})
|
|
})
|
|
@@ -187,22 +160,36 @@ export class SpellChecker {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
getWordsToCheck(view: EditorView) {
|
|
getWordsToCheck(view: EditorView) {
|
|
|
- const lang = this.language
|
|
|
|
|
- const ignoredWords = view.state.field(ignoredWordsField)
|
|
|
|
|
- _log('- ignored words', ignoredWords)
|
|
|
|
|
- if (!this.lineTracker) {
|
|
|
|
|
- this.lineTracker = new LineTracker(view.state.doc)
|
|
|
|
|
|
|
+ const wordsToCheck: Word[] = []
|
|
|
|
|
+
|
|
|
|
|
+ const { from, to } = view.viewport
|
|
|
|
|
+ const changedLineNumbers = new Set<number>()
|
|
|
|
|
+ if (this.trackedChanges.length > 0) {
|
|
|
|
|
+ this.trackedChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
|
|
|
|
|
+ if (fromB <= to && toB >= from) {
|
|
|
|
|
+ const fromLine = view.state.doc.lineAt(fromB).number
|
|
|
|
|
+ const toLine = view.state.doc.lineAt(toB).number
|
|
|
|
|
+ for (let i = fromLine; i <= toLine; i++) {
|
|
|
|
|
+ changedLineNumbers.add(i)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const fromLine = view.state.doc.lineAt(from).number
|
|
|
|
|
+ const toLine = view.state.doc.lineAt(to).number
|
|
|
|
|
+ for (let i = fromLine; i <= toLine; i++) {
|
|
|
|
|
+ changedLineNumbers.add(i)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- let wordsToCheck: Word[] = []
|
|
|
|
|
- for (const line of viewportLinesToCheck(
|
|
|
|
|
- this.lineTracker,
|
|
|
|
|
- this.firstCheck,
|
|
|
|
|
- view
|
|
|
|
|
- )) {
|
|
|
|
|
- wordsToCheck = wordsToCheck.concat(
|
|
|
|
|
- getWordsFromLine(view, line, ignoredWords, lang)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ const ignoredWords = view.state.field(ignoredWordsField)
|
|
|
|
|
+ for (const i of changedLineNumbers) {
|
|
|
|
|
+ const line = view.state.doc.line(i)
|
|
|
|
|
+ wordsToCheck.push(
|
|
|
|
|
+ ...getWordsFromLine(view, line, ignoredWords, this.language)
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
return wordsToCheck
|
|
return wordsToCheck
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -246,34 +233,29 @@ export const buildSpellCheckResult = (
|
|
|
misspellings: { index: number; suggestions: string[] }[]
|
|
misspellings: { index: number; suggestions: string[] }[]
|
|
|
) => {
|
|
) => {
|
|
|
const cacheAdditions: [Word, string[] | boolean][] = []
|
|
const cacheAdditions: [Word, string[] | boolean][] = []
|
|
|
|
|
+
|
|
|
// Put known misspellings into cache
|
|
// Put known misspellings into cache
|
|
|
const misspelledWords = misspellings.map(item => {
|
|
const misspelledWords = misspellings.map(item => {
|
|
|
- const word = { ...unknownWords[item.index] }
|
|
|
|
|
|
|
+ const word = {
|
|
|
|
|
+ ...unknownWords[item.index],
|
|
|
|
|
+ }
|
|
|
word.suggestions = item.suggestions
|
|
word.suggestions = item.suggestions
|
|
|
if (word.suggestions) {
|
|
if (word.suggestions) {
|
|
|
cacheAdditions.push([word, word.suggestions])
|
|
cacheAdditions.push([word, word.suggestions])
|
|
|
}
|
|
}
|
|
|
return word
|
|
return word
|
|
|
})
|
|
})
|
|
|
|
|
+
|
|
|
// if word was not misspelled, put it in the cache
|
|
// if word was not misspelled, put it in the cache
|
|
|
for (const word of unknownWords) {
|
|
for (const word of unknownWords) {
|
|
|
if (!misspelledWords.find(mw => mw.text === word.text)) {
|
|
if (!misspelledWords.find(mw => mw.text === word.text)) {
|
|
|
cacheAdditions.push([word, true])
|
|
cacheAdditions.push([word, true])
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- const finalMisspellings = misspelledWords.concat(knownMisspelledWords)
|
|
|
|
|
- _log('- result')
|
|
|
|
|
- _log(
|
|
|
|
|
- ' - finalMisspellings',
|
|
|
|
|
- finalMisspellings.map(w => w.text)
|
|
|
|
|
- )
|
|
|
|
|
- _log(
|
|
|
|
|
- ' - cacheAdditions',
|
|
|
|
|
- cacheAdditions.map(([w, v]) => `'${w.text}'=>${v}`)
|
|
|
|
|
- )
|
|
|
|
|
|
|
+
|
|
|
return {
|
|
return {
|
|
|
cacheAdditions,
|
|
cacheAdditions,
|
|
|
- misspelledWords: finalMisspellings,
|
|
|
|
|
|
|
+ misspelledWords: misspelledWords.concat(knownMisspelledWords),
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -294,73 +276,20 @@ export const compileEffects = (results: {
|
|
|
]
|
|
]
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const viewportRangeToCheck = (firstCheck: boolean, view: EditorView) => {
|
|
|
|
|
- const doc = view.state.doc
|
|
|
|
|
- let { from, to } = view.viewport
|
|
|
|
|
- let firstLineNumber = doc.lineAt(from).number
|
|
|
|
|
- let lastLineNumber = doc.lineAt(to).number
|
|
|
|
|
-
|
|
|
|
|
- /*
|
|
|
|
|
- * On first check, we scan the viewport plus some padding on either side.
|
|
|
|
|
- * Then on subsequent checks we just scan the viewport
|
|
|
|
|
- */
|
|
|
|
|
- if (firstCheck) {
|
|
|
|
|
- const visibleLineCount = lastLineNumber - firstLineNumber + 1
|
|
|
|
|
- const padding = Math.floor(visibleLineCount * 2)
|
|
|
|
|
- firstLineNumber = Math.max(firstLineNumber - padding, 1)
|
|
|
|
|
- lastLineNumber = Math.min(lastLineNumber + padding, doc.lines)
|
|
|
|
|
- from = doc.line(firstLineNumber).from
|
|
|
|
|
- to = doc.line(lastLineNumber).to
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return { from, to, firstLineNumber, lastLineNumber }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-export const viewportLinesToCheck = (
|
|
|
|
|
- lineTracker: LineTracker,
|
|
|
|
|
- firstCheck: boolean,
|
|
|
|
|
- view: EditorView
|
|
|
|
|
-) => {
|
|
|
|
|
- const { firstLineNumber, lastLineNumber } = viewportRangeToCheck(
|
|
|
|
|
- firstCheck,
|
|
|
|
|
- view
|
|
|
|
|
- )
|
|
|
|
|
- _log('- viewport lines', firstLineNumber, lastLineNumber)
|
|
|
|
|
- const lines = []
|
|
|
|
|
- for (
|
|
|
|
|
- let lineNumber = firstLineNumber;
|
|
|
|
|
- lineNumber <= lastLineNumber;
|
|
|
|
|
- lineNumber++
|
|
|
|
|
- ) {
|
|
|
|
|
- if (!lineTracker.lineHasChanged(lineNumber)) {
|
|
|
|
|
- continue
|
|
|
|
|
- }
|
|
|
|
|
- lines.push(view.state.doc.line(lineNumber))
|
|
|
|
|
- }
|
|
|
|
|
- _log(
|
|
|
|
|
- '- lines to check',
|
|
|
|
|
- lines.map(l => l.number)
|
|
|
|
|
- )
|
|
|
|
|
- return lines
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
export const getWordsFromLine = (
|
|
export const getWordsFromLine = (
|
|
|
view: EditorView,
|
|
view: EditorView,
|
|
|
line: Line,
|
|
line: Line,
|
|
|
ignoredWords: IgnoredWords,
|
|
ignoredWords: IgnoredWords,
|
|
|
lang: string
|
|
lang: string
|
|
|
): Word[] => {
|
|
): Word[] => {
|
|
|
- const lineNumber = line.number
|
|
|
|
|
const normalTextSpans: Array<NormalTextSpan> = getNormalTextSpansFromLine(
|
|
const normalTextSpans: Array<NormalTextSpan> = getNormalTextSpansFromLine(
|
|
|
view,
|
|
view,
|
|
|
line
|
|
line
|
|
|
)
|
|
)
|
|
|
const words: Word[] = []
|
|
const words: Word[] = []
|
|
|
- let regexResult
|
|
|
|
|
- normalTextSpans.forEach(span => {
|
|
|
|
|
- WORD_REGEX.lastIndex = 0 // reset global stateful regexp for this usage
|
|
|
|
|
- while ((regexResult = WORD_REGEX.exec(span.text))) {
|
|
|
|
|
- let word = regexResult[0]
|
|
|
|
|
|
|
+ for (const span of normalTextSpans) {
|
|
|
|
|
+ for (const match of span.text.matchAll(WORD_REGEX)) {
|
|
|
|
|
+ let word = match[0]
|
|
|
if (word.startsWith("'")) {
|
|
if (word.startsWith("'")) {
|
|
|
word = word.slice(1)
|
|
word = word.slice(1)
|
|
|
}
|
|
}
|
|
@@ -368,18 +297,19 @@ export const getWordsFromLine = (
|
|
|
word = word.slice(0, -1)
|
|
word = word.slice(0, -1)
|
|
|
}
|
|
}
|
|
|
if (!ignoredWords.has(word)) {
|
|
if (!ignoredWords.has(word)) {
|
|
|
|
|
+ const from = span.from + match.index
|
|
|
words.push(
|
|
words.push(
|
|
|
new Word({
|
|
new Word({
|
|
|
text: word,
|
|
text: word,
|
|
|
- from: span.from + regexResult.index,
|
|
|
|
|
- to: span.from + regexResult.index + word.length,
|
|
|
|
|
- lineNumber,
|
|
|
|
|
|
|
+ from,
|
|
|
|
|
+ to: from + word.length,
|
|
|
|
|
+ lineNumber: line.number,
|
|
|
lang,
|
|
lang,
|
|
|
})
|
|
})
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- })
|
|
|
|
|
|
|
+ }
|
|
|
return words
|
|
return words
|
|
|
}
|
|
}
|
|
|
|
|
|