فهرست منبع

Improve spell check when dictionary is edited (#22635)

GitOrigin-RevId: 20d36cb987d014809423240a46c7c577781dfde6
Alf Eaton 1 سال پیش
والد
کامیت
cffa9c1a28
16فایلهای تغییر یافته به همراه218 افزوده شده و 375 حذف شده
  1. 14 12
      services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx
  2. 1 41
      services/web/frontend/js/features/dictionary/ignored-words.ts
  3. 0 13
      services/web/frontend/js/features/source-editor/extensions/spelling/backend.ts
  4. 13 14
      services/web/frontend/js/features/source-editor/extensions/spelling/cache.ts
  5. 14 8
      services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx
  6. 0 31
      services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts
  7. 17 58
      services/web/frontend/js/features/source-editor/extensions/spelling/index.ts
  8. 24 0
      services/web/frontend/js/features/source-editor/extensions/spelling/learned-words.ts
  9. 10 3
      services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts
  10. 59 62
      services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts
  11. 17 23
      services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions.tsx
  12. 1 39
      services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
  13. 4 3
      services/web/frontend/js/features/source-editor/hooks/use-hunspell.ts
  14. 16 14
      services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx
  15. 12 12
      services/web/test/frontend/features/source-editor/extensions/spelling/cache.test.ts
  16. 16 42
      services/web/test/frontend/features/source-editor/extensions/spelling/spellchecker.test.ts

+ 14 - 12
services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx

@@ -2,7 +2,6 @@ import { useCallback, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import useAsync from '../../../shared/hooks/use-async'
 import { postJSON } from '../../../infrastructure/fetch-json'
-import ignoredWords from '../ignored-words'
 import { debugConsole } from '@/utils/debugging'
 import {
   OLModalBody,
@@ -15,6 +14,7 @@ import OLNotification from '@/features/ui/components/ol/ol-notification'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
 import { bsVersion } from '@/features/utils/bootstrap-5'
+import { learnedWords as initialLearnedWords } from '@/features/source-editor/extensions/spelling/learned-words'
 
 type DictionaryModalContentProps = {
   handleHide: () => void
@@ -26,22 +26,24 @@ export default function DictionaryModalContent({
   handleHide,
 }: DictionaryModalContentProps) {
   const { t } = useTranslation()
-  const [learnedWords, setLearnedWords] = useState(ignoredWords.learnedWords)
+
+  const [learnedWords, setLearnedWords] = useState<Set<string>>(
+    initialLearnedWords.global
+  )
 
   const { isError, runAsync } = useAsync()
 
   const handleRemove = useCallback(
     word => {
-      runAsync(
-        postJSON('/spelling/unlearn', {
-          body: {
-            word,
-          },
-        })
-      )
+      runAsync(postJSON('/spelling/unlearn', { body: { word } }))
         .then(() => {
-          ignoredWords.remove(word)
-          setLearnedWords(new Set(ignoredWords.learnedWords))
+          setLearnedWords(value => {
+            value.delete(word)
+            return new Set(value)
+          })
+          window.dispatchEvent(
+            new CustomEvent('editor:remove-learned-word', { detail: word })
+          )
         })
         .catch(debugConsole.error)
     },
@@ -62,7 +64,7 @@ export default function DictionaryModalContent({
           />
         ) : null}
 
-        {learnedWords?.size > 0 ? (
+        {learnedWords.size > 0 ? (
           <ul className="list-unstyled dictionary-entries-list">
             {[...learnedWords].sort(wordsSortFunction).map(learnedWord => (
               <li key={learnedWord} className="dictionary-entry">

+ 1 - 41
services/web/frontend/js/features/dictionary/ignored-words.ts

@@ -1,6 +1,4 @@
-import getMeta from '../../utils/meta'
-
-export const globalLearnedWords = new Set([
+export const globalIgnoredWords = new Set([
   'Overleaf',
   'overleaf',
   'ShareLaTeX',
@@ -22,41 +20,3 @@ export const globalLearnedWords = new Set([
   'Coronavirus',
   'coronavirus',
 ])
-
-export class IgnoredWords {
-  public learnedWords!: Set<string>
-  private readonly ignoredMisspellings: Set<string>
-
-  constructor() {
-    this.reset()
-    this.ignoredMisspellings = globalLearnedWords
-    window.addEventListener('learnedWords:doreset', () => this.reset()) // for tests
-  }
-
-  reset() {
-    this.learnedWords = new Set(getMeta('ol-learnedWords'))
-    window.dispatchEvent(new CustomEvent('learnedWords:reset'))
-  }
-
-  add(wordText: string) {
-    this.learnedWords.add(wordText)
-    window.dispatchEvent(
-      new CustomEvent('learnedWords:add', { detail: wordText })
-    )
-  }
-
-  remove(wordText: string) {
-    this.learnedWords.delete(wordText)
-    window.dispatchEvent(
-      new CustomEvent('learnedWords:remove', { detail: wordText })
-    )
-  }
-
-  has(wordText: string) {
-    return (
-      this.ignoredMisspellings.has(wordText) || this.learnedWords.has(wordText)
-    )
-  }
-}
-
-export default new IgnoredWords()

+ 0 - 13
services/web/frontend/js/features/source-editor/extensions/spelling/backend.ts

@@ -1,13 +0,0 @@
-import { postJSON } from '@/infrastructure/fetch-json'
-import { Word } from './spellchecker'
-
-export async function learnWordRequest(word?: Word) {
-  if (!word || !word.text) {
-    throw new Error(`Invalid word supplied: ${word}`)
-  }
-  return await postJSON('/spelling/learn', {
-    body: {
-      word: word.text,
-    },
-  })
-}

+ 13 - 14
services/web/frontend/js/features/source-editor/extensions/spelling/cache.ts

@@ -7,16 +7,14 @@ export const cacheKey = (lang: string, wordText: string) => {
   return `${lang}:${wordText}`
 }
 
-export type WordCacheValue = string[] | boolean | number
-
 export class WordCache {
-  private _cache: LRU<string, WordCacheValue>
+  private _cache: LRU<string, boolean>
 
   constructor() {
     this._cache = new LRU({ max: CACHE_MAX })
   }
 
-  set(lang: string, wordText: string, value: WordCacheValue) {
+  set(lang: string, wordText: string, value: boolean) {
     const key = cacheKey(lang, wordText)
     this._cache.set(key, value)
   }
@@ -31,6 +29,10 @@ export class WordCache {
     this._cache.delete(key)
   }
 
+  reset() {
+    this._cache = new LRU({ max: CACHE_MAX })
+  }
+
   /*
    * Given a language and a list of words,
    * check the cache and sort the words into two categories:
@@ -44,23 +46,20 @@ export class WordCache {
     knownMisspelledWords: Word[]
     unknownWords: Word[]
   } {
-    const knownMisspelledWords = []
-    const unknownWords = []
-    const seen: Record<string, WordCacheValue | undefined> = {}
+    const knownMisspelledWords: Word[] = []
+    const unknownWords: Word[] = []
+    const seen: Record<string, boolean | undefined> = {}
     for (const word of wordsToCheck) {
       const wordText = word.text
-      if (seen[wordText] == null) {
+      if (seen[wordText] === undefined) {
         seen[wordText] = this.get(lang, wordText)
       }
       const cached = seen[wordText]
-      if (cached == null) {
+      if (cached === undefined) {
         // Word is not known
         unknownWords.push(word)
-      } else if (cached === true) {
-        // Word is known to be correct
-      } else {
+      } else if (!cached) {
         // Word is known to be misspelled
-        word.suggestions = cached
         knownMisspelledWords.push(word)
       }
     }
@@ -74,7 +73,7 @@ export class WordCache {
 export const addWordToCache = StateEffect.define<{
   lang: string
   wordText: string
-  value: string[] | boolean
+  value: boolean
 }>()
 
 export const removeWordFromCache = StateEffect.define<{

+ 14 - 8
services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx

@@ -5,8 +5,6 @@ import {
   Prec,
 } from '@codemirror/state'
 import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view'
-import { addIgnoredWord } from './ignored-words'
-import { learnWordRequest } from './backend'
 import { Word, Mark, getMarkAtPosition } from './spellchecker'
 import { debugConsole } from '@/utils/debugging'
 import {
@@ -17,6 +15,8 @@ import { sendMB } from '@/infrastructure/event-tracking'
 import ReactDOM from 'react-dom'
 import { SpellingSuggestions } from '@/features/source-editor/extensions/spelling/spelling-suggestions'
 import { SplitTestProvider } from '@/shared/context/split-test-context'
+import { addLearnedWord } from '@/features/source-editor/extensions/spelling/learned-words'
+import { postJSON } from '@/infrastructure/fetch-json'
 
 /*
  * The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
@@ -176,10 +176,14 @@ const createSpellingSuggestionList = (word: Word) => (view: EditorView) => {
           }
         }}
         handleLearnWord={() => {
-          learnWordRequest(word)
+          postJSON('/spelling/learn', {
+            body: {
+              word: word.text,
+            },
+          })
             .then(() => {
-              view.dispatch({
-                effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
+              view.dispatch(addLearnedWord(word.text), {
+                effects: hideSpellingMenu.of(null),
               })
               sendMB('spelling-word-added', {
                 language: getSpellCheckLanguage(view.state),
@@ -203,9 +207,11 @@ const createSpellingSuggestionList = (word: Word) => (view: EditorView) => {
             return
           }
 
-          view.dispatch({
-            changes: [{ from: tooltip.pos, to: tooltip.end, insert: text }],
-            effects: [hideSpellingMenu.of(null)],
+          window.setTimeout(() => {
+            view.dispatch({
+              changes: [{ from: tooltip.pos, to: tooltip.end, insert: text }],
+              effects: [hideSpellingMenu.of(null)],
+            })
           })
           view.focus()
 

+ 0 - 31
services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts

@@ -1,31 +0,0 @@
-import { StateField, StateEffect } from '@codemirror/state'
-import ignoredWords, { IgnoredWords } from '../../../dictionary/ignored-words'
-
-export const ignoredWordsField = StateField.define<IgnoredWords>({
-  create() {
-    return ignoredWords
-  },
-  update(ignoredWords, transaction) {
-    for (const effect of transaction.effects) {
-      if (effect.is(addIgnoredWord)) {
-        const newWord = effect.value
-        ignoredWords.add(newWord.text)
-      }
-    }
-    return ignoredWords
-  },
-})
-
-export const addIgnoredWord = StateEffect.define<{
-  text: string
-}>()
-
-export const removeIgnoredWord = StateEffect.define<{
-  text: string
-}>()
-
-export const updateAfterAddingIgnoredWord = StateEffect.define<string>()
-
-export const updateAfterRemovingIgnoredWord = StateEffect.define<string>()
-
-export const resetSpellChecker = StateEffect.define()

+ 17 - 58
services/web/frontend/js/features/source-editor/extensions/spelling/index.ts

@@ -6,20 +6,12 @@ import {
   TransactionSpec,
 } from '@codemirror/state'
 import { misspelledWordsField } from './misspelled-words'
-import {
-  addIgnoredWord,
-  ignoredWordsField,
-  removeIgnoredWord,
-  resetSpellChecker,
-  updateAfterAddingIgnoredWord,
-} from './ignored-words'
-import { addWordToCache, cacheField, removeWordFromCache } from './cache'
+import { removeLearnedWord } from './learned-words'
+import { cacheField } from './cache'
 import { hideSpellingMenu, spellingMenuField } from './context-menu'
 import { SpellChecker } from './spellchecker'
 import { parserWatcher } from '../wait-for-parser'
 import type { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager'
-import { debugConsole } from '@/utils/debugging'
-import { captureException } from '@/infrastructure/error-reporter'
 
 type Options = {
   spellCheckLanguage?: string
@@ -44,12 +36,26 @@ export const spelling = ({ spellCheckLanguage, hunspellManager }: Options) => {
         : null
     ),
     misspelledWordsField,
-    ignoredWordsField,
     cacheField,
     spellingMenuField,
+    dictionary,
   ]
 }
 
+const dictionary = ViewPlugin.define(view => {
+  const listener = (event: Event) => {
+    view.dispatch(removeLearnedWord((event as CustomEvent<string>).detail))
+  }
+
+  window.addEventListener('editor:remove-learned-word', listener)
+
+  return {
+    destroy() {
+      window.removeEventListener('editor:remove-learned-word', listener)
+    },
+  }
+})
+
 const spellingTheme = EditorView.baseTheme({
   '.ol-cm-spelling-error': {
     textDecorationColor: 'red',
@@ -82,16 +88,6 @@ const spellCheckerField = StateField.define<SpellChecker | null>({
               effect.value.hunspellManager
             )
           : null
-      } else if (effect.is(addIgnoredWord)) {
-        value?.addWord(effect.value.text).catch(error => {
-          captureException(error)
-          debugConsole.error(error)
-        })
-      } else if (effect.is(removeIgnoredWord)) {
-        value?.removeWord(effect.value.text).catch(error => {
-          captureException(error)
-          debugConsole.error(error)
-        })
       }
     }
     return value
@@ -157,40 +153,3 @@ export const setSpellCheckLanguage = ({
     ],
   }
 }
-
-export const addLearnedWord = (
-  spellCheckLanguage: string,
-  word: string
-): TransactionSpec => {
-  return {
-    effects: [
-      addWordToCache.of({
-        lang: spellCheckLanguage,
-        wordText: word,
-        value: true,
-      }),
-      updateAfterAddingIgnoredWord.of(word),
-    ],
-  }
-}
-
-export const removeLearnedWord = (
-  spellCheckLanguage: string,
-  word: string
-): TransactionSpec => {
-  return {
-    effects: [
-      removeWordFromCache.of({
-        lang: spellCheckLanguage,
-        wordText: word,
-      }),
-      removeIgnoredWord.of({ text: word }),
-    ],
-  }
-}
-
-export const resetLearnedWords = (): TransactionSpec => {
-  return {
-    effects: [resetSpellChecker.of(null)],
-  }
-}

+ 24 - 0
services/web/frontend/js/features/source-editor/extensions/spelling/learned-words.ts

@@ -0,0 +1,24 @@
+import { StateEffect } from '@codemirror/state'
+import getMeta from '@/utils/meta'
+
+export const addLearnedWordEffect = StateEffect.define<string>()
+
+export const removeLearnedWordEffect = StateEffect.define<string>()
+
+export const learnedWords = {
+  global: new Set(getMeta('ol-learnedWords')),
+}
+
+export const addLearnedWord = (text: string) => {
+  learnedWords.global.add(text)
+  return {
+    effects: addLearnedWordEffect.of(text),
+  }
+}
+
+export const removeLearnedWord = (text: string) => {
+  learnedWords.global.delete(text)
+  return {
+    effects: removeLearnedWordEffect.of(text),
+  }
+}

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

@@ -1,6 +1,6 @@
 import { StateField, StateEffect, Line } from '@codemirror/state'
 import { EditorView, Decoration, DecorationSet } from '@codemirror/view'
-import { updateAfterAddingIgnoredWord } from './ignored-words'
+import { addLearnedWordEffect } from './learned-words'
 import { Word } from './spellchecker'
 import { setSpellCheckLanguageEffect } from '@/features/source-editor/extensions/spelling/index'
 
@@ -59,11 +59,15 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
           add: effect.value.map(word => createMark(word)),
           sort: true,
         })
-      } else if (effect.is(updateAfterAddingIgnoredWord)) {
+      } else if (effect.is(addLearnedWordEffect)) {
+        const word = effect.value
         // Remove existing marks matching the text of a supplied word
         marks = marks.update({
           filter(_from, _to, mark) {
-            return mark.spec.word.text !== effect.value
+            return (
+              mark.spec.word.text !== word &&
+              mark.spec.word.text !== capitaliseWord(word)
+            )
           },
         })
       } else if (effect.is(setSpellCheckLanguageEffect)) {
@@ -76,3 +80,6 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
     return EditorView.decorations.from(field)
   },
 })
+
+const capitaliseWord = (word: string) =>
+  word.charAt(0).toUpperCase() + word.substring(1)

+ 59 - 62
services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts

@@ -1,11 +1,10 @@
 import { addMisspelledWords, misspelledWordsField } from './misspelled-words'
-import { ignoredWordsField, resetSpellChecker } from './ignored-words'
-import { cacheField, addWordToCache, WordCacheValue } from './cache'
+import { addLearnedWordEffect, removeLearnedWordEffect } from './learned-words'
+import { cacheField, addWordToCache } from './cache'
 import { WORD_REGEX } from './helpers'
 import OError from '@overleaf/o-error'
 import { EditorView, ViewUpdate } from '@codemirror/view'
 import { ChangeSet, Line, Range, RangeValue } from '@codemirror/state'
-import { IgnoredWords } from '../../../dictionary/ignored-words'
 import { getNormalTextSpansFromLine } from '../../utils/tree-query'
 import { waitForParser } from '../wait-for-parser'
 import { debugConsole } from '@/utils/debugging'
@@ -77,14 +76,6 @@ export class SpellChecker {
     } 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))
-      })
-    ) {
-      // for tests
-      this.trackedChanges = ChangeSet.empty(0)
-      this.spellCheckAsap(update.view)
     }
     // At the point that the spellchecker is initialized, the editor may not
     // yet be editable, and the parser may not be ready. Therefore, to do the
@@ -100,6 +91,34 @@ export class SpellChecker {
     ) {
       this.firstCheckPending = true
       this.spellCheckAsap(update.view)
+    } else {
+      for (const tr of update.transactions) {
+        for (const effect of tr.effects) {
+          if (effect.is(addLearnedWordEffect)) {
+            this.addWord(effect.value)
+              .then(() => {
+                update.view.state.field(cacheField, false)?.reset()
+                this.trackedChanges = ChangeSet.empty(0)
+                this.spellCheckAsap(update.view)
+              })
+              .catch(error => {
+                captureException(error)
+                debugConsole.error(error)
+              })
+          } else if (effect.is(removeLearnedWordEffect)) {
+            this.removeWord(effect.value)
+              .then(() => {
+                update.view.state.field(cacheField, false)?.reset()
+                this.trackedChanges = ChangeSet.empty(0)
+                this.spellCheckAsap(update.view)
+              })
+              .catch(error => {
+                captureException(error)
+                debugConsole.error(error)
+              })
+          }
+        }
+      }
     }
   }
 
@@ -114,22 +133,29 @@ export class SpellChecker {
       this.language,
       wordsToCheck
     )
-    const processResult = (
-      misspellings: { index: number; suggestions?: string[] }[]
-    ) => {
+    const processResult = (misspellings: { index: number }[]) => {
       this.trackedChanges = ChangeSet.empty(0)
 
       if (this.firstCheck) {
         this.firstCheck = false
         this.firstCheckPending = false
       }
-      const result = buildSpellCheckResult(
+      const { misspelledWords, cacheAdditions } = buildSpellCheckResult(
         knownMisspelledWords,
         unknownWords,
         misspellings
       )
       view.dispatch({
-        effects: compileEffects(result),
+        effects: [
+          addMisspelledWords.of(misspelledWords),
+          ...cacheAdditions.map(([word, value]) => {
+            return addWordToCache.of({
+              lang: word.lang,
+              wordText: word.text,
+              value,
+            })
+          }),
+        ],
       })
     }
     if (unknownWords.length === 0) {
@@ -264,19 +290,10 @@ export class SpellChecker {
       }
     }
 
-    const ignoredWords = this.hunspellManager
-      ? null
-      : view.state.field(ignoredWordsField)
     for (const i of changedLineNumbers) {
       const line = view.state.doc.line(i)
       wordsToCheck.push(
-        ...getWordsFromLine(
-          view,
-          line,
-          ignoredWords,
-          this.language,
-          this.segmenter
-        )
+        ...getWordsFromLine(view, line, this.language, this.segmenter)
       )
     }
 
@@ -290,7 +307,6 @@ export class Word {
   public to: number
   public lineNumber: number
   public lang: string
-  public suggestions?: WordCacheValue
 
   constructor(options: {
     text: string
@@ -320,25 +336,26 @@ export class Word {
 export const buildSpellCheckResult = (
   knownMisspelledWords: Word[],
   unknownWords: Word[],
-  misspellings: { index: number; suggestions?: string[] }[]
+  misspellings: { index: number }[]
 ) => {
-  const cacheAdditions: [Word, string[] | boolean][] = []
+  const cacheAdditions: [Word, boolean][] = []
 
   // Put known misspellings into cache
   const misspelledWords = misspellings.map(item => {
     const word = {
       ...unknownWords[item.index],
     }
-    word.suggestions = item.suggestions
-    if (word.suggestions) {
-      cacheAdditions.push([word, word.suggestions])
-    }
+    cacheAdditions.push([word, false])
     return word
   })
 
+  const misspelledWordsSet = new Set<string>(
+    misspelledWords.map(word => word.text)
+  )
+
   // if word was not misspelled, put it in the cache
   for (const word of unknownWords) {
-    if (!misspelledWords.find(mw => mw.text === word.text)) {
+    if (!misspelledWordsSet.has(word.text)) {
       cacheAdditions.push([word, true])
     }
   }
@@ -349,34 +366,16 @@ export const buildSpellCheckResult = (
   }
 }
 
-export const compileEffects = (results: {
-  cacheAdditions: [Word, string[] | boolean][]
-  misspelledWords: Word[]
-}) => {
-  const { cacheAdditions, misspelledWords } = results
-  return [
-    addMisspelledWords.of(misspelledWords),
-    ...cacheAdditions.map(([word, value]) => {
-      return addWordToCache.of({
-        lang: word.lang,
-        wordText: word.text,
-        value,
-      })
-    }),
-  ]
-}
-
 export function* getWordsFromLine(
   view: EditorView,
   line: Line,
-  ignoredWords: IgnoredWords | null,
   lang: string,
   segmenter?: Intl.Segmenter
 ) {
   for (const span of getNormalTextSpansFromLine(view, line)) {
     if (segmenter) {
       for (const value of segmenter.segment(span.text)) {
-        if (value.isWordLike && !ignoredWords?.has(value.segment)) {
+        if (value.isWordLike) {
           const word = value.segment
           const from = span.from + value.index
           yield new Word({
@@ -396,15 +395,13 @@ export function* getWordsFromLine(
           word = word.slice(1)
           from++
         }
-        if (!ignoredWords?.has(word)) {
-          yield new Word({
-            text: word,
-            from,
-            to: from + word.length,
-            lineNumber: line.number,
-            lang,
-          })
-        }
+        yield new Word({
+          text: word,
+          from,
+          to: from + word.length,
+          lineNumber: line.number,
+          lang,
+        })
       }
     }
   }

+ 17 - 23
services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions.tsx

@@ -41,34 +41,28 @@ export const SpellingSuggestions: FC<SpellingSuggestionsProps> = ({
   handleLearnWord,
   handleCorrectWord,
 }) => {
-  const [suggestions, setSuggestions] = useState(() =>
-    Array.isArray(word.suggestions)
-      ? word.suggestions.slice(0, ITEMS_TO_SHOW)
-      : []
-  )
+  const [suggestions, setSuggestions] = useState<string[]>([])
 
-  const [waiting, setWaiting] = useState(!word.suggestions)
+  const [waiting, setWaiting] = useState(true)
 
   useEffect(() => {
-    if (!word.suggestions) {
-      spellChecker
-        ?.suggest(word.text)
-        .then(result => {
-          setSuggestions(result.suggestions.slice(0, ITEMS_TO_SHOW))
-          setWaiting(false)
-          sendMB('spelling-suggestion-shown', {
-            language: spellCheckLanguage,
-            count: result.suggestions.length,
-            // word: transaction.state.sliceDoc(mark.from, mark.to),
-          })
+    spellChecker
+      ?.suggest(word.text)
+      .then(result => {
+        setSuggestions(result.suggestions.slice(0, ITEMS_TO_SHOW))
+        setWaiting(false)
+        sendMB('spelling-suggestion-shown', {
+          language: spellCheckLanguage,
+          count: result.suggestions.length,
+          // word: transaction.state.sliceDoc(mark.from, mark.to),
         })
-        .catch(error => {
-          captureException(error, {
-            tags: { ol_spell_check_language: spellCheckLanguage },
-          })
-          debugConsole.error(error)
+      })
+      .catch(error => {
+        captureException(error, {
+          tags: { ol_spell_check_language: spellCheckLanguage },
         })
-    }
+        debugConsole.error(error)
+      })
   }, [word, spellChecker, spellCheckLanguage])
 
   const language = useMemo(() => {

+ 1 - 39
services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts

@@ -33,12 +33,7 @@ import { setAutoPair } from '../extensions/auto-pair'
 import { setAutoComplete } from '../extensions/auto-complete'
 import { usePhrases } from './use-phrases'
 import { setPhrases } from '../extensions/phrases'
-import {
-  addLearnedWord,
-  removeLearnedWord,
-  resetLearnedWords,
-  setSpellCheckLanguage,
-} from '../extensions/spelling'
+import { setSpellCheckLanguage } from '../extensions/spelling'
 import {
   createChangeManager,
   dispatchEditorEvent,
@@ -566,39 +561,6 @@ function useCodeMirrorScope(view: EditorView) {
     }
   }, [view, cursorHighlights, currentDoc])
 
-  const handleAddLearnedWords = useCallback(
-    (event: CustomEvent<string>) => {
-      // If the word addition is from adding the word to the dictionary via the
-      // editor, there will be a transaction running now so wait for that to
-      // finish before starting a new one
-      window.setTimeout(() => {
-        view.dispatch(addLearnedWord(spellCheckLanguage, event.detail))
-      }, 0)
-    },
-    [spellCheckLanguage, view]
-  )
-
-  useEventListener('learnedWords:add', handleAddLearnedWords)
-
-  const handleRemoveLearnedWords = useCallback(
-    (event: CustomEvent<string>) => {
-      window.setTimeout(() => {
-        view.dispatch(removeLearnedWord(spellCheckLanguage, event.detail))
-      })
-    },
-    [spellCheckLanguage, view]
-  )
-
-  useEventListener('learnedWords:remove', handleRemoveLearnedWords)
-
-  const handleResetLearnedWords = useCallback(() => {
-    window.setTimeout(() => {
-      view.dispatch(resetLearnedWords())
-    })
-  }, [view])
-
-  useEventListener('learnedWords:reset', handleResetLearnedWords)
-
   useEventListener(
     'editor:focus',
     useCallback(() => {

+ 4 - 3
services/web/frontend/js/features/source-editor/hooks/use-hunspell.ts

@@ -1,8 +1,9 @@
 import { useEffect, useState } from 'react'
 import getMeta from '@/utils/meta'
-import { globalLearnedWords } from '@/features/dictionary/ignored-words'
+import { globalIgnoredWords } from '@/features/dictionary/ignored-words'
 import { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager'
 import { debugConsole } from '@/utils/debugging'
+import { learnedWords } from '@/features/source-editor/extensions/spelling/learned-words'
 
 export const useHunspell = (spellCheckLanguage: string | null) => {
   const [hunspellManager, setHunspellManager] = useState<HunspellManager>()
@@ -14,8 +15,8 @@ export const useHunspell = (spellCheckLanguage: string | null) => {
       )
       if (lang?.dic) {
         const hunspellManager = new HunspellManager(lang.dic, [
-          ...globalLearnedWords,
-          ...getMeta('ol-learnedWords'),
+          ...globalIgnoredWords,
+          ...learnedWords.global,
         ])
         setHunspellManager(hunspellManager)
         debugConsole.log(spellCheckLanguage, hunspellManager)

+ 16 - 14
services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx

@@ -1,21 +1,26 @@
 import DictionaryModal from '@/features/dictionary/components/dictionary-modal'
 import { EditorProviders } from '../../../helpers/editor-providers'
+import { learnedWords } from '@/features/source-editor/extensions/spelling/learned-words'
 
 describe('<DictionaryModalContent />', function () {
+  let originalLearnedWords
+
   beforeEach(function () {
+    cy.then(() => {
+      originalLearnedWords = learnedWords.global
+    })
     cy.interceptCompile()
   })
 
   afterEach(function () {
-    cy.window().then(win => {
-      win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
+    cy.then(() => {
+      learnedWords.global = originalLearnedWords
     })
   })
 
   it('list words', function () {
-    cy.window().then(win => {
-      win.metaAttributesCache.set('ol-learnedWords', ['foo', 'bar'])
-      win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
+    cy.then(win => {
+      learnedWords.global = new Set(['foo', 'bar'])
     })
 
     cy.mount(
@@ -29,9 +34,8 @@ describe('<DictionaryModalContent />', function () {
   })
 
   it('shows message when empty', function () {
-    cy.window().then(win => {
-      win.metaAttributesCache.set('ol-learnedWords', [])
-      win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
+    cy.then(win => {
+      learnedWords.global = new Set([])
     })
 
     cy.mount(
@@ -46,9 +50,8 @@ describe('<DictionaryModalContent />', function () {
   it('removes words', function () {
     cy.intercept('/spelling/unlearn', { statusCode: 200 })
 
-    cy.window().then(win => {
-      win.metaAttributesCache.set('ol-learnedWords', ['Foo', 'bar'])
-      win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
+    cy.then(win => {
+      learnedWords.global = new Set(['Foo', 'bar'])
     })
 
     cy.mount(
@@ -73,9 +76,8 @@ describe('<DictionaryModalContent />', function () {
   it('handles errors', function () {
     cy.intercept('/spelling/unlearn', { statusCode: 500 }).as('unlearn')
 
-    cy.window().then(win => {
-      win.metaAttributesCache.set('ol-learnedWords', ['foo'])
-      win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
+    cy.then(win => {
+      learnedWords.global = new Set(['foo'])
     })
 
     cy.mount(

+ 12 - 12
services/web/test/frontend/features/source-editor/extensions/spelling/cache.test.ts

@@ -1,6 +1,6 @@
-import { WordCache } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/cache'
+import { WordCache } from '@/features/source-editor/extensions/spelling/cache'
 import { expect } from 'chai'
-import { Word } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/spellchecker'
+import { Word } from '@/features/source-editor/extensions/spelling/spellchecker'
 
 describe('WordCache', function () {
   describe('basic operations', function () {
@@ -18,25 +18,25 @@ describe('WordCache', function () {
 
       word = 'bar'
       expect(cache.get(lang, word)).to.not.exist
-      cache.set(lang, word, ['a', 'b'])
-      expect(cache.get(lang, word)).to.deep.equal(['a', 'b'])
+      cache.set(lang, word, false)
+      expect(cache.get(lang, word)).to.equal(false)
     })
 
     it('should store words in separate languages', function () {
       const word = 'foo'
       const otherLang = 'zz'
 
-      cache.set(lang, word, 101)
-      expect(cache.get(lang, word)).to.equal(101)
+      cache.set(lang, word, true)
+      expect(cache.get(lang, word)).to.equal(true)
       expect(cache.get(otherLang, word)).to.not.exist
 
-      cache.set(otherLang, word, 202)
-      expect(cache.get(lang, word)).to.equal(101)
-      expect(cache.get(otherLang, word)).to.equal(202)
+      cache.set(otherLang, word, false)
+      expect(cache.get(lang, word)).to.equal(true)
+      expect(cache.get(otherLang, word)).to.equal(false)
     })
 
     it('should check words against cache', function () {
-      cache.set(lang, 'foo', ['a', 'b'])
+      cache.set(lang, 'foo', false)
       cache.set(lang, 'bar', true)
       cache.set(lang, 'baz', true)
       const wordsToCheck = [
@@ -49,8 +49,8 @@ describe('WordCache', function () {
       const result = cache.checkWords(lang, wordsToCheck)
       expect(result).to.have.keys('knownMisspelledWords', 'unknownWords')
       expect(result.knownMisspelledWords).to.deep.equal([
-        { text: 'foo', suggestions: ['a', 'b'], from: 0 },
-        { text: 'foo', suggestions: ['a', 'b'], from: 3 },
+        { text: 'foo', from: 0 },
+        { text: 'foo', from: 3 },
       ])
       expect(result.unknownWords).to.deep.equal([
         { text: 'quux', from: 2 },

+ 16 - 42
services/web/test/frontend/features/source-editor/extensions/spelling/spellchecker.test.ts

@@ -5,7 +5,6 @@ import {
 } from '@/features/source-editor/extensions/spelling/spellchecker'
 import { expect } from 'chai'
 import { EditorView } from '@codemirror/view'
-import { IgnoredWords } from '@/features/dictionary/ignored-words'
 import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language'
 import { LanguageSupport } from '@codemirror/language'
 
@@ -13,11 +12,10 @@ const extensions = [new LanguageSupport(LaTeXLanguage)]
 
 describe('SpellChecker', function () {
   describe('getWordsFromLine', function () {
-    let lang: string, ignoredWords: IgnoredWords
+    let lang: string
     beforeEach(function () {
       /* Note: ignore the word 'test' */
       lang = 'en'
-      ignoredWords = new Set([]) as unknown as IgnoredWords
     })
 
     it('should get words from a line', function () {
@@ -26,7 +24,7 @@ describe('SpellChecker', function () {
         extensions,
       })
       const line = view.state.doc.line(1)
-      const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang))
+      const words = Array.from(getWordsFromLine(view, line, lang))
       expect(words).to.deep.equal([
         { text: 'Hello', from: 0, to: 5, lineNumber: 1, lang: 'en' },
         { text: 'test', from: 6, to: 10, lineNumber: 1, lang: 'en' },
@@ -35,28 +33,13 @@ describe('SpellChecker', function () {
       ])
     })
 
-    it('should ignore words in ignoredWords', function () {
-      ignoredWords = new Set(['test']) as unknown as IgnoredWords
-      const view = new EditorView({
-        doc: 'Hello test one two',
-        extensions,
-      })
-      const line = view.state.doc.line(1)
-      const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang))
-      expect(words).to.deep.equal([
-        { text: 'Hello', from: 0, to: 5, lineNumber: 1, lang: 'en' },
-        { text: 'one', from: 11, to: 14, lineNumber: 1, lang: 'en' },
-        { text: 'two', from: 15, to: 18, lineNumber: 1, lang: 'en' },
-      ])
-    })
-
     it('should get no words from an empty line', function () {
       const view = new EditorView({
         doc: '    ',
         extensions,
       })
       const line = view.state.doc.line(1)
-      const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang))
+      const words = Array.from(getWordsFromLine(view, line, lang))
       expect(words).to.deep.equal([])
     })
 
@@ -66,7 +49,7 @@ describe('SpellChecker', function () {
         extensions,
       })
       const line = view.state.doc.line(1)
-      const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang))
+      const words = Array.from(getWordsFromLine(view, line, lang))
       expect(words).to.deep.equal([
         { text: 'seven', from: 24, to: 29, lineNumber: 1, lang: 'en' },
         { text: 'eight', from: 30, to: 35, lineNumber: 1, lang: 'en' },
@@ -79,7 +62,7 @@ describe('SpellChecker', function () {
         extensions,
       })
       const line = view.state.doc.line(1)
-      const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang))
+      const words = Array.from(getWordsFromLine(view, line, lang))
       expect(words).to.deep.equal([
         { text: 'nine', from: 5, to: 9, lineNumber: 1, lang: 'en' },
         { text: 'ten', from: 15, to: 18, lineNumber: 1, lang: 'en' },
@@ -91,7 +74,7 @@ describe('SpellChecker', function () {
     it('should build an empty result', function () {
       const knownMisspelledWords: Word[] = []
       const unknownWords: Word[] = []
-      const misspellings: { index: number; suggestions: string[] }[] = []
+      const misspellings: { index: number }[] = []
       const result = buildSpellCheckResult(
         knownMisspelledWords,
         unknownWords,
@@ -103,21 +86,17 @@ describe('SpellChecker', function () {
       })
     })
     it('should build a realistic result', function () {
-      const _makeWord = (text: string, suggestions?: string[]) => {
-        const word = new Word({
+      const _makeWord = (text: string) => {
+        return new Word({
           text,
           from: 0,
           to: 0,
           lineNumber: 0,
           lang: 'xx',
         })
-        if (suggestions != null) {
-          word.suggestions = suggestions
-        }
-        return word
       }
       // We know this word is misspelled
-      const knownMisspelledWords = [_makeWord('fff', ['food', 'fleece'])]
+      const knownMisspelledWords = [_makeWord('fff')]
       // These words we didn't know
       const unknownWords = [
         _makeWord('aaa'),
@@ -126,10 +105,7 @@ describe('SpellChecker', function () {
         _makeWord('ddd'),
       ]
       // These are the suggestions we got back from the backend
-      const misspellings = [
-        { index: 1, suggestions: ['box', 'bass'] },
-        { index: 3, suggestions: ['docs', 'dance'] },
-      ]
+      const misspellings = [{ index: 1 }, { index: 3 }]
       // Build the result structure
       const result = buildSpellCheckResult(
         knownMisspelledWords,
@@ -140,21 +116,19 @@ describe('SpellChecker', function () {
       // Check cache additions
       expect(result.cacheAdditions.map(([k, v]) => [k.text, v])).to.deep.equal([
         // Put these in cache as known misspellings
-        ['bbb', ['box', 'bass']],
-        ['ddd', ['docs', 'dance']],
+        ['bbb', false],
+        ['ddd', false],
         // Put these in cache as known-correct
         ['aaa', true],
         ['ccc', true],
       ])
       // Check misspellings
-      expect(
-        result.misspelledWords.map(w => [w.text, w.suggestions])
-      ).to.deep.equal([
+      expect(result.misspelledWords.map(w => w.text)).to.deep.equal([
         // Words in the payload that we now know were misspelled
-        ['bbb', ['box', 'bass']],
-        ['ddd', ['docs', 'dance']],
+        'bbb',
+        'ddd',
         // Word we already knew was misspelled, preserved here
-        ['fff', ['food', 'fleece']],
+        'fff',
       ])
     })
   })