pdf-synctex-controls.js 11 KB

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