spellchecker.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  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. view.dispatch({
  96. effects: compileEffects(result),
  97. })
  98. }
  99. if (unknownWords.length === 0) {
  100. processResult([])
  101. } else {
  102. this._abortRequest()
  103. this.abortController = new AbortController()
  104. spellCheckRequest(this.language, unknownWords, this.abortController)
  105. .then(result => {
  106. this.abortController = null
  107. processResult(result.misspellings)
  108. })
  109. .catch(error => {
  110. this.abortController = null
  111. debugConsole.error(error)
  112. })
  113. }
  114. }
  115. _spellCheckWhenParserReady(view: EditorView) {
  116. if (this.waitingForParser) {
  117. return
  118. }
  119. this.waitingForParser = true
  120. waitForParser(view, view => view.viewport.to).then(() => {
  121. this.waitingForParser = false
  122. this._performSpellCheck(view)
  123. })
  124. }
  125. _clearPendingSpellCheck() {
  126. if (this.timeout) {
  127. window.clearTimeout(this.timeout)
  128. this.timeout = null
  129. }
  130. this._abortRequest()
  131. }
  132. _asyncSpellCheck(view: EditorView, delay: number) {
  133. this._clearPendingSpellCheck()
  134. this.timeout = window.setTimeout(() => {
  135. this._spellCheckWhenParserReady(view)
  136. this.timeout = null
  137. }, delay)
  138. }
  139. spellCheckAsap(view: EditorView) {
  140. this._asyncSpellCheck(view, 0)
  141. }
  142. scheduleSpellCheck(view: EditorView) {
  143. this._asyncSpellCheck(view, 1000)
  144. }
  145. getWordsToCheck(view: EditorView) {
  146. const wordsToCheck: Word[] = []
  147. const { from, to } = view.viewport
  148. const changedLineNumbers = new Set<number>()
  149. if (this.trackedChanges.length > 0) {
  150. this.trackedChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
  151. if (fromB <= to && toB >= from) {
  152. const fromLine = view.state.doc.lineAt(fromB).number
  153. const toLine = view.state.doc.lineAt(toB).number
  154. for (let i = fromLine; i <= toLine; i++) {
  155. changedLineNumbers.add(i)
  156. }
  157. }
  158. })
  159. } else {
  160. const fromLine = view.state.doc.lineAt(from).number
  161. const toLine = view.state.doc.lineAt(to).number
  162. for (let i = fromLine; i <= toLine; i++) {
  163. changedLineNumbers.add(i)
  164. }
  165. }
  166. const ignoredWords = view.state.field(ignoredWordsField)
  167. for (const i of changedLineNumbers) {
  168. const line = view.state.doc.line(i)
  169. wordsToCheck.push(
  170. ...getWordsFromLine(view, line, ignoredWords, this.language)
  171. )
  172. }
  173. return wordsToCheck
  174. }
  175. }
  176. export class Word {
  177. public text: string
  178. public from: number
  179. public to: number
  180. public lineNumber: number
  181. public lang: string
  182. public suggestions?: WordCacheValue
  183. constructor(options: {
  184. text: string
  185. from: number
  186. to: number
  187. lineNumber: number
  188. lang: string
  189. }) {
  190. const { text, from, to, lineNumber, lang } = options
  191. if (
  192. text == null ||
  193. from == null ||
  194. to == null ||
  195. lineNumber == null ||
  196. lang == null
  197. ) {
  198. throw new OError('Spellcheck: invalid word').withInfo({ options })
  199. }
  200. this.text = text
  201. this.from = from
  202. this.to = to
  203. this.lineNumber = lineNumber
  204. this.lang = lang
  205. }
  206. }
  207. export const buildSpellCheckResult = (
  208. knownMisspelledWords: Word[],
  209. unknownWords: Word[],
  210. misspellings: { index: number; suggestions: string[] }[]
  211. ) => {
  212. const cacheAdditions: [Word, string[] | boolean][] = []
  213. // Put known misspellings into cache
  214. const misspelledWords = misspellings.map(item => {
  215. const word = {
  216. ...unknownWords[item.index],
  217. }
  218. word.suggestions = item.suggestions
  219. if (word.suggestions) {
  220. cacheAdditions.push([word, word.suggestions])
  221. }
  222. return word
  223. })
  224. // if word was not misspelled, put it in the cache
  225. for (const word of unknownWords) {
  226. if (!misspelledWords.find(mw => mw.text === word.text)) {
  227. cacheAdditions.push([word, true])
  228. }
  229. }
  230. return {
  231. cacheAdditions,
  232. misspelledWords: misspelledWords.concat(knownMisspelledWords),
  233. }
  234. }
  235. export const compileEffects = (results: {
  236. cacheAdditions: [Word, string[] | boolean][]
  237. misspelledWords: Word[]
  238. }) => {
  239. const { cacheAdditions, misspelledWords } = results
  240. return [
  241. addMisspelledWords.of(misspelledWords),
  242. ...cacheAdditions.map(([word, value]) => {
  243. return addWordToCache.of({
  244. lang: word.lang,
  245. wordText: word.text,
  246. value,
  247. })
  248. }),
  249. ]
  250. }
  251. export const getWordsFromLine = (
  252. view: EditorView,
  253. line: Line,
  254. ignoredWords: IgnoredWords,
  255. lang: string
  256. ): Word[] => {
  257. const normalTextSpans: Array<NormalTextSpan> = getNormalTextSpansFromLine(
  258. view,
  259. line
  260. )
  261. const words: Word[] = []
  262. for (const span of normalTextSpans) {
  263. for (const match of span.text.matchAll(WORD_REGEX)) {
  264. let word = match[0]
  265. if (word.startsWith("'")) {
  266. word = word.slice(1)
  267. }
  268. if (word.endsWith("'")) {
  269. word = word.slice(0, -1)
  270. }
  271. if (!ignoredWords.has(word)) {
  272. const from = span.from + match.index
  273. words.push(
  274. new Word({
  275. text: word,
  276. from,
  277. to: from + word.length,
  278. lineNumber: line.number,
  279. lang,
  280. })
  281. )
  282. }
  283. }
  284. }
  285. return words
  286. }
  287. export type Mark = Range<RangeValue & { spec: { word: Word } }>
  288. export const getMarkAtPosition = (
  289. view: EditorView,
  290. position: number
  291. ): Mark | null => {
  292. const marks = view.state.field(misspelledWordsField)
  293. let targetMark: Mark | null = null
  294. marks.between(view.viewport.from, view.viewport.to, (from, to, value) => {
  295. if (position >= from && position <= to) {
  296. targetMark = { from, to, value }
  297. return false
  298. }
  299. })
  300. return targetMark
  301. }