pdf-synctex-controls.js 11 KB

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