annotations.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import { EditorView, ViewUpdate } from '@codemirror/view'
  2. import { Diagnostic, linter, lintGutter } from '@codemirror/lint'
  3. import {
  4. Compartment,
  5. Extension,
  6. RangeSet,
  7. RangeValue,
  8. StateEffect,
  9. StateField,
  10. Text,
  11. } from '@codemirror/state'
  12. import { Annotation } from '../../../../../types/annotation'
  13. import { debugConsole } from '@/utils/debugging'
  14. import { sendMB } from '@/infrastructure/event-tracking'
  15. import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
  16. interface CompileLogDiagnostic extends Diagnostic {
  17. compile?: true
  18. ruleId?: string
  19. id?: string
  20. entryIndex: number
  21. firstOnLine?: boolean
  22. }
  23. type RenderedDiagnostic = Pick<
  24. CompileLogDiagnostic,
  25. | 'message'
  26. | 'severity'
  27. | 'ruleId'
  28. | 'compile'
  29. | 'source'
  30. | 'id'
  31. | 'firstOnLine'
  32. >
  33. export type DiagnosticAction = (
  34. diagnostic: RenderedDiagnostic
  35. ) => HTMLButtonElement | null
  36. const diagnosticActions = importOverleafModules('diagnosticActions') as {
  37. import: { default: DiagnosticAction }
  38. }[]
  39. const compileLintSourceConf = new Compartment()
  40. export const annotations = () => [
  41. compileDiagnosticsState,
  42. compileLintSourceConf.of(compileLogLintSource()),
  43. /**
  44. * The built-in lint gutter extension, configured with zero hover delay.
  45. */
  46. lintGutter({
  47. hoverTime: 0,
  48. }),
  49. annotationsTheme,
  50. ]
  51. /**
  52. * A theme which moves the lint gutter outside the line numbers.
  53. */
  54. const annotationsTheme = EditorView.baseTheme({
  55. '.cm-gutter-lint': {
  56. order: -1,
  57. },
  58. })
  59. export const lintSourceConfig = {
  60. delay: 100,
  61. // Show highlights only for errors
  62. markerFilter(diagnostics: readonly Diagnostic[]) {
  63. return diagnostics.filter(d => d.severity === 'error')
  64. },
  65. // Do not show any tooltips for highlights within the editor content
  66. tooltipFilter() {
  67. return []
  68. },
  69. needsRefresh(update: ViewUpdate) {
  70. return update.selectionSet
  71. },
  72. }
  73. /**
  74. * A lint source using the compile log diagnostics
  75. */
  76. const compileLogLintSource = (): Extension =>
  77. linter(view => {
  78. const items: CompileLogDiagnostic[] = []
  79. // NOTE: iter() changes the order of diagnostics on the same line
  80. const cursor = view.state.field(compileDiagnosticsState).iter()
  81. while (cursor.value !== null) {
  82. const { diagnostic } = cursor.value
  83. items.push({
  84. ...diagnostic,
  85. from: cursor.from,
  86. to: cursor.to,
  87. renderMessage: () => renderMessage(diagnostic),
  88. })
  89. cursor.next()
  90. }
  91. // restore the original order of items
  92. items.sort((a, b) => a.from - b.from || a.entryIndex - b.entryIndex)
  93. return items
  94. }, lintSourceConfig)
  95. class CompileLogDiagnosticRangeValue extends RangeValue {
  96. constructor(public diagnostic: CompileLogDiagnostic) {
  97. super()
  98. }
  99. }
  100. const setCompileDiagnosticsEffect = StateEffect.define<CompileLogDiagnostic[]>()
  101. /**
  102. * A state field for the compile log diagnostics
  103. */
  104. export const compileDiagnosticsState = StateField.define<
  105. RangeSet<CompileLogDiagnosticRangeValue>
  106. >({
  107. create() {
  108. return RangeSet.empty
  109. },
  110. update(value, transaction) {
  111. for (const effect of transaction.effects) {
  112. if (effect.is(setCompileDiagnosticsEffect)) {
  113. return RangeSet.of(
  114. effect.value.map(diagnostic =>
  115. new CompileLogDiagnosticRangeValue(diagnostic).range(
  116. diagnostic.from,
  117. diagnostic.to
  118. )
  119. ),
  120. true
  121. )
  122. }
  123. }
  124. if (transaction.docChanged) {
  125. value = value.map(transaction.changes)
  126. }
  127. return value
  128. },
  129. })
  130. export const setAnnotations = (doc: Text, annotations: Annotation[]) => {
  131. const diagnostics: CompileLogDiagnostic[] = []
  132. for (const annotation of annotations) {
  133. // ignore "whole document" (row: -1) annotations
  134. if (annotation.row !== -1) {
  135. try {
  136. diagnostics.push(convertAnnotationToDiagnostic(doc, annotation))
  137. } catch (error) {
  138. // ignore invalid annotations
  139. debugConsole.debug('invalid annotation position', error)
  140. }
  141. }
  142. }
  143. return {
  144. effects: setCompileDiagnosticsEffect.of(diagnostics),
  145. }
  146. }
  147. export const showCompileLogDiagnostics = (show: boolean) => {
  148. return {
  149. effects: [
  150. // reconfigure the compile log lint source
  151. compileLintSourceConf.reconfigure(show ? compileLogLintSource() : []),
  152. ],
  153. }
  154. }
  155. const convertAnnotationToDiagnostic = (
  156. doc: Text,
  157. annotation: Annotation
  158. ): CompileLogDiagnostic => {
  159. if (annotation.row < 0) {
  160. throw new Error(`Invalid annotation row ${annotation.row}`)
  161. }
  162. const line = doc.line(annotation.row + 1)
  163. return {
  164. from: line.from,
  165. to: line.to, // NOTE: highlight whole line as synctex doesn't output column number
  166. severity: annotation.type,
  167. message: annotation.text,
  168. ruleId: annotation.ruleId,
  169. compile: true,
  170. id: annotation.id,
  171. entryIndex: annotation.entryIndex,
  172. source: annotation.source,
  173. firstOnLine: annotation.firstOnLine,
  174. }
  175. }
  176. export const renderMessage = (diagnostic: RenderedDiagnostic) => {
  177. const { message, severity, ruleId, compile = false } = diagnostic
  178. const div = document.createElement('div')
  179. div.classList.add('ol-cm-diagnostic-message')
  180. div.append(message)
  181. const activeDiagnosticActions = diagnosticActions
  182. .map(m => m.import.default(diagnostic))
  183. .filter(Boolean) as HTMLButtonElement[]
  184. if (activeDiagnosticActions.length) {
  185. const actions = document.createElement('div')
  186. actions.classList.add('ol-cm-diagnostic-actions')
  187. actions.append(...activeDiagnosticActions)
  188. div.append(actions)
  189. }
  190. window.setTimeout(() => {
  191. if (div.isConnected) {
  192. sendMB('lint-gutter-marker-view', { severity, ruleId, compile })
  193. }
  194. }, 500) // 500ms delay to indicate intention, rather than accidental hover
  195. return div
  196. }