diff --git a/src/components/DataEntry/DataEntryTable/EntryCellComponents/GlossWithSuggestions.tsx b/src/components/DataEntry/DataEntryTable/EntryCellComponents/GlossWithSuggestions.tsx index 34c81b5b63..ed3bc95ce7 100644 --- a/src/components/DataEntry/DataEntryTable/EntryCellComponents/GlossWithSuggestions.tsx +++ b/src/components/DataEntry/DataEntryTable/EntryCellComponents/GlossWithSuggestions.tsx @@ -27,8 +27,6 @@ export default function GlossWithSuggestions( ): ReactElement { const spellChecker = useContext(SpellCheckerContext); - const maxSuggestions = 5; - useEffect(() => { if (props.onUpdate) { props.onUpdate(); @@ -39,11 +37,8 @@ export default function GlossWithSuggestions( - options.length <= maxSuggestions - ? options - : options.slice(0, maxSuggestions) - } + // there's a bug with disappearing options if filterOptions isn't specified + filterOptions={(options) => options} // freeSolo allows use of a typed entry not available as a drop-down option freeSolo includeInputInList diff --git a/src/utilities/spellChecker.ts b/src/utilities/spellChecker.ts index fedada7794..e938ae1529 100644 --- a/src/utilities/spellChecker.ts +++ b/src/utilities/spellChecker.ts @@ -8,9 +8,12 @@ interface SplitWord { final?: string; } +const maxSuggestions = 5; + export default class SpellChecker { private bcp47: Bcp47Code | undefined; private dictLoader: DictionaryLoader | undefined; + private dictLoaded: { [key: string]: string[] } = {}; private spell: nspell | undefined; constructor(lang?: string) { @@ -29,9 +32,11 @@ export default class SpellChecker { this.bcp47 = bcp47; this.dictLoader = new DictionaryLoader(bcp47); + this.dictLoaded = {}; await this.dictLoader.loadDictionary().then((dic) => { if (dic !== undefined) { this.spell = nspell("SET UTF-8", dic); + this.addToDictLoaded(dic); if (process.env.NODE_ENV === "development") { console.log(`Loaded spell-checker: ${bcp47}`); } @@ -47,6 +52,7 @@ export default class SpellChecker { const part = await this.dictLoader.loadDictPart(word); if (part) { + this.addToDictLoaded(part); this.spell.personal(part); } } @@ -55,6 +61,15 @@ export default class SpellChecker { return this.spell?.correct(word); } + addToDictLoaded(entries: string): void { + entries.split("\n").map((w) => { + if (!(w[0] in this.dictLoaded)) { + this.dictLoaded[w[0]] = []; + } + this.dictLoaded[w[0]].push(w); + }); + } + static cleanAndSplit(word: string): SplitWord { // Trim whitespace from the start and non-letter/-mark/-number characters from the end. // Use of \p{L}\p{M}\p{N} here matches that in split_dictionary.py. @@ -88,15 +103,30 @@ export default class SpellChecker { // Don't await--just load for future use. this.load(final); + // Get spelling suggestions. let suggestions = this.spell.suggest(final); - if (!suggestions.length) { - // Extend the current word to get suggestions 1 or 2 characters longer. - suggestions = this.spell.suggest(`${final}..`); + + // Add lookahead suggestions. + if (this.dictLoaded[final[0]] && suggestions.length < maxSuggestions) { + const lookahead = this.dictLoaded[final[0]].filter( + (entry) => + entry.length >= final.length && + entry.substring(0, final.length) === final && + !suggestions.includes(entry) + ); + suggestions.push(...lookahead.sort()); + } + + // Limit to maxSuggestions. + if (suggestions.length > maxSuggestions) { + suggestions = suggestions.slice(0, maxSuggestions); } + // Prepend the start of the typed phrase, if any. if (suggestions.length && allButFinal) { suggestions = suggestions.map((w) => allButFinal + w); } + return suggestions; } } diff --git a/src/utilities/tests/dictionaryLoader.test.ts b/src/utilities/tests/dictionaryLoader.test.ts index e66dc0357d..2ba97d462f 100644 --- a/src/utilities/tests/dictionaryLoader.test.ts +++ b/src/utilities/tests/dictionaryLoader.test.ts @@ -13,7 +13,7 @@ const bcp47 = Bcp47Code.Es; describe("DictionaryLoader", () => { describe("constructor", () => { - it(" ets lang and gets keys", () => { + it("gets lang and gets keys", () => { const loader = new DictionaryLoader(bcp47); expect(loader.lang === bcp47); expect(mockGetKeys).toHaveBeenCalledTimes(1); diff --git a/src/utilities/tests/spellChecker.test.ts b/src/utilities/tests/spellChecker.test.ts index f83cc04403..009c291c92 100644 --- a/src/utilities/tests/spellChecker.test.ts +++ b/src/utilities/tests/spellChecker.test.ts @@ -1,11 +1,16 @@ import SpellChecker from "utilities/spellChecker"; jest.mock("resources/dictionaries", () => ({ - getDict: () => Promise.resolve(`1\n${mockValidWord}`), + getDict: () => Promise.resolve(`2\n${mockValidWordA}\n${mockValidWordBExt}`), getKeys: () => [], })); -const mockValidWord = "mockWord"; +const invalidWord = "asdfghjkl"; +const mockWord = "mockWord"; +const mockValidWordA = `${mockWord}A`; +const mockWordB = `${mockWord}B`; +const mockWordC = `${mockWord}C`; +const mockValidWordBExt = `${mockWordB}Extended`; describe("SpellChecker", () => { describe("correct", () => { @@ -13,7 +18,7 @@ describe("SpellChecker", () => { const spellChecker = new SpellChecker("en"); // Give the dictionary half-a-sec to load. setTimeout(() => { - expect(spellChecker.correct(mockValidWord)).toEqual(true); + expect(spellChecker.correct(mockValidWordA)).toEqual(true); done(); }, 500); }); @@ -22,7 +27,7 @@ describe("SpellChecker", () => { const spellChecker = new SpellChecker("en"); // Give the dictionary half-a-sec to load. setTimeout(() => { - expect(spellChecker.correct("abjkdsjf")).toEqual(false); + expect(spellChecker.correct(invalidWord)).toEqual(false); done(); }, 500); }); @@ -73,13 +78,38 @@ describe("SpellChecker", () => { }); describe("getSpellingSuggestions", () => { - it("returns suggestions", (done) => { + it("returns nothing for gibberish", (done) => { const spellChecker = new SpellChecker("en"); // Give the dictionary half-a-sec to load. setTimeout(() => { - expect( - spellChecker.getSpellingSuggestions(`${mockValidWord}`) - ).toHaveLength(1); + const suggestions = spellChecker.getSpellingSuggestions(invalidWord); + expect(suggestions).toHaveLength(0); + done(); + }, 500); + }); + + it("returns spelling correction", (done) => { + const spellChecker = new SpellChecker("en"); + // Give the dictionary half-a-sec to load. + setTimeout(() => { + const suggestions = spellChecker.getSpellingSuggestions(mockWordC); + // Returns suggestion with 1 letter different. + expect(suggestions).toContain(mockValidWordA); + // Don't return lookahead for word 1 letter different. + expect(suggestions).not.toContain(mockValidWordBExt); + done(); + }, 500); + }); + + it("returns spelling correction and lookahead", (done) => { + const spellChecker = new SpellChecker("en"); + // Give the dictionary half-a-sec to load. + setTimeout(() => { + const suggestions = spellChecker.getSpellingSuggestions(mockWordB); + // Returns suggestion with 1 letter different. + expect(suggestions).toContain(mockValidWordA); + // Returns suggestions with many letters added. + expect(suggestions).toContain(mockValidWordBExt); done(); }, 500); });