first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -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
},
})

View File

@@ -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 }
}

View File

@@ -0,0 +1 @@
export const WORD_REGEX = /\\?['\p{L}]+/gu

View File

@@ -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),
],
}
}

View File

@@ -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),
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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>
)
}