pdf-synctex-controls.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  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 = <Icon type="refresh" spin className="synctex-spin-icon" />
  32. } else if (!isDetachLayout) {
  33. buttonIcon = <Icon type="arrow-left" className="synctex-control-icon" />
  34. }
  35. return (
  36. <OverlayTrigger
  37. placement={tooltipPlacement}
  38. overlay={
  39. <Tooltip id="sync-to-code-tooltip">
  40. {t('go_to_pdf_location_in_code')}
  41. </Tooltip>
  42. }
  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. </OverlayTrigger>
  56. )
  57. }
  58. function GoToPdfButton({
  59. cursorPosition,
  60. syncToPdf,
  61. syncToPdfInFlight,
  62. isDetachLayout,
  63. }) {
  64. const { t } = useTranslation()
  65. const tooltipPlacement = isDetachLayout ? 'bottom' : 'right'
  66. const buttonClasses = classNames('synctex-control', {
  67. 'detach-synctex-control': !!isDetachLayout,
  68. })
  69. let buttonIcon = null
  70. if (syncToPdfInFlight) {
  71. buttonIcon = <Icon type="refresh" spin className="synctex-spin-icon" />
  72. } else if (!isDetachLayout) {
  73. buttonIcon = <Icon type="arrow-right" className="synctex-control-icon" />
  74. }
  75. return (
  76. <OverlayTrigger
  77. placement={tooltipPlacement}
  78. overlay={
  79. <Tooltip id="sync-to-pdf-tooltip">
  80. {t('go_to_code_location_in_pdf')}
  81. </Tooltip>
  82. }
  83. >
  84. <Button
  85. bsStyle="default"
  86. bsSize="xs"
  87. onClick={() => syncToPdf(cursorPosition)}
  88. disabled={syncToPdfInFlight || !cursorPosition}
  89. className={buttonClasses}
  90. aria-label={t('go_to_code_location_in_pdf')}
  91. >
  92. {buttonIcon}
  93. {isDetachLayout ? <span>&nbsp;{t('show_in_pdf')}</span> : ''}
  94. </Button>
  95. </OverlayTrigger>
  96. )
  97. }
  98. function PdfSynctexControls() {
  99. const ide = useIdeContext()
  100. const { _id: projectId } = useProjectContext()
  101. const { detachRole } = useLayoutContext()
  102. const { clsiServerId, pdfUrl, pdfViewer, position, setHighlights } =
  103. useCompileContext()
  104. const [cursorPosition, setCursorPosition] = useState(() => {
  105. const position = localStorage.getItem(
  106. `doc.position.${ide.editorManager.getCurrentDocId()}`
  107. )
  108. return position ? position.cursorPosition : null
  109. })
  110. const isMounted = useIsMounted()
  111. const { signal } = useAbortController()
  112. // for detacher editor tab, which cannot access pdfUrl in a scope value in
  113. // detached state
  114. const [pdfExists, setPdfExists] = useDetachState(
  115. 'pdf-exists',
  116. !!pdfUrl,
  117. 'detached',
  118. 'detacher'
  119. )
  120. useEffect(() => {
  121. setPdfExists(!!pdfUrl)
  122. }, [pdfUrl, setPdfExists])
  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] = useDetachState(
  129. 'sync-to-pdf-inflight',
  130. false,
  131. 'detached',
  132. 'detacher'
  133. )
  134. const [syncToCodeInFlight, setSyncToCodeInFlight] = useState(false)
  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 goToCodeLine = useDetachAction(
  166. 'go-to-code-line',
  167. _goToCodeLine,
  168. 'detached',
  169. 'detacher'
  170. )
  171. const _goToPdfLocation = useCallback(
  172. params => {
  173. setSyncToPdfInFlight(true)
  174. if (clsiServerId) {
  175. params += `&clsiserverid=${clsiServerId}`
  176. }
  177. getJSON(`/project/${projectId}/sync/code?${params}`, { signal })
  178. .then(data => {
  179. setHighlights(data.pdf)
  180. })
  181. .catch(error => {
  182. console.error(error)
  183. })
  184. .finally(() => {
  185. if (isMounted.current) {
  186. setSyncToPdfInFlight(false)
  187. }
  188. })
  189. },
  190. [
  191. clsiServerId,
  192. isMounted,
  193. projectId,
  194. setHighlights,
  195. setSyncToPdfInFlight,
  196. signal,
  197. ]
  198. )
  199. const goToPdfLocation = useDetachAction(
  200. 'go-to-pdf-location',
  201. _goToPdfLocation,
  202. 'detacher',
  203. 'detached'
  204. )
  205. const syncToPdf = useCallback(
  206. cursorPosition => {
  207. const params = new URLSearchParams({
  208. file: getCurrentFilePath(),
  209. line: cursorPosition.row + 1,
  210. column: cursorPosition.column,
  211. }).toString()
  212. goToPdfLocation(params)
  213. },
  214. [getCurrentFilePath, goToPdfLocation]
  215. )
  216. const syncToCode = useCallback(
  217. (position, visualOffset = 0) => {
  218. setSyncToCodeInFlight(true)
  219. // FIXME: this actually works better if it's halfway across the
  220. // page (or the visible part of the page). Synctex doesn't
  221. // always find the right place in the file when the point is at
  222. // the edge of the page, it sometimes returns the start of the
  223. // next paragraph instead.
  224. const h = position.offset.left
  225. // Compute the vertical position to pass to synctex, which
  226. // works with coordinates increasing from the top of the page
  227. // down. This matches the browser's DOM coordinate of the
  228. // click point, but the pdf position is measured from the
  229. // bottom of the page so we need to invert it.
  230. let v = 0
  231. if (position.pageSize?.height) {
  232. v += position.pageSize.height - position.offset.top // measure from pdf point (inverted)
  233. } else {
  234. v += position.offset.top // measure from html click position
  235. }
  236. v += visualOffset
  237. const params = new URLSearchParams({
  238. page: position.page + 1,
  239. h: h.toFixed(2),
  240. v: v.toFixed(2),
  241. })
  242. if (clsiServerId) {
  243. params.set('clsiserverid', clsiServerId)
  244. }
  245. getJSON(`/project/${projectId}/sync/pdf?${params}`, { signal })
  246. .then(data => {
  247. const [{ file, line }] = data.code
  248. goToCodeLine(file, line)
  249. })
  250. .catch(error => {
  251. console.error(error)
  252. })
  253. .finally(() => {
  254. if (isMounted.current) {
  255. setSyncToCodeInFlight(false)
  256. }
  257. })
  258. },
  259. [
  260. clsiServerId,
  261. projectId,
  262. signal,
  263. isMounted,
  264. setSyncToCodeInFlight,
  265. goToCodeLine,
  266. ]
  267. )
  268. useEffect(() => {
  269. const listener = event => syncToCode(event.detail)
  270. window.addEventListener('synctex:sync-to-position', listener)
  271. return () => {
  272. window.removeEventListener('synctex:sync-to-position', listener)
  273. }
  274. }, [syncToCode])
  275. if (!position) {
  276. return null
  277. }
  278. if (!pdfExists || pdfViewer === 'native') {
  279. return null
  280. }
  281. if (detachRole === 'detacher') {
  282. return (
  283. <>
  284. <GoToPdfButton
  285. cursorPosition={cursorPosition}
  286. syncToPdf={syncToPdf}
  287. syncToPdfInFlight={syncToPdfInFlight}
  288. isDetachLayout
  289. />
  290. </>
  291. )
  292. } else if (detachRole === 'detached') {
  293. return (
  294. <>
  295. <GoToCodeButton
  296. position={position}
  297. syncToCode={syncToCode}
  298. syncToCodeInFlight={syncToCodeInFlight}
  299. isDetachLayout
  300. />
  301. </>
  302. )
  303. } else {
  304. return (
  305. <>
  306. <GoToPdfButton
  307. cursorPosition={cursorPosition}
  308. syncToPdf={syncToPdf}
  309. syncToPdfInFlight={syncToPdfInFlight}
  310. />
  311. <GoToCodeButton
  312. position={position}
  313. syncToCode={syncToCode}
  314. syncToCodeInFlight={syncToCodeInFlight}
  315. />
  316. </>
  317. )
  318. }
  319. }
  320. export default memo(PdfSynctexControls)
  321. GoToCodeButton.propTypes = {
  322. isDetachLayout: PropTypes.bool,
  323. position: PropTypes.object.isRequired,
  324. syncToCode: PropTypes.func.isRequired,
  325. syncToCodeInFlight: PropTypes.bool.isRequired,
  326. }
  327. GoToPdfButton.propTypes = {
  328. cursorPosition: PropTypes.object,
  329. isDetachLayout: PropTypes.bool,
  330. syncToPdf: PropTypes.func.isRequired,
  331. syncToPdfInFlight: PropTypes.bool.isRequired,
  332. }