spellchecker.ts 8.9 KB

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