pdf-synctex-controls.jsx 11 KB

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