use-synctex.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import { useCallback, useEffect, useState, useRef } from 'react'
  2. import { useProjectContext } from '../../../shared/context/project-context'
  3. import { getJSON } from '../../../infrastructure/fetch-json'
  4. import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
  5. import useIsMounted from '../../../shared/hooks/use-is-mounted'
  6. import useAbortController from '../../../shared/hooks/use-abort-controller'
  7. import useDetachState from '../../../shared/hooks/use-detach-state'
  8. import useDetachAction from '../../../shared/hooks/use-detach-action'
  9. import localStorage from '../../../infrastructure/local-storage'
  10. import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
  11. import useScopeEventListener from '../../../shared/hooks/use-scope-event-listener'
  12. import * as eventTracking from '../../../infrastructure/event-tracking'
  13. import { debugConsole } from '@/utils/debugging'
  14. import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
  15. import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
  16. import useEventListener from '@/shared/hooks/use-event-listener'
  17. import { CursorPosition } from '@/features/ide-react/types/cursor-position'
  18. import { isValidTeXFile } from '@/main/is-valid-tex-file'
  19. import { PdfScrollPosition } from '@/shared/hooks/use-pdf-scroll-position'
  20. import { showFileErrorToast } from '@/features/pdf-preview/components/synctex-toasts'
  21. export default function useSynctex(): {
  22. syncToPdf: () => void
  23. syncToCode: ({ visualOffset }: { visualOffset?: number }) => void
  24. syncToPdfInFlight: boolean
  25. syncToCodeInFlight: boolean
  26. canSyncToPdf: boolean
  27. } {
  28. const { _id: projectId, rootDocId } = useProjectContext()
  29. const { clsiServerId, pdfFile, position, setShowLogs, setHighlights } =
  30. useCompileContext()
  31. const { selectedEntities } = useFileTreeData()
  32. const { findEntityByPath, dirname, pathInFolder } = useFileTreePathContext()
  33. const { getCurrentDocumentId, openDocWithId, openDocName } =
  34. useEditorManagerContext()
  35. const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(
  36. () => {
  37. const position = localStorage.getItem(
  38. `doc.position.${getCurrentDocumentId()}`
  39. )
  40. return position ? position.cursorPosition : null
  41. }
  42. )
  43. const isMounted = useIsMounted()
  44. const { signal } = useAbortController()
  45. useEventListener(
  46. 'cursor:editor:update',
  47. useCallback((event: CustomEvent) => setCursorPosition(event.detail), [])
  48. )
  49. const [syncToPdfInFlight, setSyncToPdfInFlight] = useState(false)
  50. const [syncToCodeInFlight, setSyncToCodeInFlight] = useDetachState(
  51. 'sync-to-code-inflight',
  52. false,
  53. 'detacher',
  54. 'detached'
  55. )
  56. const getCurrentFilePath = useCallback(() => {
  57. const docId = getCurrentDocumentId()
  58. if (!docId || !rootDocId) {
  59. return null
  60. }
  61. let path = pathInFolder(docId)
  62. if (!path) {
  63. return null
  64. }
  65. // If the root file is folder/main.tex, then synctex sees the path as folder/./main.tex
  66. const rootDocDirname = dirname(rootDocId)
  67. if (rootDocDirname) {
  68. path = path.replace(RegExp(`^${rootDocDirname}`), `${rootDocDirname}/.`)
  69. }
  70. return path
  71. }, [dirname, getCurrentDocumentId, pathInFolder, rootDocId])
  72. const goToCodeLine = useCallback(
  73. (file?: string, line?: number) => {
  74. if (file) {
  75. const doc = findEntityByPath(file)?.entity
  76. if (doc) {
  77. openDocWithId(doc._id, {
  78. gotoLine: line,
  79. })
  80. return
  81. }
  82. }
  83. showFileErrorToast()
  84. },
  85. [findEntityByPath, openDocWithId]
  86. )
  87. const goToPdfLocation = useCallback(
  88. (params: string) => {
  89. setSyncToPdfInFlight(true)
  90. if (clsiServerId) {
  91. params += `&clsiserverid=${clsiServerId}`
  92. }
  93. if (pdfFile?.editorId) params += `&editorId=${pdfFile.editorId}`
  94. if (pdfFile?.build) params += `&buildId=${pdfFile.build}`
  95. getJSON(`/project/${projectId}/sync/code?${params}`, { signal })
  96. .then(data => {
  97. setShowLogs(false)
  98. setHighlights(data.pdf)
  99. })
  100. .catch(debugConsole.error)
  101. .finally(() => {
  102. if (isMounted.current) {
  103. setSyncToPdfInFlight(false)
  104. }
  105. })
  106. },
  107. [
  108. pdfFile,
  109. clsiServerId,
  110. isMounted,
  111. projectId,
  112. setShowLogs,
  113. setHighlights,
  114. setSyncToPdfInFlight,
  115. signal,
  116. ]
  117. )
  118. const cursorPositionRef = useRef(cursorPosition)
  119. useEffect(() => {
  120. cursorPositionRef.current = cursorPosition
  121. }, [cursorPosition])
  122. const syncToPdf = useCallback(() => {
  123. const file = getCurrentFilePath()
  124. if (cursorPositionRef.current) {
  125. const { row, column } = cursorPositionRef.current
  126. const params = new URLSearchParams({
  127. file: file ?? '',
  128. line: String(row + 1),
  129. column: String(column),
  130. }).toString()
  131. eventTracking.sendMB('jump-to-location', {
  132. direction: 'code-location-in-pdf',
  133. method: 'arrow',
  134. })
  135. goToPdfLocation(params)
  136. }
  137. }, [getCurrentFilePath, goToPdfLocation])
  138. useScopeEventListener(
  139. 'cursor:editor:syncToPdf',
  140. useCallback(() => {
  141. syncToPdf()
  142. }, [syncToPdf])
  143. )
  144. const positionRef = useRef(position)
  145. useEffect(() => {
  146. positionRef.current = position
  147. }, [position])
  148. const _syncToCode = useCallback(
  149. ({
  150. position = positionRef.current,
  151. visualOffset = 0,
  152. }: {
  153. position?: PdfScrollPosition
  154. visualOffset?: number
  155. }) => {
  156. if (!position) {
  157. return
  158. }
  159. setSyncToCodeInFlight(true)
  160. // FIXME: this actually works better if it's halfway across the
  161. // page (or the visible part of the page). Synctex doesn't
  162. // always find the right place in the file when the point is at
  163. // the edge of the page, it sometimes returns the start of the
  164. // next paragraph instead.
  165. const h = position.offset.left
  166. // Compute the vertical position to pass to synctex, which
  167. // works with coordinates increasing from the top of the page
  168. // down. This matches the browser's DOM coordinate of the
  169. // click point, but the pdf position is measured from the
  170. // bottom of the page so we need to invert it.
  171. let v = 0
  172. if (position.pageSize?.height) {
  173. v += position.pageSize.height - position.offset.top // measure from pdf point (inverted)
  174. } else {
  175. v += position.offset.top // measure from html click position
  176. }
  177. v += visualOffset
  178. const params = new URLSearchParams({
  179. page: position.page + 1,
  180. h: h.toFixed(2),
  181. v: v.toFixed(2),
  182. })
  183. if (clsiServerId) {
  184. params.set('clsiserverid', clsiServerId)
  185. }
  186. if (pdfFile?.editorId) params.set('editorId', pdfFile.editorId)
  187. if (pdfFile?.build) params.set('buildId', pdfFile.build)
  188. getJSON(`/project/${projectId}/sync/pdf?${params}`, { signal })
  189. .then(data => {
  190. const [{ file, line }] = data.code
  191. goToCodeLine(file, line)
  192. })
  193. .catch(debugConsole.error)
  194. .finally(() => {
  195. if (isMounted.current) {
  196. setSyncToCodeInFlight(false)
  197. }
  198. })
  199. },
  200. [
  201. pdfFile,
  202. clsiServerId,
  203. projectId,
  204. signal,
  205. isMounted,
  206. setSyncToCodeInFlight,
  207. goToCodeLine,
  208. ]
  209. )
  210. const syncToCode = useDetachAction(
  211. 'sync-to-code',
  212. _syncToCode,
  213. 'detached',
  214. 'detacher'
  215. )
  216. useEventListener(
  217. 'synctex:sync-to-position',
  218. useCallback(
  219. (event: CustomEvent) => syncToCode({ position: event.detail }),
  220. [syncToCode]
  221. )
  222. )
  223. const [hasSingleSelectedDoc, setHasSingleSelectedDoc] = useDetachState(
  224. 'has-single-selected-doc',
  225. false,
  226. 'detacher',
  227. 'detached'
  228. )
  229. useEffect(() => {
  230. if (selectedEntities.length !== 1) {
  231. setHasSingleSelectedDoc(false)
  232. return
  233. }
  234. if (selectedEntities[0].type !== 'doc') {
  235. setHasSingleSelectedDoc(false)
  236. return
  237. }
  238. setHasSingleSelectedDoc(true)
  239. }, [selectedEntities, setHasSingleSelectedDoc])
  240. const canSyncToPdf: boolean =
  241. hasSingleSelectedDoc &&
  242. cursorPosition &&
  243. openDocName &&
  244. isValidTeXFile(openDocName)
  245. return {
  246. syncToCode,
  247. syncToPdf,
  248. syncToPdfInFlight,
  249. syncToCodeInFlight,
  250. canSyncToPdf,
  251. }
  252. }