use-synctex.ts 8.8 KB

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