Przeglądaj źródła

Merge pull request #25128 from overleaf/dp-synctex

Add synctex controls with buttons hidden to new editor

GitOrigin-RevId: 27566210444ca6d83fef977290fa7c2700f2bb62
David 1 rok temu
rodzic
commit
5b499efd23

+ 11 - 0
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 EditorPanel from './editor-panel'
 import { useRailContext } from '../contexts/rail-context'
 import { useRailContext } from '../contexts/rail-context'
 import HistoryContainer from '@/features/ide-react/components/history-container'
 import HistoryContainer from '@/features/ide-react/components/history-container'
+import { DefaultSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
 
 
 export default function MainLayout() {
 export default function MainLayout() {
   const [resizing, setResizing] = useState(false)
   const [resizing, setResizing] = useState(false)
@@ -80,6 +81,11 @@ export default function MainLayout() {
                   tooltipWhenOpen={t('tooltip_hide_pdf')}
                   tooltipWhenOpen={t('tooltip_hide_pdf')}
                   tooltipWhenClosed={t('tooltip_show_pdf')}
                   tooltipWhenClosed={t('tooltip_show_pdf')}
                 />
                 />
+                {pdfLayout === 'sideBySide' && (
+                  <div className="synctex-controls" hidden>
+                    <DefaultSynctexControl />
+                  </div>
+                )}
               </HorizontalResizeHandle>
               </HorizontalResizeHandle>
               <Panel
               <Panel
                 collapsible
                 collapsible
@@ -95,6 +101,11 @@ export default function MainLayout() {
                 onCollapse={handlePdfPaneCollapse}
                 onCollapse={handlePdfPaneCollapse}
               >
               >
                 <PdfPreview />
                 <PdfPreview />
+                {pdfLayout === 'flat' && view === 'pdf' && (
+                  <div className="synctex-controls" hidden>
+                    <DefaultSynctexControl />
+                  </div>
+                )}
               </Panel>
               </Panel>
             </PanelGroup>
             </PanelGroup>
           </Panel>
           </Panel>

+ 3 - 1
services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar.jsx → 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 OlButtonToolbar from '@/features/ui/components/ol/ol-button-toolbar'
 import PdfCompileButton from '@/features/pdf-preview/components/pdf-compile-button'
 import PdfCompileButton from '@/features/pdf-preview/components/pdf-compile-button'
 import PdfHybridDownloadButton from '@/features/pdf-preview/components/pdf-hybrid-download-button'
 import PdfHybridDownloadButton from '@/features/pdf-preview/components/pdf-hybrid-download-button'
+import { DetachedSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
 
 
 function PdfPreviewHybridToolbar() {
 function PdfPreviewHybridToolbar() {
   // TODO: add detached pdf logic
   // TODO: add detached pdf logic
@@ -13,7 +14,8 @@ function PdfPreviewHybridToolbar() {
       </div>
       </div>
       <div className="toolbar-pdf-right">
       <div className="toolbar-pdf-right">
         <div className="toolbar-pdf-controls" id="toolbar-pdf-controls" />
         <div className="toolbar-pdf-controls" id="toolbar-pdf-controls" />
-        {/* TODO: should we have switch to editor/code check/synctex buttons? */}
+        <DetachedSynctexControl />
+        {/* TODO: should we have switch to editor/code check? */}
       </div>
       </div>
     </OlButtonToolbar>
     </OlButtonToolbar>
   )
   )

+ 9 - 279
services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx

@@ -1,31 +1,15 @@
 import classNames from 'classnames'
 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 { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 import { useLayoutContext } from '../../../shared/context/layout-context'
 import { useLayoutContext } from '../../../shared/context/layout-context'
 import { useTranslation } from 'react-i18next'
 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 * 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 OLTooltip from '@/features/ui/components/ol/ol-tooltip'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import MaterialIcon from '@/shared/components/material-icon'
 import MaterialIcon from '@/shared/components/material-icon'
 import { Spinner } from 'react-bootstrap-5'
 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 { 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({
 const GoToCodeButton = memo(function GoToCodeButton({
   syncToCode,
   syncToCode,
@@ -138,263 +122,15 @@ const GoToPdfButton = memo(function GoToPdfButton({
 })
 })
 
 
 function PdfSynctexControls() {
 function PdfSynctexControls() {
-  const { _id: projectId, rootDocId } = useProjectContext()
-
   const { detachRole } = useLayoutContext()
   const { detachRole } = useLayoutContext()
-
+  const { pdfUrl, pdfViewer, position } = useCompileContext()
   const {
   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<CursorPosition | null>(
-    () => {
-      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) {
   if (!position) {
     return null
     return null
@@ -404,12 +140,6 @@ function PdfSynctexControls() {
     return null
     return null
   }
   }
 
 
-  const canSyncToPdf: boolean =
-    hasSingleSelectedDoc &&
-    cursorPosition &&
-    openDocName &&
-    isValidTeXFile(openDocName)
-
   if (detachRole === 'detacher') {
   if (detachRole === 'detacher') {
     return (
     return (
       <GoToPdfButton
       <GoToPdfButton

+ 291 - 0
services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts

@@ -0,0 +1,291 @@
+import { useCallback, useEffect, useState, useRef } from 'react'
+import { useProjectContext } from '../../../shared/context/project-context'
+import { getJSON } from '../../../infrastructure/fetch-json'
+import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
+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 { 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 { showFileErrorToast } from '@/features/pdf-preview/components/synctex-toasts'
+
+export default function useSynctex(): {
+  syncToPdf: () => 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<CursorPosition | null>(
+    () => {
+      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,
+  }
+}