use-synctex.ts 8.6 KB

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