pdf-synctex-controls.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. import classNames from 'classnames'
  2. import { memo, useCallback, useEffect, useState } 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 { useCompileContext } from '../../../shared/context/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. function GoToCodeButton({
  19. position,
  20. syncToCode,
  21. syncToCodeInFlight,
  22. isDetachLayout,
  23. }) {
  24. const { t } = useTranslation()
  25. const tooltipPlacement = isDetachLayout ? 'bottom' : 'right'
  26. const buttonClasses = classNames('synctex-control', {
  27. 'detach-synctex-control': !!isDetachLayout,
  28. })
  29. let buttonIcon = null
  30. if (syncToCodeInFlight) {
  31. buttonIcon = (
  32. <Icon type="refresh" spin classes={{ icon: 'synctex-spin-icon' }} />
  33. )
  34. } else if (!isDetachLayout) {
  35. buttonIcon = (
  36. <Icon type="arrow-left" classes={{ icon: 'synctex-control-icon' }} />
  37. )
  38. }
  39. return (
  40. <OverlayTrigger
  41. placement={tooltipPlacement}
  42. overlay={
  43. <Tooltip id="sync-to-code-tooltip">
  44. {t('go_to_pdf_location_in_code')}
  45. </Tooltip>
  46. }
  47. >
  48. <Button
  49. bsStyle="default"
  50. bsSize="xs"
  51. onClick={() => syncToCode(position, 72)}
  52. disabled={syncToCodeInFlight}
  53. className={buttonClasses}
  54. aria-label={t('go_to_pdf_location_in_code')}
  55. >
  56. {buttonIcon}
  57. {isDetachLayout ? <span>&nbsp;{t('show_in_code')}</span> : ''}
  58. </Button>
  59. </OverlayTrigger>
  60. )
  61. }
  62. function GoToPdfButton({
  63. cursorPosition,
  64. syncToPdf,
  65. syncToPdfInFlight,
  66. isDetachLayout,
  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 = (
  76. <Icon type="refresh" spin classes={{ icon: 'synctex-spin-icon' }} />
  77. )
  78. } else if (!isDetachLayout) {
  79. buttonIcon = (
  80. <Icon type="arrow-right" classes={{ icon: 'synctex-control-icon' }} />
  81. )
  82. }
  83. return (
  84. <OverlayTrigger
  85. placement={tooltipPlacement}
  86. overlay={
  87. <Tooltip id="sync-to-pdf-tooltip">
  88. {t('go_to_code_location_in_pdf')}
  89. </Tooltip>
  90. }
  91. >
  92. <Button
  93. bsStyle="default"
  94. bsSize="xs"
  95. onClick={() => syncToPdf(cursorPosition)}
  96. disabled={syncToPdfInFlight || !cursorPosition}
  97. className={buttonClasses}
  98. aria-label={t('go_to_code_location_in_pdf')}
  99. >
  100. {buttonIcon}
  101. {isDetachLayout ? <span>&nbsp;{t('show_in_pdf')}</span> : ''}
  102. </Button>
  103. </OverlayTrigger>
  104. )
  105. }
  106. function PdfSynctexControls() {
  107. const ide = useIdeContext()
  108. const { _id: projectId } = useProjectContext()
  109. const { detachRole } = useLayoutContext()
  110. const { clsiServerId, pdfUrl, pdfViewer, position, setHighlights } =
  111. useCompileContext()
  112. const [cursorPosition, setCursorPosition] = useState(() => {
  113. const position = localStorage.getItem(
  114. `doc.position.${ide.editorManager.getCurrentDocId()}`
  115. )
  116. return position ? position.cursorPosition : null
  117. })
  118. const isMounted = useIsMounted()
  119. const { signal } = useAbortController()
  120. // for detacher editor tab, which cannot access pdfUrl in a scope value in
  121. // detached state
  122. const [pdfExists, setPdfExists] = useDetachState(
  123. 'pdf-exists',
  124. !!pdfUrl,
  125. 'detached',
  126. 'detacher'
  127. )
  128. useEffect(() => {
  129. setPdfExists(!!pdfUrl)
  130. }, [pdfUrl, setPdfExists])
  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] = useDetachState(
  137. 'sync-to-pdf-inflight',
  138. false,
  139. 'detached',
  140. 'detacher'
  141. )
  142. const [syncToCodeInFlight, setSyncToCodeInFlight] = useState(false)
  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 goToCodeLine = useDetachAction(
  174. 'go-to-code-line',
  175. _goToCodeLine,
  176. 'detached',
  177. 'detacher'
  178. )
  179. const _goToPdfLocation = useCallback(
  180. params => {
  181. setSyncToPdfInFlight(true)
  182. if (clsiServerId) {
  183. params += `&clsiserverid=${clsiServerId}`
  184. }
  185. getJSON(`/project/${projectId}/sync/code?${params}`, { signal })
  186. .then(data => {
  187. setHighlights(data.pdf)
  188. })
  189. .catch(error => {
  190. console.error(error)
  191. })
  192. .finally(() => {
  193. if (isMounted.current) {
  194. setSyncToPdfInFlight(false)
  195. }
  196. })
  197. },
  198. [
  199. clsiServerId,
  200. isMounted,
  201. projectId,
  202. setHighlights,
  203. setSyncToPdfInFlight,
  204. signal,
  205. ]
  206. )
  207. const goToPdfLocation = useDetachAction(
  208. 'go-to-pdf-location',
  209. _goToPdfLocation,
  210. 'detacher',
  211. 'detached'
  212. )
  213. const syncToPdf = useCallback(
  214. cursorPosition => {
  215. const params = new URLSearchParams({
  216. file: getCurrentFilePath(),
  217. line: cursorPosition.row + 1,
  218. column: cursorPosition.column,
  219. }).toString()
  220. goToPdfLocation(params)
  221. },
  222. [getCurrentFilePath, goToPdfLocation]
  223. )
  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(error => {
  259. console.error(error)
  260. })
  261. .finally(() => {
  262. if (isMounted.current) {
  263. setSyncToCodeInFlight(false)
  264. }
  265. })
  266. },
  267. [
  268. clsiServerId,
  269. projectId,
  270. signal,
  271. isMounted,
  272. setSyncToCodeInFlight,
  273. goToCodeLine,
  274. ]
  275. )
  276. useEffect(() => {
  277. const listener = event => syncToCode(event.detail)
  278. window.addEventListener('synctex:sync-to-position', listener)
  279. return () => {
  280. window.removeEventListener('synctex:sync-to-position', listener)
  281. }
  282. }, [syncToCode])
  283. if (!pdfExists || pdfViewer === 'native') {
  284. return null
  285. }
  286. if (detachRole === 'detacher') {
  287. return (
  288. <>
  289. <GoToPdfButton
  290. cursorPosition={cursorPosition}
  291. syncToPdf={syncToPdf}
  292. syncToPdfInFlight={syncToPdfInFlight}
  293. isDetachLayout
  294. />
  295. </>
  296. )
  297. } else if (detachRole === 'detached') {
  298. return (
  299. <>
  300. <GoToCodeButton
  301. position={position}
  302. syncToCode={syncToCode}
  303. syncToCodeInFlight={syncToCodeInFlight}
  304. isDetachLayout
  305. />
  306. </>
  307. )
  308. } else {
  309. return (
  310. <>
  311. <GoToPdfButton
  312. cursorPosition={cursorPosition}
  313. syncToPdf={syncToPdf}
  314. syncToPdfInFlight={syncToPdfInFlight}
  315. />
  316. <GoToCodeButton
  317. position={position}
  318. syncToCode={syncToCode}
  319. syncToCodeInFlight={syncToCodeInFlight}
  320. />
  321. </>
  322. )
  323. }
  324. }
  325. export default memo(PdfSynctexControls)
  326. GoToCodeButton.propTypes = {
  327. isDetachLayout: PropTypes.bool,
  328. position: PropTypes.object.isRequired,
  329. syncToCode: PropTypes.func.isRequired,
  330. syncToCodeInFlight: PropTypes.bool.isRequired,
  331. }
  332. GoToPdfButton.propTypes = {
  333. cursorPosition: PropTypes.object,
  334. isDetachLayout: PropTypes.bool,
  335. syncToPdf: PropTypes.func.isRequired,
  336. syncToPdfInFlight: PropTypes.bool.isRequired,
  337. }