spellchecker.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. import { addMisspelledWords, misspelledWordsField } from './misspelled-words'
  2. import { ignoredWordsField, resetSpellChecker } from './ignored-words'
  3. import { cacheField, addWordToCache, WordCacheValue } from './cache'
  4. import { WORD_REGEX } from './helpers'
  5. import OError from '@overleaf/o-error'
  6. import { spellCheckRequest } from './backend'
  7. import { EditorView, ViewUpdate } from '@codemirror/view'
  8. import { ChangeSet, Line, Range, RangeValue } from '@codemirror/state'
  9. import { IgnoredWords } from '../../../dictionary/ignored-words'
  10. import {
  11. getNormalTextSpansFromLine,
  12. NormalTextSpan,
  13. } from '../../utils/tree-query'
  14. import { waitForParser } from '../wait-for-parser'
  15. import { debugConsole } from '@/utils/debugging'
  16. import type { HunspellManager } from '../../hunspell/HunspellManager'
  17. /*
  18. * Spellchecker, handles updates, schedules spelling checks
  19. */
  20. export class SpellChecker {
  21. private abortController?: AbortController | null = null
  22. private timeout: number | null = null
  23. private firstCheck = true
  24. private waitingForParser = false
  25. private firstCheckPending = false
  26. private trackedChanges: ChangeSet
  27. // eslint-disable-next-line no-useless-constructor
  28. constructor(
  29. private readonly language: string,
  30. private hunspellManager?: HunspellManager
  31. ) {
  32. debugConsole.log('SpellChecker', language, hunspellManager)
  33. this.trackedChanges = ChangeSet.empty(0)
  34. }
  35. destroy() {
  36. this._clearPendingSpellCheck()
  37. // this.hunspellManager?.destroy()
  38. }
  39. _abortRequest() {
  40. if (this.abortController) {
  41. this.abortController.abort()
  42. this.abortController = null
  43. }
  44. }
  45. handleUpdate(update: ViewUpdate) {
  46. if (update.docChanged) {
  47. this.trackedChanges = this.trackedChanges.compose(update.changes)
  48. this.scheduleSpellCheck(update.view)
  49. } else if (update.viewportChanged) {
  50. this.trackedChanges = ChangeSet.empty(0)
  51. this.scheduleSpellCheck(update.view)
  52. } else if (
  53. update.transactions.some(tr => {
  54. return tr.effects.some(effect => effect.is(resetSpellChecker))
  55. })
  56. ) {
  57. // for tests
  58. this.trackedChanges = ChangeSet.empty(0)
  59. this.spellCheckAsap(update.view)
  60. }
  61. // At the point that the spellchecker is initialized, the editor may not
  62. // yet be editable, and the parser may not be ready. Therefore, to do the
  63. // initial spellcheck, watch for changes in the editability of the editor
  64. // and kick off the process that performs a spellcheck once the parser is
  65. // ready. CM6 dispatches a transaction after every chunk of parser work and
  66. // when the editability changes, which means the spell checker is
  67. // initialized as soon as possible.
  68. else if (
  69. this.firstCheck &&
  70. !this.firstCheckPending &&
  71. update.state.facet(EditorView.editable)
  72. ) {
  73. this.firstCheckPending = true
  74. this.spellCheckAsap(update.view)
  75. }
  76. }
  77. _performSpellCheck(view: EditorView) {
  78. const wordsToCheck = this.getWordsToCheck(view)
  79. if (wordsToCheck.length === 0) {
  80. return
  81. }
  82. const cache = view.state.field(cacheField)
  83. const { knownMisspelledWords, unknownWords } = cache.checkWords(
  84. this.language,
  85. wordsToCheck
  86. )
  87. const processResult = (
  88. misspellings: { index: number; suggestions?: string[] }[]
  89. ) => {
  90. this.trackedChanges = ChangeSet.empty(0)
  91. if (this.firstCheck) {
  92. this.firstCheck = false
  93. this.firstCheckPending = false
  94. }
  95. const result = buildSpellCheckResult(
  96. knownMisspelledWords,
  97. unknownWords,
  98. misspellings
  99. )
  100. view.dispatch({
  101. effects: compileEffects(result),
  102. })
  103. }
  104. if (unknownWords.length === 0) {
  105. processResult([])
  106. } else {
  107. this._abortRequest()
  108. this.abortController = new AbortController()
  109. if (this.hunspellManager) {
  110. const signal = this.abortController.signal
  111. this.hunspellManager.send(
  112. {
  113. type: 'spell',
  114. words: unknownWords.map(word => word.text),
  115. },
  116. (result: any) => {
  117. if (!signal.aborted) {
  118. if (result.error) {
  119. debugConsole.error(result.error)
  120. } else {
  121. processResult(result.misspellings)
  122. }
  123. }
  124. }
  125. )
  126. } else {
  127. spellCheckRequest(this.language, unknownWords, this.abortController)
  128. .then(result => {
  129. this.abortController = null
  130. return processResult(result.misspellings)
  131. })
  132. .catch(error => {
  133. this.abortController = null
  134. debugConsole.error(error)
  135. })
  136. }
  137. }
  138. }
  139. suggest(word: string) {
  140. return new Promise<{ suggestions: string[] }>((resolve, reject) => {
  141. if (this.hunspellManager) {
  142. this.hunspellManager.send({ type: 'suggest', word }, result => {
  143. if ((result as { error: true }).error) {
  144. reject(new Error())
  145. } else {
  146. resolve(result as { suggestions: string[] })
  147. }
  148. })
  149. }
  150. })
  151. }
  152. addWord(word: string) {
  153. return new Promise<void>((resolve, reject) => {
  154. if (this.hunspellManager) {
  155. this.hunspellManager.send({ type: 'add_word', word }, result => {
  156. if ((result as { error: true }).error) {
  157. reject(new Error())
  158. } else {
  159. resolve()
  160. }
  161. })
  162. }
  163. })
  164. }
  165. removeWord(word: string) {
  166. return new Promise<void>((resolve, reject) => {
  167. if (this.hunspellManager) {
  168. this.hunspellManager.send({ type: 'remove_word', word }, result => {
  169. if ((result as { error: true }).error) {
  170. reject(new Error())
  171. } else {
  172. resolve()
  173. }
  174. })
  175. }
  176. })
  177. }
  178. _spellCheckWhenParserReady(view: EditorView) {
  179. if (this.waitingForParser) {
  180. return
  181. }
  182. this.waitingForParser = true
  183. waitForParser(view, view => view.viewport.to).then(() => {
  184. this.waitingForParser = false
  185. this._performSpellCheck(view)
  186. })
  187. }
  188. _clearPendingSpellCheck() {
  189. if (this.timeout) {
  190. window.clearTimeout(this.timeout)
  191. this.timeout = null
  192. }
  193. this._abortRequest()
  194. }
  195. _asyncSpellCheck(view: EditorView, delay: number) {
  196. this._clearPendingSpellCheck()
  197. this.timeout = window.setTimeout(() => {
  198. this._spellCheckWhenParserReady(view)
  199. this.timeout = null
  200. }, delay)
  201. }
  202. spellCheckAsap(view: EditorView) {
  203. this._asyncSpellCheck(view, 0)
  204. }
  205. scheduleSpellCheck(view: EditorView) {
  206. this._asyncSpellCheck(view, 1000)
  207. }
  208. getWordsToCheck(view: EditorView) {
  209. const wordsToCheck: Word[] = []
  210. const { from, to } = view.viewport
  211. const changedLineNumbers = new Set<number>()
  212. if (this.trackedChanges.length > 0) {
  213. this.trackedChanges.iterChangedRanges((_fromA, _toA, fromB, toB) => {
  214. if (fromB <= to && toB >= from) {
  215. const fromLine = view.state.doc.lineAt(fromB).number
  216. const toLine = view.state.doc.lineAt(toB).number
  217. for (let i = fromLine; i <= toLine; i++) {
  218. changedLineNumbers.add(i)
  219. }
  220. }
  221. })
  222. } else {
  223. const fromLine = view.state.doc.lineAt(from).number
  224. const toLine = view.state.doc.lineAt(to).number
  225. for (let i = fromLine; i <= toLine; i++) {
  226. changedLineNumbers.add(i)
  227. }
  228. }
  229. const ignoredWords = this.hunspellManager
  230. ? null
  231. : view.state.field(ignoredWordsField)
  232. for (const i of changedLineNumbers) {
  233. const line = view.state.doc.line(i)
  234. wordsToCheck.push(
  235. ...getWordsFromLine(view, line, ignoredWords, this.language)
  236. )
  237. }
  238. return wordsToCheck
  239. }
  240. }
  241. export class Word {
  242. public text: string
  243. public from: number
  244. public to: number
  245. public lineNumber: number
  246. public lang: string
  247. public suggestions?: WordCacheValue
  248. constructor(options: {
  249. text: string
  250. from: number
  251. to: number
  252. lineNumber: number
  253. lang: string
  254. }) {
  255. const { text, from, to, lineNumber, lang } = options
  256. if (
  257. text == null ||
  258. from == null ||
  259. to == null ||
  260. lineNumber == null ||
  261. lang == null
  262. ) {
  263. throw new OError('Spellcheck: invalid word').withInfo({ options })
  264. }
  265. this.text = text
  266. this.from = from
  267. this.to = to
  268. this.lineNumber = lineNumber
  269. this.lang = lang
  270. }
  271. }
  272. export const buildSpellCheckResult = (
  273. knownMisspelledWords: Word[],
  274. unknownWords: Word[],
  275. misspellings: { index: number; suggestions?: string[] }[]
  276. ) => {
  277. const cacheAdditions: [Word, string[] | boolean][] = []
  278. // Put known misspellings into cache
  279. const misspelledWords = misspellings.map(item => {
  280. const word = {
  281. ...unknownWords[item.index],
  282. }
  283. word.suggestions = item.suggestions
  284. if (word.suggestions) {
  285. cacheAdditions.push([word, word.suggestions])
  286. }
  287. return word
  288. })
  289. // if word was not misspelled, put it in the cache
  290. for (const word of unknownWords) {
  291. if (!misspelledWords.find(mw => mw.text === word.text)) {
  292. cacheAdditions.push([word, true])
  293. }
  294. }
  295. return {
  296. cacheAdditions,
  297. misspelledWords: misspelledWords.concat(knownMisspelledWords),
  298. }
  299. }
  300. export const compileEffects = (results: {
  301. cacheAdditions: [Word, string[] | boolean][]
  302. misspelledWords: Word[]
  303. }) => {
  304. const { cacheAdditions, misspelledWords } = results
  305. return [
  306. addMisspelledWords.of(misspelledWords),
  307. ...cacheAdditions.map(([word, value]) => {
  308. return addWordToCache.of({
  309. lang: word.lang,
  310. wordText: word.text,
  311. value,
  312. })
  313. }),
  314. ]
  315. }
  316. export const getWordsFromLine = (
  317. view: EditorView,
  318. line: Line,
  319. ignoredWords: IgnoredWords | null,
  320. lang: string
  321. ): Word[] => {
  322. const normalTextSpans: Array<NormalTextSpan> = getNormalTextSpansFromLine(
  323. view,
  324. line
  325. )
  326. const words: Word[] = []
  327. for (const span of normalTextSpans) {
  328. for (const match of span.text.matchAll(WORD_REGEX)) {
  329. const word = match[0].replace(/^'+/, '').replace(/'+$/, '')
  330. if (!ignoredWords?.has(word)) {
  331. const from = span.from + match.index
  332. words.push(
  333. new Word({
  334. text: word,
  335. from,
  336. to: from + word.length,
  337. lineNumber: line.number,
  338. lang,
  339. })
  340. )
  341. }
  342. }
  343. }
  344. return words
  345. }
  346. export type Mark = Range<RangeValue & { spec: { word: Word } }>
  347. export const getMarkAtPosition = (
  348. view: EditorView,
  349. position: number
  350. ): Mark | null => {
  351. const marks = view.state.field(misspelledWordsField)
  352. let targetMark: Mark | null = null
  353. marks.between(view.viewport.from, view.viewport.to, (from, to, value) => {
  354. if (position >= from && position <= to) {
  355. targetMark = { from, to, value }
  356. return false
  357. }
  358. })
  359. return targetMark
  360. }