pdf-synctex-controls.jsx 13 KB

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