use-synctex.ts 8.5 KB

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