pdf-synctex-controls.js 11 KB

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