Sfoglia il codice sorgente

Merge pull request #18435 from overleaf/dp-ae-pdf-viewer-controls

Update PDF viewer controls

GitOrigin-RevId: 4e15b7cbd34e878d0175be635369b8d620188203
David 2 anni fa
parent
commit
55e54ce875

+ 1 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -328,6 +328,7 @@ const _ProjectController = {
         'pdf-caching-mode',
         'pdf-caching-prefetch-large',
         'pdf-caching-prefetching',
+        'pdf-controls',
         'pdfjs-40',
         'personal-access-token',
         'revert-file',

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

@@ -808,6 +808,7 @@
   "newsletter": "",
   "newsletter_onboarding_accept": "",
   "next": "",
+  "next_page": "",
   "next_payment_of_x_collectected_on_y": "",
   "no_actions": "",
   "no_borders": "",
@@ -934,6 +935,7 @@
   "postal_code": "",
   "premium_feature": "",
   "premium_plan_label": "",
+  "previous_page": "",
   "price": "",
   "primarily_work_study_question": "",
   "primarily_work_study_question_company": "",
@@ -1529,6 +1531,7 @@
   "view_metrics_commons_subtext": "",
   "view_metrics_group_subtext": "",
   "view_more": "",
+  "view_options": "",
   "view_pdf": "",
   "view_your_invoices": "",
   "viewing_x": "",

+ 70 - 11
services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.jsx

@@ -2,6 +2,7 @@ import PropTypes from 'prop-types'
 import { memo, useCallback, useEffect, useRef, useState } from 'react'
 import { debounce, throttle } from 'lodash'
 import PdfViewerControls from './pdf-viewer-controls'
+import PdfViewerControlsToolbar from './pdf-viewer-controls-toolbar'
 import { useProjectContext } from '../../../shared/context/project-context'
 import usePersistedState from '../../../shared/hooks/use-persisted-state'
 import { buildHighlightElement } from '../util/highlights'
@@ -14,6 +15,7 @@ import * as eventTracking from '../../../infrastructure/event-tracking'
 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'
 
 function PdfJsViewer({ url, pdfFile }) {
   const { _id: projectId } = useProjectContext()
@@ -23,16 +25,34 @@ function PdfJsViewer({ url, pdfFile }) {
 
   const { setLoadingError } = usePdfPreviewContext()
 
+  const hasNewPdfToolbar = useFeatureFlag('pdf-controls')
+
   // state values persisted in localStorage to restore on load
   const [scale, setScale] = usePersistedState(
     `pdf-viewer-scale:${projectId}`,
     'page-width'
   )
 
+  // rawScale is different from scale as it is always a number.
+  // This is relevant when scale is e.g. 'page-width'.
+  const [rawScale, setRawScale] = useState(null)
+  const [page, setPage] = useState(null)
+  const [totalPages, setTotalPages] = useState(null)
+
   // local state values
   const [pdfJsWrapper, setPdfJsWrapper] = useState()
   const [initialised, setInitialised] = useState(false)
 
+  const handlePageChange = useCallback(
+    newPage => {
+      setPage(newPage)
+      if (pdfJsWrapper?.viewer) {
+        pdfJsWrapper.viewer.currentPageNumber = newPage
+      }
+    },
+    [pdfJsWrapper, setPage]
+  )
+
   // create the viewer when the container is mounted
   const handleContainer = useCallback(
     parent => {
@@ -109,13 +129,27 @@ function PdfJsViewer({ url, pdfFile }) {
       pdfJsWrapper.eventBus.off('pagerendered', handleRendered)
     }
 
+    const handleRenderedInitialPageNumber = () => {
+      setPage(pdfJsWrapper.viewer.currentPageNumber)
+    }
+
+    const handleScaleChanged = () => {
+      setRawScale(pdfJsWrapper.viewer.currentScale)
+    }
+
     // `pagesinit` fires when the data for rendering the first page is ready.
     pdfJsWrapper.eventBus.on('pagesinit', handlePagesinit)
     // `pagerendered` fires when a page was actually rendered.
     pdfJsWrapper.eventBus.on('pagerendered', handleRendered)
+    // Once a page has been rendered we know the scale that it has been rendered to.
+    pdfJsWrapper.eventBus.on('pagerendered', handleScaleChanged)
+    // Once a page has been rendered we can set the initial current page number.
+    pdfJsWrapper.eventBus.on('pagerendered', handleRenderedInitialPageNumber)
     return () => {
       pdfJsWrapper.eventBus.off('pagesinit', handlePagesinit)
       pdfJsWrapper.eventBus.off('pagerendered', handleRendered)
+      pdfJsWrapper.eventBus.off('pagerendered', handleScaleChanged)
+      pdfJsWrapper.eventBus.off('pagerendered', handleRenderedInitialPageNumber)
     }
   }, [pdfJsWrapper, firstRenderDone, startFetch])
 
@@ -138,6 +172,9 @@ function PdfJsViewer({ url, pdfFile }) {
       }
       pdfJsWrapper
         .loadDocument({ url, pdfFile, abortController, handleFetchError })
+        .then(doc => {
+          setTotalPages(doc.numPages)
+        })
         .catch(error => {
           if (abortController.signal.aborted) return
           debugConsole.error(error)
@@ -175,6 +212,7 @@ function PdfJsViewer({ url, pdfFile }) {
 
       const scrollListener = () => {
         storePosition(pdfJsWrapper)
+        setPage(pdfJsWrapper.viewer.currentPageNumber)
       }
 
       pdfJsWrapper.container.addEventListener('scroll', scrollListener)
@@ -233,7 +271,6 @@ function PdfJsViewer({ url, pdfFile }) {
     }
   }, [pdfJsWrapper])
 
-  // restore the saved scale and scroll position
   const positionRef = useRef(position)
   useEffect(() => {
     positionRef.current = position
@@ -244,6 +281,7 @@ function PdfJsViewer({ url, pdfFile }) {
     scaleRef.current = scale
   }, [scale])
 
+  // restore the saved scale and scroll position
   useEffect(() => {
     if (initialised && pdfJsWrapper) {
       if (!pdfJsWrapper.isVisible()) {
@@ -332,6 +370,8 @@ function PdfJsViewer({ url, pdfFile }) {
   const setZoom = useCallback(
     zoom => {
       switch (zoom) {
+        // TODO: We can remove fit-width and fit-height once the
+        // pdf toolbar is fully rolled out
         case 'fit-width':
           setScale('page-width')
           break
@@ -339,7 +379,6 @@ function PdfJsViewer({ url, pdfFile }) {
         case 'fit-height':
           setScale('page-height')
           break
-
         case 'zoom-in':
           if (pdfJsWrapper) {
             setScale(pdfJsWrapper.viewer.currentScale * 1.25)
@@ -351,6 +390,9 @@ function PdfJsViewer({ url, pdfFile }) {
             setScale(pdfJsWrapper.viewer.currentScale * 0.75)
           }
           break
+
+        default:
+          setScale(zoom)
       }
     },
     [pdfJsWrapper, setScale]
@@ -394,7 +436,7 @@ function PdfJsViewer({ url, pdfFile }) {
 
           case '0':
             event.preventDefault()
-            setZoom('fit-width')
+            setZoom('page-width')
             break
         }
       }
@@ -432,20 +474,37 @@ function PdfJsViewer({ url, pdfFile }) {
     }
   }, [pdfJsWrapper])
 
+  // Don't render the toolbar until we have the necessary information
+  const toolbarInfoLoaded =
+    rawScale !== null && page !== null && totalPages !== null
+
   /* eslint-disable jsx-a11y/no-noninteractive-tabindex */
   /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
   return (
-    <div className="pdfjs-viewer pdfjs-viewer-outer" ref={handleContainer}>
-      <div
-        className="pdfjs-viewer-inner"
-        role="tabpanel"
-        tabIndex="0"
-        onKeyDown={handleKeyDown}
-      >
+    <div
+      className="pdfjs-viewer pdfjs-viewer-outer"
+      ref={handleContainer}
+      onKeyDown={handleKeyDown}
+      role="tabpanel"
+      tabIndex={0}
+    >
+      <div className="pdfjs-viewer-inner">
         <div className="pdfViewer" />
       </div>
       <div className="pdfjs-controls" tabIndex="0">
-        <PdfViewerControls setZoom={setZoom} />
+        {hasNewPdfToolbar ? (
+          toolbarInfoLoaded && (
+            <PdfViewerControlsToolbar
+              setZoom={setZoom}
+              rawScale={rawScale}
+              setPage={handlePageChange}
+              page={page}
+              totalPages={totalPages}
+            />
+          )
+        ) : (
+          <PdfViewerControls setZoom={setZoom} />
+        )}
       </div>
     </div>
   )

+ 76 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-page-number-control.tsx

@@ -0,0 +1,76 @@
+import { ButtonGroup } from 'react-bootstrap'
+import PDFToolbarButton from './pdf-toolbar-button'
+import { useTranslation } from 'react-i18next'
+import { useState, useEffect } from 'react'
+
+type PdfPageNumberControlProps = {
+  setPage: (page: number) => void
+  page: number
+  totalPages: number
+}
+
+function PdfPageNumberControl({
+  setPage,
+  page,
+  totalPages,
+}: PdfPageNumberControlProps) {
+  const { t } = useTranslation()
+
+  const [pageInputValue, setPageInputValue] = useState(page.toString())
+
+  useEffect(() => {
+    setPageInputValue(page.toString())
+  }, [page])
+
+  const handleSubmit = (event: React.SyntheticEvent) => {
+    event.preventDefault()
+    const parsedValue = Number(pageInputValue)
+    if (parsedValue < 1) {
+      setPage(1)
+      setPageInputValue('1')
+    } else if (parsedValue > totalPages) {
+      setPage(totalPages)
+      setPageInputValue(`${totalPages}`)
+    } else {
+      setPage(parsedValue)
+    }
+  }
+
+  return (
+    <>
+      <ButtonGroup className="pdfjs-toolbar-buttons ">
+        <PDFToolbarButton
+          tooltipId="pdf-controls-previous-page-tooltip"
+          icon="keyboard_arrow_up"
+          label={t('previous_page')}
+          disabled={page === 1}
+          onClick={() => setPage(page - 1)}
+        />
+        <PDFToolbarButton
+          tooltipId="pdf-controls-next-page-tooltip"
+          icon="keyboard_arrow_down"
+          label={t('next_page')}
+          disabled={page === totalPages}
+          onClick={() => setPage(page + 1)}
+        />
+      </ButtonGroup>
+      <div className="pdfjs-page-number-input">
+        <form onSubmit={handleSubmit}>
+          <input
+            inputMode="numeric"
+            value={pageInputValue}
+            onFocus={event => event.target.select()}
+            onBlur={handleSubmit}
+            onChange={event => {
+              const rawValue = event.target.value
+              setPageInputValue(rawValue.replace(/\D/g, ''))
+            }}
+          />
+        </form>
+        <span>/ {totalPages}</span>
+      </div>
+    </>
+  )
+}
+
+export default PdfPageNumberControl

+ 6 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.jsx

@@ -9,6 +9,7 @@ import PdfHybridDownloadButton from './pdf-hybrid-download-button'
 import PdfHybridCodeCheckButton from './pdf-hybrid-code-check-button'
 import PdfOrphanRefreshButton from './pdf-orphan-refresh-button'
 import { DetachedSynctexControl } from './detach-synctex-control'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 import Icon from '../../../shared/components/icon'
 
 const ORPHAN_UI_TIMEOUT_MS = 5000
@@ -54,6 +55,8 @@ function PdfPreviewHybridToolbar() {
 }
 
 function PdfPreviewHybridToolbarInner() {
+  const hasNewPdfToolbar = useFeatureFlag('pdf-controls')
+
   return (
     <>
       <div className="toolbar-pdf-left">
@@ -62,6 +65,9 @@ function PdfPreviewHybridToolbarInner() {
         <PdfHybridDownloadButton />
       </div>
       <div className="toolbar-pdf-right">
+        {hasNewPdfToolbar && (
+          <div className="toolbar-pdf-controls" id="toolbar-pdf-controls" />
+        )}
         <PdfHybridCodeCheckButton />
         <SwitchToEditorButton />
         <DetachedSynctexControl />

+ 45 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-toolbar-button.tsx

@@ -0,0 +1,45 @@
+import Button from 'react-bootstrap/lib/Button'
+import MaterialIcon from '@/shared/components/material-icon'
+import Tooltip from '@/shared/components/tooltip'
+
+type PDFToolbarButtonProps = {
+  tooltipId: string
+  icon: string
+  label: string
+  onClick: () => void
+  shortcut?: string
+  disabled?: boolean
+}
+
+export default function PDFToolbarButton({
+  tooltipId,
+  disabled,
+  label,
+  icon,
+  onClick,
+  shortcut,
+}: PDFToolbarButtonProps) {
+  return (
+    <Tooltip
+      id={tooltipId}
+      description={
+        <>
+          <div>{label}</div>
+          {shortcut && <div>{shortcut}</div>}
+        </>
+      }
+      overlayProps={{ placement: 'bottom' }}
+    >
+      <Button
+        aria-label={label}
+        bsSize="large"
+        bsStyle={null}
+        className="pdfjs-toolbar-button"
+        disabled={disabled}
+        onClick={onClick}
+      >
+        <MaterialIcon type={icon} />
+      </Button>
+    </Tooltip>
+  )
+}

+ 76 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-viewer-controls-menu-button.tsx

@@ -0,0 +1,76 @@
+import { useRef } from 'react'
+
+import PdfPageNumberControl from './pdf-page-number-control'
+import PdfZoomButtons from './pdf-zoom-buttons'
+import { Button, Overlay, Popover } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import MaterialIcon from '@/shared/components/material-icon'
+import Tooltip from '@/shared/components/tooltip'
+import useDropdown from '@/shared/hooks/use-dropdown'
+
+type PdfViewerControlsMenuButtonProps = {
+  setZoom: (zoom: string) => void
+  setPage: (page: number) => void
+  page: number
+  totalPages: number
+}
+
+export default function PdfViewerControlsMenuButton({
+  setZoom,
+  setPage,
+  page,
+  totalPages,
+}: PdfViewerControlsMenuButtonProps) {
+  const { t } = useTranslation()
+
+  const {
+    open: popoverOpen,
+    onToggle: togglePopover,
+    ref: popoverRef,
+  } = useDropdown()
+
+  const targetRef = useRef<any>(null)
+
+  return (
+    <>
+      <Tooltip
+        id="pdf-controls-menu-tooltip"
+        description={t('view_options')}
+        overlayProps={{ placement: 'bottom' }}
+      >
+        <Button
+          className="pdfjs-toolbar-popover-button"
+          onClick={togglePopover}
+          ref={targetRef}
+        >
+          <MaterialIcon type="more_horiz" />
+        </Button>
+      </Tooltip>
+
+      <Overlay
+        show={popoverOpen}
+        target={targetRef.current}
+        placement="bottom"
+        containerPadding={0}
+        animation
+        rootClose
+        onHide={() => togglePopover(false)}
+      >
+        <Popover
+          className="pdfjs-toolbar-popover"
+          id="pdf-toolbar-popover-menu"
+          ref={popoverRef}
+        >
+          <PdfPageNumberControl
+            setPage={setPage}
+            page={page}
+            totalPages={totalPages}
+          />
+          <div className="pdfjs-zoom-controls">
+            <PdfZoomButtons setZoom={setZoom} />
+          </div>
+        </Popover>
+      </Overlay>
+    </>
+  )
+}

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

@@ -0,0 +1,112 @@
+import { memo, useCallback, useState } from 'react'
+import { createPortal } from 'react-dom'
+import PdfPageNumberControl from './pdf-page-number-control'
+import PdfZoomButtons from './pdf-zoom-buttons'
+import PdfZoomDropdown from './pdf-zoom-dropdown'
+
+import { useResizeObserver } from '@/shared/hooks/use-resize-observer'
+import PdfViewerControlsMenuButton from './pdf-viewer-controls-menu-button'
+
+type PdfViewerControlsToolbarProps = {
+  setZoom: (zoom: string) => void
+  rawScale: number
+  setPage: (page: number) => void
+  page: number
+  totalPages: number
+}
+
+function PdfViewerControlsToolbar({
+  setZoom,
+  rawScale,
+  setPage,
+  page,
+  totalPages,
+}: PdfViewerControlsToolbarProps) {
+  const toolbarControlsElement = document.querySelector('#toolbar-pdf-controls')
+
+  const [availableWidth, setAvailableWidth] = useState<number>(1000)
+
+  const handleResize = useCallback(
+    element => {
+      setAvailableWidth(element.offsetWidth)
+    },
+    [setAvailableWidth]
+  )
+
+  const { elementRef: pdfControlsRef } = useResizeObserver(handleResize)
+
+  if (!toolbarControlsElement) {
+    return null
+  }
+
+  const InnerControlsComponent =
+    availableWidth >= 300
+      ? PdfViewerControlsToolbarFull
+      : PdfViewerControlsToolbarSmall
+
+  return createPortal(
+    <div className="pdfjs-viewer-controls" ref={pdfControlsRef}>
+      <InnerControlsComponent
+        setZoom={setZoom}
+        rawScale={rawScale}
+        setPage={setPage}
+        page={page}
+        totalPages={totalPages}
+      />
+    </div>,
+
+    toolbarControlsElement
+  )
+}
+
+type InnerControlsProps = {
+  setZoom: (zoom: string) => void
+  rawScale: number
+  setPage: (page: number) => void
+  page: number
+  totalPages: number
+}
+
+function PdfViewerControlsToolbarFull({
+  setZoom,
+  rawScale,
+  setPage,
+  page,
+  totalPages,
+}: InnerControlsProps) {
+  return (
+    <>
+      <PdfPageNumberControl
+        setPage={setPage}
+        page={page}
+        totalPages={totalPages}
+      />
+      <div className="pdfjs-zoom-controls">
+        <PdfZoomButtons setZoom={setZoom} />
+        <PdfZoomDropdown rawScale={rawScale} setZoom={setZoom} />
+      </div>
+    </>
+  )
+}
+
+function PdfViewerControlsToolbarSmall({
+  setZoom,
+  rawScale,
+  setPage,
+  page,
+  totalPages,
+}: InnerControlsProps) {
+  return (
+    <div className="pdfjs-viewer-controls-small">
+      <PdfZoomDropdown rawScale={rawScale} setZoom={setZoom} />
+      <PdfViewerControlsMenuButton
+        setZoom={setZoom}
+        setPage={setPage}
+        page={page}
+        totalPages={totalPages}
+      />
+    </div>
+  )
+}
+
+export default memo(PdfViewerControlsToolbar)

+ 37 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-zoom-buttons.tsx

@@ -0,0 +1,37 @@
+import { ButtonGroup } from 'react-bootstrap'
+import PDFToolbarButton from './pdf-toolbar-button'
+import { useTranslation } from 'react-i18next'
+
+const isMac = /Mac/.test(window.navigator?.platform)
+
+type PdfZoomButtonsProps = {
+  setZoom: (zoom: string) => void
+}
+
+function PdfZoomButtons({ setZoom }: PdfZoomButtonsProps) {
+  const { t } = useTranslation()
+
+  const zoomInShortcut = isMac ? '⌘+' : 'Ctrl++'
+  const zoomOutShortcut = isMac ? '⌘-' : 'Ctrl+-'
+
+  return (
+    <ButtonGroup className="pdfjs-toolbar-buttons">
+      <PDFToolbarButton
+        tooltipId="pdf-controls-zoom-out-tooltip"
+        label={t('zoom_out')}
+        icon="remove"
+        onClick={() => setZoom('zoom-out')}
+        shortcut={zoomOutShortcut}
+      />
+      <PDFToolbarButton
+        tooltipId="pdf-controls-zoom-in-tooltip"
+        label={t('zoom_in')}
+        icon="add"
+        onClick={() => setZoom('zoom-in')}
+        shortcut={zoomInShortcut}
+      />
+    </ButtonGroup>
+  )
+}
+
+export default PdfZoomButtons

+ 133 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx

@@ -0,0 +1,133 @@
+import { Dropdown, MenuItem } from 'react-bootstrap'
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import ControlledDropdown from '@/shared/components/controlled-dropdown'
+import classNames from 'classnames'
+
+const isMac = /Mac/.test(window.navigator?.platform)
+
+const shortcuts = isMac
+  ? {
+      'zoom-in': ['⌘', '+'],
+      'zoom-out': ['⌘', '-'],
+      'fit-to-width': ['⌘', '0'],
+    }
+  : {
+      'zoom-in': ['Ctrl', '+'],
+      'zoom-out': ['Ctrl', '-'],
+      'fit-to-width': ['Ctrl', '0'],
+    }
+
+type PdfZoomDropdownProps = {
+  setZoom: (zoom: string) => void
+  rawScale: number
+}
+
+const zoomValues = ['0.5', '0.75', '1', '1.5', '2', '4']
+
+const rawScaleToPercentage = (rawScale: number) => {
+  return `${Math.round(rawScale * 100)}%`
+}
+
+function PdfZoomDropdown({ setZoom, rawScale }: PdfZoomDropdownProps) {
+  const { t } = useTranslation()
+
+  const [customZoomValue, setCustomZoomValue] = useState<string>(
+    rawScaleToPercentage(rawScale)
+  )
+
+  useEffect(() => {
+    setCustomZoomValue(rawScaleToPercentage(rawScale))
+  }, [rawScale])
+
+  return (
+    <ControlledDropdown
+      id="pdf-zoom-dropdown"
+      onSelect={eventKey => {
+        if (eventKey !== 'custom-zoom') setZoom(eventKey)
+      }}
+      pullRight
+    >
+      <Dropdown.Toggle
+        className="btn pdfjs-zoom-dropdown-button small"
+        value={rawScale}
+        title={rawScaleToPercentage(rawScale)}
+      />
+      <Dropdown.Menu className="pdfjs-zoom-dropdown-menu">
+        <MenuItem
+          draggable={false}
+          disabled
+          className="pdfjs-custom-zoom-menu-item"
+          key="custom-zoom"
+          eventKey="custom-zoom"
+        >
+          <input
+            type="text"
+            onFocus={event => event.target.select()}
+            value={customZoomValue}
+            onKeyDown={event => {
+              if (event.key === 'Enter') {
+                const zoom = Number(customZoomValue.replace('%', '')) / 100
+
+                // Only allow zoom values between 10% and 999%
+                if (zoom < 0.1) {
+                  setZoom('0.1')
+                } else if (zoom > 9.99) {
+                  setZoom('9.99')
+                } else {
+                  setZoom(`${zoom}`)
+                }
+              }
+            }}
+            onChange={event => {
+              const rawValue = event.target.value
+              const parsedValue = rawValue.replace(/[^0-9%]/g, '')
+              setCustomZoomValue(parsedValue)
+            }}
+          />
+        </MenuItem>
+        <MenuItem divider />
+        <MenuItem draggable={false} key="zoom-in" eventKey="zoom-in">
+          <span>{t('zoom_in')}</span>
+          <Shortcut keys={shortcuts['zoom-in']} />
+        </MenuItem>
+        <MenuItem draggable={false} key="zoom-out" eventKey="zoom-out">
+          <span>{t('zoom_out')}</span>
+          <Shortcut keys={shortcuts['zoom-out']} />
+        </MenuItem>
+        <MenuItem draggable={false} key="page-width" eventKey="page-width">
+          {t('fit_to_width')}
+          <Shortcut keys={shortcuts['fit-to-width']} />
+        </MenuItem>
+        <MenuItem draggable={false} key="page-height" eventKey="page-height">
+          {t('fit_to_height')}
+        </MenuItem>
+        <MenuItem divider />
+        {zoomValues.map(value => (
+          <MenuItem draggable={false} key={value} eventKey={value}>
+            {rawScaleToPercentage(Number(value))}
+          </MenuItem>
+        ))}
+      </Dropdown.Menu>
+    </ControlledDropdown>
+  )
+}
+
+function Shortcut({ keys }: { keys: string[] }) {
+  return (
+    <span className="pull-right">
+      {keys.map((key, idx) => (
+        <span
+          className={classNames({
+            'pdfjs-zoom-dropdown-mac-shortcut-char': key.length === 1,
+          })}
+          key={`${key}${idx}`}
+        >
+          {key}
+        </span>
+      ))}
+    </span>
+  )
+}
+
+export default PdfZoomDropdown

+ 160 - 38
services/web/frontend/stylesheets/app/editor/pdf.less

@@ -17,15 +17,26 @@
 
 .toolbar-pdf-orphan,
 .toolbar-pdf-left,
-.toolbar-pdf-right {
+.toolbar-pdf-right,
+.toolbar-pdf-controls {
   display: flex;
   align-items: center;
   align-self: stretch;
+}
+
+.toolbar-pdf-orphan,
+.toolbar-pdf-controls {
   flex: 1 1 100%;
 }
 
+.toolbar-pdf-controls {
+  margin-right: 4px;
+  justify-content: flex-end;
+}
+
 .toolbar-pdf-right {
-  flex: 1 0 auto;
+  flex: 1;
+  justify-content: flex-end;
 }
 
 .toolbar-pdf-orphan {
@@ -203,52 +214,163 @@
       background-color: @link-color;
     }
   }
-  .pdfjs-controls {
-    position: absolute;
-    padding: @line-height-computed / 2;
-    top: 0;
-    left: 0;
-    display: inline-block;
-    z-index: 10; // above the PDF viewer
+}
 
+// TODO: remove this block once the new pdfjs toolbar is fully rolled out
+.pdfjs-controls {
+  position: absolute;
+  padding: @line-height-computed / 2;
+  top: 0;
+  left: 0;
+  display: inline-block;
+  z-index: 10; // above the PDF viewer
+
+  .btn-group {
+    transition:
+      opacity 0.5s ease,
+      visibility 0 linear 0.5s;
+    visibility: hidden;
+    opacity: 0;
+  }
+
+  &:focus-within,
+  &:hover {
+    // make .pdfjs-controls and its children visible when it or any of its descendants are focused
     .btn-group {
-      transition:
-        opacity 0.5s ease,
-        visibility 0 linear 0.5s;
-      visibility: hidden;
-      opacity: 0;
-    }
-
-    &:focus-within,
-    &:hover {
-      // make .pdfjs-controls and its children visible when it or any of its descendants are focused
-      .btn-group {
-        transition: none;
-        visibility: visible;
-        opacity: 1;
-      }
+      transition: none;
+      visibility: visible;
+      opacity: 1;
     }
+  }
 
-    &:hover,
-    &.flash {
-      .btn-group {
-        transition: none;
-        visibility: visible;
-        opacity: 1;
-      }
+  &:hover,
+  &.flash {
+    .btn-group {
+      transition: none;
+      visibility: visible;
+      opacity: 1;
     }
+  }
 
-    i.fa-arrows-h {
-      border-right: 2px solid @content-primary;
-      border-left: 2px solid @content-primary;
-    }
-    i.fa-arrows-v {
-      border-top: 2px solid @content-primary;
-      border-bottom: 2px solid @content-primary;
+  i.fa-arrows-h {
+    border-right: 2px solid @content-primary;
+    border-left: 2px solid @content-primary;
+  }
+  i.fa-arrows-v {
+    border-top: 2px solid @content-primary;
+    border-bottom: 2px solid @content-primary;
+  }
+}
+
+.pdfjs-viewer-controls {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  width: 100%;
+}
+
+.pdfjs-zoom-controls {
+  border-left: 1px solid rgba(125, 125, 125, 0.3);
+}
+
+.pdfjs-toolbar-buttons {
+  display: flex;
+  gap: 8px;
+  margin-left: 8px;
+  margin-right: 8px;
+}
+
+.pdfjs-toolbar-button {
+  padding: 2px !important;
+  display: flex;
+  align-items: center;
+
+  &:hover {
+    color: @toolbar-btn-color;
+  }
+}
+
+.pdfjs-zoom-dropdown-button {
+  width: 60px;
+  text-align: right;
+  font-size: 14px;
+  font-weight: normal;
+
+  .caret {
+    margin-left: 4px;
+  }
+}
+
+.pdfjs-zoom-dropdown-mac-shortcut-char {
+  display: inline-block;
+  width: 1em;
+  text-align: center;
+}
+
+.pdfjs-custom-zoom-menu-item {
+  a:hover {
+    background-color: initial !important;
+    color: initial !important;
+    cursor: initial !important;
+  }
+
+  &.disabled {
+    a {
+      color: initial !important;
     }
   }
 }
 
+.pdfjs-page-number-input {
+  color: @toolbar-btn-color;
+  font-size: 14px;
+  padding: 8px 8px 8px 0;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+
+  input {
+    color: initial;
+    border: 1px solid @neutral-60;
+    width: 32px;
+    height: 24px;
+    border-radius: @border-radius-base;
+    text-align: center;
+  }
+}
+
+.pdfjs-viewer-controls-small {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.pdfjs-toolbar-popover-button {
+  padding: 2px !important;
+  display: flex;
+  align-items: center;
+}
+
+.pdfjs-toolbar-popover {
+  background-color: @editor-toolbar-bg;
+  border-radius: 4px;
+
+  .arrow {
+    display: none;
+  }
+
+  button {
+    background-color: transparent;
+    color: @toolbar-btn-color;
+  }
+
+  .popover-content {
+    display: flex;
+    align-items: center;
+    padding: 0;
+  }
+}
+
 // The new viewer UI has overflow on the inner element,
 // so disable the overflow on the outer element
 .pdf-viewer .pdfjs-viewer.pdfjs-viewer-outer {

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

@@ -1181,6 +1181,7 @@
   "newsletter_info_unsubscribed": "You are currently <0>unsubscribed</0> to the __appName__ newsletter.",
   "newsletter_onboarding_accept": "I’d like emails about product offers and company news and events.",
   "next": "Next",
+  "next_page": "Next page",
   "next_payment_of_x_collectected_on_y": "The next payment of <0>__paymentAmmount__</0> will be collected on <1>__collectionDate__</1>.",
   "nl": "Dutch",
   "no": "Norwegian",
@@ -1380,6 +1381,7 @@
   "premium_plan_label": "You’re using <b>Overleaf Premium</b>",
   "presentation": "Presentation",
   "press_and_awards": "Press &amp; awards",
+  "previous_page": "Previous page",
   "price": "Price",
   "primarily_work_study_question": "Where do you primarily work or study?",
   "primarily_work_study_question_company": "Company",
@@ -2136,6 +2138,7 @@
   "view_metrics_commons_subtext": "Monitor and download usage metrics for your Commons subscription",
   "view_metrics_group_subtext": "Monitor and download usage metrics for your group subscription",
   "view_more": "View more",
+  "view_options": "View options",
   "view_pdf": "View PDF",
   "view_source": "View Source",
   "view_your_invoices": "View Your Invoices",