pdf-synctex-controls.jsx 13 KB

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