Răsfoiți Sursa

Merge pull request #18895 from overleaf/dp-presentation-mode

Add pdf presentation mode

GitOrigin-RevId: e6ac1ae339e9690a733a110c6f0a33149e869dd6
David 2 ani în urmă
părinte
comite
dcb7944b05

+ 1 - 1
services/web/config/settings.defaults.js

@@ -84,7 +84,6 @@ const httpPermissionsPolicy = {
     'camera',
     'camera',
     'display-capture',
     'display-capture',
     'encrypted-media',
     'encrypted-media',
-    'fullscreen',
     'gamepad',
     'gamepad',
     'geolocation',
     'geolocation',
     'gyroscope',
     'gyroscope',
@@ -107,6 +106,7 @@ const httpPermissionsPolicy = {
   ],
   ],
   allowed: {
   allowed: {
     autoplay: 'self "https://videos.ctfassets.net"',
     autoplay: 'self "https://videos.ctfassets.net"',
+    fullscreen: 'self',
   },
   },
 }
 }
 
 

+ 2 - 0
services/web/frontend/extracted-translations.json

@@ -935,6 +935,7 @@
   "postal_code": "",
   "postal_code": "",
   "premium_feature": "",
   "premium_feature": "",
   "premium_plan_label": "",
   "premium_plan_label": "",
+  "present": "",
   "previous_page": "",
   "previous_page": "",
   "price": "",
   "price": "",
   "primarily_work_study_question": "",
   "primarily_work_study_question": "",
@@ -1635,6 +1636,7 @@
   "youve_unlinked_all_users": "",
   "youve_unlinked_all_users": "",
   "zoom_in": "",
   "zoom_in": "",
   "zoom_out": "",
   "zoom_out": "",
+  "zoom_to": "",
   "zotero_cta": "",
   "zotero_cta": "",
   "zotero_groups_loading_error": "",
   "zotero_groups_loading_error": "",
   "zotero_groups_relink": "",
   "zotero_groups_relink": "",

+ 16 - 2
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 { debugConsole } from '@/utils/debugging'
 import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider'
 import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider'
 import { useFeatureFlag } from '@/shared/context/split-test-context'
 import { useFeatureFlag } from '@/shared/context/split-test-context'
+import usePresentationMode from '../hooks/use-presentation-mode'
 
 
 type PdfJsViewerProps = {
 type PdfJsViewerProps = {
   url: string
   url: string
@@ -48,13 +49,17 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
   const [initialised, setInitialised] = useState(false)
   const [initialised, setInitialised] = useState(false)
 
 
   const handlePageChange = useCallback(
   const handlePageChange = useCallback(
-    newPage => {
+    (newPage: number) => {
+      if (!totalPages || newPage < 1 || newPage > totalPages) {
+        return
+      }
+
       setPage(newPage)
       setPage(newPage)
       if (pdfJsWrapper?.viewer) {
       if (pdfJsWrapper?.viewer) {
         pdfJsWrapper.viewer.currentPageNumber = newPage
         pdfJsWrapper.viewer.currentPageNumber = newPage
       }
       }
     },
     },
-    [pdfJsWrapper, setPage]
+    [pdfJsWrapper, setPage, totalPages]
   )
   )
 
 
   // create the viewer when the container is mounted
   // create the viewer when the container is mounted
@@ -461,6 +466,14 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
     [initialised, setZoom]
     [initialised, setZoom]
   )
   )
 
 
+  const requestPresentationMode = usePresentationMode(
+    pdfJsWrapper,
+    page,
+    handlePageChange,
+    scale,
+    setScale
+  )
+
   // Don't render the toolbar until we have the necessary information
   // Don't render the toolbar until we have the necessary information
   const toolbarInfoLoaded =
   const toolbarInfoLoaded =
     rawScale !== null && page !== null && totalPages !== null
     rawScale !== null && page !== null && totalPages !== null
@@ -487,6 +500,7 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
         {hasNewPdfToolbar ? (
         {hasNewPdfToolbar ? (
           toolbarInfoLoaded && (
           toolbarInfoLoaded && (
             <PdfViewerControlsToolbar
             <PdfViewerControlsToolbar
+              requestPresentationMode={requestPresentationMode}
               setZoom={setZoom}
               setZoom={setZoom}
               rawScale={rawScale}
               rawScale={rawScale}
               setPage={handlePageChange}
               setPage={handlePageChange}

+ 16 - 2
services/web/frontend/js/features/pdf-preview/components/pdf-viewer-controls-toolbar.tsx

@@ -9,6 +9,7 @@ import PdfViewerControlsMenuButton from './pdf-viewer-controls-menu-button'
 import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 
 
 type PdfViewerControlsToolbarProps = {
 type PdfViewerControlsToolbarProps = {
+  requestPresentationMode: () => void
   setZoom: (zoom: string) => void
   setZoom: (zoom: string) => void
   rawScale: number
   rawScale: number
   setPage: (page: number) => void
   setPage: (page: number) => void
@@ -17,6 +18,7 @@ type PdfViewerControlsToolbarProps = {
 }
 }
 
 
 function PdfViewerControlsToolbar({
 function PdfViewerControlsToolbar({
+  requestPresentationMode,
   setZoom,
   setZoom,
   rawScale,
   rawScale,
   setPage,
   setPage,
@@ -54,6 +56,7 @@ function PdfViewerControlsToolbar({
   return createPortal(
   return createPortal(
     <div className="pdfjs-viewer-controls" ref={pdfControlsRef}>
     <div className="pdfjs-viewer-controls" ref={pdfControlsRef}>
       <InnerControlsComponent
       <InnerControlsComponent
+        requestPresentationMode={requestPresentationMode}
         setZoom={setZoom}
         setZoom={setZoom}
         rawScale={rawScale}
         rawScale={rawScale}
         setPage={setPage}
         setPage={setPage}
@@ -67,6 +70,7 @@ function PdfViewerControlsToolbar({
 }
 }
 
 
 type InnerControlsProps = {
 type InnerControlsProps = {
+  requestPresentationMode: () => void
   setZoom: (zoom: string) => void
   setZoom: (zoom: string) => void
   rawScale: number
   rawScale: number
   setPage: (page: number) => void
   setPage: (page: number) => void
@@ -75,6 +79,7 @@ type InnerControlsProps = {
 }
 }
 
 
 function PdfViewerControlsToolbarFull({
 function PdfViewerControlsToolbarFull({
+  requestPresentationMode,
   setZoom,
   setZoom,
   rawScale,
   rawScale,
   setPage,
   setPage,
@@ -90,13 +95,18 @@ function PdfViewerControlsToolbarFull({
       />
       />
       <div className="pdfjs-zoom-controls">
       <div className="pdfjs-zoom-controls">
         <PdfZoomButtons setZoom={setZoom} />
         <PdfZoomButtons setZoom={setZoom} />
-        <PdfZoomDropdown rawScale={rawScale} setZoom={setZoom} />
+        <PdfZoomDropdown
+          requestPresentationMode={requestPresentationMode}
+          rawScale={rawScale}
+          setZoom={setZoom}
+        />
       </div>
       </div>
     </>
     </>
   )
   )
 }
 }
 
 
 function PdfViewerControlsToolbarSmall({
 function PdfViewerControlsToolbarSmall({
+  requestPresentationMode,
   setZoom,
   setZoom,
   rawScale,
   rawScale,
   setPage,
   setPage,
@@ -105,7 +115,11 @@ function PdfViewerControlsToolbarSmall({
 }: InnerControlsProps) {
 }: InnerControlsProps) {
   return (
   return (
     <div className="pdfjs-viewer-controls-small">
     <div className="pdfjs-viewer-controls-small">
-      <PdfZoomDropdown rawScale={rawScale} setZoom={setZoom} />
+      <PdfZoomDropdown
+        requestPresentationMode={requestPresentationMode}
+        rawScale={rawScale}
+        setZoom={setZoom}
+      />
       <PdfViewerControlsMenuButton
       <PdfViewerControlsMenuButton
         setZoom={setZoom}
         setZoom={setZoom}
         setPage={setPage}
         setPage={setPage}

+ 23 - 2
services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx

@@ -19,6 +19,7 @@ const shortcuts = isMac
     }
     }
 
 
 type PdfZoomDropdownProps = {
 type PdfZoomDropdownProps = {
+  requestPresentationMode: () => void
   setZoom: (zoom: string) => void
   setZoom: (zoom: string) => void
   rawScale: number
   rawScale: number
 }
 }
@@ -29,7 +30,11 @@ const rawScaleToPercentage = (rawScale: number) => {
   return `${Math.round(rawScale * 100)}%`
   return `${Math.round(rawScale * 100)}%`
 }
 }
 
 
-function PdfZoomDropdown({ setZoom, rawScale }: PdfZoomDropdownProps) {
+function PdfZoomDropdown({
+  requestPresentationMode,
+  setZoom,
+  rawScale,
+}: PdfZoomDropdownProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 
   const [customZoomValue, setCustomZoomValue] = useState<string>(
   const [customZoomValue, setCustomZoomValue] = useState<string>(
@@ -44,7 +49,16 @@ function PdfZoomDropdown({ setZoom, rawScale }: PdfZoomDropdownProps) {
     <ControlledDropdown
     <ControlledDropdown
       id="pdf-zoom-dropdown"
       id="pdf-zoom-dropdown"
       onSelect={eventKey => {
       onSelect={eventKey => {
-        if (eventKey !== 'custom-zoom') setZoom(eventKey)
+        if (eventKey === 'custom-zoom') {
+          return
+        }
+
+        if (eventKey === 'present') {
+          requestPresentationMode()
+          return
+        }
+
+        setZoom(eventKey)
       }}
       }}
       pullRight
       pullRight
     >
     >
@@ -102,7 +116,14 @@ function PdfZoomDropdown({ setZoom, rawScale }: PdfZoomDropdownProps) {
         <MenuItem draggable={false} key="page-height" eventKey="page-height">
         <MenuItem draggable={false} key="page-height" eventKey="page-height">
           {t('fit_to_height')}
           {t('fit_to_height')}
         </MenuItem>
         </MenuItem>
+        {document.fullscreenEnabled && <MenuItem divider />}
+        {document.fullscreenEnabled && (
+          <MenuItem draggable={false} key="present" eventKey="present">
+            {t('present')}
+          </MenuItem>
+        )}
         <MenuItem divider />
         <MenuItem divider />
+        <MenuItem header>{t('zoom_to')}</MenuItem>
         {zoomValues.map(value => (
         {zoomValues.map(value => (
           <MenuItem draggable={false} key={value} eventKey={value}>
           <MenuItem draggable={false} key={value} eventKey={value}>
             {rawScaleToPercentage(Number(value))}
             {rawScaleToPercentage(Number(value))}

+ 112 - 0
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<StoredPDFState>({})
+
+  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
+}

+ 2 - 0
services/web/locales/en.json

@@ -1383,6 +1383,7 @@
   "premium_feature": "Premium feature",
   "premium_feature": "Premium feature",
   "premium_features": "Premium features",
   "premium_features": "Premium features",
   "premium_plan_label": "You’re using <b>Overleaf Premium</b>",
   "premium_plan_label": "You’re using <b>Overleaf Premium</b>",
+  "present": "Present",
   "presentation": "Presentation",
   "presentation": "Presentation",
   "press_and_awards": "Press &amp; awards",
   "press_and_awards": "Press &amp; awards",
   "previous_page": "Previous page",
   "previous_page": "Previous page",
@@ -2276,6 +2277,7 @@
   "zip_contents_too_large": "Zip contents too large",
   "zip_contents_too_large": "Zip contents too large",
   "zoom_in": "Zoom in",
   "zoom_in": "Zoom in",
   "zoom_out": "Zoom out",
   "zoom_out": "Zoom out",
+  "zoom_to": "Zoom to",
   "zotero": "Zotero",
   "zotero": "Zotero",
   "zotero_and_mendeley_integrations": "<0>Zotero</0> and <0>Mendeley</0> integrations",
   "zotero_and_mendeley_integrations": "<0>Zotero</0> and <0>Mendeley</0> integrations",
   "zotero_cta": "Get Zotero integration",
   "zotero_cta": "Get Zotero integration",

+ 1 - 1
services/web/test/acceptance/src/HttpPermissionsPolicyTests.js

@@ -9,7 +9,7 @@ describe('HttpPermissionsPolicy', function () {
     const response = await fetch(BASE_URL)
     const response = await fetch(BASE_URL)
 
 
     expect(response.headers.get('permissions-policy')).to.equal(
     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)'
     )
     )
   })
   })