spellchecker.ts 11 KB

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