codemirror-outline.tsx 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. import { createPortal } from 'react-dom'
  2. import { useCodeMirrorStateContext } from './codemirror-editor'
  3. import React, { useCallback, useMemo, useState } from 'react'
  4. import OutlinePane from '../../outline/components/outline-pane'
  5. import { documentOutline } from '../languages/latex/document-outline'
  6. import isValidTeXFile from '../../../main/is-valid-tex-file'
  7. import useScopeValue from '../../../shared/hooks/use-scope-value'
  8. import useScopeEventEmitter from '../../../shared/hooks/use-scope-event-emitter'
  9. import * as eventTracking from '../../../infrastructure/event-tracking'
  10. import { nestOutline } from '../utils/tree-query'
  11. import { ProjectionStatus } from '../utils/tree-operations/projection'
  12. import useEventListener from '../../../shared/hooks/use-event-listener'
  13. import useDeepCompareMemo from '../../../shared/hooks/use-deep-compare-memo'
  14. import useDebounce from '../../../shared/hooks/use-debounce'
  15. const closestSectionLineNumber = (
  16. outline: { line: number }[] | undefined,
  17. lineNumber: number
  18. ): number => {
  19. if (!outline) {
  20. return -1
  21. }
  22. let highestLine = -1
  23. for (const section of outline) {
  24. if (section.line > lineNumber) {
  25. return highestLine
  26. }
  27. highestLine = section.line
  28. }
  29. return highestLine
  30. }
  31. export const CodemirrorOutline = React.memo(function CodemirrorOutline() {
  32. const state = useCodeMirrorStateContext()
  33. const debouncedState = useDebounce(state, 100)
  34. const [docName] = useScopeValue<string>('editor.open_doc_name')
  35. const goToLineEmitter = useScopeEventEmitter('editor:gotoLine', true)
  36. const outlineToggledEmitter = useScopeEventEmitter('outline-toggled')
  37. const [currentlyHighlightedLine, setCurrentlyHighlightedLine] =
  38. useState<number>(-1)
  39. const isTexFile = useMemo(() => isValidTeXFile(docName), [docName])
  40. const [ignoreNextCursorUpdate, setIgnoreNextCursorUpdate] =
  41. useState<boolean>(false)
  42. const [ignoreNextScroll, setIgnoreNextScroll] = useState<boolean>(false)
  43. const [binaryFileOpened, setBinaryFileOpened] = useState<boolean>(false)
  44. useEventListener(
  45. 'file-view:file-opened',
  46. useCallback(_ => {
  47. setBinaryFileOpened(true)
  48. }, [])
  49. )
  50. useEventListener(
  51. 'scroll:editor:update',
  52. useCallback(
  53. evt => {
  54. if (ignoreNextScroll) {
  55. setIgnoreNextScroll(false)
  56. return
  57. }
  58. setCurrentlyHighlightedLine(evt.detail + 1)
  59. },
  60. [ignoreNextScroll]
  61. )
  62. )
  63. useEventListener(
  64. 'cursor:editor:update',
  65. useCallback(
  66. evt => {
  67. if (ignoreNextCursorUpdate) {
  68. setIgnoreNextCursorUpdate(false)
  69. return
  70. }
  71. setCurrentlyHighlightedLine(evt.detail.row + 1)
  72. },
  73. [ignoreNextCursorUpdate]
  74. )
  75. )
  76. useEventListener(
  77. 'doc:after-opened',
  78. useCallback(evt => {
  79. if (evt.detail) {
  80. setIgnoreNextCursorUpdate(true)
  81. }
  82. setBinaryFileOpened(false)
  83. setIgnoreNextScroll(true)
  84. }, [])
  85. )
  86. const outlineStatus = useMemo(
  87. () =>
  88. debouncedState.field(documentOutline, false)?.status ||
  89. ProjectionStatus.Pending,
  90. [debouncedState]
  91. )
  92. const flatOutline = useMemo(() => {
  93. const outlineResult = debouncedState.field(documentOutline, false)
  94. if (outlineResult?.status !== ProjectionStatus.Pending) {
  95. // We have a (potentially partial) outline.
  96. return outlineResult?.items!.map(element => {
  97. // Remove {from, to} to not trip up deep comparison
  98. const { level, title, line } = element
  99. return { level, title, line }
  100. })
  101. }
  102. return undefined
  103. }, [debouncedState])
  104. const outline = useDeepCompareMemo(() => {
  105. return flatOutline ? nestOutline(flatOutline) : []
  106. }, [flatOutline])
  107. const jumpToLine = useCallback(
  108. (lineNumber, syncToPdf) => {
  109. setIgnoreNextScroll(true)
  110. goToLineEmitter(lineNumber, 0, syncToPdf)
  111. eventTracking.sendMB('outline-jump-to-line')
  112. },
  113. [goToLineEmitter]
  114. )
  115. const onToggle = useCallback(
  116. isOpen => {
  117. outlineToggledEmitter(isOpen)
  118. },
  119. [outlineToggledEmitter]
  120. )
  121. const highlightedLine = useMemo(
  122. () => closestSectionLineNumber(flatOutline, currentlyHighlightedLine),
  123. [flatOutline, currentlyHighlightedLine]
  124. )
  125. const outlineDomElement = document.querySelector('.outline-container')
  126. if (!outlineDomElement) {
  127. return null
  128. }
  129. return createPortal(
  130. <OutlinePane
  131. outline={outline}
  132. onToggle={onToggle}
  133. eventTracking={eventTracking}
  134. isTexFile={isTexFile && !binaryFileOpened}
  135. jumpToLine={jumpToLine}
  136. highlightedLine={highlightedLine}
  137. show
  138. isPartial={outlineStatus === ProjectionStatus.Partial}
  139. />,
  140. outlineDomElement
  141. )
  142. })