document-diff-viewer.tsx 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import { useCallback, useEffect, useRef, useState } from 'react'
  2. import withErrorBoundary from '../../../../infrastructure/error-boundary'
  3. import { ErrorBoundaryFallback } from '../../../../shared/components/error-boundary-fallback'
  4. import {
  5. EditorSelection,
  6. EditorState,
  7. Extension,
  8. StateEffect,
  9. } from '@codemirror/state'
  10. import { EditorView, lineNumbers } from '@codemirror/view'
  11. import { indentationMarkers } from '@replit/codemirror-indentation-markers'
  12. import { highlights, setHighlightsEffect } from '../../extensions/highlights'
  13. import useScopeValue from '../../../../shared/hooks/use-scope-value'
  14. import { theme, Options } from '../../extensions/theme'
  15. import { indentUnit } from '@codemirror/language'
  16. import { Highlight } from '../../services/types/doc'
  17. import useIsMounted from '../../../../shared/hooks/use-is-mounted'
  18. import {
  19. highlightLocations,
  20. highlightLocationsField,
  21. scrollToHighlight,
  22. } from '../../extensions/highlight-locations'
  23. import Icon from '../../../../shared/components/icon'
  24. import { useTranslation } from 'react-i18next'
  25. type FontFamily = 'monaco' | 'lucida'
  26. type LineHeight = 'compact' | 'normal' | 'wide'
  27. type OverallTheme = '' | 'light-'
  28. function extensions(themeOptions: Options): Extension[] {
  29. return [
  30. EditorView.editable.of(false),
  31. lineNumbers(),
  32. EditorView.lineWrapping,
  33. indentUnit.of(' '), // TODO: Vary this by file type
  34. indentationMarkers({ hideFirstIndent: true, highlightActiveBlock: false }),
  35. highlights(),
  36. highlightLocations(),
  37. theme(themeOptions),
  38. ]
  39. }
  40. function DocumentDiffViewer({
  41. doc,
  42. highlights,
  43. }: {
  44. doc: string
  45. highlights: Highlight[]
  46. }) {
  47. const [fontFamily] = useScopeValue<FontFamily>('settings.fontFamily')
  48. const [fontSize] = useScopeValue<number>('settings.fontSize')
  49. const [lineHeight] = useScopeValue<LineHeight>('settings.lineHeight')
  50. const [overallTheme] = useScopeValue<OverallTheme>('settings.overallTheme')
  51. const isMounted = useIsMounted()
  52. const { t } = useTranslation()
  53. const [state, setState] = useState(() => {
  54. return EditorState.create({
  55. doc,
  56. extensions: extensions({
  57. fontFamily,
  58. fontSize,
  59. lineHeight,
  60. overallTheme,
  61. }),
  62. })
  63. })
  64. const view = useRef(
  65. new EditorView({
  66. state,
  67. dispatch: tr => {
  68. view.update([tr])
  69. if (isMounted.current) {
  70. setState(view.state)
  71. }
  72. },
  73. })
  74. ).current
  75. const highlightLocations = state.field(highlightLocationsField)
  76. // Append the editor view DOM to the container node when mounted
  77. const containerRef = useCallback(
  78. node => {
  79. if (node) {
  80. node.appendChild(view.dom)
  81. }
  82. },
  83. [view]
  84. )
  85. const scrollToPrevious = useCallback(() => {
  86. if (highlightLocations.previous) {
  87. scrollToHighlight(view, highlightLocations.previous)
  88. }
  89. }, [highlightLocations.previous, view])
  90. const scrollToNext = useCallback(() => {
  91. if (highlightLocations.next) {
  92. scrollToHighlight(view, highlightLocations.next)
  93. }
  94. }, [highlightLocations.next, view])
  95. const { before, after } = highlightLocations
  96. useEffect(() => {
  97. const effects: StateEffect<unknown>[] = [setHighlightsEffect.of(highlights)]
  98. if (highlights.length > 0) {
  99. const { from, to } = highlights[0].range
  100. effects.push(
  101. EditorView.scrollIntoView(EditorSelection.range(from, to), {
  102. y: 'center',
  103. })
  104. )
  105. }
  106. view.dispatch({
  107. changes: { from: 0, to: view.state.doc.length, insert: doc },
  108. effects,
  109. })
  110. }, [doc, highlights, view])
  111. return (
  112. <div className="document-diff-container">
  113. <div ref={containerRef} className="cm-viewer-container" />
  114. {before > 0 ? (
  115. <button
  116. className="btn btn-secondary previous-highlight-button"
  117. onClick={scrollToPrevious}
  118. >
  119. <Icon type="arrow-up" />
  120. &nbsp;
  121. {t('n_more_updates_above', { count: before })}
  122. </button>
  123. ) : null}
  124. {after > 0 ? (
  125. <button
  126. className="btn btn-secondary next-highlight-button"
  127. onClick={scrollToNext}
  128. >
  129. <Icon type="arrow-down" />
  130. &nbsp;
  131. {t('n_more_updates_below', { count: after })}
  132. </button>
  133. ) : null}
  134. </div>
  135. )
  136. }
  137. export default withErrorBoundary(DocumentDiffViewer, ErrorBoundaryFallback)