pdf-synctex-controls.js 11 KB

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