outline-context.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import {
  2. createContext,
  3. Dispatch,
  4. FC,
  5. SetStateAction,
  6. useCallback,
  7. useContext,
  8. useMemo,
  9. useState,
  10. } from 'react'
  11. import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
  12. import useEventListener from '@/shared/hooks/use-event-listener'
  13. import * as eventTracking from '@/infrastructure/event-tracking'
  14. import useScopeValue from '@/shared/hooks/use-scope-value'
  15. import { isValidTeXFile } from '@/main/is-valid-tex-file'
  16. import localStorage from '@/infrastructure/local-storage'
  17. import { useProjectContext } from '@/shared/context/project-context'
  18. export type PartialFlatOutline = {
  19. level: number
  20. title: string
  21. line: number
  22. }[]
  23. export type FlatOutlineState =
  24. | {
  25. items: PartialFlatOutline
  26. partial: boolean
  27. }
  28. | undefined
  29. const OutlineContext = createContext<
  30. | {
  31. flatOutline: FlatOutlineState
  32. setFlatOutline: Dispatch<SetStateAction<FlatOutlineState>>
  33. highlightedLine: number
  34. jumpToLine: (lineNumber: number, syncToPdf: boolean) => void
  35. canShowOutline: boolean
  36. outlineExpanded: boolean
  37. toggleOutlineExpanded: () => void
  38. }
  39. | undefined
  40. >(undefined)
  41. export const OutlineProvider: FC = ({ children }) => {
  42. const [flatOutline, setFlatOutline] = useState<FlatOutlineState>(undefined)
  43. const [currentlyHighlightedLine, setCurrentlyHighlightedLine] =
  44. useState<number>(-1)
  45. const [binaryFileOpened, setBinaryFileOpened] = useState<boolean>(false)
  46. const [ignoreNextCursorUpdate, setIgnoreNextCursorUpdate] =
  47. useState<boolean>(false)
  48. const [ignoreNextScroll, setIgnoreNextScroll] = useState<boolean>(false)
  49. const goToLineEmitter = useScopeEventEmitter('editor:gotoLine', true)
  50. useEventListener(
  51. 'file-view:file-opened',
  52. useCallback(_ => {
  53. setBinaryFileOpened(true)
  54. }, [])
  55. )
  56. useEventListener(
  57. 'scroll:editor:update',
  58. useCallback(
  59. evt => {
  60. if (ignoreNextScroll) {
  61. setIgnoreNextScroll(false)
  62. return
  63. }
  64. setCurrentlyHighlightedLine(evt.detail + 1)
  65. },
  66. [ignoreNextScroll]
  67. )
  68. )
  69. useEventListener(
  70. 'cursor:editor:update',
  71. useCallback(
  72. evt => {
  73. if (ignoreNextCursorUpdate) {
  74. setIgnoreNextCursorUpdate(false)
  75. return
  76. }
  77. setCurrentlyHighlightedLine(evt.detail.row + 1)
  78. },
  79. [ignoreNextCursorUpdate]
  80. )
  81. )
  82. useEventListener(
  83. 'doc:after-opened',
  84. useCallback(evt => {
  85. if (evt.detail.isNewDoc) {
  86. setIgnoreNextCursorUpdate(true)
  87. }
  88. setBinaryFileOpened(false)
  89. setIgnoreNextScroll(true)
  90. }, [])
  91. )
  92. const jumpToLine = useCallback(
  93. (lineNumber: number, syncToPdf: boolean) => {
  94. setIgnoreNextScroll(true)
  95. goToLineEmitter(lineNumber, 0, syncToPdf)
  96. eventTracking.sendMB('outline-jump-to-line')
  97. },
  98. [goToLineEmitter]
  99. )
  100. const highlightedLine = useMemo(
  101. () =>
  102. closestSectionLineNumber(flatOutline?.items, currentlyHighlightedLine),
  103. [flatOutline, currentlyHighlightedLine]
  104. )
  105. const [docName] = useScopeValue<string | null>('editor.open_doc_name')
  106. const isTexFile = useMemo(
  107. () => (docName ? isValidTeXFile(docName) : false),
  108. [docName]
  109. )
  110. const { _id: projectId } = useProjectContext()
  111. const storageKey = `file_outline.expanded.${projectId}`
  112. const [outlineExpanded, setOutlineExpanded] = useState(
  113. () => localStorage.getItem(storageKey) !== false
  114. )
  115. const canShowOutline = isTexFile && !binaryFileOpened
  116. const toggleOutlineExpanded = useCallback(() => {
  117. if (canShowOutline) {
  118. localStorage.setItem(storageKey, !outlineExpanded)
  119. eventTracking.sendMB(
  120. outlineExpanded ? 'outline-collapse' : 'outline-expand'
  121. )
  122. setOutlineExpanded(!outlineExpanded)
  123. }
  124. }, [canShowOutline, outlineExpanded, storageKey])
  125. const value = useMemo(
  126. () => ({
  127. flatOutline,
  128. setFlatOutline,
  129. highlightedLine,
  130. jumpToLine,
  131. canShowOutline,
  132. outlineExpanded,
  133. toggleOutlineExpanded,
  134. }),
  135. [
  136. flatOutline,
  137. highlightedLine,
  138. jumpToLine,
  139. canShowOutline,
  140. outlineExpanded,
  141. toggleOutlineExpanded,
  142. ]
  143. )
  144. return (
  145. <OutlineContext.Provider value={value}>{children}</OutlineContext.Provider>
  146. )
  147. }
  148. export const useOutlineContext = () => {
  149. const context = useContext(OutlineContext)
  150. if (!context) {
  151. throw new Error(
  152. 'useOutlineProvider is only available inside OutlineProvider'
  153. )
  154. }
  155. return context
  156. }
  157. const closestSectionLineNumber = (
  158. outline: { line: number }[] | undefined,
  159. lineNumber: number
  160. ): number => {
  161. if (!outline) {
  162. return -1
  163. }
  164. let highestLine = -1
  165. for (const section of outline) {
  166. if (section.line > lineNumber) {
  167. return highestLine
  168. }
  169. highestLine = section.line
  170. }
  171. return highestLine
  172. }