From 5b499efd23537adf1a2a1be1a4abcd61c8d1134e Mon Sep 17 00:00:00 2001
From: David <33458145+davidmcpowell@users.noreply.github.com>
Date: Wed, 30 Apr 2025 13:23:51 +0100
Subject: [PATCH] Merge pull request #25128 from overleaf/dp-synctex
Add synctex controls with buttons hidden to new editor
GitOrigin-RevId: 27566210444ca6d83fef977290fa7c2700f2bb62
---
.../ide-redesign/components/main-layout.tsx | 11 +
...bar.jsx => pdf-preview-hybrid-toolbar.tsx} | 4 +-
.../components/pdf-synctex-controls.tsx | 288 +----------------
.../features/pdf-preview/hooks/use-synctex.ts | 291 ++++++++++++++++++
4 files changed, 314 insertions(+), 280 deletions(-)
rename services/web/frontend/js/features/ide-redesign/components/pdf-preview/{pdf-preview-hybrid-toolbar.jsx => pdf-preview-hybrid-toolbar.tsx} (79%)
create mode 100644 services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts
diff --git a/services/web/frontend/js/features/ide-redesign/components/main-layout.tsx b/services/web/frontend/js/features/ide-redesign/components/main-layout.tsx
index cf71ed07cb..ccdf7c8b53 100644
--- a/services/web/frontend/js/features/ide-redesign/components/main-layout.tsx
+++ b/services/web/frontend/js/features/ide-redesign/components/main-layout.tsx
@@ -12,6 +12,7 @@ import { useState } from 'react'
import EditorPanel from './editor-panel'
import { useRailContext } from '../contexts/rail-context'
import HistoryContainer from '@/features/ide-react/components/history-container'
+import { DefaultSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
export default function MainLayout() {
const [resizing, setResizing] = useState(false)
@@ -80,6 +81,11 @@ export default function MainLayout() {
tooltipWhenOpen={t('tooltip_hide_pdf')}
tooltipWhenClosed={t('tooltip_show_pdf')}
/>
+ {pdfLayout === 'sideBySide' && (
+
+
+
+ )}
+ {pdfLayout === 'flat' && view === 'pdf' && (
+
+
+
+ )}
diff --git a/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.jsx b/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.tsx
similarity index 79%
rename from services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.jsx
rename to services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.tsx
index f2cdca095c..43478fcf0c 100644
--- a/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.jsx
+++ b/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.tsx
@@ -2,6 +2,7 @@ import { memo } from 'react'
import OlButtonToolbar from '@/features/ui/components/ol/ol-button-toolbar'
import PdfCompileButton from '@/features/pdf-preview/components/pdf-compile-button'
import PdfHybridDownloadButton from '@/features/pdf-preview/components/pdf-hybrid-download-button'
+import { DetachedSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
function PdfPreviewHybridToolbar() {
// TODO: add detached pdf logic
@@ -13,7 +14,8 @@ function PdfPreviewHybridToolbar() {
- {/* TODO: should we have switch to editor/code check/synctex buttons? */}
+
+ {/* TODO: should we have switch to editor/code check? */}
)
diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx
index 7d91f3251f..2695616fb4 100644
--- a/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx
+++ b/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx
@@ -1,31 +1,15 @@
import classNames from 'classnames'
-import { memo, useCallback, useEffect, useState, useRef, useMemo } from 'react'
-import { useProjectContext } from '../../../shared/context/project-context'
-import { getJSON } from '../../../infrastructure/fetch-json'
+import { memo, useCallback, useMemo } from 'react'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useLayoutContext } from '../../../shared/context/layout-context'
import { useTranslation } from 'react-i18next'
-import useIsMounted from '../../../shared/hooks/use-is-mounted'
-import useAbortController from '../../../shared/hooks/use-abort-controller'
-import useDetachState from '../../../shared/hooks/use-detach-state'
-import useDetachAction from '../../../shared/hooks/use-detach-action'
-import localStorage from '../../../infrastructure/local-storage'
-import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
-import useScopeEventListener from '../../../shared/hooks/use-scope-event-listener'
import * as eventTracking from '../../../infrastructure/event-tracking'
-import { debugConsole } from '@/utils/debugging'
-import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import { Spinner } from 'react-bootstrap-5'
-import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
-import useEventListener from '@/shared/hooks/use-event-listener'
-import { CursorPosition } from '@/features/ide-react/types/cursor-position'
-import { isValidTeXFile } from '@/main/is-valid-tex-file'
-import { PdfScrollPosition } from '@/shared/hooks/use-pdf-scroll-position'
import { Placement } from 'react-bootstrap-5/types'
-import { showFileErrorToast } from '@/features/pdf-preview/components/synctex-toasts'
+import useSynctex from '../hooks/use-synctex'
const GoToCodeButton = memo(function GoToCodeButton({
syncToCode,
@@ -138,263 +122,15 @@ const GoToPdfButton = memo(function GoToPdfButton({
})
function PdfSynctexControls() {
- const { _id: projectId, rootDocId } = useProjectContext()
-
const { detachRole } = useLayoutContext()
-
+ const { pdfUrl, pdfViewer, position } = useCompileContext()
const {
- clsiServerId,
- pdfFile,
- pdfUrl,
- pdfViewer,
- position,
- setShowLogs,
- setHighlights,
- } = useCompileContext()
-
- const { selectedEntities } = useFileTreeData()
- const { findEntityByPath, dirname, pathInFolder } = useFileTreePathContext()
- const { getCurrentDocumentId, openDocWithId, openDocName } =
- useEditorManagerContext()
-
- const [cursorPosition, setCursorPosition] = useState(
- () => {
- const position = localStorage.getItem(
- `doc.position.${getCurrentDocumentId()}`
- )
- return position ? position.cursorPosition : null
- }
- )
-
- const isMounted = useIsMounted()
-
- const { signal } = useAbortController()
-
- useEventListener(
- 'cursor:editor:update',
- useCallback(event => setCursorPosition(event.detail), [])
- )
-
- const [syncToPdfInFlight, setSyncToPdfInFlight] = useState(false)
- const [syncToCodeInFlight, setSyncToCodeInFlight] = useDetachState(
- 'sync-to-code-inflight',
- false,
- 'detacher',
- 'detached'
- )
-
- const getCurrentFilePath = useCallback(() => {
- const docId = getCurrentDocumentId()
-
- if (!docId || !rootDocId) {
- return null
- }
-
- let path = pathInFolder(docId)
-
- if (!path) {
- return null
- }
-
- // If the root file is folder/main.tex, then synctex sees the path as folder/./main.tex
- const rootDocDirname = dirname(rootDocId)
-
- if (rootDocDirname) {
- path = path.replace(RegExp(`^${rootDocDirname}`), `${rootDocDirname}/.`)
- }
-
- return path
- }, [dirname, getCurrentDocumentId, pathInFolder, rootDocId])
-
- const goToCodeLine = useCallback(
- (file, line) => {
- if (file) {
- const doc = findEntityByPath(file)?.entity
- if (doc) {
- openDocWithId(doc._id, {
- gotoLine: line,
- })
- return
- }
- }
- showFileErrorToast()
- },
- [findEntityByPath, openDocWithId]
- )
-
- const goToPdfLocation = useCallback(
- params => {
- setSyncToPdfInFlight(true)
-
- if (clsiServerId) {
- params += `&clsiserverid=${clsiServerId}`
- }
- if (pdfFile?.editorId) params += `&editorId=${pdfFile.editorId}`
- if (pdfFile?.build) params += `&buildId=${pdfFile.build}`
-
- getJSON(`/project/${projectId}/sync/code?${params}`, { signal })
- .then(data => {
- setShowLogs(false)
- setHighlights(data.pdf)
- })
- .catch(debugConsole.error)
- .finally(() => {
- if (isMounted.current) {
- setSyncToPdfInFlight(false)
- }
- })
- },
- [
- pdfFile,
- clsiServerId,
- isMounted,
- projectId,
- setShowLogs,
- setHighlights,
- setSyncToPdfInFlight,
- signal,
- ]
- )
-
- const cursorPositionRef = useRef(cursorPosition)
-
- useEffect(() => {
- cursorPositionRef.current = cursorPosition
- }, [cursorPosition])
-
- const syncToPdf = useCallback(() => {
- const file = getCurrentFilePath()
-
- if (cursorPositionRef.current) {
- const { row, column } = cursorPositionRef.current
-
- const params = new URLSearchParams({
- file: file ?? '',
- line: String(row + 1),
- column: String(column),
- }).toString()
-
- eventTracking.sendMB('jump-to-location', {
- direction: 'code-location-in-pdf',
- method: 'arrow',
- })
-
- goToPdfLocation(params)
- }
- }, [getCurrentFilePath, goToPdfLocation])
-
- useScopeEventListener(
- 'cursor:editor:syncToPdf',
- useCallback(() => {
- syncToPdf()
- }, [syncToPdf])
- )
-
- const positionRef = useRef(position)
- useEffect(() => {
- positionRef.current = position
- }, [position])
-
- const _syncToCode = useCallback(
- ({
- position = positionRef.current,
- visualOffset = 0,
- }: {
- position?: PdfScrollPosition
- visualOffset?: number
- }) => {
- if (!position) {
- return
- }
-
- setSyncToCodeInFlight(true)
- // FIXME: this actually works better if it's halfway across the
- // page (or the visible part of the page). Synctex doesn't
- // always find the right place in the file when the point is at
- // the edge of the page, it sometimes returns the start of the
- // next paragraph instead.
- const h = position.offset.left
-
- // Compute the vertical position to pass to synctex, which
- // works with coordinates increasing from the top of the page
- // down. This matches the browser's DOM coordinate of the
- // click point, but the pdf position is measured from the
- // bottom of the page so we need to invert it.
- let v = 0
- if (position.pageSize?.height) {
- v += position.pageSize.height - position.offset.top // measure from pdf point (inverted)
- } else {
- v += position.offset.top // measure from html click position
- }
- v += visualOffset
-
- const params = new URLSearchParams({
- page: position.page + 1,
- h: h.toFixed(2),
- v: v.toFixed(2),
- })
-
- if (clsiServerId) {
- params.set('clsiserverid', clsiServerId)
- }
- if (pdfFile?.editorId) params.set('editorId', pdfFile.editorId)
- if (pdfFile?.build) params.set('buildId', pdfFile.build)
-
- getJSON(`/project/${projectId}/sync/pdf?${params}`, { signal })
- .then(data => {
- const [{ file, line }] = data.code
- goToCodeLine(file, line)
- })
- .catch(debugConsole.error)
- .finally(() => {
- if (isMounted.current) {
- setSyncToCodeInFlight(false)
- }
- })
- },
- [
- pdfFile,
- clsiServerId,
- projectId,
- signal,
- isMounted,
- setSyncToCodeInFlight,
- goToCodeLine,
- ]
- )
-
- const syncToCode = useDetachAction(
- 'sync-to-code',
- _syncToCode,
- 'detached',
- 'detacher'
- )
-
- useEventListener(
- 'synctex:sync-to-position',
- useCallback(event => syncToCode({ position: event.detail }), [syncToCode])
- )
-
- const [hasSingleSelectedDoc, setHasSingleSelectedDoc] = useDetachState(
- 'has-single-selected-doc',
- false,
- 'detacher',
- 'detached'
- )
-
- useEffect(() => {
- if (selectedEntities.length !== 1) {
- setHasSingleSelectedDoc(false)
- return
- }
-
- if (selectedEntities[0].type !== 'doc') {
- setHasSingleSelectedDoc(false)
- return
- }
-
- setHasSingleSelectedDoc(true)
- }, [selectedEntities, setHasSingleSelectedDoc])
+ syncToCode,
+ syncToPdf,
+ syncToCodeInFlight,
+ syncToPdfInFlight,
+ canSyncToPdf,
+ } = useSynctex()
if (!position) {
return null
@@ -404,12 +140,6 @@ function PdfSynctexControls() {
return null
}
- const canSyncToPdf: boolean =
- hasSingleSelectedDoc &&
- cursorPosition &&
- openDocName &&
- isValidTeXFile(openDocName)
-
if (detachRole === 'detacher') {
return (
void
+ syncToCode: ({ visualOffset }: { visualOffset?: number }) => void
+ syncToPdfInFlight: boolean
+ syncToCodeInFlight: boolean
+ canSyncToPdf: boolean
+} {
+ const { _id: projectId, rootDocId } = useProjectContext()
+
+ const { clsiServerId, pdfFile, position, setShowLogs, setHighlights } =
+ useCompileContext()
+
+ const { selectedEntities } = useFileTreeData()
+ const { findEntityByPath, dirname, pathInFolder } = useFileTreePathContext()
+ const { getCurrentDocumentId, openDocWithId, openDocName } =
+ useEditorManagerContext()
+
+ const [cursorPosition, setCursorPosition] = useState(
+ () => {
+ const position = localStorage.getItem(
+ `doc.position.${getCurrentDocumentId()}`
+ )
+ return position ? position.cursorPosition : null
+ }
+ )
+
+ const isMounted = useIsMounted()
+
+ const { signal } = useAbortController()
+
+ useEventListener(
+ 'cursor:editor:update',
+ useCallback(event => setCursorPosition(event.detail), [])
+ )
+
+ const [syncToPdfInFlight, setSyncToPdfInFlight] = useState(false)
+ const [syncToCodeInFlight, setSyncToCodeInFlight] = useDetachState(
+ 'sync-to-code-inflight',
+ false,
+ 'detacher',
+ 'detached'
+ )
+
+ const getCurrentFilePath = useCallback(() => {
+ const docId = getCurrentDocumentId()
+
+ if (!docId || !rootDocId) {
+ return null
+ }
+
+ let path = pathInFolder(docId)
+
+ if (!path) {
+ return null
+ }
+
+ // If the root file is folder/main.tex, then synctex sees the path as folder/./main.tex
+ const rootDocDirname = dirname(rootDocId)
+
+ if (rootDocDirname) {
+ path = path.replace(RegExp(`^${rootDocDirname}`), `${rootDocDirname}/.`)
+ }
+
+ return path
+ }, [dirname, getCurrentDocumentId, pathInFolder, rootDocId])
+
+ const goToCodeLine = useCallback(
+ (file, line) => {
+ if (file) {
+ const doc = findEntityByPath(file)?.entity
+ if (doc) {
+ openDocWithId(doc._id, {
+ gotoLine: line,
+ })
+ return
+ }
+ }
+ showFileErrorToast()
+ },
+ [findEntityByPath, openDocWithId]
+ )
+
+ const goToPdfLocation = useCallback(
+ params => {
+ setSyncToPdfInFlight(true)
+
+ if (clsiServerId) {
+ params += `&clsiserverid=${clsiServerId}`
+ }
+ if (pdfFile?.editorId) params += `&editorId=${pdfFile.editorId}`
+ if (pdfFile?.build) params += `&buildId=${pdfFile.build}`
+
+ getJSON(`/project/${projectId}/sync/code?${params}`, { signal })
+ .then(data => {
+ setShowLogs(false)
+ setHighlights(data.pdf)
+ })
+ .catch(debugConsole.error)
+ .finally(() => {
+ if (isMounted.current) {
+ setSyncToPdfInFlight(false)
+ }
+ })
+ },
+ [
+ pdfFile,
+ clsiServerId,
+ isMounted,
+ projectId,
+ setShowLogs,
+ setHighlights,
+ setSyncToPdfInFlight,
+ signal,
+ ]
+ )
+
+ const cursorPositionRef = useRef(cursorPosition)
+
+ useEffect(() => {
+ cursorPositionRef.current = cursorPosition
+ }, [cursorPosition])
+
+ const syncToPdf = useCallback(() => {
+ const file = getCurrentFilePath()
+
+ if (cursorPositionRef.current) {
+ const { row, column } = cursorPositionRef.current
+
+ const params = new URLSearchParams({
+ file: file ?? '',
+ line: String(row + 1),
+ column: String(column),
+ }).toString()
+
+ eventTracking.sendMB('jump-to-location', {
+ direction: 'code-location-in-pdf',
+ method: 'arrow',
+ })
+
+ goToPdfLocation(params)
+ }
+ }, [getCurrentFilePath, goToPdfLocation])
+
+ useScopeEventListener(
+ 'cursor:editor:syncToPdf',
+ useCallback(() => {
+ syncToPdf()
+ }, [syncToPdf])
+ )
+
+ const positionRef = useRef(position)
+ useEffect(() => {
+ positionRef.current = position
+ }, [position])
+
+ const _syncToCode = useCallback(
+ ({
+ position = positionRef.current,
+ visualOffset = 0,
+ }: {
+ position?: PdfScrollPosition
+ visualOffset?: number
+ }) => {
+ if (!position) {
+ return
+ }
+
+ setSyncToCodeInFlight(true)
+ // FIXME: this actually works better if it's halfway across the
+ // page (or the visible part of the page). Synctex doesn't
+ // always find the right place in the file when the point is at
+ // the edge of the page, it sometimes returns the start of the
+ // next paragraph instead.
+ const h = position.offset.left
+
+ // Compute the vertical position to pass to synctex, which
+ // works with coordinates increasing from the top of the page
+ // down. This matches the browser's DOM coordinate of the
+ // click point, but the pdf position is measured from the
+ // bottom of the page so we need to invert it.
+ let v = 0
+ if (position.pageSize?.height) {
+ v += position.pageSize.height - position.offset.top // measure from pdf point (inverted)
+ } else {
+ v += position.offset.top // measure from html click position
+ }
+ v += visualOffset
+
+ const params = new URLSearchParams({
+ page: position.page + 1,
+ h: h.toFixed(2),
+ v: v.toFixed(2),
+ })
+
+ if (clsiServerId) {
+ params.set('clsiserverid', clsiServerId)
+ }
+ if (pdfFile?.editorId) params.set('editorId', pdfFile.editorId)
+ if (pdfFile?.build) params.set('buildId', pdfFile.build)
+
+ getJSON(`/project/${projectId}/sync/pdf?${params}`, { signal })
+ .then(data => {
+ const [{ file, line }] = data.code
+ goToCodeLine(file, line)
+ })
+ .catch(debugConsole.error)
+ .finally(() => {
+ if (isMounted.current) {
+ setSyncToCodeInFlight(false)
+ }
+ })
+ },
+ [
+ pdfFile,
+ clsiServerId,
+ projectId,
+ signal,
+ isMounted,
+ setSyncToCodeInFlight,
+ goToCodeLine,
+ ]
+ )
+
+ const syncToCode = useDetachAction(
+ 'sync-to-code',
+ _syncToCode,
+ 'detached',
+ 'detacher'
+ )
+
+ useEventListener(
+ 'synctex:sync-to-position',
+ useCallback(event => syncToCode({ position: event.detail }), [syncToCode])
+ )
+
+ const [hasSingleSelectedDoc, setHasSingleSelectedDoc] = useDetachState(
+ 'has-single-selected-doc',
+ false,
+ 'detacher',
+ 'detached'
+ )
+
+ useEffect(() => {
+ if (selectedEntities.length !== 1) {
+ setHasSingleSelectedDoc(false)
+ return
+ }
+
+ if (selectedEntities[0].type !== 'doc') {
+ setHasSingleSelectedDoc(false)
+ return
+ }
+
+ setHasSingleSelectedDoc(true)
+ }, [selectedEntities, setHasSingleSelectedDoc])
+
+ const canSyncToPdf: boolean =
+ hasSingleSelectedDoc &&
+ cursorPosition &&
+ openDocName &&
+ isValidTeXFile(openDocName)
+
+ return {
+ syncToCode,
+ syncToPdf,
+ syncToPdfInFlight,
+ syncToCodeInFlight,
+ canSyncToPdf,
+ }
+}