From 3636ca7f095d5cf124ed429940f2de3cc6b7654f Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Mon, 14 Sep 2026 12:04:52 +0200 Subject: [PATCH] Add single-page view to the PDF viewer Readers used to other PDF tools can show one page fitted to the pane and flip pages with the mouse wheel. --- src/components/HelpDialog.tsx | 1 + src/components/PdfViewer.tsx | 80 ++++++++++++++++++++++++++++++++--- src/styles/index.css | 5 +++ 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 4cc66de..4d52d07 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -264,6 +264,7 @@ function annotateHelp(): { lead: ReactNode; sections: HelpSection[] } { Read the PDF in the middle pane; its text is selectable. Use{' '} {MOD}+F to search within it. After following an internal link (e.g. a reference), the ↩ / ↪ buttons jump back to where you were and forward again. + The ▯ button switches to a single-page view, where the mouse wheel flips pages.
  • Annotate on the right. Repeatable entries show + Add and a diff --git a/src/components/PdfViewer.tsx b/src/components/PdfViewer.tsx index b9ffc70..9783046 100644 --- a/src/components/PdfViewer.tsx +++ b/src/components/PdfViewer.tsx @@ -8,6 +8,7 @@ import { useStore, selectCurrentPaper, PDF_ZOOM_MIN, PDF_ZOOM_MAX } from '../sta import { MARK_COLORS, sortMarksForCycling, type MarkRect, type PdfMark } from '../model/pdfMarks' import { detectEntryBox } from '../model/refPreview' import { getPlatform } from '../platform' +import { safeGet, safeSet } from '../state/settings' // Side-effect import: configures the pdf.js worker. import '../platform/pdfjs' @@ -183,6 +184,8 @@ export function destinationPoint(dest: unknown[]): { x: number | null; y: number /** On-screen size cap (CSS px) for the internal-link hover preview. The crop * is scaled down, never clipped, so a wide reference entry stays whole. */ +const SINGLE_PAGE_KEY = 'slr.pdf.singlePage' + const LINK_PREVIEW_MAX_W = 560 const LINK_PREVIEW_MAX_H = 240 @@ -349,6 +352,13 @@ export function PdfViewer() { const [currentPage, setCurrentPage] = useState(1) const [pageInput, setPageInput] = useState('1') const [width, setWidth] = useState(600) + const [height, setHeight] = useState(800) + // Single-page view: the page is fitted to the pane and the wheel flips + // pages, like a reader's "fit page" mode. Pages stay mounted (search, marks + // and link jumps keep working); a large gap just keeps neighbours out of view. + const [singlePage, setSinglePage] = useState(() => safeGet(SINGLE_PAGE_KEY) === '1') + /** Width/height of page 1, for fitting a whole page into the pane. */ + const [pageAspect, setPageAspect] = useState(1 / Math.SQRT2) const containerRef = useRef(null) const pageRefs = useRef<(HTMLDivElement | null)[]>([]) const pageInputRef = useRef(null) @@ -410,7 +420,7 @@ export function PdfViewer() { const zoomOut = useStore((s) => s.zoomOutPdf) const resetZoom = useStore((s) => s.resetPdfZoom) // The page renders at the fit-to-width base size scaled by the zoom factor. - const renderWidth = Math.round(width * zoom) + const renderWidth = Math.round((singlePage ? Math.min(width, height * pageAspect) : width) * zoom) // Resolve the PDF source only when the paper identity or its pdf path changes. useEffect(() => { @@ -512,6 +522,7 @@ export function PdfViewer() { const ro = new ResizeObserver((entries) => { const w = entries[0]?.contentRect.width ?? 600 setWidth(Math.max(240, Math.floor(w - 24))) + setHeight(Math.max(240, Math.floor((entries[0]?.contentRect.height ?? 800) - 8))) }) ro.observe(el) return () => ro.disconnect() @@ -573,7 +584,24 @@ export function PdfViewer() { if (count === 0) return const clamped = Math.min(Math.max(1, n), count) setCurrentPage(clamped) - pageRefs.current[clamped - 1]?.scrollIntoView({ block: 'start' }) + const pageEl = pageRefs.current[clamped - 1] + const fits = pageEl && containerRef.current && pageEl.offsetHeight <= containerRef.current.clientHeight + pageEl?.scrollIntoView({ block: singlePage && fits ? 'center' : 'start' }) + } + + const currentPageRef = useRef(currentPage) + currentPageRef.current = currentPage + const scrollToPageRef = useRef(scrollToPage) + scrollToPageRef.current = scrollToPage + + const toggleSinglePage = () => { + const page = currentPage + setSinglePage((on) => { + safeSet(SINGLE_PAGE_KEY, on ? '0' : '1') + return !on + }) + // The layout changes size; land back on the page being read once it has. + requestAnimationFrame(() => requestAnimationFrame(() => scrollToPageRef.current(page))) } // Jump to the remembered page/offset once this paper's pages mount, and @@ -1153,15 +1181,36 @@ export function PdfViewer() { useEffect(() => { const root = containerRef.current if (!root) return + let lastWheel = 0 const onWheel = (e: WheelEvent) => { - if (!e.ctrlKey && !e.metaKey) return + if (e.ctrlKey || e.metaKey) { + e.preventDefault() + if (e.deltaY < 0) zoomIn() + else if (e.deltaY > 0) zoomOut() + return + } + if (!singlePage || e.deltaY === 0) return + // A page taller than the pane (zoomed in) scrolls natively until its + // edge is reached; only then does the wheel flip. + const pageEl = pageRefs.current[currentPageRef.current - 1] + if (pageEl) { + const page = pageEl.getBoundingClientRect() + const view = root.getBoundingClientRect() + if (e.deltaY > 0 ? page.bottom > view.bottom + 1 : page.top < view.top - 1) return + } e.preventDefault() - if (e.deltaY < 0) zoomIn() - else if (e.deltaY > 0) zoomOut() + // A mouse wheel notch is one large delta: one flip each. A trackpad + // gesture (inertia included) is a stream of small deltas: one flip per + // gesture, which ends after a short pause. + const notch = e.deltaMode !== WheelEvent.DOM_DELTA_PIXEL || Math.abs(e.deltaY) >= 50 + const quiet = e.timeStamp - lastWheel > (notch ? 80 : 200) + lastWheel = e.timeStamp + if (!quiet) return + scrollToPageRef.current(currentPageRef.current + Math.sign(e.deltaY)) } root.addEventListener('wheel', onWheel, { passive: false }) return () => root.removeEventListener('wheel', onWheel) - }, [zoomIn, zoomOut]) + }, [zoomIn, zoomOut, singlePage]) // Clear highlights when the viewer unmounts. useEffect(() => clearHighlights, []) @@ -1472,6 +1521,16 @@ export function PdfViewer() { + + {searchOpen && ( @@ -1595,7 +1654,7 @@ export function PdfViewer() { )}
    MAX_PDF_PAGES ? doc.numPages : 0) pdfDocRef.current = doc // for resolving link destinations on hover + Promise.resolve() + .then(() => doc.getPage(1)) + .then((p) => { + const vp = p.getViewport({ scale: 1 }) + setPageAspect(vp.width / vp.height) + }) + .catch(() => {}) // keep the A4 default }} onLoadError={(err) => setError(String(err?.message ?? err))} loading={
    Loading PDF…
    } diff --git a/src/styles/index.css b/src/styles/index.css index 51e17cf..e5e6c71 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -961,6 +961,11 @@ button.paper-search-case:hover { gap: 12px; background: var(--bg); } +/* Single-page view: neighbouring pages are pushed a full pane away so only + the current one is visible (see `singlePage` in PdfViewer). */ +.pdf-scroll.single-page .react-pdf__Page { + margin-bottom: 100vh; +} .pdf-scroll.placing-note { cursor: crosshair; }