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, + } +}