first commit
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { StateField, StateEffect } from '@codemirror/state'
|
||||
import LRU from 'lru-cache'
|
||||
import { Word } from './spellchecker'
|
||||
const CACHE_MAX = 15000
|
||||
|
||||
export const cacheKey = (lang: string, wordText: string) => {
|
||||
return `${lang}:${wordText}`
|
||||
}
|
||||
|
||||
export class WordCache {
|
||||
private _cache: LRU<string, boolean>
|
||||
|
||||
constructor() {
|
||||
this._cache = new LRU({ max: CACHE_MAX })
|
||||
}
|
||||
|
||||
set(lang: string, wordText: string, value: boolean) {
|
||||
const key = cacheKey(lang, wordText)
|
||||
this._cache.set(key, value)
|
||||
}
|
||||
|
||||
get(lang: string, wordText: string) {
|
||||
const key = cacheKey(lang, wordText)
|
||||
return this._cache.get(key)
|
||||
}
|
||||
|
||||
remove(lang: string, wordText: string) {
|
||||
const key = cacheKey(lang, wordText)
|
||||
this._cache.delete(key)
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._cache = new LRU({ max: CACHE_MAX })
|
||||
}
|
||||
|
||||
/*
|
||||
* Given a language and a list of words,
|
||||
* check the cache and sort the words into two categories:
|
||||
* - words we know to be misspelled
|
||||
* - words that are presently unknown to us
|
||||
*/
|
||||
checkWords(
|
||||
lang: string,
|
||||
wordsToCheck: Word[]
|
||||
): {
|
||||
knownMisspelledWords: Word[]
|
||||
unknownWords: Word[]
|
||||
} {
|
||||
const knownMisspelledWords: Word[] = []
|
||||
const unknownWords: Word[] = []
|
||||
const seen: Record<string, boolean | undefined> = {}
|
||||
for (const word of wordsToCheck) {
|
||||
const wordText = word.text
|
||||
if (seen[wordText] === undefined) {
|
||||
seen[wordText] = this.get(lang, wordText)
|
||||
}
|
||||
const cached = seen[wordText]
|
||||
if (cached === undefined) {
|
||||
// Word is not known
|
||||
unknownWords.push(word)
|
||||
} else if (!cached) {
|
||||
// Word is known to be misspelled
|
||||
knownMisspelledWords.push(word)
|
||||
}
|
||||
}
|
||||
return {
|
||||
knownMisspelledWords,
|
||||
unknownWords,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const addWordToCache = StateEffect.define<{
|
||||
lang: string
|
||||
wordText: string
|
||||
value: boolean
|
||||
}>()
|
||||
|
||||
export const removeWordFromCache = StateEffect.define<{
|
||||
lang: string
|
||||
wordText: string
|
||||
}>()
|
||||
|
||||
// Share a single instance of WordCache between all instances of the CM6 source editor. This means that cached words are
|
||||
// retained when switching away from CM6 and then back to it.
|
||||
const wordCache = new WordCache()
|
||||
|
||||
export const cacheField = StateField.define<WordCache>({
|
||||
create() {
|
||||
return wordCache
|
||||
},
|
||||
update(cache, transaction) {
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(addWordToCache)) {
|
||||
const { lang, wordText, value } = effect.value
|
||||
cache.set(lang, wordText, value)
|
||||
} else if (effect.is(removeWordFromCache)) {
|
||||
const { lang, wordText } = effect.value
|
||||
cache.remove(lang, wordText)
|
||||
}
|
||||
}
|
||||
return cache
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
StateField,
|
||||
StateEffect,
|
||||
Prec,
|
||||
EditorSelection,
|
||||
} from '@codemirror/state'
|
||||
import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view'
|
||||
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'
|
||||
import { addLearnedWord } from '@/features/source-editor/extensions/spelling/learned-words'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
|
||||
/*
|
||||
* The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
|
||||
* 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 { 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({
|
||||
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({
|
||||
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={(focus = true) => {
|
||||
view.dispatch({
|
||||
effects: hideSpellingMenu.of(null),
|
||||
})
|
||||
if (focus) {
|
||||
view.focus()
|
||||
}
|
||||
}}
|
||||
handleLearnWord={() => {
|
||||
const tooltip = view.state.field(spellingMenuField)
|
||||
if (tooltip) {
|
||||
window.setTimeout(() => {
|
||||
view.dispatch({
|
||||
selection: EditorSelection.cursor(tooltip.end ?? tooltip.pos),
|
||||
})
|
||||
})
|
||||
}
|
||||
view.focus()
|
||||
|
||||
postJSON('/spelling/learn', {
|
||||
body: {
|
||||
word: word.text,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
view.dispatch(addLearnedWord(word.text), {
|
||||
effects: 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
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
const changes = view.state.changes([
|
||||
{ from: tooltip.pos, to: tooltip.end, insert: text },
|
||||
])
|
||||
|
||||
view.dispatch({
|
||||
changes,
|
||||
effects: [hideSpellingMenu.of(null)],
|
||||
selection: EditorSelection.cursor(tooltip.end ?? tooltip.pos).map(
|
||||
changes
|
||||
),
|
||||
})
|
||||
})
|
||||
view.focus()
|
||||
|
||||
sendMB('spelling-suggestion-click', {
|
||||
language: getSpellCheckLanguage(view.state),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</SplitTestProvider>,
|
||||
dom
|
||||
)
|
||||
|
||||
const destroy = () => {
|
||||
ReactDOM.unmountComponentAtNode(dom)
|
||||
}
|
||||
|
||||
return { dom, destroy }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const WORD_REGEX = /\\?['\p{L}]+/gu
|
||||
@@ -0,0 +1,155 @@
|
||||
import { EditorView, ViewPlugin } from '@codemirror/view'
|
||||
import {
|
||||
EditorState,
|
||||
StateEffect,
|
||||
StateField,
|
||||
TransactionSpec,
|
||||
} from '@codemirror/state'
|
||||
import { misspelledWordsField } from './misspelled-words'
|
||||
import { removeLearnedWord } from './learned-words'
|
||||
import { cacheField } from './cache'
|
||||
import { hideSpellingMenu, spellingMenuField } from './context-menu'
|
||||
import { SpellChecker } from './spellchecker'
|
||||
import { parserWatcher } from '../wait-for-parser'
|
||||
import type { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager'
|
||||
|
||||
type Options = {
|
||||
spellCheckLanguage?: string
|
||||
hunspellManager?: HunspellManager
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom extension that creates a spell checker for the current language (from the user settings).
|
||||
* The spell check runs on the server whenever a line changes.
|
||||
* The mis-spelled words, ignored words and spell-checked words are stored in a state field.
|
||||
* 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, hunspellManager }: Options) => {
|
||||
return [
|
||||
spellingTheme,
|
||||
parserWatcher,
|
||||
spellCheckLanguageField.init(() => spellCheckLanguage),
|
||||
spellCheckerField.init(() =>
|
||||
spellCheckLanguage
|
||||
? new SpellChecker(spellCheckLanguage, hunspellManager)
|
||||
: null
|
||||
),
|
||||
misspelledWordsField,
|
||||
cacheField,
|
||||
spellingMenuField,
|
||||
dictionary,
|
||||
]
|
||||
}
|
||||
|
||||
const dictionary = ViewPlugin.define(view => {
|
||||
const listener = (event: Event) => {
|
||||
view.dispatch(removeLearnedWord((event as CustomEvent<string>).detail))
|
||||
}
|
||||
|
||||
window.addEventListener('editor:remove-learned-word', listener)
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
window.removeEventListener('editor:remove-learned-word', listener)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const spellingTheme = EditorView.baseTheme({
|
||||
'.ol-cm-spelling-error': {
|
||||
textDecorationColor: 'red',
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationStyle: 'dotted',
|
||||
textDecorationThickness: '2px',
|
||||
textDecorationSkipInk: 'none',
|
||||
textUnderlineOffset: '0.2em',
|
||||
},
|
||||
'.cm-tooltip.ol-cm-spelling-context-menu-tooltip': {
|
||||
borderWidth: '0',
|
||||
background: 'transparent',
|
||||
},
|
||||
})
|
||||
|
||||
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()
|
||||
value = effect.value.spellCheckLanguage
|
||||
? new SpellChecker(
|
||||
effect.value.spellCheckLanguage,
|
||||
effect.value.hunspellManager
|
||||
)
|
||||
: null
|
||||
}
|
||||
}
|
||||
return value
|
||||
},
|
||||
provide(field) {
|
||||
return [
|
||||
ViewPlugin.define(view => {
|
||||
return {
|
||||
destroy: () => {
|
||||
view.state.field(field)?.destroy()
|
||||
},
|
||||
}
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: (_event, view) => {
|
||||
if (view.state.facet(EditorView.editable)) {
|
||||
view.state.field(field)?.scheduleSpellCheck(view)
|
||||
}
|
||||
},
|
||||
}),
|
||||
EditorView.updateListener.of(update => {
|
||||
if (update.state.facet(EditorView.editable)) {
|
||||
update.state.field(field)?.handleUpdate(update)
|
||||
}
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export const getSpellCheckLanguage = (state: EditorState) =>
|
||||
state.field(spellCheckLanguageField, false)
|
||||
|
||||
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: [
|
||||
setSpellCheckLanguageEffect.of({
|
||||
spellCheckLanguage,
|
||||
hunspellManager,
|
||||
}),
|
||||
hideSpellingMenu.of(null),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { StateEffect } from '@codemirror/state'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
export const addLearnedWordEffect = StateEffect.define<string>()
|
||||
|
||||
export const removeLearnedWordEffect = StateEffect.define<string>()
|
||||
|
||||
export const learnedWords = {
|
||||
global: new Set(getMeta('ol-learnedWords')),
|
||||
}
|
||||
|
||||
export const addLearnedWord = (text: string) => {
|
||||
learnedWords.global.add(text)
|
||||
return {
|
||||
effects: addLearnedWordEffect.of(text),
|
||||
}
|
||||
}
|
||||
|
||||
export const removeLearnedWord = (text: string) => {
|
||||
learnedWords.global.delete(text)
|
||||
return {
|
||||
effects: removeLearnedWordEffect.of(text),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { StateField, StateEffect, Line } from '@codemirror/state'
|
||||
import { EditorView, Decoration, DecorationSet } from '@codemirror/view'
|
||||
import { addLearnedWordEffect } from './learned-words'
|
||||
import { Word } from './spellchecker'
|
||||
import { setSpellCheckLanguageEffect } from '@/features/source-editor/extensions/spelling/index'
|
||||
|
||||
export const addMisspelledWords = StateEffect.define<Word[]>()
|
||||
|
||||
const createMark = (word: Word) => {
|
||||
return Decoration.mark({
|
||||
class: 'ol-cm-spelling-error',
|
||||
word,
|
||||
}).range(word.from, word.to)
|
||||
}
|
||||
|
||||
/*
|
||||
* State for misspelled words, the results of a
|
||||
* spellcheck request. Misspelled words are marked
|
||||
* with a red wavy underline.
|
||||
*/
|
||||
export const misspelledWordsField = StateField.define<DecorationSet>({
|
||||
create() {
|
||||
return Decoration.none
|
||||
},
|
||||
update(marks, transaction) {
|
||||
if (transaction.docChanged) {
|
||||
// Remove any marks whose text has just been edited
|
||||
marks = marks.update({
|
||||
filter(from, to) {
|
||||
return !transaction.changes.touchesRange(from, to)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
marks = marks.map(transaction.changes)
|
||||
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(addMisspelledWords)) {
|
||||
const { doc } = transaction.state
|
||||
|
||||
// collect the lines that contained mispelled words, so existing marks can be removed
|
||||
const affectedLines = new Map<number, Line>()
|
||||
for (const word of effect.value) {
|
||||
if (!affectedLines.has(word.lineNumber)) {
|
||||
affectedLines.set(word.lineNumber, doc.line(word.lineNumber))
|
||||
}
|
||||
}
|
||||
|
||||
// Merge the new misspelled words into the existing set of marks
|
||||
marks = marks.update({
|
||||
filter(from, to) {
|
||||
for (const line of affectedLines.values()) {
|
||||
if (to > line.from && from < line.to) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
add: effect.value.map(word => createMark(word)),
|
||||
sort: true,
|
||||
})
|
||||
} else if (effect.is(addLearnedWordEffect)) {
|
||||
const word = effect.value
|
||||
// Remove existing marks matching the text of a supplied word
|
||||
marks = marks.update({
|
||||
filter(_from, _to, mark) {
|
||||
return (
|
||||
mark.spec.word.text !== word &&
|
||||
mark.spec.word.text !== capitaliseWord(word)
|
||||
)
|
||||
},
|
||||
})
|
||||
} else if (effect.is(setSpellCheckLanguageEffect)) {
|
||||
marks = Decoration.none
|
||||
}
|
||||
}
|
||||
return marks
|
||||
},
|
||||
provide: field => {
|
||||
return EditorView.decorations.from(field)
|
||||
},
|
||||
})
|
||||
|
||||
const capitaliseWord = (word: string) =>
|
||||
word.charAt(0).toUpperCase() + word.substring(1)
|
||||
@@ -0,0 +1,440 @@
|
||||
import { addMisspelledWords, misspelledWordsField } from './misspelled-words'
|
||||
import { addLearnedWordEffect, removeLearnedWordEffect } from './learned-words'
|
||||
import { cacheField, addWordToCache } from './cache'
|
||||
import { WORD_REGEX } from './helpers'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { EditorView, ViewUpdate } from '@codemirror/view'
|
||||
import { ChangeSet, Line, Range, RangeValue } from '@codemirror/state'
|
||||
import { getNormalTextSpansFromLine } from '../../utils/tree-query'
|
||||
import { waitForParser } from '../wait-for-parser'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import type { HunspellManager } from '../../hunspell/HunspellManager'
|
||||
import { captureException } from '@/infrastructure/error-reporter'
|
||||
|
||||
/*
|
||||
* Spellchecker, handles updates, schedules spelling checks
|
||||
*/
|
||||
export class SpellChecker {
|
||||
private abortController?: AbortController | null = null
|
||||
private timeout: number | null = null
|
||||
private firstCheck = true
|
||||
private waitingForParser = false
|
||||
private firstCheckPending = false
|
||||
private trackedChanges: ChangeSet
|
||||
private destroyed = false
|
||||
private readonly segmenter?: Intl.Segmenter
|
||||
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor(
|
||||
private readonly language: string,
|
||||
private hunspellManager?: HunspellManager
|
||||
) {
|
||||
debugConsole.log('SpellChecker', language, hunspellManager)
|
||||
this.trackedChanges = ChangeSet.empty(0)
|
||||
|
||||
const locale = language.replace(/_/, '-')
|
||||
|
||||
try {
|
||||
if (Intl.Segmenter) {
|
||||
const supportedLocales = Intl.Segmenter.supportedLocalesOf([locale], {
|
||||
localeMatcher: 'lookup',
|
||||
})
|
||||
|
||||
if (supportedLocales.includes(locale)) {
|
||||
this.segmenter = new Intl.Segmenter(locale, {
|
||||
localeMatcher: 'lookup',
|
||||
granularity: 'word',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore, not supported for some reason
|
||||
debugConsole.error(error)
|
||||
}
|
||||
|
||||
if (this.segmenter) {
|
||||
debugConsole.log(`Using Intl.Segmenter for ${locale}`)
|
||||
} else {
|
||||
debugConsole.warn(`Not using Intl.Segmenter for ${locale}`)
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._clearPendingSpellCheck()
|
||||
this.destroyed = true
|
||||
}
|
||||
|
||||
_abortRequest() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort()
|
||||
this.abortController = null
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdate(update: ViewUpdate) {
|
||||
if (update.docChanged) {
|
||||
this.trackedChanges = this.trackedChanges.compose(update.changes)
|
||||
this.scheduleSpellCheck(update.view)
|
||||
} else if (update.viewportChanged) {
|
||||
this.trackedChanges = ChangeSet.empty(0)
|
||||
this.scheduleSpellCheck(update.view)
|
||||
}
|
||||
// At the point that the spellchecker is initialized, the editor may not
|
||||
// yet be editable, and the parser may not be ready. Therefore, to do the
|
||||
// initial spellcheck, watch for changes in the editability of the editor
|
||||
// and kick off the process that performs a spellcheck once the parser is
|
||||
// ready. CM6 dispatches a transaction after every chunk of parser work and
|
||||
// when the editability changes, which means the spell checker is
|
||||
// initialized as soon as possible.
|
||||
else if (
|
||||
this.firstCheck &&
|
||||
!this.firstCheckPending &&
|
||||
update.state.facet(EditorView.editable)
|
||||
) {
|
||||
this.firstCheckPending = true
|
||||
this.spellCheckAsap(update.view)
|
||||
} else {
|
||||
for (const tr of update.transactions) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(addLearnedWordEffect)) {
|
||||
this.addWord(effect.value)
|
||||
.then(() => {
|
||||
update.view.state.field(cacheField, false)?.reset()
|
||||
this.trackedChanges = ChangeSet.empty(0)
|
||||
this.spellCheckAsap(update.view)
|
||||
})
|
||||
.catch(error => {
|
||||
captureException(error)
|
||||
debugConsole.error(error)
|
||||
})
|
||||
} else if (effect.is(removeLearnedWordEffect)) {
|
||||
this.removeWord(effect.value)
|
||||
.then(() => {
|
||||
update.view.state.field(cacheField, false)?.reset()
|
||||
this.trackedChanges = ChangeSet.empty(0)
|
||||
this.spellCheckAsap(update.view)
|
||||
})
|
||||
.catch(error => {
|
||||
captureException(error)
|
||||
debugConsole.error(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_performSpellCheck(view: EditorView) {
|
||||
const wordsToCheck = this.getWordsToCheck(view)
|
||||
if (wordsToCheck.length === 0) {
|
||||
this.trackedChanges = ChangeSet.empty(0)
|
||||
return
|
||||
}
|
||||
const cache = view.state.field(cacheField)
|
||||
const { knownMisspelledWords, unknownWords } = cache.checkWords(
|
||||
this.language,
|
||||
wordsToCheck
|
||||
)
|
||||
const processResult = (misspellings: { index: number }[]) => {
|
||||
this.trackedChanges = ChangeSet.empty(0)
|
||||
|
||||
if (this.firstCheck) {
|
||||
this.firstCheck = false
|
||||
this.firstCheckPending = false
|
||||
}
|
||||
const { misspelledWords, cacheAdditions } = buildSpellCheckResult(
|
||||
knownMisspelledWords,
|
||||
unknownWords,
|
||||
misspellings
|
||||
)
|
||||
view.dispatch({
|
||||
effects: [
|
||||
addMisspelledWords.of(misspelledWords),
|
||||
...cacheAdditions.map(([word, value]) => {
|
||||
return addWordToCache.of({
|
||||
lang: word.lang,
|
||||
wordText: word.text,
|
||||
value,
|
||||
})
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
if (unknownWords.length === 0) {
|
||||
processResult([])
|
||||
} else {
|
||||
this._abortRequest()
|
||||
this.abortController = new AbortController()
|
||||
if (this.hunspellManager) {
|
||||
const signal = this.abortController.signal
|
||||
this.hunspellManager.send(
|
||||
{
|
||||
type: 'spell',
|
||||
words: unknownWords.map(word => word.text),
|
||||
},
|
||||
result => {
|
||||
if (!signal.aborted) {
|
||||
if ('error' in result) {
|
||||
debugConsole.error(result.error)
|
||||
captureException(
|
||||
new Error('Error running spellcheck for word'),
|
||||
{ tags: { ol_spell_check_language: this.language } }
|
||||
)
|
||||
} else {
|
||||
processResult(result.misspellings)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suggest(word: string) {
|
||||
return new Promise<{ suggestions: string[] }>((resolve, reject) => {
|
||||
if (this.hunspellManager) {
|
||||
this.hunspellManager.send({ type: 'suggest', word }, result => {
|
||||
if ('error' in result) {
|
||||
reject(new Error('Error finding spelling suggestions for word'))
|
||||
} else {
|
||||
resolve(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addWord(word: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (this.hunspellManager) {
|
||||
this.hunspellManager.send({ type: 'add_word', word }, result => {
|
||||
if ('error' in result) {
|
||||
reject(new Error('Error adding word to spellcheck'))
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
removeWord(word: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (this.hunspellManager) {
|
||||
this.hunspellManager.send({ type: 'remove_word', word }, result => {
|
||||
if ('error' in result) {
|
||||
reject(new Error('Error removing word from spellcheck'))
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_spellCheckWhenParserReady(view: EditorView) {
|
||||
if (this.waitingForParser) {
|
||||
return
|
||||
}
|
||||
|
||||
this.waitingForParser = true
|
||||
waitForParser(view, view => view.viewport.to).then(() => {
|
||||
this.waitingForParser = false
|
||||
this._performSpellCheck(view)
|
||||
})
|
||||
}
|
||||
|
||||
_clearPendingSpellCheck() {
|
||||
if (this.timeout) {
|
||||
window.clearTimeout(this.timeout)
|
||||
this.timeout = null
|
||||
}
|
||||
this._abortRequest()
|
||||
}
|
||||
|
||||
_asyncSpellCheck(view: EditorView, delay: number) {
|
||||
this._clearPendingSpellCheck()
|
||||
|
||||
this.timeout = window.setTimeout(() => {
|
||||
this._spellCheckWhenParserReady(view)
|
||||
this.timeout = null
|
||||
}, delay)
|
||||
}
|
||||
|
||||
spellCheckAsap(view: EditorView) {
|
||||
if (this.destroyed) {
|
||||
debugConsole.warn(
|
||||
'spellCheckAsap called after spellchecker was destroyed. Ignoring.'
|
||||
)
|
||||
return
|
||||
}
|
||||
this._asyncSpellCheck(view, 0)
|
||||
}
|
||||
|
||||
scheduleSpellCheck(view: EditorView) {
|
||||
if (this.destroyed) {
|
||||
debugConsole.warn(
|
||||
'scheduleSpellCheck called after spellchecker was destroyed. Ignoring.'
|
||||
)
|
||||
return
|
||||
}
|
||||
this._asyncSpellCheck(view, 1000)
|
||||
}
|
||||
|
||||
getWordsToCheck(view: EditorView) {
|
||||
const wordsToCheck: Word[] = []
|
||||
|
||||
const { from, to } = view.viewport
|
||||
const changedLineNumbers = new Set<number>()
|
||||
if (!this.trackedChanges.empty) {
|
||||
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
|
||||
for (let i = fromLine; i <= toLine; i++) {
|
||||
changedLineNumbers.add(i)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const fromLine = view.state.doc.lineAt(from).number
|
||||
const toLine = view.state.doc.lineAt(to).number
|
||||
for (let i = fromLine; i <= toLine; i++) {
|
||||
changedLineNumbers.add(i)
|
||||
}
|
||||
}
|
||||
|
||||
for (const i of changedLineNumbers) {
|
||||
const line = view.state.doc.line(i)
|
||||
wordsToCheck.push(
|
||||
...getWordsFromLine(view, line, this.language, this.segmenter)
|
||||
)
|
||||
}
|
||||
|
||||
return wordsToCheck
|
||||
}
|
||||
}
|
||||
|
||||
export class Word {
|
||||
public text: string
|
||||
public from: number
|
||||
public to: number
|
||||
public lineNumber: number
|
||||
public lang: string
|
||||
|
||||
constructor(options: {
|
||||
text: string
|
||||
from: number
|
||||
to: number
|
||||
lineNumber: number
|
||||
lang: string
|
||||
}) {
|
||||
const { text, from, to, lineNumber, lang } = options
|
||||
if (
|
||||
text == null ||
|
||||
from == null ||
|
||||
to == null ||
|
||||
lineNumber == null ||
|
||||
lang == null
|
||||
) {
|
||||
throw new OError('Spellcheck: invalid word').withInfo({ options })
|
||||
}
|
||||
this.text = text
|
||||
this.from = from
|
||||
this.to = to
|
||||
this.lineNumber = lineNumber
|
||||
this.lang = lang
|
||||
}
|
||||
}
|
||||
|
||||
export const buildSpellCheckResult = (
|
||||
knownMisspelledWords: Word[],
|
||||
unknownWords: Word[],
|
||||
misspellings: { index: number }[]
|
||||
) => {
|
||||
const cacheAdditions: [Word, boolean][] = []
|
||||
|
||||
// Put known misspellings into cache
|
||||
const misspelledWords = misspellings.map(item => {
|
||||
const word = {
|
||||
...unknownWords[item.index],
|
||||
}
|
||||
cacheAdditions.push([word, false])
|
||||
return word
|
||||
})
|
||||
|
||||
const misspelledWordsSet = new Set<string>(
|
||||
misspelledWords.map(word => word.text)
|
||||
)
|
||||
|
||||
// if word was not misspelled, put it in the cache
|
||||
for (const word of unknownWords) {
|
||||
if (!misspelledWordsSet.has(word.text)) {
|
||||
cacheAdditions.push([word, true])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cacheAdditions,
|
||||
misspelledWords: misspelledWords.concat(knownMisspelledWords),
|
||||
}
|
||||
}
|
||||
|
||||
export function* getWordsFromLine(
|
||||
view: EditorView,
|
||||
line: Line,
|
||||
lang: string,
|
||||
segmenter?: Intl.Segmenter
|
||||
) {
|
||||
for (const span of getNormalTextSpansFromLine(view, line)) {
|
||||
if (segmenter) {
|
||||
for (const value of segmenter.segment(span.text)) {
|
||||
if (value.isWordLike) {
|
||||
const word = value.segment
|
||||
const from = span.from + value.index
|
||||
yield new Word({
|
||||
text: word,
|
||||
from,
|
||||
to: from + word.length,
|
||||
lineNumber: line.number,
|
||||
lang,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const match of span.text.matchAll(WORD_REGEX)) {
|
||||
let word = match[0].replace(/'+$/, '')
|
||||
let from = span.from + match.index
|
||||
while (word.startsWith("'")) {
|
||||
word = word.slice(1)
|
||||
from++
|
||||
}
|
||||
yield new Word({
|
||||
text: word,
|
||||
from,
|
||||
to: from + word.length,
|
||||
lineNumber: line.number,
|
||||
lang,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Mark = Range<RangeValue & { spec: { word: Word } }>
|
||||
|
||||
export const getMarkAtPosition = (
|
||||
view: EditorView,
|
||||
position: number
|
||||
): Mark | null => {
|
||||
const marks = view.state.field(misspelledWordsField)
|
||||
|
||||
let targetMark: Mark | null = null
|
||||
marks.between(view.viewport.from, view.viewport.to, (from, to, value) => {
|
||||
if (position >= from && position <= to) {
|
||||
targetMark = { from, to, value }
|
||||
return false
|
||||
}
|
||||
})
|
||||
return targetMark
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import { Dropdown } from 'react-bootstrap-5'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
export const SpellingSuggestionsLanguage = memo<{
|
||||
language: { name: string }
|
||||
handleClose: (focus: boolean) => void
|
||||
}>(({ language, handleClose }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
// open the left menu
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('ui.toggle-left-menu', { detail: true })
|
||||
)
|
||||
// focus the spell check setting
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('ui.focus-setting', { detail: 'spellCheckLanguage' })
|
||||
)
|
||||
handleClose(false)
|
||||
}, [handleClose])
|
||||
|
||||
return (
|
||||
<OLTooltip
|
||||
id="spell-check-client-tooltip"
|
||||
description={t('change_language')}
|
||||
overlayProps={{ placement: 'right', delay: 100 }}
|
||||
>
|
||||
<span>
|
||||
<Dropdown.Item
|
||||
className="d-flex gap-2 align-items-center"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<MaterialIcon type="settings" />
|
||||
<span>{language.name}</span>
|
||||
</Dropdown.Item>
|
||||
</span>
|
||||
</OLTooltip>
|
||||
)
|
||||
})
|
||||
SpellingSuggestionsLanguage.displayName = 'SpellingSuggestionsLanguage'
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
FC,
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { SpellChecker, Word } from './spellchecker'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import classnames from 'classnames'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import { SpellingSuggestionsLanguage } from './spelling-suggestions-language'
|
||||
import { captureException } from '@/infrastructure/error-reporter'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { SpellCheckLanguage } from '../../../../../../types/project-settings'
|
||||
import { Dropdown } from 'react-bootstrap-5'
|
||||
|
||||
const ITEMS_TO_SHOW = 8
|
||||
|
||||
type SpellingSuggestionsProps = {
|
||||
word: Word
|
||||
spellCheckLanguage?: string
|
||||
spellChecker?: SpellChecker | null
|
||||
handleClose: () => void
|
||||
handleLearnWord: () => void
|
||||
handleCorrectWord: (text: string) => void
|
||||
}
|
||||
|
||||
export const SpellingSuggestions: FC<SpellingSuggestionsProps> = ({
|
||||
word,
|
||||
spellCheckLanguage,
|
||||
spellChecker,
|
||||
handleClose,
|
||||
handleLearnWord,
|
||||
handleCorrectWord,
|
||||
}) => {
|
||||
const [suggestions, setSuggestions] = useState<string[]>([])
|
||||
|
||||
const [waiting, setWaiting] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
spellChecker
|
||||
?.suggest(word.text)
|
||||
.then(result => {
|
||||
setSuggestions(result.suggestions.slice(0, ITEMS_TO_SHOW))
|
||||
setWaiting(false)
|
||||
sendMB('spelling-suggestion-shown', {
|
||||
language: spellCheckLanguage,
|
||||
count: result.suggestions.length,
|
||||
// word: transaction.state.sliceDoc(mark.from, mark.to),
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
captureException(error, {
|
||||
tags: { ol_spell_check_language: spellCheckLanguage },
|
||||
})
|
||||
debugConsole.error(error)
|
||||
})
|
||||
}, [word, spellChecker, spellCheckLanguage])
|
||||
|
||||
const language = useMemo(() => {
|
||||
if (spellCheckLanguage) {
|
||||
return (getMeta('ol-languages') ?? []).find(
|
||||
item => item.code === spellCheckLanguage
|
||||
)
|
||||
}
|
||||
}, [spellCheckLanguage])
|
||||
|
||||
if (!language) {
|
||||
return null
|
||||
}
|
||||
|
||||
const innerProps = {
|
||||
suggestions,
|
||||
waiting,
|
||||
handleClose,
|
||||
handleCorrectWord,
|
||||
handleLearnWord,
|
||||
language,
|
||||
}
|
||||
|
||||
return <B5SpellingSuggestions {...innerProps} />
|
||||
}
|
||||
|
||||
type SpellingSuggestionsInnerProps = {
|
||||
suggestions: string[]
|
||||
waiting: boolean
|
||||
handleClose: () => void
|
||||
handleCorrectWord: (text: string) => void
|
||||
handleLearnWord: () => void
|
||||
language: SpellCheckLanguage
|
||||
}
|
||||
|
||||
const B5SpellingSuggestions: FC<SpellingSuggestionsInnerProps> = ({
|
||||
suggestions,
|
||||
waiting,
|
||||
language,
|
||||
handleClose,
|
||||
handleCorrectWord,
|
||||
handleLearnWord,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dropdown>
|
||||
<Dropdown.Menu
|
||||
className={classnames('dropdown-menu', 'dropdown-menu-unpositioned', {
|
||||
hidden: waiting,
|
||||
})}
|
||||
show={!waiting}
|
||||
tabIndex={0}
|
||||
role="menu"
|
||||
onKeyDown={event => {
|
||||
switch (event.code) {
|
||||
case 'Escape':
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
handleClose()
|
||||
break
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Array.isArray(suggestions) &&
|
||||
suggestions.map((suggestion, index) => (
|
||||
<BS5ListItem
|
||||
key={suggestion}
|
||||
content={suggestion}
|
||||
handleClick={event => {
|
||||
event.preventDefault()
|
||||
handleCorrectWord(suggestion)
|
||||
}}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={index === 0}
|
||||
/>
|
||||
))}
|
||||
{suggestions?.length > 0 && <Dropdown.Divider />}
|
||||
<BS5ListItem
|
||||
content={t('add_to_dictionary')}
|
||||
handleClick={event => {
|
||||
event.preventDefault()
|
||||
handleLearnWord()
|
||||
}}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={suggestions?.length === 0}
|
||||
/>
|
||||
|
||||
<Dropdown.Divider />
|
||||
<SpellingSuggestionsLanguage
|
||||
language={language}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
const BS5ListItem: FC<{
|
||||
content: string
|
||||
handleClick: MouseEventHandler<HTMLButtonElement>
|
||||
autoFocus?: boolean
|
||||
}> = ({ content, handleClick, autoFocus }) => {
|
||||
const handleListItem = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node && autoFocus) node.focus()
|
||||
},
|
||||
[autoFocus]
|
||||
)
|
||||
return (
|
||||
<Dropdown.Item
|
||||
role="menuitem"
|
||||
className="btn-link text-left dropdown-menu-button"
|
||||
onClick={handleClick}
|
||||
ref={handleListItem}
|
||||
>
|
||||
{content}
|
||||
</Dropdown.Item>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user