use-synctex.ts 8.9 KB

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