Use Hunspell for client-side spellchecking (#20286)

GitOrigin-RevId: c4d0d9e06fe0cc9d7cb7a058fd0768eb024e44f5
This commit is contained in:
Alf Eaton
2024-10-14 10:58:36 +00:00
committed by Copybot
parent 2d737810d4
commit 24c8629cd4
40 changed files with 5391 additions and 514 deletions
@@ -1,327 +0,0 @@
import {
StateField,
StateEffect,
EditorSelection,
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'
const ITEMS_TO_SHOW = 8
/*
* The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
* Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener.
*/
let openingUntil = 0
/*
* Hide the spelling menu on click
*/
const handleClickEvent = (event: MouseEvent, view: EditorView) => {
if (Date.now() < openingUntil) {
return
}
if (view.state.field(spellingMenuField, false)) {
view.dispatch({
effects: hideSpellingMenu.of(null),
})
}
}
/*
* Detect when the user right-clicks on a misspelled word,
* and show a menu of suggestions
*/
const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => {
const position = view.posAtCoords(
{
x: event.pageX,
y: event.pageY,
},
false
)
const targetMark = getMarkAtPosition(view, position)
if (!targetMark) {
return
}
const { from, to, value } = targetMark
const targetWord = value.spec.word
if (!targetWord) {
debugConsole.debug(
'>> spelling no word associated with decorated range, stopping'
)
return
}
event.preventDefault()
openingUntil = Date.now() + 100
view.dispatch({
selection: EditorSelection.range(from, to),
effects: showSpellingMenu.of({
mark: targetMark,
word: targetWord,
}),
})
}
const handleShortcutEvent = (view: EditorView) => {
const targetMark = getMarkAtPosition(view, view.state.selection.main.from)
if (!targetMark || !targetMark.value) {
return false
}
view.dispatch({
selection: EditorSelection.range(targetMark.from, targetMark.to),
effects: showSpellingMenu.of({
mark: targetMark,
word: targetMark.value.spec.word,
}),
})
return true
}
/*
* Spelling menu "tooltip" field.
* Manages the menu of suggestions shown on right-click
*/
export const spellingMenuField = StateField.define<Tooltip | null>({
create() {
return null
},
update(value, transaction) {
if (value) {
value = {
...value,
pos: transaction.changes.mapPos(value.pos),
end: value.end ? transaction.changes.mapPos(value.end) : undefined,
}
}
for (const effect of transaction.effects) {
if (effect.is(hideSpellingMenu)) {
value = null
} else if (effect.is(showSpellingMenu)) {
const { mark, word } = effect.value
// Build a "Tooltip" showing the suggestions
value = {
pos: mark.from,
end: mark.to,
above: false,
strictSide: false,
create: view => {
return createSpellingSuggestionList(word, view)
},
}
}
}
return value
},
provide: field => {
return [
showTooltip.from(field),
EditorView.domEventHandlers({
contextmenu: handleContextMenuEvent,
click: handleClickEvent,
}),
Prec.highest(
keymap.of([
{ key: 'Ctrl-Space', run: handleShortcutEvent },
{ key: 'Alt-Space', run: handleShortcutEvent },
])
),
]
},
})
const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
const hideSpellingMenu = StateEffect.define()
/*
* Creates the suggestion menu dom, to be displayed in the
* spelling menu "tooltip"
* */
const createSpellingSuggestionList = (word: Word, view: EditorView) => {
// Wrapper div.
// Note, CM6 doesn't like showing complex elements
// 'inside' its Tooltip element, so we style this
// wrapper div to be basically invisible, and allow
// the dropdown list to hang off of it, giving the illusion that
// the list _is_ the tooltip.
// See the theme in spelling/index for styling that makes this work.
const dom = document.createElement('div')
dom.classList.add('ol-cm-spelling-context-menu-tooltip')
// List
const list = document.createElement('ul')
list.setAttribute('tabindex', '0')
list.setAttribute('role', 'menu')
list.addEventListener('keydown', event => {
if (event.code === 'Tab') {
// preventing selecting next element
event.preventDefault()
}
})
list.addEventListener('keyup', event => {
switch (event.code) {
case 'ArrowDown': {
// get currently selected option
const selectedButton =
list.querySelector<HTMLButtonElement>('li button:focus')
if (!selectedButton) {
return list
.querySelector<HTMLButtonElement>('li[role="option"] button')
?.focus()
}
// get next option
let nextElement = selectedButton.parentElement?.nextElementSibling
if (nextElement?.getAttribute('role') !== 'option') {
nextElement = nextElement?.nextElementSibling
}
nextElement?.querySelector('button')?.focus()
break
}
case 'ArrowUp': {
// get currently selected option
const selectedButton =
list.querySelector<HTMLButtonElement>('li button:focus')
if (!selectedButton) {
return list
.querySelector<HTMLButtonElement>(
'li[role="option"]:last-child button'
)
?.focus()
}
// get previous option
let previousElement =
selectedButton.parentElement?.previousElementSibling
if (previousElement?.getAttribute('role') !== 'option') {
previousElement = previousElement?.previousElementSibling
}
previousElement?.querySelector('button')?.focus()
break
}
case 'Escape':
case 'Tab': {
view.dispatch({
effects: hideSpellingMenu.of(null),
})
view.focus()
break
}
}
})
list.classList.add('dropdown-menu', 'dropdown-menu-unpositioned')
// List items, with links inside
if (Array.isArray(word.suggestions)) {
for (const suggestion of word.suggestions.slice(0, ITEMS_TO_SHOW)) {
const li = makeLinkItem(suggestion, event => {
const text = (event.target as HTMLElement).innerText
handleCorrectWord(word, text, view)
event.preventDefault()
})
list.appendChild(li)
}
}
setTimeout(() => {
list.querySelector<HTMLButtonElement>('li:first-child button')?.focus()
}, 0)
// Divider
const divider = document.createElement('li')
divider.classList.add('divider')
list.append(divider)
// Add to Dictionary
const addToDictionary = makeLinkItem(
'Add to Dictionary',
async function (event: Event) {
await handleLearnWord(word, view)
event.preventDefault()
}
)
list.append(addToDictionary)
dom.appendChild(list)
return { dom }
}
const makeLinkItem = (suggestion: string, handler: EventListener) => {
const li = document.createElement('li')
const button = document.createElement('button')
li.setAttribute('role', 'option')
button.classList.add('btn-link', 'text-left', 'dropdown-menu-button')
button.onclick = handler
button.textContent = suggestion
li.appendChild(button)
return li
}
/*
* Learn a word, adding it to the local cache
* and sending it to the spelling backend
*/
const handleLearnWord = async function (word: Word, view: EditorView) {
try {
await learnWordRequest(word)
view.dispatch({
effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
})
} catch (err) {
debugConsole.error(err)
}
}
/*
* Correct a word, removing the marked range
* and replacing it with the chosen text
*/
const handleCorrectWord = (word: Word, text: string, view: EditorView) => {
const tooltip = view.state.field(spellingMenuField)
if (!tooltip) {
throw new Error('No active tooltip')
}
const existingText = view.state.doc.sliceString(tooltip.pos, tooltip.end)
// Defend against erroneous replacement, if the word at this
// position is not actually what we think it is
if (existingText !== word.text) {
debugConsole.debug(
'>> spelling word-to-correct does not match, stopping',
tooltip.pos,
tooltip.end,
existingText,
word
)
return
}
view.dispatch({
changes: [
{
from: tooltip.pos,
to: tooltip.end,
insert: text,
},
],
effects: [hideSpellingMenu.of(null)],
})
view.focus()
}
@@ -0,0 +1,224 @@
import {
StateField,
StateEffect,
EditorSelection,
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 {
getSpellChecker,
getSpellCheckLanguage,
} from '@/features/source-editor/extensions/spelling/index'
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'
/*
* The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
* Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener.
*/
let openingUntil = 0
/*
* Hide the spelling menu on click
*/
const handleClickEvent = (event: MouseEvent, view: EditorView) => {
if (Date.now() < openingUntil) {
return
}
if (view.state.field(spellingMenuField, false)) {
view.dispatch({
effects: hideSpellingMenu.of(null),
})
}
}
/*
* Detect when the user right-clicks on a misspelled word,
* and show a menu of suggestions
*/
const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => {
const position = view.posAtCoords(
{
x: event.pageX,
y: event.pageY,
},
false
)
const targetMark = getMarkAtPosition(view, position)
if (!targetMark) {
return
}
const { from, to, value } = targetMark
const targetWord = value.spec.word
if (!targetWord) {
debugConsole.debug(
'>> spelling no word associated with decorated range, stopping'
)
return
}
event.preventDefault()
openingUntil = Date.now() + 100
view.dispatch({
selection: EditorSelection.range(from, to),
effects: showSpellingMenu.of({
mark: targetMark,
word: targetWord,
}),
})
}
const handleShortcutEvent = (view: EditorView) => {
const targetMark = getMarkAtPosition(view, view.state.selection.main.from)
if (!targetMark || !targetMark.value) {
return false
}
view.dispatch({
selection: EditorSelection.range(targetMark.from, targetMark.to),
effects: showSpellingMenu.of({
mark: targetMark,
word: targetMark.value.spec.word,
}),
})
return true
}
/*
* Spelling menu "tooltip" field.
* Manages the menu of suggestions shown on right-click
*/
export const spellingMenuField = StateField.define<Tooltip | null>({
create() {
return null
},
update(value, transaction) {
if (value) {
value = {
...value,
pos: transaction.changes.mapPos(value.pos),
end: value.end ? transaction.changes.mapPos(value.end) : undefined,
}
}
for (const effect of transaction.effects) {
if (effect.is(hideSpellingMenu)) {
value = null
} else if (effect.is(showSpellingMenu)) {
const { mark, word } = effect.value
// Build a "Tooltip" showing the suggestions
value = {
pos: mark.from,
end: mark.to,
above: false,
strictSide: false,
create: createSpellingSuggestionList(word),
}
}
}
return value
},
provide: field => {
return [
showTooltip.from(field),
EditorView.domEventHandlers({
contextmenu: handleContextMenuEvent,
click: handleClickEvent,
}),
Prec.highest(
keymap.of([
{ key: 'Ctrl-Space', run: handleShortcutEvent },
{ key: 'Alt-Space', run: handleShortcutEvent },
])
),
]
},
})
const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
export const hideSpellingMenu = StateEffect.define()
/*
* Creates the suggestion menu dom, to be displayed in the
* spelling menu "tooltip"
* */
const createSpellingSuggestionList = (word: Word) => (view: EditorView) => {
const dom = document.createElement('div')
dom.classList.add('ol-cm-spelling-context-menu-tooltip')
ReactDOM.render(
<SplitTestProvider>
<SpellingSuggestions
word={word}
spellCheckLanguage={getSpellCheckLanguage(view.state)}
spellChecker={getSpellChecker(view.state)}
handleClose={() => {
view.dispatch({
effects: hideSpellingMenu.of(null),
})
view.focus()
}}
handleLearnWord={() => {
learnWordRequest(word)
.then(() => {
view.dispatch({
effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
})
sendMB('spelling-word-added', {
language: getSpellCheckLanguage(view.state),
})
})
.catch(error => {
debugConsole.error(error)
})
}}
handleCorrectWord={(text: string) => {
const tooltip = view.state.field(spellingMenuField)
if (!tooltip) {
throw new Error('No active tooltip')
}
const existingText = view.state.doc.sliceString(
tooltip.pos,
tooltip.end
)
if (existingText !== word.text) {
return
}
view.dispatch({
changes: [{ from: tooltip.pos, to: tooltip.end, insert: text }],
effects: [hideSpellingMenu.of(null)],
})
view.focus()
sendMB('spelling-suggestion-click', {
language: getSpellCheckLanguage(view.state),
})
}}
/>
</SplitTestProvider>,
dom
)
const destroy = () => {
ReactDOM.unmountComponentAtNode(dom)
}
return { dom, destroy }
}
@@ -20,6 +20,12 @@ 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()
@@ -1,26 +1,29 @@
import { EditorView, ViewPlugin } from '@codemirror/view'
import {
Compartment,
Facet,
EditorState,
StateEffect,
StateField,
TransactionSpec,
} from '@codemirror/state'
import { misspelledWordsField, resetMisspelledWords } from './misspelled-words'
import { misspelledWordsField } from './misspelled-words'
import {
addIgnoredWord,
ignoredWordsField,
removeIgnoredWord,
resetSpellChecker,
updateAfterAddingIgnoredWord,
} from './ignored-words'
import { addWordToCache, cacheField, removeWordFromCache } from './cache'
import { spellingMenuField } from './context-menu'
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'
const spellCheckLanguageConf = new Compartment()
const spellCheckLanguageFacet = Facet.define<string | undefined>()
type Options = { spellCheckLanguage?: string }
type Options = {
spellCheckLanguage?: string
hunspellManager?: HunspellManager
}
/**
* A custom extension that creates a spell checker for the current language (from the user settings).
@@ -29,12 +32,16 @@ type Options = { spellCheckLanguage?: string }
* Mis-spelled words are decorated with a Mark decoration.
* The suggestions menu is displayed in a tooltip, activated with a right-click on the decoration.
*/
export const spelling = ({ spellCheckLanguage }: Options) => {
export const spelling = ({ spellCheckLanguage, hunspellManager }: Options) => {
return [
spellingTheme,
parserWatcher,
spellCheckLanguageConf.of(spellCheckLanguageFacet.of(spellCheckLanguage)),
spellCheckField,
spellCheckLanguageField.init(() => spellCheckLanguage),
spellCheckerField.init(() =>
spellCheckLanguage
? new SpellChecker(spellCheckLanguage, hunspellManager)
: null
),
misspelledWordsField,
ignoredWordsField,
cacheField,
@@ -56,16 +63,27 @@ const spellingTheme = EditorView.baseTheme({
},
})
const spellCheckField = StateField.define<SpellChecker | null>({
create(state) {
const [spellCheckLanguage] = state.facet(spellCheckLanguageFacet)
return spellCheckLanguage ? new SpellChecker(spellCheckLanguage) : null
export const getSpellChecker = (state: EditorState) =>
state.field(spellCheckerField, false)
const spellCheckerField = StateField.define<SpellChecker | null>({
create() {
return null
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setSpellCheckLanguageEffect)) {
value?.destroy()
return effect.value ? new SpellChecker(effect.value) : null
value = effect.value.spellCheckLanguage
? new SpellChecker(
effect.value.spellCheckLanguage,
effect.value.hunspellManager
)
: null
} else if (effect.is(addIgnoredWord)) {
value?.addWord(effect.value.text).catch(debugConsole.error)
} else if (effect.is(removeIgnoredWord)) {
value?.removeWord(effect.value.text).catch(debugConsole.error)
}
}
return value
@@ -80,7 +98,7 @@ const spellCheckField = StateField.define<SpellChecker | null>({
}
}),
EditorView.domEventHandlers({
focus: (event, view) => {
focus: (_event, view) => {
if (view.state.facet(EditorView.editable)) {
view.state.field(field)?.scheduleSpellCheck(view)
}
@@ -95,18 +113,39 @@ const spellCheckField = StateField.define<SpellChecker | null>({
},
})
const setSpellCheckLanguageEffect = StateEffect.define<string | undefined>()
export const getSpellCheckLanguage = (state: EditorState) =>
state.field(spellCheckLanguageField, false)
export const setSpelling = ({
const spellCheckLanguageField = StateField.define<string | undefined>({
create() {
return undefined
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setSpellCheckLanguageEffect)) {
value = effect.value.spellCheckLanguage
}
}
return value
},
})
export const setSpellCheckLanguageEffect = StateEffect.define<{
spellCheckLanguage: string | undefined
hunspellManager?: HunspellManager
}>()
export const setSpellCheckLanguage = ({
spellCheckLanguage,
hunspellManager,
}: Options): TransactionSpec => {
return {
effects: [
resetMisspelledWords.of(null),
spellCheckLanguageConf.reconfigure(
spellCheckLanguageFacet.of(spellCheckLanguage)
),
setSpellCheckLanguageEffect.of(spellCheckLanguage),
setSpellCheckLanguageEffect.of({
spellCheckLanguage,
hunspellManager,
}),
hideSpellingMenu.of(null),
],
}
}
@@ -137,6 +176,7 @@ export const removeLearnedWord = (
lang: spellCheckLanguage,
wordText: word,
}),
removeIgnoredWord.of({ text: word }),
],
}
}
@@ -2,11 +2,10 @@ import { StateField, StateEffect } from '@codemirror/state'
import { EditorView, Decoration, DecorationSet } from '@codemirror/view'
import { updateAfterAddingIgnoredWord } from './ignored-words'
import { Word } from './spellchecker'
import { setSpellCheckLanguageEffect } from '@/features/source-editor/extensions/spelling/index'
export const addMisspelledWords = StateEffect.define<Word[]>()
export const resetMisspelledWords = StateEffect.define()
const createMark = (word: Word) => {
return Decoration.mark({
class: 'ol-cm-spelling-error',
@@ -39,14 +38,17 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
if (effect.is(addMisspelledWords)) {
// Merge the new misspelled words into the existing set of marks
marks = marks.update({
add: effect.value.map(word => createMark(word)),
add: effect.value.map(word => createMark(word)), // TODO: make sure these positions are still accurate
sort: true,
})
} else if (effect.is(updateAfterAddingIgnoredWord)) {
// Remove a misspelled word, all instances that match text
const word = effect.value
marks = removeAllMarksMatchingWordText(marks, word)
} else if (effect.is(resetMisspelledWords)) {
// Remove existing marks matching the text of a supplied word
marks = marks.update({
filter(_from, _to, mark) {
return mark.spec.word.text !== effect.value
},
})
} else if (effect.is(setSpellCheckLanguageEffect)) {
marks = Decoration.none
}
}
@@ -56,14 +58,3 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
return EditorView.decorations.from(field)
},
})
/*
* Remove existing marks matching the text of a supplied word
*/
const removeAllMarksMatchingWordText = (marks: DecorationSet, word: string) => {
return marks.update({
filter: (from, to, mark) => {
return mark.spec.word.text !== word
},
})
}
@@ -13,6 +13,7 @@ import {
} from '../../utils/tree-query'
import { waitForParser } from '../wait-for-parser'
import { debugConsole } from '@/utils/debugging'
import type { HunspellManager } from '../../hunspell/HunspellManager'
/*
* Spellchecker, handles updates, schedules spelling checks
@@ -26,13 +27,17 @@ export class SpellChecker {
private trackedChanges: ChangeSet
// eslint-disable-next-line no-useless-constructor
constructor(private readonly language: string) {
this.language = language
constructor(
private readonly language: string,
private hunspellManager?: HunspellManager
) {
debugConsole.log('SpellChecker', language, hunspellManager)
this.trackedChanges = ChangeSet.empty(0)
}
destroy() {
this._clearPendingSpellCheck()
// this.hunspellManager?.destroy()
}
_abortRequest() {
@@ -86,7 +91,7 @@ export class SpellChecker {
wordsToCheck
)
const processResult = (
misspellings: { index: number; suggestions: string[] }[]
misspellings: { index: number; suggestions?: string[] }[]
) => {
this.trackedChanges = ChangeSet.empty(0)
@@ -108,18 +113,79 @@ export class SpellChecker {
} else {
this._abortRequest()
this.abortController = new AbortController()
spellCheckRequest(this.language, unknownWords, this.abortController)
.then(result => {
this.abortController = null
processResult(result.misspellings)
})
.catch(error => {
this.abortController = null
debugConsole.error(error)
})
if (this.hunspellManager) {
const signal = this.abortController.signal
this.hunspellManager.send(
{
type: 'spell',
words: unknownWords.map(word => word.text),
},
(result: any) => {
if (!signal.aborted) {
if (result.error) {
debugConsole.error(result.error)
} else {
processResult(result.misspellings)
}
}
}
)
} else {
spellCheckRequest(this.language, unknownWords, this.abortController)
.then(result => {
this.abortController = null
return processResult(result.misspellings)
})
.catch(error => {
this.abortController = null
debugConsole.error(error)
})
}
}
}
suggest(word: string) {
return new Promise<{ suggestions: string[] }>((resolve, reject) => {
if (this.hunspellManager) {
this.hunspellManager.send({ type: 'suggest', word }, result => {
if ((result as { error: true }).error) {
reject(new Error())
} else {
resolve(result as { suggestions: string[] })
}
})
}
})
}
addWord(word: string) {
return new Promise<void>((resolve, reject) => {
if (this.hunspellManager) {
this.hunspellManager.send({ type: 'add_word', word }, result => {
if ((result as { error: true }).error) {
reject(new Error())
} else {
resolve()
}
})
}
})
}
removeWord(word: string) {
return new Promise<void>((resolve, reject) => {
if (this.hunspellManager) {
this.hunspellManager.send({ type: 'remove_word', word }, result => {
if ((result as { error: true }).error) {
reject(new Error())
} else {
resolve()
}
})
}
})
}
_spellCheckWhenParserReady(view: EditorView) {
if (this.waitingForParser) {
return
@@ -163,7 +229,7 @@ export class SpellChecker {
const { from, to } = view.viewport
const changedLineNumbers = new Set<number>()
if (this.trackedChanges.length > 0) {
this.trackedChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
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
@@ -180,7 +246,9 @@ export class SpellChecker {
}
}
const ignoredWords = view.state.field(ignoredWordsField)
const ignoredWords = this.hunspellManager
? null
: view.state.field(ignoredWordsField)
for (const i of changedLineNumbers) {
const line = view.state.doc.line(i)
wordsToCheck.push(
@@ -228,7 +296,7 @@ export class Word {
export const buildSpellCheckResult = (
knownMisspelledWords: Word[],
unknownWords: Word[],
misspellings: { index: number; suggestions: string[] }[]
misspellings: { index: number; suggestions?: string[] }[]
) => {
const cacheAdditions: [Word, string[] | boolean][] = []
@@ -277,7 +345,7 @@ export const compileEffects = (results: {
export const getWordsFromLine = (
view: EditorView,
line: Line,
ignoredWords: IgnoredWords,
ignoredWords: IgnoredWords | null,
lang: string
): Word[] => {
const normalTextSpans: Array<NormalTextSpan> = getNormalTextSpansFromLine(
@@ -287,14 +355,8 @@ export const getWordsFromLine = (
const words: Word[] = []
for (const span of normalTextSpans) {
for (const match of span.text.matchAll(WORD_REGEX)) {
let word = match[0]
if (word.startsWith("'")) {
word = word.slice(1)
}
if (word.endsWith("'")) {
word = word.slice(0, -1)
}
if (!ignoredWords.has(word)) {
const word = match[0].replace(/^'+/, '').replace(/'+$/, '')
if (!ignoredWords?.has(word)) {
const from = span.from + match.index
words.push(
new Word({
@@ -0,0 +1,189 @@
import {
FC,
MouseEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { SpellChecker, Word } from './spellchecker'
import { useTranslation } from 'react-i18next'
import SplitTestBadge from '@/shared/components/split-test-badge'
import getMeta from '@/utils/meta'
import classnames from 'classnames'
import { SpellCheckLanguage } from '../../../../../../types/project-settings'
import Icon from '@/shared/components/icon'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { sendMB } from '@/infrastructure/event-tracking'
const ITEMS_TO_SHOW = 8
// TODO: messaging below the spelling suggestions
const SHOW_FOOTER = false
// (index % length) that works for negative index
const wrapArrayIndex = (index: number, length: number) =>
((index % length) + length) % length
export const SpellingSuggestions: FC<{
word: Word
spellCheckLanguage?: string
spellChecker?: SpellChecker | null
handleClose: () => void
handleLearnWord: () => void
handleCorrectWord: (text: string) => void
}> = ({
word,
spellCheckLanguage,
spellChecker,
handleClose,
handleLearnWord,
handleCorrectWord,
}) => {
const { t } = useTranslation()
const [suggestions, setSuggestions] = useState(() =>
Array.isArray(word.suggestions)
? word.suggestions.slice(0, ITEMS_TO_SHOW)
: []
)
const [waiting, setWaiting] = useState(!word.suggestions)
const [selectedIndex, setSelectedIndex] = useState(0)
const itemsLength = suggestions.length + 1
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),
})
})
}
}, [word, spellChecker, spellCheckLanguage])
const language = useMemo(() => {
if (spellCheckLanguage) {
return getMeta('ol-languages').find(
item => item.code === spellCheckLanguage
)
}
}, [spellCheckLanguage])
return (
<ul
className={classnames('dropdown-menu', 'dropdown-menu-unpositioned', {
hidden: waiting,
})}
tabIndex={0}
role="menu"
onKeyDown={event => {
switch (event.code) {
case 'ArrowDown':
setSelectedIndex(value => wrapArrayIndex(value + 1, itemsLength))
break
case 'ArrowUp':
setSelectedIndex(value => wrapArrayIndex(value - 1, itemsLength))
break
case 'Escape':
case 'Tab':
event.preventDefault()
handleClose()
break
}
}}
>
{Array.isArray(suggestions) && (
<>
{suggestions.map((suggestion, index) => (
<ListItem
key={suggestion}
content={suggestion}
selected={index === selectedIndex}
handleClick={event => {
event.preventDefault()
handleCorrectWord(suggestion)
}}
/>
))}
{suggestions.length > 0 && <li className="divider" />}
</>
)}
<ListItem
content={t('add_to_dictionary')}
selected={selectedIndex === itemsLength - 1}
handleClick={event => {
event.preventDefault()
handleLearnWord()
}}
/>
{SHOW_FOOTER && language && (
<>
<li className="divider" />
<li>
<Footer language={language} />
</li>
</>
)}
</ul>
)
}
const Footer: FC<{ language: SpellCheckLanguage }> = ({ language }) => {
const spellCheckClientEnabled = useFeatureFlag('spell-check-client')
if (!spellCheckClientEnabled) {
return null
}
return language.dic ? (
<div>
<SplitTestBadge
splitTestName="spell-check-client"
displayOnVariants={['enabled']}
/>{' '}
<small>{language?.name}</small>
</div>
) : (
<div>
<Icon type="warning" fw /> <small>{language?.name}</small>
</div>
)
}
const ListItem: FC<{
content: string
selected: boolean
handleClick: MouseEventHandler<HTMLButtonElement>
}> = ({ content, selected, handleClick }) => {
const handleListItem = useCallback(
(element: HTMLElement | null) => {
if (element && selected) {
window.setTimeout(() => {
element.focus()
})
}
},
[selected]
)
return (
<li role="menuitem">
<button
className="btn-link text-left dropdown-menu-button"
onClick={handleClick}
ref={handleListItem}
>
{content}
</button>
</li>
)
}
@@ -37,7 +37,7 @@ import {
addLearnedWord,
removeLearnedWord,
resetLearnedWords,
setSpelling,
setSpellCheckLanguage,
} from '../extensions/spelling'
import {
createChangeManager,
@@ -65,6 +65,7 @@ import { setMathPreview } from '@/features/source-editor/extensions/math-preview
import { useRangesContext } from '@/features/review-panel-new/context/ranges-context'
import { updateRanges } from '@/features/source-editor/extensions/ranges'
import { useThreadsContext } from '@/features/review-panel-new/context/threads-context'
import { useHunspell } from '@/features/source-editor/hooks/use-hunspell'
function useCodeMirrorScope(view: EditorView) {
const { fileTreeData } = useFileTreeData()
@@ -111,6 +112,8 @@ function useCodeMirrorScope(view: EditorView) {
'project.spellCheckLanguage'
)
const hunspellManager = useHunspell(spellCheckLanguage)
const [visual] = useScopeValue<boolean>('editor.showVisual')
const { referenceKeys } = useReferencesContext()
@@ -214,14 +217,16 @@ function useCodeMirrorScope(view: EditorView) {
const spellingRef = useRef({
spellCheckLanguage,
hunspellManager,
})
useEffect(() => {
spellingRef.current = {
spellCheckLanguage,
hunspellManager,
}
view.dispatch(setSpelling(spellingRef.current))
}, [view, spellCheckLanguage])
view.dispatch(setSpellCheckLanguage(spellingRef.current))
}, [view, spellCheckLanguage, hunspellManager])
// listen to doc:after-opened, and focus the editor if it's not a new doc
useEffect(() => {
@@ -0,0 +1,35 @@
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { useEffect, useState } from 'react'
import getMeta from '@/utils/meta'
import { globalLearnedWords } from '@/features/dictionary/ignored-words'
import { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager'
import { debugConsole } from '@/utils/debugging'
export const useHunspell = (spellCheckLanguage: string | null) => {
const [hunspellManager, setHunspellManager] = useState<HunspellManager>()
useEffect(() => {
if (isSplitTestEnabled('spell-check-client')) {
if (spellCheckLanguage) {
const languages = getMeta('ol-languages')
const lang = languages.find(item => item.code === spellCheckLanguage)
if (lang?.dic) {
const hunspellManager = new HunspellManager(lang.dic, [
...globalLearnedWords,
...getMeta('ol-learnedWords'),
])
setHunspellManager(hunspellManager)
debugConsole.log(spellCheckLanguage, hunspellManager)
return () => {
hunspellManager.destroy()
}
} else {
setHunspellManager(undefined)
}
}
}
}, [spellCheckLanguage])
return hunspellManager
}
@@ -0,0 +1,9 @@
FROM emscripten/emsdk:3.1.67
RUN apt-get update && apt-get install -y \
autoconf \
automake \
autopoint \
build-essential \
libtool \
cmake
@@ -0,0 +1,111 @@
import { v4 as uuid } from 'uuid'
import { createWorker } from '@/utils/worker'
import getMeta from '@/utils/meta'
import { debugConsole } from '@/utils/debugging'
type Message =
| {
id?: string
type: 'spell'
words: string[]
}
| {
id?: string
type: 'suggest'
word: string
}
| {
id?: string
type: 'add_word'
word: string
}
| {
id?: string
type: 'remove_word'
word: string
}
| {
id?: string
type: 'destroy'
}
export class HunspellManager {
dictionariesRoot: string
hunspellWorker!: Worker
abortController: AbortController | undefined
listening = false
loaded = false
pendingMessages: Message[] = []
callbacks: Map<string, (value: unknown) => void> = new Map()
constructor(
private readonly language: string,
private readonly learnedWords: string[]
) {
const baseAssetPath = new URL(
getMeta('ol-baseAssetPath'),
window.location.href
)
this.dictionariesRoot = new URL(
getMeta('ol-dictionariesRoot'),
baseAssetPath
).toString()
createWorker(() => {
this.hunspellWorker = new Worker(
new URL('./hunspell.worker.ts', import.meta.url),
{ type: 'module' }
)
this.hunspellWorker.addEventListener('message', this.receive.bind(this))
})
}
destroy() {
this.send({ type: 'destroy' }, () => {
this.hunspellWorker.terminate()
})
}
send(message: Message, callback: (value: unknown) => void) {
debugConsole.log(message)
if (callback) {
message.id = uuid()
this.callbacks.set(message.id, callback)
}
if (this.listening) {
this.hunspellWorker.postMessage(message)
} else {
this.pendingMessages.push(message)
}
}
receive(event: MessageEvent) {
debugConsole.log(event.data)
const { id, listening, loaded, ...rest } = event.data
if (id) {
const callback = this.callbacks.get(id)
if (callback) {
this.callbacks.delete(id)
callback(rest)
}
} else if (listening) {
this.listening = true
this.hunspellWorker.postMessage({
type: 'init',
lang: this.language,
learnedWords: this.learnedWords, // TODO: add words
dictionariesRoot: this.dictionariesRoot,
})
for (const message of this.pendingMessages) {
this.hunspellWorker.postMessage(message)
this.pendingMessages.length = 0
}
} else if (loaded) {
this.loaded = true
// TODO: use this to display pending state?
}
}
}
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
# build an Emscripten SDK Docker image with Hunspell's build dependencies installed
docker build --pull --tag overleaf/emsdk .
# compile Hunspell to WASM and copy the output files from the Docker container
docker run --rm \
--workdir /opt \
--volume "$(pwd)/wasm":/wasm \
--volume "$(pwd)/compile.sh":/opt/compile.sh:ro \
overleaf/emsdk \
bash compile.sh
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -e
COMMIT="e994dceb97fb695bca6bfe5ad5665525426bf01f"
curl -L "https://github.com/hunspell/hunspell/archive/${COMMIT}.tar.gz" | tar xvz
cd "hunspell-${COMMIT}"
autoreconf -fiv
emconfigure ./configure --disable-shared --enable-static
emmake make
em++ \
-s EXPORTED_FUNCTIONS="['_Hunspell_create', '_Hunspell_destroy', '_Hunspell_spell', '_Hunspell_suggest', '_Hunspell_free_list', '_Hunspell_add_dic', '_Hunspell_add', '_Hunspell_remove', '_free', '_malloc', 'FS']" \
-s EXPORTED_RUNTIME_METHODS="['ccall', 'cwrap', 'getValue', 'stringToNewUTF8', 'UTF8ToString', 'WORKERFS']" \
-s ENVIRONMENT=worker \
-s ALLOW_MEMORY_GROWTH \
-lworkerfs.js \
-O2 \
-g2 \
src/hunspell/.libs/libhunspell-1.7.a \
-o hunspell.mjs
cp hunspell.{mjs,wasm} /wasm/
@@ -0,0 +1,237 @@
import Hunspell from './wasm/hunspell'
type SpellChecker = {
spell(words: string[]): { index: number }[]
suggest(word: string): string[]
addWord(word: string): void
removeWord(word: string): void
destroy(): void
}
const createSpellChecker = async ({
lang,
learnedWords,
dictionariesRoot,
}: {
lang: string
learnedWords: string[]
dictionariesRoot: string
}) => {
const hunspell = await Hunspell()
const {
cwrap,
FS,
WORKERFS,
stringToNewUTF8,
_malloc,
_free,
getValue,
UTF8ToString,
} = hunspell
// https://github.com/hunspell/hunspell/blob/master/src/hunspell/hunspell.h
// https://github.com/kwonoj/hunspell-asm/blob/master/src/wrapHunspellInterface.ts
const create = cwrap('Hunspell_create', 'number', ['number', 'number'])
const destroy = cwrap('Hunspell_destroy', 'number', ['number', 'number'])
const spell = cwrap('Hunspell_spell', 'number', ['number', 'number'])
const suggest = cwrap('Hunspell_suggest', 'number', [
'number',
'number',
'number',
])
const addWord = cwrap('Hunspell_add', 'number', ['number', 'number'])
const removeWord = cwrap('Hunspell_remove', 'number', ['number', 'number'])
const freeList = cwrap('Hunspell_free_list', 'number', [
'number',
'number',
'number',
])
FS.mkdir('/dictionaries')
const [dic, aff] = await Promise.all([
fetch(new URL(`./${lang}.dic`, dictionariesRoot)).then(response =>
response.blob()
),
fetch(new URL(`./${lang}.aff`, dictionariesRoot)).then(response =>
response.blob()
),
])
FS.mount(
WORKERFS,
{
blobs: [
{ name: 'index.dic', data: dic },
{ name: 'index.aff', data: aff },
],
},
'/dictionaries'
)
const dicPtr = stringToNewUTF8('/dictionaries/index.dic')
const affPtr = stringToNewUTF8('/dictionaries/index.aff')
const spellPtr = create(affPtr, dicPtr)
for (const word of learnedWords) {
const wordPtr = stringToNewUTF8(word)
addWord(spellPtr, wordPtr)
_free(wordPtr)
}
const spellChecker: SpellChecker = {
spell(words) {
const misspellings: { index: number }[] = []
for (const [index, word] of words.entries()) {
const wordPtr = stringToNewUTF8(word)
const spellResult = spell(spellPtr, wordPtr)
_free(wordPtr)
if (spellResult === 0) {
misspellings.push({ index })
}
}
return misspellings
},
suggest(word) {
const suggestions: string[] = []
const suggestionListPtr = _malloc(4)
const wordPtr = stringToNewUTF8(word)
const suggestionCount = suggest(spellPtr, suggestionListPtr, wordPtr)
_free(wordPtr)
const suggestionListValuePtr = getValue(suggestionListPtr, '*')
for (let i = 0; i < suggestionCount; i++) {
const suggestion = UTF8ToString(
getValue(suggestionListValuePtr + i * 4, '*')
)
suggestions.push(suggestion)
}
freeList(spellPtr, suggestionListPtr, suggestionCount)
_free(suggestionListPtr)
return suggestions
},
addWord(word) {
const wordPtr = stringToNewUTF8(word)
const result = addWord(spellPtr, wordPtr)
_free(wordPtr)
if (result !== 0) {
throw new Error('The word could not be added to the dictionary')
}
},
removeWord(word) {
const wordPtr = stringToNewUTF8(word)
const result = removeWord(spellPtr, wordPtr)
_free(wordPtr)
if (result !== 0) {
throw new Error('The word could not be removed from the dictionary')
}
},
destroy() {
destroy(spellPtr)
_free(spellPtr)
_free(dicPtr)
_free(affPtr)
},
}
return spellChecker
}
let spellCheckerPromise: Promise<SpellChecker>
self.addEventListener('message', async event => {
switch (event.data.type) {
case 'init':
try {
spellCheckerPromise = createSpellChecker(event.data)
await spellCheckerPromise
self.postMessage({ loaded: true })
} catch (error) {
console.error(error)
self.postMessage({ error: true })
}
break
case 'spell':
{
const { id, words } = event.data
try {
const spellChecker = await spellCheckerPromise
const misspellings = spellChecker.spell(words)
self.postMessage({ id, misspellings })
} catch (error) {
console.error(error)
self.postMessage({ id, error: true })
}
}
break
case 'suggest':
{
const { id, word } = event.data
try {
const spellChecker = await spellCheckerPromise
const suggestions = spellChecker.suggest(word)
self.postMessage({ id, suggestions })
} catch (error) {
console.error(error)
self.postMessage({ id, error: true })
}
}
break
case 'add_word':
{
const { id, word } = event.data
try {
const spellChecker = await spellCheckerPromise
spellChecker.addWord(word)
self.postMessage({ id })
} catch (error) {
console.error(error)
self.postMessage({ id, error: true })
}
}
break
case 'remove_word':
{
const { id, word } = event.data
try {
const spellChecker = await spellCheckerPromise
spellChecker.removeWord(word)
self.postMessage({ id })
} catch (error) {
console.error(error)
self.postMessage({ id, error: true })
}
}
break
case 'destroy':
{
const { id } = event.data
try {
const spellChecker = await spellCheckerPromise
spellChecker.destroy()
self.postMessage({ id })
} catch (error) {
console.error(error)
self.postMessage({ id, error: true })
}
}
break
}
})
self.postMessage({ listening: true })
@@ -0,0 +1,66 @@
/* eslint no-dupe-class-members: 0 */
declare class Hunspell {
cwrap(
method: 'Hunspell_create',
output: string,
input: string[]
): (affPtr: number, dicPtr: number) => number
cwrap(
method: 'Hunspell_destroy',
output: string,
input: string[]
): (spellPtr: number) => number
cwrap(
method: 'Hunspell_spell',
output: string,
input: string[]
): (spellPtr: number, wordPtr: number) => number
cwrap(
method: 'Hunspell_suggest',
output: string,
input: string[]
): (spellPtr: number, suggestionListPtr: number, wordPtr: number) => number
cwrap(
method: 'Hunspell_add',
output: string,
input: string[]
): (spellPtr: number, wordPtr: number) => number
cwrap(
method: 'Hunspell_remove',
output: string,
input: string[]
): (spellPtr: number, wordPtr: number) => number
cwrap(
method: 'Hunspell_free_list',
output: string,
input: string[]
): (spellPtr: number, suggestionListPtr: number, n: number) => number
stringToNewUTF8(input: string): number
UTF8ToString(input: number): string
_malloc(length: number): number
_free(ptr: number): void
getValue(ptr: number, type: string): number
FS: {
mkdir(path: string): void
mount(
type: any,
data: { blobs: Record<{ name: string; data: BlobPart }>[] },
dir: string
): void
}
WORKERFS: any
}
declare const factory = async (options?: Record<string, any>) =>
new Hunspell(options)
export default factory
File diff suppressed because it is too large Load Diff