mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-30 20:31:34 +02:00
Improve spell check when dictionary is edited (#22635)
GitOrigin-RevId: 20d36cb987d014809423240a46c7c577781dfde6
This commit is contained in:
@@ -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,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()
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user