diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js index 6cde785084..f15832b4c3 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js @@ -86,7 +86,7 @@ export function FileTreeSelectableProvider({ onSelect, children }) { rootDocId ) - const { fileTreeData } = useFileTreeData() + const { fileTreeData, setSelectedEntities } = useFileTreeData() const [selectedEntityIds, dispatch] = useReducer( permissionsLevel === 'readOnly' @@ -129,11 +129,18 @@ export function FileTreeSelectableProvider({ onSelect, children }) { if (_.isEqual(selectedEntityIds, previousSelectedEntityIds)) { return } - const selectedEntities = Array.from(selectedEntityIds) + const _selectedEntities = Array.from(selectedEntityIds) .map(id => findInTree(fileTreeData, id)) .filter(Boolean) - onSelect(selectedEntities) - }, [fileTreeData, selectedEntityIds, previousSelectedEntityIds, onSelect]) + onSelect(_selectedEntities) + setSelectedEntities(_selectedEntities) + }, [ + fileTreeData, + selectedEntityIds, + previousSelectedEntityIds, + onSelect, + setSelectedEntities, + ]) useEffect(() => { // listen for `editor.openDoc` and selected that doc diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js b/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js index 7e56f04dff..d0f0a1aa5f 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js @@ -1,5 +1,5 @@ import classNames from 'classnames' -import { memo, useCallback, useEffect, useState } from 'react' +import { memo, useCallback, useEffect, useState, useMemo } from 'react' import PropTypes from 'prop-types' import { useIdeContext } from '../../../shared/context/ide-context' import { useProjectContext } from '../../../shared/context/project-context' @@ -15,12 +15,14 @@ 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' function GoToCodeButton({ position, syncToCode, syncToCodeInFlight, isDetachLayout, + hasSingleSelectedDoc, }) { const { t } = useTranslation() const tooltipPlacement = isDetachLayout ? 'bottom' : 'right' @@ -48,7 +50,7 @@ function GoToCodeButton({ bsStyle="default" bsSize="xs" onClick={() => syncToCode(position, 72)} - disabled={syncToCodeInFlight} + disabled={syncToCodeInFlight || !hasSingleSelectedDoc} className={buttonClasses} aria-label={t('go_to_pdf_location_in_code')} > @@ -64,6 +66,7 @@ function GoToPdfButton({ syncToPdf, syncToPdfInFlight, isDetachLayout, + hasSingleSelectedDoc, }) { const { t } = useTranslation() const tooltipPlacement = isDetachLayout ? 'bottom' : 'right' @@ -91,7 +94,7 @@ function GoToPdfButton({ bsStyle="default" bsSize="xs" onClick={() => syncToPdf(cursorPosition)} - disabled={syncToPdfInFlight || !cursorPosition} + disabled={syncToPdfInFlight || !cursorPosition || !hasSingleSelectedDoc} className={buttonClasses} aria-label={t('go_to_code_location_in_pdf')} > @@ -118,6 +121,8 @@ function PdfSynctexControls() { setHighlights, } = useCompileContext() + const { selectedEntities } = useFileTreeData() + const [cursorPosition, setCursorPosition] = useState(() => { const position = localStorage.getItem( `doc.position.${ide.editorManager.getCurrentDocId()}` @@ -320,6 +325,17 @@ function PdfSynctexControls() { } }, [syncToCode]) + const hasSingleSelectedDoc = useMemo(() => { + if (selectedEntities.length !== 1) { + return false + } + + if (selectedEntities[0].type !== 'doc') { + return false + } + return true + }, [selectedEntities]) + if (!position) { return null } @@ -336,6 +352,7 @@ function PdfSynctexControls() { syncToPdf={syncToPdf} syncToPdfInFlight={syncToPdfInFlight} isDetachLayout + hasSingleSelectedDoc={hasSingleSelectedDoc} /> ) @@ -347,6 +364,7 @@ function PdfSynctexControls() { syncToCode={syncToCode} syncToCodeInFlight={syncToCodeInFlight} isDetachLayout + hasSingleSelectedDoc={hasSingleSelectedDoc} /> ) @@ -357,12 +375,14 @@ function PdfSynctexControls() { cursorPosition={cursorPosition} syncToPdf={syncToPdf} syncToPdfInFlight={syncToPdfInFlight} + hasSingleSelectedDoc={hasSingleSelectedDoc} /> ) @@ -376,6 +396,7 @@ GoToCodeButton.propTypes = { position: PropTypes.object.isRequired, syncToCode: PropTypes.func.isRequired, syncToCodeInFlight: PropTypes.bool.isRequired, + hasSingleSelectedDoc: PropTypes.bool.isRequired, } GoToPdfButton.propTypes = { @@ -383,4 +404,5 @@ GoToPdfButton.propTypes = { isDetachLayout: PropTypes.bool, syncToPdf: PropTypes.func.isRequired, syncToPdfInFlight: PropTypes.bool.isRequired, + hasSingleSelectedDoc: PropTypes.bool.isRequired, } diff --git a/services/web/frontend/js/shared/context/file-tree-data-context.js b/services/web/frontend/js/shared/context/file-tree-data-context.js index 387605c01f..dbf221b7fe 100644 --- a/services/web/frontend/js/shared/context/file-tree-data-context.js +++ b/services/web/frontend/js/shared/context/file-tree-data-context.js @@ -4,6 +4,7 @@ import { useReducer, useContext, useMemo, + useState, } from 'react' import PropTypes from 'prop-types' import useScopeValue from '../hooks/use-scope-value' @@ -144,6 +145,8 @@ export function FileTreeDataProvider({ children }) { initialState ) + const [selectedEntities, setSelectedEntities] = useState([]) + useDeepCompareEffect(() => { dispatch({ type: ACTION_TYPES.RESET, @@ -205,6 +208,8 @@ export function FileTreeDataProvider({ children }) { fileCount, fileTreeData, hasFolders: fileTreeData?.folders.length > 0, + selectedEntities, + setSelectedEntities, } }, [ dispatchCreateDoc, @@ -215,6 +220,8 @@ export function FileTreeDataProvider({ children }) { dispatchRename, fileCount, fileTreeData, + selectedEntities, + setSelectedEntities, ]) return ( diff --git a/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js b/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js index 376502bb1c..5cb5e6baec 100644 --- a/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js +++ b/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js @@ -8,6 +8,7 @@ import fs from 'fs' import path from 'path' import { expect } from 'chai' import { useCompileContext } from '../../../../../frontend/js/shared/context/compile-context' +import { useFileTreeData } from '../../../../../frontend/js/shared/context/file-tree-data-context' import { useEffect } from 'react' const examplePDF = path.join(__dirname, '../fixtures/test-example.pdf') @@ -84,6 +85,14 @@ const mockHighlights = [ }, ] +const mockPosition = { + page: 1, + offset: { top: 10, left: 10 }, + pageSize: { height: 500, width: 500 }, +} + +const mockSelectedEntities = [{ type: 'doc' }] + const mockSynctex = () => fetchMock .get('express:/project/:projectId/sync/code', () => { @@ -93,6 +102,27 @@ const mockSynctex = () => return { code: [{ file: 'main.tex', line: 100 }] } }) +const WithPosition = ({ mockPosition }) => { + const { setPosition } = useCompileContext() + + // mock PDF scroll position update + useEffect(() => { + setPosition(mockPosition) + }, [mockPosition, setPosition]) + + return null +} + +const WithSelectedEntities = ({ mockSelectedEntities = [] }) => { + const { setSelectedEntities } = useFileTreeData() + + useEffect(() => { + setSelectedEntities(mockSelectedEntities) + }, [mockSelectedEntities, setSelectedEntities]) + + return null +} + describe('', function () { beforeEach(function () { window.metaAttributesCache = new Map() @@ -108,24 +138,10 @@ describe('', function () { }) it('handles clicks on sync buttons', async function () { - const Inner = () => { - const { setPosition } = useCompileContext() - - // mock PDF scroll position update - useEffect(() => { - setPosition({ - page: 1, - offset: { top: 10, left: 10 }, - pageSize: { height: 500, width: 500 }, - }) - }, [setPosition]) - - return null - } - const { container } = renderWithEditorContext( <> - + + , { scope } @@ -170,6 +186,50 @@ describe('', function () { }) }) + it('disables button when multiple entities are selected', async function () { + renderWithEditorContext( + <> + + + + , + { scope } + ) + + const syncToPdfButton = await screen.findByRole('button', { + name: 'Go to code location in PDF', + }) + expect(syncToPdfButton.disabled).to.be.true + + const syncToCodeButton = await screen.findByRole('button', { + name: /Go to PDF location in code/, + }) + expect(syncToCodeButton.disabled).to.be.true + }) + + it('disables button when a file is selected', async function () { + renderWithEditorContext( + <> + + + + , + { scope } + ) + + const syncToPdfButton = await screen.findByRole('button', { + name: 'Go to code location in PDF', + }) + expect(syncToPdfButton.disabled).to.be.true + + const syncToCodeButton = await screen.findByRole('button', { + name: /Go to PDF location in code/, + }) + expect(syncToCodeButton.disabled).to.be.true + }) + describe('with detacher role', async function () { beforeEach(function () { window.metaAttributesCache.set('ol-detachRole', 'detacher') @@ -190,7 +250,15 @@ describe('', function () { }) it('send go to PDF location action', async function () { - renderWithEditorContext(, { scope }) + renderWithEditorContext( + <> + + + + , + { scope } + ) + sysendTestHelper.resetHistory() const syncToPdfButton = await screen.findByRole('button', { @@ -218,9 +286,14 @@ describe('', function () { }) it('update inflight state', async function () { - const { container } = renderWithEditorContext(, { - scope, - }) + const { container } = renderWithEditorContext( + <> + + + + , + { scope } + ) sysendTestHelper.resetHistory() const syncToPdfButton = await screen.findByRole('button', { @@ -277,9 +350,14 @@ describe('', function () { }) it('send go to code line action and update inflight state', async function () { - const { container } = renderWithEditorContext(, { - scope, - }) + const { container } = renderWithEditorContext( + <> + + + + , + { scope } + ) sysendTestHelper.resetHistory() const syncToCodeButton = await screen.findByRole('button', { @@ -313,7 +391,14 @@ describe('', function () { }) it('sends PDF exists state', async function () { - renderWithEditorContext(, { scope }) + renderWithEditorContext( + <> + + + + , + { scope } + ) sysendTestHelper.resetHistory() await waitFor(() => { @@ -328,7 +413,14 @@ describe('', function () { }) it('reacts to go to PDF location action', async function () { - renderWithEditorContext(, { scope }) + renderWithEditorContext( + <> + + + + , + { scope } + ) sysendTestHelper.resetHistory() await waitFor(() => {