pdf-synctex-controls.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. import classNames from 'classnames'
  2. import { memo, useCallback, useEffect, useState, useRef, useMemo } from 'react'
  3. import { useProjectContext } from '../../../shared/context/project-context'
  4. import { getJSON } from '../../../infrastructure/fetch-json'
  5. import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
  6. import { useLayoutContext } from '../../../shared/context/layout-context'
  7. import useScopeValue from '../../../shared/hooks/use-scope-value'
  8. import { useTranslation } from 'react-i18next'
  9. import useIsMounted from '../../../shared/hooks/use-is-mounted'
  10. import useAbortController from '../../../shared/hooks/use-abort-controller'
  11. import useDetachState from '../../../shared/hooks/use-detach-state'
  12. import useDetachAction from '../../../shared/hooks/use-detach-action'
  13. import localStorage from '../../../infrastructure/local-storage'
  14. import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
  15. import useScopeEventListener from '../../../shared/hooks/use-scope-event-listener'
  16. import * as eventTracking from '../../../infrastructure/event-tracking'
  17. import { debugConsole } from '@/utils/debugging'
  18. import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
  19. import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
  20. import OLButton from '@/features/ui/components/ol/ol-button'
  21. import MaterialIcon from '@/shared/components/material-icon'
  22. import { Spinner } from 'react-bootstrap-5'
  23. import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
  24. import useEventListener from '@/shared/hooks/use-event-listener'
  25. import { CursorPosition } from '@/features/ide-react/types/cursor-position'
  26. import { isValidTeXFile } from '@/main/is-valid-tex-file'
  27. import { PdfScrollPosition } from '@/shared/hooks/use-pdf-scroll-position'
  28. import { Placement } from 'react-bootstrap-5/types'
  29. const GoToCodeButton = memo(function GoToCodeButton({
  30. syncToCode,
  31. syncToCodeInFlight,
  32. isDetachLayout,
  33. }: {
  34. syncToCode: ({ visualOffset }: { visualOffset: number }) => void
  35. syncToCodeInFlight: boolean
  36. isDetachLayout?: boolean
  37. }) {
  38. const { t } = useTranslation()
  39. const buttonClasses = classNames('synctex-control', {
  40. 'detach-synctex-control': !!isDetachLayout,
  41. })
  42. let buttonIcon = null
  43. if (syncToCodeInFlight) {
  44. buttonIcon = (
  45. <Spinner animation="border" aria-hidden="true" size="sm" role="status" />
  46. )
  47. } else if (!isDetachLayout) {
  48. buttonIcon = (
  49. <MaterialIcon type="arrow_left_alt" className="synctex-control-icon" />
  50. )
  51. }
  52. const syncToCodeWithButton = useCallback(() => {
  53. eventTracking.sendMB('jump-to-location', {
  54. direction: 'pdf-location-in-code',
  55. method: 'arrow',
  56. })
  57. syncToCode({ visualOffset: 72 })
  58. }, [syncToCode])
  59. const overlayProps = useMemo(
  60. () => ({
  61. placement: (isDetachLayout ? 'bottom' : 'right') as Placement,
  62. }),
  63. [isDetachLayout]
  64. )
  65. return (
  66. <OLTooltip
  67. id="sync-to-code"
  68. description={t('go_to_pdf_location_in_code')}
  69. overlayProps={overlayProps}
  70. >
  71. <OLButton
  72. variant="secondary"
  73. size="sm"
  74. onClick={syncToCodeWithButton}
  75. disabled={syncToCodeInFlight}
  76. className={buttonClasses}
  77. aria-label={t('go_to_pdf_location_in_code')}
  78. >
  79. {buttonIcon}
  80. {isDetachLayout ? <span>&nbsp;{t('show_in_code')}</span> : ''}
  81. </OLButton>
  82. </OLTooltip>
  83. )
  84. })
  85. const GoToPdfButton = memo(function GoToPdfButton({
  86. syncToPdf,
  87. syncToPdfInFlight,
  88. isDetachLayout,
  89. canSyncToPdf,
  90. }: {
  91. syncToPdf: () => void
  92. syncToPdfInFlight: boolean
  93. canSyncToPdf: boolean
  94. isDetachLayout?: boolean
  95. }) {
  96. const { t } = useTranslation()
  97. const tooltipPlacement = isDetachLayout ? 'bottom' : 'right'
  98. const buttonClasses = classNames('synctex-control', {
  99. 'detach-synctex-control': !!isDetachLayout,
  100. })
  101. let buttonIcon = null
  102. if (syncToPdfInFlight) {
  103. buttonIcon = (
  104. <Spinner animation="border" aria-hidden="true" size="sm" role="status" />
  105. )
  106. } else if (!isDetachLayout) {
  107. buttonIcon = (
  108. <MaterialIcon type="arrow_right_alt" className="synctex-control-icon" />
  109. )
  110. }
  111. return (
  112. <OLTooltip
  113. id="sync-to-pdf"
  114. description={t('go_to_code_location_in_pdf')}
  115. overlayProps={{ placement: tooltipPlacement }}
  116. >
  117. <OLButton
  118. variant="secondary"
  119. size="sm"
  120. onClick={syncToPdf}
  121. disabled={syncToPdfInFlight || !canSyncToPdf}
  122. className={buttonClasses}
  123. aria-label={t('go_to_code_location_in_pdf')}
  124. >
  125. {buttonIcon}
  126. {isDetachLayout ? <span>&nbsp;{t('show_in_pdf')}</span> : ''}
  127. </OLButton>
  128. </OLTooltip>
  129. )
  130. })
  131. function PdfSynctexControls() {
  132. const { _id: projectId, rootDocId } = useProjectContext()
  133. const { detachRole } = useLayoutContext()
  134. const {
  135. clsiServerId,
  136. pdfFile,
  137. pdfUrl,
  138. pdfViewer,
  139. position,
  140. setShowLogs,
  141. setHighlights,
  142. } = useCompileContext()
  143. const { selectedEntities } = useFileTreeData()
  144. const { findEntityByPath, dirname, pathInFolder } = useFileTreePathContext()
  145. const { getCurrentDocumentId, openDocWithId, openDocName } =
  146. useEditorManagerContext()
  147. const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(
  148. () => {
  149. const position = localStorage.getItem(
  150. `doc.position.${getCurrentDocumentId()}`
  151. )
  152. return position ? position.cursorPosition : null
  153. }
  154. )
  155. const isMounted = useIsMounted()
  156. const { signal } = useAbortController()
  157. useEventListener(
  158. 'cursor:editor:update',
  159. useCallback(event => setCursorPosition(event.detail), [])
  160. )
  161. const [syncToPdfInFlight, setSyncToPdfInFlight] = useState(false)
  162. const [syncToCodeInFlight, setSyncToCodeInFlight] = useDetachState(
  163. 'sync-to-code-inflight',
  164. false,
  165. 'detacher',
  166. 'detached'
  167. )
  168. const [, setSynctexError] = useScopeValue('sync_tex_error')
  169. const getCurrentFilePath = useCallback(() => {
  170. const docId = getCurrentDocumentId()
  171. if (!docId || !rootDocId) {
  172. return null
  173. }
  174. let path = pathInFolder(docId)
  175. if (!path) {
  176. return null
  177. }
  178. // If the root file is folder/main.tex, then synctex sees the path as folder/./main.tex
  179. const rootDocDirname = dirname(rootDocId)
  180. if (rootDocDirname) {
  181. path = path.replace(RegExp(`^${rootDocDirname}`), `${rootDocDirname}/.`)
  182. }
  183. return path
  184. }, [dirname, getCurrentDocumentId, pathInFolder, rootDocId])
  185. const goToCodeLine = useCallback(
  186. (file, line) => {
  187. if (file) {
  188. const doc = findEntityByPath(file)?.entity
  189. if (!doc) {
  190. debugConsole.warn(`Document with path ${file} not found`)
  191. return
  192. }
  193. openDocWithId(doc._id, {
  194. gotoLine: line,
  195. })
  196. } else {
  197. setSynctexError(true)
  198. window.setTimeout(() => {
  199. if (isMounted.current) {
  200. setSynctexError(false)
  201. }
  202. }, 4000)
  203. }
  204. },
  205. [findEntityByPath, openDocWithId, isMounted, setSynctexError]
  206. )
  207. const goToPdfLocation = useCallback(
  208. params => {
  209. setSyncToPdfInFlight(true)
  210. if (clsiServerId) {
  211. params += `&clsiserverid=${clsiServerId}`
  212. }
  213. if (pdfFile?.editorId) params += `&editorId=${pdfFile.editorId}`
  214. if (pdfFile?.build) params += `&buildId=${pdfFile.build}`
  215. getJSON(`/project/${projectId}/sync/code?${params}`, { signal })
  216. .then(data => {
  217. setShowLogs(false)
  218. setHighlights(data.pdf)
  219. })
  220. .catch(debugConsole.error)
  221. .finally(() => {
  222. if (isMounted.current) {
  223. setSyncToPdfInFlight(false)
  224. }
  225. })
  226. },
  227. [
  228. pdfFile,
  229. clsiServerId,
  230. isMounted,
  231. projectId,
  232. setShowLogs,
  233. setHighlights,
  234. setSyncToPdfInFlight,
  235. signal,
  236. ]
  237. )
  238. const cursorPositionRef = useRef(cursorPosition)
  239. useEffect(() => {
  240. cursorPositionRef.current = cursorPosition
  241. }, [cursorPosition])
  242. const syncToPdf = useCallback(() => {
  243. const file = getCurrentFilePath()
  244. if (cursorPositionRef.current) {
  245. const { row, column } = cursorPositionRef.current
  246. const params = new URLSearchParams({
  247. file: file ?? '',
  248. line: String(row + 1),
  249. column: String(column),
  250. }).toString()
  251. eventTracking.sendMB('jump-to-location', {
  252. direction: 'code-location-in-pdf',
  253. method: 'arrow',
  254. })
  255. goToPdfLocation(params)
  256. }
  257. }, [getCurrentFilePath, goToPdfLocation])
  258. useScopeEventListener(
  259. 'cursor:editor:syncToPdf',
  260. useCallback(() => {
  261. syncToPdf()
  262. }, [syncToPdf])
  263. )
  264. const positionRef = useRef(position)
  265. useEffect(() => {
  266. positionRef.current = position
  267. }, [position])
  268. const _syncToCode = useCallback(
  269. ({
  270. position = positionRef.current,
  271. visualOffset = 0,
  272. }: {
  273. position?: PdfScrollPosition
  274. visualOffset?: number
  275. }) => {
  276. if (!position) {
  277. return
  278. }
  279. setSyncToCodeInFlight(true)
  280. // FIXME: this actually works better if it's halfway across the
  281. // page (or the visible part of the page). Synctex doesn't
  282. // always find the right place in the file when the point is at
  283. // the edge of the page, it sometimes returns the start of the
  284. // next paragraph instead.
  285. const h = position.offset.left
  286. // Compute the vertical position to pass to synctex, which
  287. // works with coordinates increasing from the top of the page
  288. // down. This matches the browser's DOM coordinate of the
  289. // click point, but the pdf position is measured from the
  290. // bottom of the page so we need to invert it.
  291. let v = 0
  292. if (position.pageSize?.height) {
  293. v += position.pageSize.height - position.offset.top // measure from pdf point (inverted)
  294. } else {
  295. v += position.offset.top // measure from html click position
  296. }
  297. v += visualOffset
  298. const params = new URLSearchParams({
  299. page: position.page + 1,
  300. h: h.toFixed(2),
  301. v: v.toFixed(2),
  302. })
  303. if (clsiServerId) {
  304. params.set('clsiserverid', clsiServerId)
  305. }
  306. if (pdfFile?.editorId) params.set('editorId', pdfFile.editorId)
  307. if (pdfFile?.build) params.set('buildId', pdfFile.build)
  308. getJSON(`/project/${projectId}/sync/pdf?${params}`, { signal })
  309. .then(data => {
  310. const [{ file, line }] = data.code
  311. goToCodeLine(file, line)
  312. })
  313. .catch(debugConsole.error)
  314. .finally(() => {
  315. if (isMounted.current) {
  316. setSyncToCodeInFlight(false)
  317. }
  318. })
  319. },
  320. [
  321. pdfFile,
  322. clsiServerId,
  323. projectId,
  324. signal,
  325. isMounted,
  326. setSyncToCodeInFlight,
  327. goToCodeLine,
  328. ]
  329. )
  330. const syncToCode = useDetachAction(
  331. 'sync-to-code',
  332. _syncToCode,
  333. 'detached',
  334. 'detacher'
  335. )
  336. useEventListener(
  337. 'synctex:sync-to-position',
  338. useCallback(event => syncToCode({ position: event.detail }), [syncToCode])
  339. )
  340. const [hasSingleSelectedDoc, setHasSingleSelectedDoc] = useDetachState(
  341. 'has-single-selected-doc',
  342. false,
  343. 'detacher',
  344. 'detached'
  345. )
  346. useEffect(() => {
  347. if (selectedEntities.length !== 1) {
  348. setHasSingleSelectedDoc(false)
  349. return
  350. }
  351. if (selectedEntities[0].type !== 'doc') {
  352. setHasSingleSelectedDoc(false)
  353. return
  354. }
  355. setHasSingleSelectedDoc(true)
  356. }, [selectedEntities, setHasSingleSelectedDoc])
  357. if (!position) {
  358. return null
  359. }
  360. if (!pdfUrl || pdfViewer === 'native') {
  361. return null
  362. }
  363. const canSyncToPdf: boolean =
  364. hasSingleSelectedDoc &&
  365. cursorPosition &&
  366. openDocName &&
  367. isValidTeXFile(openDocName)
  368. if (detachRole === 'detacher') {
  369. return (
  370. <GoToPdfButton
  371. syncToPdf={syncToPdf}
  372. syncToPdfInFlight={syncToPdfInFlight}
  373. isDetachLayout
  374. canSyncToPdf={canSyncToPdf}
  375. />
  376. )
  377. } else if (detachRole === 'detached') {
  378. return (
  379. <GoToCodeButton
  380. syncToCode={syncToCode}
  381. syncToCodeInFlight={syncToCodeInFlight}
  382. isDetachLayout
  383. />
  384. )
  385. } else {
  386. return (
  387. <>
  388. <GoToPdfButton
  389. syncToPdf={syncToPdf}
  390. syncToPdfInFlight={syncToPdfInFlight}
  391. canSyncToPdf={canSyncToPdf}
  392. />
  393. <GoToCodeButton
  394. syncToCode={syncToCode}
  395. syncToCodeInFlight={syncToCodeInFlight}
  396. />
  397. </>
  398. )
  399. }
  400. }
  401. export default memo(PdfSynctexControls)