annotations.ts 6.8 KB

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