pdf-synctex-controls.jsx 12 KB

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