diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 11a2cbdb70..a9db3a1344 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -84,7 +84,6 @@ const httpPermissionsPolicy = { 'camera', 'display-capture', 'encrypted-media', - 'fullscreen', 'gamepad', 'geolocation', 'gyroscope', @@ -107,6 +106,7 @@ const httpPermissionsPolicy = { ], allowed: { autoplay: 'self "https://videos.ctfassets.net"', + fullscreen: 'self', }, } diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index bb58d3a1fc..363f49811c 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -935,6 +935,7 @@ "postal_code": "", "premium_feature": "", "premium_plan_label": "", + "present": "", "previous_page": "", "price": "", "primarily_work_study_question": "", @@ -1635,6 +1636,7 @@ "youve_unlinked_all_users": "", "zoom_in": "", "zoom_out": "", + "zoom_to": "", "zotero_cta": "", "zotero_groups_loading_error": "", "zotero_groups_relink": "", diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx index 14925739a3..216c47c6d9 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx @@ -15,6 +15,7 @@ import { getPdfCachingMetrics } from '../util/metrics' import { debugConsole } from '@/utils/debugging' import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider' import { useFeatureFlag } from '@/shared/context/split-test-context' +import usePresentationMode from '../hooks/use-presentation-mode' type PdfJsViewerProps = { url: string @@ -48,13 +49,17 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) { const [initialised, setInitialised] = useState(false) const handlePageChange = useCallback( - newPage => { + (newPage: number) => { + if (!totalPages || newPage < 1 || newPage > totalPages) { + return + } + setPage(newPage) if (pdfJsWrapper?.viewer) { pdfJsWrapper.viewer.currentPageNumber = newPage } }, - [pdfJsWrapper, setPage] + [pdfJsWrapper, setPage, totalPages] ) // create the viewer when the container is mounted @@ -461,6 +466,14 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) { [initialised, setZoom] ) + const requestPresentationMode = usePresentationMode( + pdfJsWrapper, + page, + handlePageChange, + scale, + setScale + ) + // Don't render the toolbar until we have the necessary information const toolbarInfoLoaded = rawScale !== null && page !== null && totalPages !== null @@ -487,6 +500,7 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) { {hasNewPdfToolbar ? ( toolbarInfoLoaded && ( void setZoom: (zoom: string) => void rawScale: number setPage: (page: number) => void @@ -17,6 +18,7 @@ type PdfViewerControlsToolbarProps = { } function PdfViewerControlsToolbar({ + requestPresentationMode, setZoom, rawScale, setPage, @@ -54,6 +56,7 @@ function PdfViewerControlsToolbar({ return createPortal(
void setZoom: (zoom: string) => void rawScale: number setPage: (page: number) => void @@ -75,6 +79,7 @@ type InnerControlsProps = { } function PdfViewerControlsToolbarFull({ + requestPresentationMode, setZoom, rawScale, setPage, @@ -90,13 +95,18 @@ function PdfViewerControlsToolbarFull({ />
- +
) } function PdfViewerControlsToolbarSmall({ + requestPresentationMode, setZoom, rawScale, setPage, @@ -105,7 +115,11 @@ function PdfViewerControlsToolbarSmall({ }: InnerControlsProps) { return (
- + void setZoom: (zoom: string) => void rawScale: number } @@ -29,7 +30,11 @@ const rawScaleToPercentage = (rawScale: number) => { return `${Math.round(rawScale * 100)}%` } -function PdfZoomDropdown({ setZoom, rawScale }: PdfZoomDropdownProps) { +function PdfZoomDropdown({ + requestPresentationMode, + setZoom, + rawScale, +}: PdfZoomDropdownProps) { const { t } = useTranslation() const [customZoomValue, setCustomZoomValue] = useState( @@ -44,7 +49,16 @@ function PdfZoomDropdown({ setZoom, rawScale }: PdfZoomDropdownProps) { { - if (eventKey !== 'custom-zoom') setZoom(eventKey) + if (eventKey === 'custom-zoom') { + return + } + + if (eventKey === 'present') { + requestPresentationMode() + return + } + + setZoom(eventKey) }} pullRight > @@ -102,7 +116,14 @@ function PdfZoomDropdown({ setZoom, rawScale }: PdfZoomDropdownProps) { {t('fit_to_height')} + {document.fullscreenEnabled && } + {document.fullscreenEnabled && ( + + {t('present')} + + )} + {t('zoom_to')} {zoomValues.map(value => ( {rawScaleToPercentage(Number(value))} diff --git a/services/web/frontend/js/features/pdf-preview/hooks/use-presentation-mode.ts b/services/web/frontend/js/features/pdf-preview/hooks/use-presentation-mode.ts new file mode 100644 index 0000000000..e2cb859292 --- /dev/null +++ b/services/web/frontend/js/features/pdf-preview/hooks/use-presentation-mode.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import PDFJSWrapper from '../util/pdf-js-wrapper' + +type StoredPDFState = { + scrollMode?: number + spreadMode?: number + currentScaleValue?: string +} + +export default function usePresentationMode( + pdfJsWrapper: PDFJSWrapper | null | undefined, + page: number | null, + handlePageChange: (page: number) => void, + scale: string, + setScale: (scale: string) => void +): () => void { + const storedState = useRef({}) + + const [presentationMode, setPresentationMode] = useState(false) + + const arrowKeyListener = useCallback( + event => { + if (page !== null) { + switch (event.key) { + case 'ArrowLeft': + case 'ArrowUp': + handlePageChange(page - 1) + break + + case 'ArrowRight': + case 'ArrowDown': + handlePageChange(page + 1) + break + + case ' ': + if (event.shiftKey) { + handlePageChange(page - 1) + } else { + handlePageChange(page + 1) + } + break + } + } + }, + [page, handlePageChange] + ) + + useEffect(() => { + if (presentationMode) { + window.addEventListener('keydown', arrowKeyListener) + + return () => { + window.removeEventListener('keydown', arrowKeyListener) + } + } + }, [presentationMode, arrowKeyListener]) + + const requestPresentationMode = useCallback(() => { + if (pdfJsWrapper) { + pdfJsWrapper.container.parentNode.requestFullscreen() + } + }, [pdfJsWrapper]) + + const handleEnterFullscreen = useCallback(() => { + if (pdfJsWrapper) { + storedState.current.scrollMode = pdfJsWrapper.viewer.scrollMode + storedState.current.spreadMode = pdfJsWrapper.viewer.spreadMode + storedState.current.currentScaleValue = scale + + setScale('page-fit') + pdfJsWrapper.viewer.scrollMode = 3 // page + pdfJsWrapper.viewer.spreadMode = 0 // none + + setPresentationMode(true) + } + }, [pdfJsWrapper, setScale, scale]) + + const handleExitFullscreen = useCallback(() => { + if (pdfJsWrapper) { + pdfJsWrapper.viewer.scrollMode = storedState.current.scrollMode + pdfJsWrapper.viewer.spreadMode = storedState.current.spreadMode + + if (storedState.current.currentScaleValue !== undefined) { + setScale(storedState.current.currentScaleValue) + } + + setPresentationMode(false) + } + }, [pdfJsWrapper, setScale]) + + const handleFullscreenChange = useCallback(() => { + if (pdfJsWrapper) { + const fullscreen = + document.fullscreenElement === pdfJsWrapper.container.parentNode + + if (fullscreen) { + handleEnterFullscreen() + } else { + handleExitFullscreen() + } + } + }, [pdfJsWrapper, handleEnterFullscreen, handleExitFullscreen]) + + useEffect(() => { + window.addEventListener('fullscreenchange', handleFullscreenChange) + return () => { + window.removeEventListener('fullscreenchange', handleFullscreenChange) + } + }, [handleFullscreenChange]) + + return requestPresentationMode +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index b7f50eabd1..72b49074b3 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1383,6 +1383,7 @@ "premium_feature": "Premium feature", "premium_features": "Premium features", "premium_plan_label": "You’re using Overleaf Premium", + "present": "Present", "presentation": "Presentation", "press_and_awards": "Press & awards", "previous_page": "Previous page", @@ -2276,6 +2277,7 @@ "zip_contents_too_large": "Zip contents too large", "zoom_in": "Zoom in", "zoom_out": "Zoom out", + "zoom_to": "Zoom to", "zotero": "Zotero", "zotero_and_mendeley_integrations": "<0>Zotero and <0>Mendeley integrations", "zotero_cta": "Get Zotero integration", diff --git a/services/web/test/acceptance/src/HttpPermissionsPolicyTests.js b/services/web/test/acceptance/src/HttpPermissionsPolicyTests.js index 563fe6b974..7d8f22bf92 100644 --- a/services/web/test/acceptance/src/HttpPermissionsPolicyTests.js +++ b/services/web/test/acceptance/src/HttpPermissionsPolicyTests.js @@ -9,7 +9,7 @@ describe('HttpPermissionsPolicy', function () { const response = await fetch(BASE_URL) expect(response.headers.get('permissions-policy')).to.equal( - 'accelerometer=(), attribution-reporting=(), browsing-topics=(), camera=(), display-capture=(), encrypted-media=(), fullscreen=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), identity-credentials-get=(), idle-detection=(), local-fonts=(), magnetometer=(), microphone=(), midi=(), otp-credentials=(), payment=(), picture-in-picture=(), screen-wake-lock=(), serial=(), storage-access=(), usb=(), window-management=(), xr-spatial-tracking=(), autoplay=(self "https://videos.ctfassets.net")' + 'accelerometer=(), attribution-reporting=(), browsing-topics=(), camera=(), display-capture=(), encrypted-media=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), identity-credentials-get=(), idle-detection=(), local-fonts=(), magnetometer=(), microphone=(), midi=(), otp-credentials=(), payment=(), picture-in-picture=(), screen-wake-lock=(), serial=(), storage-access=(), usb=(), window-management=(), xr-spatial-tracking=(), autoplay=(self "https://videos.ctfassets.net"), fullscreen=(self)' ) })