mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Ensure that the correct lines are spell-checked (#19967)
GitOrigin-RevId: f63601288821d37bddcddc776270263e291d3985
This commit is contained in:
parent
607b3e3494
commit
abfa29e629
5 changed files with 95 additions and 654 deletions
|
@ -1,142 +0,0 @@
|
|||
import OError from '@overleaf/o-error'
|
||||
import { Text } from '@codemirror/state'
|
||||
import { Word } from './spellchecker'
|
||||
import { ViewUpdate } from '@codemirror/view'
|
||||
|
||||
export class LineTracker {
|
||||
private _lines: boolean[]
|
||||
constructor(doc: Text) {
|
||||
/*
|
||||
* Maintain an array of booleans, one for each line of the document.
|
||||
* `true` means a line has changed
|
||||
*/
|
||||
this._lines = new Array(doc.lines).fill(true)
|
||||
}
|
||||
|
||||
dump() {
|
||||
return [...this._lines]
|
||||
}
|
||||
|
||||
count() {
|
||||
return this._lines.length
|
||||
}
|
||||
|
||||
lineHasChanged(lineNumber: number) {
|
||||
return this._lines[lineNumber - 1] === true
|
||||
}
|
||||
|
||||
/*
|
||||
* Given a list of words, clear the 'changed' mark
|
||||
* on the lines the words are on
|
||||
*/
|
||||
clearChangedLinesForWords(words: Word[]) {
|
||||
words.forEach(word => {
|
||||
this.clearLine(word.lineNumber)
|
||||
})
|
||||
}
|
||||
|
||||
clearLine(lineNumber: number) {
|
||||
this._lines[lineNumber - 1] = false
|
||||
}
|
||||
|
||||
clearAllLines() {
|
||||
this._lines = this._lines.map(() => false)
|
||||
}
|
||||
|
||||
resetAllLines() {
|
||||
this._lines = this._lines.map(() => true)
|
||||
}
|
||||
|
||||
markLineAsUpdated(lineNumber: number) {
|
||||
this._lines[lineNumber - 1] = true
|
||||
}
|
||||
|
||||
/*
|
||||
* On update, for all changes, mark the affected lines
|
||||
* as changed
|
||||
*/
|
||||
applyUpdate(update: ViewUpdate) {
|
||||
for (const transaction of update.transactions) {
|
||||
if (transaction.docChanged) {
|
||||
let lineShift = 0
|
||||
transaction.changes.iterChanges(
|
||||
(fromA, toA, fromB, toB, insertedText) => {
|
||||
const insertedLength = insertedText.length
|
||||
const removedLength = toA - fromA
|
||||
const hasInserted = insertedLength > 0
|
||||
const hasRemoved = removedLength > 0
|
||||
const oldDoc = transaction.startState.doc
|
||||
if (hasRemoved) {
|
||||
const startLine = oldDoc.lineAt(fromA).number
|
||||
const endLine = oldDoc.lineAt(toA).number
|
||||
/* Mark start line as changed, and remove deleted lines
|
||||
* Example:
|
||||
* with this text:
|
||||
* |1|aaaa|
|
||||
* |2|bbbb| => [false, false, false, false]
|
||||
* |3|cccc|
|
||||
* |4|dddd|
|
||||
*
|
||||
* with a selection covering 'bbcccc' across lines 2 and 3,
|
||||
* press backspace,
|
||||
* resulting in:
|
||||
* |1|aaaa|
|
||||
* |2|bb| => [false, true, false]
|
||||
* |3|dddd|
|
||||
*/
|
||||
this._lines.splice(
|
||||
startLine - 1 + lineShift,
|
||||
endLine - startLine + 1,
|
||||
true
|
||||
)
|
||||
lineShift -= endLine - startLine
|
||||
}
|
||||
if (hasInserted) {
|
||||
const startLine = oldDoc.lineAt(fromA).number
|
||||
/* Mark start line as changed, and insert new (changed) lines after.
|
||||
* Example:
|
||||
* with this text:
|
||||
* |1|aaaa|
|
||||
* |2|bbbb| => [false, false, false]
|
||||
* |3|cccc|
|
||||
*
|
||||
* with the cursor at the end of line 2,
|
||||
* insert the following text:
|
||||
* |1|xx|
|
||||
* |2|yy|
|
||||
*
|
||||
* results in:
|
||||
* |1|aaaa|
|
||||
* |2|bbbbxx| => [false, true, true, false]
|
||||
* |3|yy|
|
||||
* |4|cccc|
|
||||
*/
|
||||
|
||||
const changes = new Array(insertedText.lines).fill(true)
|
||||
try {
|
||||
this._lines.splice(startLine - 1 + lineShift, 1, ...changes)
|
||||
lineShift += changes.length - 1
|
||||
} catch (error) {
|
||||
if (error instanceof RangeError) {
|
||||
throw new OError(error.message).withInfo({
|
||||
changesSize: changes.length,
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (update.state.doc.lines !== this._lines.length) {
|
||||
throw new OError(
|
||||
'LineTracker length does not match document line count'
|
||||
).withInfo({
|
||||
documentLines: update.state.doc.lines,
|
||||
trackerLines: this._lines.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
import { StateField, StateEffect, Transaction } from '@codemirror/state'
|
||||
import { StateField, StateEffect } from '@codemirror/state'
|
||||
import { EditorView, Decoration, DecorationSet } from '@codemirror/view'
|
||||
import { updateAfterAddingIgnoredWord } from './ignored-words'
|
||||
import _ from 'lodash'
|
||||
import { Word } from './spellchecker'
|
||||
|
||||
export const addMisspelledWords = StateEffect.define<Word[]>()
|
||||
|
@ -9,11 +8,10 @@ export const addMisspelledWords = StateEffect.define<Word[]>()
|
|||
export const resetMisspelledWords = StateEffect.define()
|
||||
|
||||
const createMark = (word: Word) => {
|
||||
const mark = Decoration.mark({
|
||||
return Decoration.mark({
|
||||
class: 'ol-cm-spelling-error',
|
||||
word,
|
||||
})
|
||||
return mark.range(word.from, word.to)
|
||||
}).range(word.from, word.to)
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -26,13 +24,24 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
|
|||
return Decoration.none
|
||||
},
|
||||
update(marks, transaction) {
|
||||
if (transaction.docChanged) {
|
||||
// Remove any marks whose text has just been edited
|
||||
marks = marks.update({
|
||||
filter(from, to) {
|
||||
return !transaction.changes.touchesRange(from, to)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
marks = marks.map(transaction.changes)
|
||||
marks = removeMarksUnderEdit(marks, transaction)
|
||||
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(addMisspelledWords)) {
|
||||
// We're setting a new list of misspelled words
|
||||
const misspelledWords = effect.value
|
||||
marks = mergeMarks(marks, misspelledWords)
|
||||
// Merge the new misspelled words into the existing set of marks
|
||||
marks = marks.update({
|
||||
add: effect.value.map(word => createMark(word)),
|
||||
sort: true,
|
||||
})
|
||||
} else if (effect.is(updateAfterAddingIgnoredWord)) {
|
||||
// Remove a misspelled word, all instances that match text
|
||||
const word = effect.value
|
||||
|
@ -48,46 +57,6 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
|
|||
},
|
||||
})
|
||||
|
||||
/*
|
||||
* Remove any marks whos text has just been edited
|
||||
*/
|
||||
const removeMarksUnderEdit = (
|
||||
marks: DecorationSet,
|
||||
transaction: Transaction
|
||||
) => {
|
||||
transaction.changes.iterChanges((fromA, toA, fromB, toB) => {
|
||||
marks = marks.update({
|
||||
// Filter out marks that overlap the change span
|
||||
filter: (from, to, mark) => {
|
||||
const changeStartWithinMark = from <= fromB && to >= fromB
|
||||
const changeEndWithinMark = from <= toB && to >= toB
|
||||
const markHasBeenEdited = changeStartWithinMark || changeEndWithinMark
|
||||
return !markHasBeenEdited
|
||||
},
|
||||
})
|
||||
})
|
||||
return marks
|
||||
}
|
||||
|
||||
/*
|
||||
* Given the set of marks, and a list of new misspelled-words,
|
||||
* merge these together into a new set of marks
|
||||
*/
|
||||
const mergeMarks = (marks: DecorationSet, words: Word[]) => {
|
||||
const affectedLines = new Set(words.map(w => w.lineNumber))
|
||||
marks = marks
|
||||
.update({
|
||||
filter: (from, to, mark) => {
|
||||
return !affectedLines.has(mark.spec.word.lineNumber)
|
||||
},
|
||||
})
|
||||
.update({
|
||||
add: _.sortBy(words, ['from']).map(w => createMark(w)),
|
||||
sort: true,
|
||||
})
|
||||
return marks
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove existing marks matching the text of a supplied word
|
||||
*/
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
import { addMisspelledWords, misspelledWordsField } from './misspelled-words'
|
||||
import { ignoredWordsField, resetSpellChecker } from './ignored-words'
|
||||
import { LineTracker } from './line-tracker'
|
||||
import { cacheField, addWordToCache, WordCacheValue } from './cache'
|
||||
import { WORD_REGEX } from './helpers'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { spellCheckRequest } from './backend'
|
||||
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 {
|
||||
getNormalTextSpansFromLine,
|
||||
|
@ -15,10 +14,6 @@ import {
|
|||
import { waitForParser } from '../wait-for-parser'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
const _log = (...args: any) => {
|
||||
debugConsole.debug('[SpellChecker]: ', ...args)
|
||||
}
|
||||
|
||||
/*
|
||||
* Spellchecker, handles updates, schedules spelling checks
|
||||
*/
|
||||
|
@ -26,43 +21,41 @@ export class SpellChecker {
|
|||
private abortController?: AbortController | null = null
|
||||
private timeout: number | null = null
|
||||
private firstCheck = true
|
||||
private lineTracker: LineTracker | null = null
|
||||
private waitingForParser = false
|
||||
private firstCheckPending = false
|
||||
private trackedChanges: ChangeSet
|
||||
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor(private readonly language: string) {
|
||||
this.language = language
|
||||
this.trackedChanges = ChangeSet.empty(0)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
_log('destroy')
|
||||
this._clearPendingSpellCheck()
|
||||
}
|
||||
|
||||
_abortRequest() {
|
||||
if (this.abortController) {
|
||||
_log('abort request')
|
||||
this.abortController.abort()
|
||||
this.abortController = null
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdate(update: ViewUpdate) {
|
||||
if (!this.lineTracker) {
|
||||
this.lineTracker = new LineTracker(update.state.doc)
|
||||
}
|
||||
if (update.docChanged) {
|
||||
this.lineTracker.applyUpdate(update)
|
||||
this.trackedChanges = this.trackedChanges.compose(update.changes)
|
||||
this.scheduleSpellCheck(update.view)
|
||||
} else if (update.viewportChanged) {
|
||||
this.trackedChanges = ChangeSet.empty(0)
|
||||
this.scheduleSpellCheck(update.view)
|
||||
} else if (
|
||||
update.transactions.some(tr => {
|
||||
return tr.effects.some(effect => effect.is(resetSpellChecker))
|
||||
})
|
||||
) {
|
||||
this.lineTracker.resetAllLines()
|
||||
// for tests
|
||||
this.trackedChanges = ChangeSet.empty(0)
|
||||
this.spellCheckAsap(update.view)
|
||||
}
|
||||
// At the point that the spellchecker is initialized, the editor may not
|
||||
|
@ -78,21 +71,15 @@ export class SpellChecker {
|
|||
update.state.facet(EditorView.editable)
|
||||
) {
|
||||
this.firstCheckPending = true
|
||||
_log('Scheduling initial spellcheck')
|
||||
this.spellCheckAsap(update.view)
|
||||
}
|
||||
}
|
||||
|
||||
_performSpellCheck(view: EditorView) {
|
||||
_log('Begin ---------------->')
|
||||
const wordsToCheck = this.getWordsToCheck(view)
|
||||
if (wordsToCheck.length === 0) {
|
||||
return
|
||||
}
|
||||
_log(
|
||||
'- words to check',
|
||||
wordsToCheck.map(w => w.text)
|
||||
)
|
||||
const cache = view.state.field(cacheField)
|
||||
const { knownMisspelledWords, unknownWords } = cache.checkWords(
|
||||
this.language,
|
||||
|
@ -101,7 +88,8 @@ export class SpellChecker {
|
|||
const processResult = (
|
||||
misspellings: { index: number; suggestions: string[] }[]
|
||||
) => {
|
||||
this.lineTracker?.clearChangedLinesForWords(wordsToCheck)
|
||||
this.trackedChanges = ChangeSet.empty(0)
|
||||
|
||||
if (this.firstCheck) {
|
||||
this.firstCheck = false
|
||||
this.firstCheckPending = false
|
||||
|
@ -111,25 +99,13 @@ export class SpellChecker {
|
|||
unknownWords,
|
||||
misspellings
|
||||
)
|
||||
_log('- result', result)
|
||||
window.setTimeout(() => {
|
||||
view.dispatch({
|
||||
effects: compileEffects(result),
|
||||
})
|
||||
}, 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) {
|
||||
_log('- skip request')
|
||||
processResult([])
|
||||
} else {
|
||||
this._abortRequest()
|
||||
|
@ -141,7 +117,7 @@ export class SpellChecker {
|
|||
})
|
||||
.catch(error => {
|
||||
this.abortController = null
|
||||
_log('>> error in spellcheck request', error)
|
||||
debugConsole.error(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -152,10 +128,7 @@ export class SpellChecker {
|
|||
}
|
||||
|
||||
this.waitingForParser = true
|
||||
waitForParser(
|
||||
view,
|
||||
view => viewportRangeToCheck(this.firstCheck, view).to
|
||||
).then(() => {
|
||||
waitForParser(view, view => view.viewport.to).then(() => {
|
||||
this.waitingForParser = false
|
||||
this._performSpellCheck(view)
|
||||
})
|
||||
|
@ -187,22 +160,36 @@ export class SpellChecker {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -246,34 +233,29 @@ export const buildSpellCheckResult = (
|
|||
misspellings: { index: number; suggestions: string[] }[]
|
||||
) => {
|
||||
const cacheAdditions: [Word, string[] | boolean][] = []
|
||||
|
||||
// Put known misspellings into cache
|
||||
const misspelledWords = misspellings.map(item => {
|
||||
const word = { ...unknownWords[item.index] }
|
||||
const word = {
|
||||
...unknownWords[item.index],
|
||||
}
|
||||
word.suggestions = item.suggestions
|
||||
if (word.suggestions) {
|
||||
cacheAdditions.push([word, word.suggestions])
|
||||
}
|
||||
return word
|
||||
})
|
||||
|
||||
// if word was not misspelled, put it in the cache
|
||||
for (const word of unknownWords) {
|
||||
if (!misspelledWords.find(mw => mw.text === word.text)) {
|
||||
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 {
|
||||
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 = (
|
||||
view: EditorView,
|
||||
line: Line,
|
||||
ignoredWords: IgnoredWords,
|
||||
lang: string
|
||||
): Word[] => {
|
||||
const lineNumber = line.number
|
||||
const normalTextSpans: Array<NormalTextSpan> = getNormalTextSpansFromLine(
|
||||
view,
|
||||
line
|
||||
)
|
||||
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("'")) {
|
||||
word = word.slice(1)
|
||||
}
|
||||
|
@ -368,18 +297,19 @@ export const getWordsFromLine = (
|
|||
word = word.slice(0, -1)
|
||||
}
|
||||
if (!ignoredWords.has(word)) {
|
||||
const from = span.from + match.index
|
||||
words.push(
|
||||
new 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,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
|
|
|
@ -1,206 +0,0 @@
|
|||
import { LineTracker } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/line-tracker'
|
||||
// import sinon from 'sinon'
|
||||
import { expect } from 'chai'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { Word } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/spellchecker'
|
||||
|
||||
const doc = [
|
||||
'Hello test one two',
|
||||
'three four five six',
|
||||
'seven eight nine.',
|
||||
].join('\n')
|
||||
|
||||
describe('LineTracker', function () {
|
||||
describe('basic operations', function () {
|
||||
let state: EditorState,
|
||||
view: EditorView,
|
||||
lineTracker: LineTracker,
|
||||
check: (spec: [number, any][]) => void
|
||||
beforeEach(function () {
|
||||
state = EditorState.create({
|
||||
doc,
|
||||
extensions: [
|
||||
EditorView.updateListener.of(update => {
|
||||
lineTracker.applyUpdate(update)
|
||||
}),
|
||||
],
|
||||
})
|
||||
view = new EditorView({ state })
|
||||
lineTracker = new LineTracker(view.state.doc)
|
||||
check = spec => {
|
||||
spec.forEach(([n, expectedValue]) => {
|
||||
expect(lineTracker.lineHasChanged(n)).to.equal(expectedValue)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('start with the correct number of lines', function () {
|
||||
expect(state.doc.lines).to.equal(3)
|
||||
expect(lineTracker.count()).to.equal(state.doc.lines)
|
||||
})
|
||||
|
||||
it('starts with all lines marked as changed', function () {
|
||||
expect(state.doc.lines).to.equal(3)
|
||||
check([
|
||||
[1, true],
|
||||
[2, true],
|
||||
[3, true],
|
||||
])
|
||||
})
|
||||
|
||||
it('clears a line', function () {
|
||||
check([[1, true]])
|
||||
lineTracker.clearLine(1)
|
||||
check([[1, false]])
|
||||
})
|
||||
|
||||
it('clears lines based on a list of words', function () {
|
||||
lineTracker.clearChangedLinesForWords([
|
||||
{ lineNumber: 1 },
|
||||
{ lineNumber: 3 },
|
||||
] as Word[])
|
||||
check([
|
||||
[1, false],
|
||||
[2, true],
|
||||
[3, false],
|
||||
])
|
||||
})
|
||||
|
||||
it('should update lines in response to text insertion', function () {
|
||||
lineTracker.clearChangedLinesForWords([
|
||||
{ lineNumber: 1 },
|
||||
{ lineNumber: 2 },
|
||||
{ lineNumber: 3 },
|
||||
] as Word[])
|
||||
check([
|
||||
[1, false],
|
||||
[2, false],
|
||||
[3, false],
|
||||
])
|
||||
|
||||
let transaction = view.state.update({
|
||||
changes: [{ from: 0, insert: 'x' }],
|
||||
})
|
||||
view.dispatch(transaction)
|
||||
check([
|
||||
[1, true],
|
||||
[2, false],
|
||||
[3, false],
|
||||
])
|
||||
|
||||
transaction = view.state.update({
|
||||
changes: [{ from: view.state.doc.length - 2, insert: 'x' }],
|
||||
})
|
||||
view.dispatch(transaction)
|
||||
check([
|
||||
[1, true],
|
||||
[2, false],
|
||||
[3, true],
|
||||
])
|
||||
})
|
||||
|
||||
it('should update lines in response to large text insertion', function () {
|
||||
lineTracker.clearChangedLinesForWords([
|
||||
{ lineNumber: 1 },
|
||||
{ lineNumber: 2 },
|
||||
{ lineNumber: 3 },
|
||||
] as Word[])
|
||||
check([
|
||||
[1, false],
|
||||
[2, false],
|
||||
[3, false],
|
||||
])
|
||||
|
||||
const text = new Array(1000).fill('x').join('\n')
|
||||
|
||||
const transaction = view.state.update({
|
||||
changes: [{ from: 0, insert: text }],
|
||||
})
|
||||
view.dispatch(transaction)
|
||||
expect(lineTracker.count()).to.equal(1002)
|
||||
const expectations: [number, boolean][] = []
|
||||
for (let i = 1; i <= 1000; i++) {
|
||||
expectations.push([i, true])
|
||||
}
|
||||
expectations.push([1001, false])
|
||||
expectations.push([1002, false])
|
||||
check(expectations)
|
||||
})
|
||||
|
||||
it('should update lines in response to removal of a line', function () {
|
||||
lineTracker.clearChangedLinesForWords([
|
||||
{ lineNumber: 1 },
|
||||
{ lineNumber: 2 },
|
||||
{ lineNumber: 3 },
|
||||
] as Word[])
|
||||
check([
|
||||
[1, false],
|
||||
[2, false],
|
||||
[3, false],
|
||||
])
|
||||
|
||||
// Overwrite the line plus some part of the second line
|
||||
const transaction = view.state.update({
|
||||
changes: [{ from: 0, to: doc[0].length + 3, insert: 'x' }],
|
||||
})
|
||||
view.dispatch(transaction)
|
||||
check([
|
||||
[1, true],
|
||||
[2, false],
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle multiple changes', function () {
|
||||
lineTracker.clearChangedLinesForWords([
|
||||
{ lineNumber: 1 },
|
||||
{ lineNumber: 2 },
|
||||
{ lineNumber: 3 },
|
||||
] as Word[])
|
||||
check([
|
||||
[1, false],
|
||||
[2, false],
|
||||
[3, false],
|
||||
])
|
||||
|
||||
const transaction = view.state.update({
|
||||
changes: [
|
||||
{ from: 0, insert: 'x' },
|
||||
{ from: doc[0].length + 2, insert: 'xxxxx\nxxxxx\nxxxx' },
|
||||
],
|
||||
})
|
||||
view.dispatch(transaction)
|
||||
check([
|
||||
[1, true],
|
||||
[2, true],
|
||||
[3, true],
|
||||
[4, false],
|
||||
[5, false],
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle multiple deletions', function () {
|
||||
const transaction = view.state.update({
|
||||
changes: [
|
||||
{ from: 0, to: 24, insert: '' },
|
||||
{ from: 39, to: 44, insert: '' },
|
||||
],
|
||||
})
|
||||
view.dispatch(transaction)
|
||||
check([
|
||||
[1, true],
|
||||
[2, true],
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle multiple insertions', function () {
|
||||
const transactionUndo = view.state.update({
|
||||
changes: [
|
||||
{ from: 0, to: 0, insert: 'big change\n'.repeat(20) },
|
||||
{ from: 50, to: 50, insert: 'xxxx' },
|
||||
],
|
||||
})
|
||||
view.dispatch(transactionUndo)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,29 +1,15 @@
|
|||
import {
|
||||
getWordsFromLine,
|
||||
viewportLinesToCheck,
|
||||
buildSpellCheckResult,
|
||||
Word,
|
||||
} from '../../../../../../frontend/js/features/source-editor/extensions/spelling/spellchecker'
|
||||
import { LineTracker } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/line-tracker'
|
||||
} from '@/features/source-editor/extensions/spelling/spellchecker'
|
||||
import { expect } from 'chai'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { EditorState, Line } from '@codemirror/state'
|
||||
import _ from 'lodash'
|
||||
import { IgnoredWords } from '../../../../../../frontend/js/features/dictionary/ignored-words'
|
||||
import { LaTeXLanguage } from '../../../../../../frontend/js/features/source-editor/languages/latex/latex-language'
|
||||
import { IgnoredWords } from '@/features/dictionary/ignored-words'
|
||||
import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language'
|
||||
import { LanguageSupport } from '@codemirror/language'
|
||||
|
||||
const latex = new LanguageSupport(LaTeXLanguage)
|
||||
|
||||
const makeView = (text: string) => {
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: text,
|
||||
extensions: [latex],
|
||||
}),
|
||||
})
|
||||
return view
|
||||
}
|
||||
const extensions = [new LanguageSupport(LaTeXLanguage)]
|
||||
|
||||
describe('SpellChecker', function () {
|
||||
describe('getWordsFromLine', function () {
|
||||
|
@ -35,7 +21,10 @@ describe('SpellChecker', function () {
|
|||
})
|
||||
|
||||
it('should get words from a line', function () {
|
||||
const view = makeView('Hello test one two')
|
||||
const view = new EditorView({
|
||||
doc: 'Hello test one two',
|
||||
extensions,
|
||||
})
|
||||
const line = view.state.doc.line(1)
|
||||
const words = getWordsFromLine(view, line, ignoredWords, lang)
|
||||
expect(words).to.deep.equal([
|
||||
|
@ -48,7 +37,10 @@ describe('SpellChecker', function () {
|
|||
|
||||
it('should ignore words in ignoredWords', function () {
|
||||
ignoredWords = new Set(['test']) as unknown as IgnoredWords
|
||||
const view = makeView('Hello test one two')
|
||||
const view = new EditorView({
|
||||
doc: 'Hello test one two',
|
||||
extensions,
|
||||
})
|
||||
const line = view.state.doc.line(1)
|
||||
const words = getWordsFromLine(view, line, ignoredWords, lang)
|
||||
expect(words).to.deep.equal([
|
||||
|
@ -59,14 +51,20 @@ describe('SpellChecker', function () {
|
|||
})
|
||||
|
||||
it('should get no words from an empty line', function () {
|
||||
const view = makeView(' ')
|
||||
const view = new EditorView({
|
||||
doc: ' ',
|
||||
extensions,
|
||||
})
|
||||
const line = view.state.doc.line(1)
|
||||
const words = getWordsFromLine(view, line, ignoredWords, lang)
|
||||
expect(words).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should ignore content of some commands in the text', function () {
|
||||
const view = makeView('\\usepackage[foo]{ bar } seven eight')
|
||||
const view = new EditorView({
|
||||
doc: '\\usepackage[foo]{ bar } seven eight',
|
||||
extensions,
|
||||
})
|
||||
const line = view.state.doc.line(1)
|
||||
const words = getWordsFromLine(view, line, ignoredWords, lang)
|
||||
expect(words).to.deep.equal([
|
||||
|
@ -76,7 +74,10 @@ describe('SpellChecker', function () {
|
|||
})
|
||||
|
||||
it('should ignore command names in the text', function () {
|
||||
const view = makeView('\\foo nine \\bar ten \\baz[]{}')
|
||||
const view = new EditorView({
|
||||
doc: '\\foo nine \\bar ten \\baz[]{}',
|
||||
extensions,
|
||||
})
|
||||
const line = view.state.doc.line(1)
|
||||
const words = getWordsFromLine(view, line, ignoredWords, lang)
|
||||
expect(words).to.deep.equal([
|
||||
|
@ -86,117 +87,6 @@ describe('SpellChecker', function () {
|
|||
})
|
||||
})
|
||||
|
||||
describe('viewportLinesToCheck', function () {
|
||||
const expectLines = (lines: Line[], expectations: any) => {
|
||||
expect(lines.map(l => l.number)).to.deep.equal(expectations)
|
||||
}
|
||||
const expectLineRange = (lines: Line[], from: number, to: number) => {
|
||||
expect(lines.map(l => l.number)).to.deep.equal(_.range(from, to + 1))
|
||||
}
|
||||
|
||||
let view: EditorView
|
||||
beforeEach(function () {
|
||||
view = makeView(new Array(1000).fill('aa bb cc dd').join('\n'))
|
||||
// Test preconditions on these structures
|
||||
const viewport = view.viewport
|
||||
expect(view.state.doc.lines).to.equal(1000)
|
||||
const firstVisibleLine = view.state.doc.lineAt(viewport.from).number
|
||||
const lastVisibleLine = view.state.doc.lineAt(viewport.to).number
|
||||
expect(firstVisibleLine).to.equal(1)
|
||||
expect(lastVisibleLine).to.equal(36)
|
||||
})
|
||||
|
||||
describe('when all lines are changed', function () {
|
||||
let lineTracker: LineTracker
|
||||
beforeEach(function () {
|
||||
lineTracker = new LineTracker(view.state.doc)
|
||||
})
|
||||
|
||||
it('should check all lines in the viewport when not first check', function () {
|
||||
const firstCheck = false
|
||||
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
|
||||
expectLineRange(linesToCheck, 1, 36)
|
||||
})
|
||||
|
||||
it('should check more lines than the viewport on first check', function () {
|
||||
const firstCheck = true
|
||||
const lineTracker = new LineTracker(view.state.doc)
|
||||
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
|
||||
expectLineRange(linesToCheck, 1, 108)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when no lines have changed', function () {
|
||||
let lineTracker: LineTracker
|
||||
beforeEach(function () {
|
||||
lineTracker = new LineTracker(view.state.doc)
|
||||
lineTracker.clearAllLines()
|
||||
})
|
||||
|
||||
it('on first check, should not check any lines', function () {
|
||||
const firstCheck = true
|
||||
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
|
||||
expect(linesToCheck).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('on not first check, should not check any lines', function () {
|
||||
const firstCheck = false
|
||||
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
|
||||
expectLines(linesToCheck, [])
|
||||
})
|
||||
})
|
||||
|
||||
describe('when some lines have changed in viewport', function () {
|
||||
let lineTracker: LineTracker
|
||||
beforeEach(function () {
|
||||
lineTracker = new LineTracker(view.state.doc)
|
||||
lineTracker.clearAllLines()
|
||||
lineTracker.markLineAsUpdated(3)
|
||||
lineTracker.markLineAsUpdated(7)
|
||||
})
|
||||
|
||||
it('should check correct lines', function () {
|
||||
const firstCheck = false
|
||||
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
|
||||
expectLines(linesToCheck, [3, 7])
|
||||
})
|
||||
})
|
||||
|
||||
describe('when some lines have changed outside viewport', function () {
|
||||
let lineTracker: LineTracker
|
||||
beforeEach(function () {
|
||||
lineTracker = new LineTracker(view.state.doc)
|
||||
lineTracker.clearAllLines()
|
||||
lineTracker.markLineAsUpdated(300)
|
||||
lineTracker.markLineAsUpdated(307)
|
||||
})
|
||||
|
||||
it('should not check lines', function () {
|
||||
const firstCheck = false
|
||||
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
|
||||
expectLines(linesToCheck, [])
|
||||
})
|
||||
})
|
||||
|
||||
describe('when some lines have changed inside and outside viewport', function () {
|
||||
let lineTracker: LineTracker
|
||||
beforeEach(function () {
|
||||
lineTracker = new LineTracker(view.state.doc)
|
||||
lineTracker.clearAllLines()
|
||||
lineTracker.markLineAsUpdated(10)
|
||||
lineTracker.markLineAsUpdated(12)
|
||||
lineTracker.markLineAsUpdated(300)
|
||||
lineTracker.markLineAsUpdated(307)
|
||||
})
|
||||
|
||||
it('should check only lines in viewport', function () {
|
||||
const firstCheck = false
|
||||
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
|
||||
expectLines(linesToCheck, [10, 12])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSpellCheckResult', function () {
|
||||
it('should build an empty result', function () {
|
||||
const knownMisspelledWords: Word[] = []
|
||||
|
|
Loading…
Reference in a new issue