pdf-synctex-controls.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import { memo, useCallback, useEffect, useState } from 'react'
  2. import { useIdeContext } from '../../../shared/context/ide-context'
  3. import { useProjectContext } from '../../../shared/context/project-context'
  4. import { getJSON } from '../../../infrastructure/fetch-json'
  5. import { useCompileContext } from '../../../shared/context/compile-context'
  6. import useScopeValue from '../../../shared/hooks/use-scope-value'
  7. import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
  8. import Icon from '../../../shared/components/icon'
  9. import { useTranslation } from 'react-i18next'
  10. import useIsMounted from '../../../shared/hooks/use-is-mounted'
  11. import useAbortController from '../../../shared/hooks/use-abort-controller'
  12. function PdfSynctexControls() {
  13. const ide = useIdeContext()
  14. const { _id: projectId } = useProjectContext()
  15. const {
  16. clsiServerId,
  17. pdfUrl,
  18. pdfViewer,
  19. position,
  20. setHighlights,
  21. } = useCompileContext()
  22. const [cursorPosition, setCursorPosition] = useState(null)
  23. const isMounted = useIsMounted()
  24. const { signal } = useAbortController()
  25. useEffect(() => {
  26. const listener = event => setCursorPosition(event.detail)
  27. window.addEventListener('cursor:editor:update', listener)
  28. return () => window.removeEventListener('cursor:editor:update', listener)
  29. }, [ide])
  30. const [syncToPdfInFlight, setSyncToPdfInFlight] = useState(false)
  31. const [syncToCodeInFlight, setSyncToCodeInFlight] = useState(false)
  32. const [, setSynctexError] = useScopeValue('sync_tex_error')
  33. const { t } = useTranslation()
  34. const getCurrentFilePath = useCallback(() => {
  35. const docId = ide.editorManager.getCurrentDocId()
  36. const doc = ide.fileTreeManager.findEntityById(docId)
  37. let path = ide.fileTreeManager.getEntityPath(doc)
  38. // If the root file is folder/main.tex, then synctex sees the path as folder/./main.tex
  39. const rootDocDirname = ide.fileTreeManager.getRootDocDirname()
  40. if (rootDocDirname) {
  41. path = path.replace(RegExp(`^${rootDocDirname}`), `${rootDocDirname}/.`)
  42. }
  43. return path
  44. }, [ide])
  45. const syncToPdf = useCallback(
  46. cursorPosition => {
  47. setSyncToPdfInFlight(true)
  48. const params = new URLSearchParams({
  49. file: getCurrentFilePath(),
  50. line: cursorPosition.row + 1,
  51. column: cursorPosition.column,
  52. })
  53. if (clsiServerId) {
  54. params.set('clsiserverid', clsiServerId)
  55. }
  56. getJSON(`/project/${projectId}/sync/code?${params}`, { signal })
  57. .then(data => {
  58. setHighlights(data.pdf)
  59. })
  60. .catch(error => {
  61. console.error(error)
  62. })
  63. .finally(() => {
  64. if (isMounted.current) {
  65. setSyncToPdfInFlight(false)
  66. }
  67. })
  68. },
  69. [
  70. clsiServerId,
  71. projectId,
  72. setHighlights,
  73. getCurrentFilePath,
  74. signal,
  75. isMounted,
  76. ]
  77. )
  78. const syncToCode = useCallback(
  79. (position, visualOffset = 0) => {
  80. // FIXME: this actually works better if it's halfway across the
  81. // page (or the visible part of the page). Synctex doesn't
  82. // always find the right place in the file when the point is at
  83. // the edge of the page, it sometimes returns the start of the
  84. // next paragraph instead.
  85. const h = position.offset.left
  86. // Compute the vertical position to pass to synctex, which
  87. // works with coordinates increasing from the top of the page
  88. // down. This matches the browser's DOM coordinate of the
  89. // click point, but the pdf position is measured from the
  90. // bottom of the page so we need to invert it.
  91. let v = 0
  92. if (position.pageSize?.height) {
  93. v += position.pageSize.height - position.offset.top // measure from pdf point (inverted)
  94. } else {
  95. v += position.offset.top // measure from html click position
  96. }
  97. v += visualOffset
  98. setSyncToCodeInFlight(true)
  99. const params = new URLSearchParams({
  100. page: position.page + 1,
  101. h: h.toFixed(2),
  102. v: v.toFixed(2),
  103. })
  104. if (clsiServerId) {
  105. params.set('clsiserverid', clsiServerId)
  106. }
  107. getJSON(`/project/${projectId}/sync/pdf?${params}`, { signal })
  108. .then(data => {
  109. const [{ file, line }] = data.code
  110. if (file) {
  111. const doc = ide.fileTreeManager.findEntityByPath(file)
  112. ide.editorManager.openDoc(doc, {
  113. gotoLine: line,
  114. })
  115. } else {
  116. setSynctexError(true)
  117. window.setTimeout(() => {
  118. if (isMounted.current) {
  119. setSynctexError(false)
  120. }
  121. }, 4000)
  122. }
  123. })
  124. .catch(error => {
  125. console.error(error)
  126. })
  127. .finally(() => {
  128. if (isMounted.current) {
  129. setSyncToCodeInFlight(false)
  130. }
  131. })
  132. },
  133. [clsiServerId, ide, projectId, setSynctexError, signal, isMounted]
  134. )
  135. useEffect(() => {
  136. const listener = event => syncToCode(event.detail)
  137. window.addEventListener('synctex:sync-to-position', listener)
  138. return () => {
  139. window.removeEventListener('synctex:sync-to-position', listener)
  140. }
  141. }, [syncToCode])
  142. if (!pdfUrl || pdfViewer === 'native') {
  143. return null
  144. }
  145. return (
  146. <>
  147. <OverlayTrigger
  148. placement="right"
  149. overlay={
  150. <Tooltip id="sync-to-pdf-tooltip">
  151. {t('go_to_code_location_in_pdf')}
  152. </Tooltip>
  153. }
  154. >
  155. <Button
  156. bsStyle="default"
  157. bsSize="xs"
  158. onClick={() => syncToPdf(cursorPosition)}
  159. disabled={syncToPdfInFlight}
  160. className="synctex-control"
  161. aria-label={t('go_to_code_location_in_pdf')}
  162. >
  163. {syncToPdfInFlight ? (
  164. <Icon type="refresh" spin classes={{ icon: 'synctex-spin-icon' }} />
  165. ) : (
  166. <Icon
  167. type="arrow-right"
  168. classes={{ icon: 'synctex-control-icon' }}
  169. />
  170. )}
  171. </Button>
  172. </OverlayTrigger>
  173. <OverlayTrigger
  174. placement="right"
  175. overlay={
  176. <Tooltip id="sync-to-code-tooltip">
  177. {t('go_to_pdf_location_in_code')}
  178. </Tooltip>
  179. }
  180. >
  181. <Button
  182. bsStyle="default"
  183. bsSize="xs"
  184. onClick={() => syncToCode(position, 72)}
  185. disabled={syncToCodeInFlight}
  186. className="synctex-control"
  187. aria-label={t('go_to_pdf_location_in_code')}
  188. >
  189. {syncToCodeInFlight ? (
  190. <Icon type="refresh" spin classes={{ icon: 'synctex-spin-icon' }} />
  191. ) : (
  192. <Icon
  193. type="arrow-left"
  194. classes={{ icon: 'synctex-control-icon' }}
  195. />
  196. )}
  197. </Button>
  198. </OverlayTrigger>
  199. </>
  200. )
  201. }
  202. export default memo(PdfSynctexControls)