use-synctex.ts 8.8 KB

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