spellchecker.ts 11 KB

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