Use Hunspell for client-side spellchecking (#20286)
GitOrigin-RevId: c4d0d9e06fe0cc9d7cb7a058fd0768eb024e44f5
This commit is contained in:
@@ -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 }),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
+9
-18
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+86
-24
@@ -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({
|
||||
|
||||
+189
@@ -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
Binary file not shown.
Reference in New Issue
Block a user