codemirror-outline.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import { createPortal } from 'react-dom'
  2. import { useCodeMirrorStateContext } from './codemirror-editor'
  3. import React, { useCallback, useEffect, useMemo, useRef, 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, Outline } from '../utils/tree-query'
  11. import { ProjectionStatus } from '../utils/tree-operations/projection'
  12. import useEventListener from '../../../shared/hooks/use-event-listener'
  13. import useDebounce from '../../../shared/hooks/use-debounce'
  14. const closestSectionLineNumber = (
  15. outline: { line: number }[] | undefined,
  16. lineNumber: number
  17. ): number => {
  18. if (!outline) {
  19. return -1
  20. }
  21. let highestLine = -1
  22. for (const section of outline) {
  23. if (section.line > lineNumber) {
  24. return highestLine
  25. }
  26. highestLine = section.line
  27. }
  28. return highestLine
  29. }
  30. type PartialFlatOutline = {
  31. level: number
  32. title: string
  33. line: number
  34. }[]
  35. const outlineChanged = (
  36. a: PartialFlatOutline | undefined,
  37. b: PartialFlatOutline
  38. ): boolean => {
  39. if (!a) {
  40. return true
  41. }
  42. if (a.length !== b.length) {
  43. return true
  44. }
  45. for (let i = 0; i < a.length; i++) {
  46. const aItem = a[i]
  47. const bItem = b[i]
  48. if (
  49. aItem.level !== bItem.level ||
  50. aItem.line !== bItem.line ||
  51. aItem.title !== bItem.title
  52. ) {
  53. return true
  54. }
  55. }
  56. return false
  57. }
  58. export const CodemirrorOutline = React.memo(function CodemirrorOutline() {
  59. const state = useCodeMirrorStateContext()
  60. const debouncedState = useDebounce(state, 100)
  61. const [docName] = useScopeValue<string>('editor.open_doc_name')
  62. const goToLineEmitter = useScopeEventEmitter('editor:gotoLine', true)
  63. const outlineToggledEmitter = useScopeEventEmitter('outline-toggled')
  64. const [currentlyHighlightedLine, setCurrentlyHighlightedLine] =
  65. useState<number>(-1)
  66. const isTexFile = useMemo(() => isValidTeXFile(docName), [docName])
  67. const [ignoreNextCursorUpdate, setIgnoreNextCursorUpdate] =
  68. useState<boolean>(false)
  69. const [ignoreNextScroll, setIgnoreNextScroll] = useState<boolean>(false)
  70. const [binaryFileOpened, setBinaryFileOpened] = useState<boolean>(false)
  71. useEventListener(
  72. 'file-view:file-opened',
  73. useCallback(_ => {
  74. setBinaryFileOpened(true)
  75. }, [])
  76. )
  77. useEventListener(
  78. 'scroll:editor:update',
  79. useCallback(
  80. evt => {
  81. if (ignoreNextScroll) {
  82. setIgnoreNextScroll(false)
  83. return
  84. }
  85. setCurrentlyHighlightedLine(evt.detail + 1)
  86. },
  87. [ignoreNextScroll]
  88. )
  89. )
  90. useEventListener(
  91. 'cursor:editor:update',
  92. useCallback(
  93. evt => {
  94. if (ignoreNextCursorUpdate) {
  95. setIgnoreNextCursorUpdate(false)
  96. return
  97. }
  98. setCurrentlyHighlightedLine(evt.detail.row + 1)
  99. },
  100. [ignoreNextCursorUpdate]
  101. )
  102. )
  103. useEventListener(
  104. 'doc:after-opened',
  105. useCallback(evt => {
  106. if (evt.detail) {
  107. setIgnoreNextCursorUpdate(true)
  108. }
  109. setBinaryFileOpened(false)
  110. setIgnoreNextScroll(true)
  111. }, [])
  112. )
  113. const outlineResult = debouncedState.field(documentOutline, false)
  114. // when the outline projection changes, calculate the flat outline
  115. const flatOutline = useMemo<PartialFlatOutline | undefined>(() => {
  116. if (!outlineResult || outlineResult.status === ProjectionStatus.Pending) {
  117. return undefined
  118. }
  119. // We have a (potentially partial) outline.
  120. return outlineResult.items.map(element => {
  121. const { level, title, line } = element
  122. return { level, title, line }
  123. })
  124. }, [outlineResult])
  125. const [outline, setOutline] = useState<Outline[]>([])
  126. const prevFlatOutlineRef = useRef<PartialFlatOutline | undefined>(undefined)
  127. // when the flat outline changes, calculate the nested outline
  128. useEffect(() => {
  129. const prevFlatOutline = prevFlatOutlineRef.current
  130. prevFlatOutlineRef.current = flatOutline
  131. if (flatOutline) {
  132. if (outlineChanged(prevFlatOutline, flatOutline)) {
  133. setOutline(nestOutline(flatOutline))
  134. }
  135. } else {
  136. setOutline([])
  137. }
  138. }, [flatOutline])
  139. const jumpToLine = useCallback(
  140. (lineNumber, syncToPdf) => {
  141. setIgnoreNextScroll(true)
  142. goToLineEmitter(lineNumber, 0, syncToPdf)
  143. eventTracking.sendMB('outline-jump-to-line')
  144. },
  145. [goToLineEmitter]
  146. )
  147. const highlightedLine = useMemo(
  148. () => closestSectionLineNumber(flatOutline, currentlyHighlightedLine),
  149. [flatOutline, currentlyHighlightedLine]
  150. )
  151. const outlineDomElement = document.querySelector('.outline-container')
  152. if (!outlineDomElement) {
  153. return null
  154. }
  155. return createPortal(
  156. <OutlinePane
  157. outline={outline}
  158. onToggle={outlineToggledEmitter}
  159. eventTracking={eventTracking}
  160. isTexFile={isTexFile && !binaryFileOpened}
  161. jumpToLine={jumpToLine}
  162. highlightedLine={highlightedLine}
  163. show
  164. isPartial={outlineResult?.status === ProjectionStatus.Partial}
  165. />,
  166. outlineDomElement
  167. )
  168. })