Преглед изворни кода

Ensure that the correct lines are spell-checked (#19967)

GitOrigin-RevId: f63601288821d37bddcddc776270263e291d3985
Alf Eaton пре 2 година
родитељ
комит
abfa29e629

+ 0 - 142
services/web/frontend/js/features/source-editor/extensions/spelling/line-tracker.ts

@@ -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,
-        })
-      }
-    }
-  }
-}

+ 18 - 49
services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts

@@ -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 { EditorView, Decoration, DecorationSet } from '@codemirror/view'
 import { updateAfterAddingIgnoredWord } from './ignored-words'
 import { updateAfterAddingIgnoredWord } from './ignored-words'
-import _ from 'lodash'
 import { Word } from './spellchecker'
 import { Word } from './spellchecker'
 
 
 export const addMisspelledWords = StateEffect.define<Word[]>()
 export const addMisspelledWords = StateEffect.define<Word[]>()
@@ -9,11 +8,10 @@ export const addMisspelledWords = StateEffect.define<Word[]>()
 export const resetMisspelledWords = StateEffect.define()
 export const resetMisspelledWords = StateEffect.define()
 
 
 const createMark = (word: Word) => {
 const createMark = (word: Word) => {
-  const mark = Decoration.mark({
+  return Decoration.mark({
     class: 'ol-cm-spelling-error',
     class: 'ol-cm-spelling-error',
     word,
     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
     return Decoration.none
   },
   },
   update(marks, transaction) {
   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 = marks.map(transaction.changes)
-    marks = removeMarksUnderEdit(marks, transaction)
+
     for (const effect of transaction.effects) {
     for (const effect of transaction.effects) {
       if (effect.is(addMisspelledWords)) {
       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)) {
       } else if (effect.is(updateAfterAddingIgnoredWord)) {
         // Remove a misspelled word, all instances that match text
         // Remove a misspelled word, all instances that match text
         const word = effect.value
         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
  * Remove existing marks matching the text of a supplied word
  */
  */

+ 53 - 123
services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts

@@ -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
 }
 }
 
 

+ 0 - 206
services/web/test/frontend/features/source-editor/extensions/spelling/line-tracker.test.ts

@@ -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)
-    })
-  })
-})

+ 24 - 134
services/web/test/frontend/features/source-editor/extensions/spelling/spellchecker.test.ts

@@ -1,29 +1,15 @@
 import {
 import {
   getWordsFromLine,
   getWordsFromLine,
-  viewportLinesToCheck,
   buildSpellCheckResult,
   buildSpellCheckResult,
   Word,
   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 { expect } from 'chai'
 import { EditorView } from '@codemirror/view'
 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'
 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('SpellChecker', function () {
   describe('getWordsFromLine', function () {
   describe('getWordsFromLine', function () {
@@ -35,7 +21,10 @@ describe('SpellChecker', function () {
     })
     })
 
 
     it('should get words from a line', 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 line = view.state.doc.line(1)
       const words = getWordsFromLine(view, line, ignoredWords, lang)
       const words = getWordsFromLine(view, line, ignoredWords, lang)
       expect(words).to.deep.equal([
       expect(words).to.deep.equal([
@@ -48,7 +37,10 @@ describe('SpellChecker', function () {
 
 
     it('should ignore words in ignoredWords', function () {
     it('should ignore words in ignoredWords', function () {
       ignoredWords = new Set(['test']) as unknown as IgnoredWords
       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 line = view.state.doc.line(1)
       const words = getWordsFromLine(view, line, ignoredWords, lang)
       const words = getWordsFromLine(view, line, ignoredWords, lang)
       expect(words).to.deep.equal([
       expect(words).to.deep.equal([
@@ -59,14 +51,20 @@ describe('SpellChecker', function () {
     })
     })
 
 
     it('should get no words from an empty line', 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 line = view.state.doc.line(1)
       const words = getWordsFromLine(view, line, ignoredWords, lang)
       const words = getWordsFromLine(view, line, ignoredWords, lang)
       expect(words).to.deep.equal([])
       expect(words).to.deep.equal([])
     })
     })
 
 
     it('should ignore content of some commands in the text', function () {
     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 line = view.state.doc.line(1)
       const words = getWordsFromLine(view, line, ignoredWords, lang)
       const words = getWordsFromLine(view, line, ignoredWords, lang)
       expect(words).to.deep.equal([
       expect(words).to.deep.equal([
@@ -76,7 +74,10 @@ describe('SpellChecker', function () {
     })
     })
 
 
     it('should ignore command names in the text', 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 line = view.state.doc.line(1)
       const words = getWordsFromLine(view, line, ignoredWords, lang)
       const words = getWordsFromLine(view, line, ignoredWords, lang)
       expect(words).to.deep.equal([
       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 () {
   describe('buildSpellCheckResult', function () {
     it('should build an empty result', function () {
     it('should build an empty result', function () {
       const knownMisspelledWords: Word[] = []
       const knownMisspelledWords: Word[] = []