From 4b9f74d5a8819a568809ff4192905943acba072e Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 16 Nov 2025 20:13:47 +0100 Subject: [PATCH] more pdfjs --- frontend/src/app/PanelManagerContext.tsx | 13 - frontend/src/detail/PreviewZoomOverlay.tsx | 2 +- frontend/src/preview/DocumentViewerLayout.tsx | 10 +- frontend/src/preview/DocumentViewerPanel.tsx | 2 +- frontend/src/preview/PdfViewer.tsx | 232 +++++++++++++----- frontend/src/styles/documents/viewer.css | 24 +- frontend/src/styles/preview/preview-zoom.css | 10 +- 7 files changed, 205 insertions(+), 88 deletions(-) diff --git a/frontend/src/app/PanelManagerContext.tsx b/frontend/src/app/PanelManagerContext.tsx index b197a7c..7706ca5 100644 --- a/frontend/src/app/PanelManagerContext.tsx +++ b/frontend/src/app/PanelManagerContext.tsx @@ -166,19 +166,6 @@ export const PanelManagerProvider: React.FC = ({ chil if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && effectiveDetailOpen) { closeDetailPanel(); } - - const normalizedAction = action === 'resized' - ? `${panel} resized to ${value}px` - : `${panel} ${action}`; - console.log(normalizedAction, { - sidebar: sidebarWidth > 0 ? `${sidebarWidth}px` : 'closed', - detail: `${detailWidth}px`, - freeSpace, - viewportWidth, - freeRatio, - minimalFreeRatio: MINIMAL_FREE_RATIO, - meetsThreshold, - }); }, [collapsed, closeDetailPanel, detailPanelOpen]); const collapseSidebar = useCallback(() => { diff --git a/frontend/src/detail/PreviewZoomOverlay.tsx b/frontend/src/detail/PreviewZoomOverlay.tsx index 7fa1f47..ff33c25 100644 --- a/frontend/src/detail/PreviewZoomOverlay.tsx +++ b/frontend/src/detail/PreviewZoomOverlay.tsx @@ -267,7 +267,6 @@ const PreviewZoomOverlay: React.FC = ({ }; const handleContentClick = (event: React.MouseEvent) => { - event.stopPropagation(); if (isPdfDisplay) { return; } @@ -347,6 +346,7 @@ const PreviewZoomOverlay: React.FC = ({ src={effectiveDisplay.url} title={effectiveDisplay.alt || 'Document preview'} className="preview-zoom__pdf-viewer" + viewportRef={scrollRef} /> ) : ( diff --git a/frontend/src/preview/DocumentViewerLayout.tsx b/frontend/src/preview/DocumentViewerLayout.tsx index f841b05..a66a915 100644 --- a/frontend/src/preview/DocumentViewerLayout.tsx +++ b/frontend/src/preview/DocumentViewerLayout.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import type { JSX } from 'react'; import DocumentInfoPanel from '../documents/DocumentInfoPanel'; import { DownloadIcon } from '../ui/icons'; @@ -62,6 +62,7 @@ const DocumentViewerLayout = ({ layoutMode = 'split', }: DocumentViewerLayoutProps): JSX.Element => { const isStacked = layoutMode === 'stacked'; + const viewportRef = useRef(null); const previewContent = useMemo(() => { if (!document || !previewEntry?.url) { @@ -95,6 +96,7 @@ const DocumentViewerLayout = ({ ); } @@ -123,17 +125,17 @@ const DocumentViewerLayout = ({ ); - }, [previewEntry, document]); + }, [document, previewEntry, viewportRef]); const renderViewportPane = useCallback(() => ( -
+
{!previewEntry?.url ? (
{previewLoadingMessage}
) : ( previewContent )}
- ), [previewEntry?.url, previewLoadingMessage, previewContent]); + ), [previewContent, previewEntry?.url, previewLoadingMessage, viewportRef]); const viewportPane = renderViewportPane(); diff --git a/frontend/src/preview/DocumentViewerPanel.tsx b/frontend/src/preview/DocumentViewerPanel.tsx index da525c6..1fd9faa 100644 --- a/frontend/src/preview/DocumentViewerPanel.tsx +++ b/frontend/src/preview/DocumentViewerPanel.tsx @@ -271,7 +271,7 @@ const DocumentViewerPanel: React.FC = ({ } }, [hydrateDocument, document?.id]); -const panelRef = useRef(null); + const panelRef = useRef(null); const isStackedLayout = useViewerLayoutMode(panelRef, document?.id); const { diff --git a/frontend/src/preview/PdfViewer.tsx b/frontend/src/preview/PdfViewer.tsx index 719e6ab..3bb2332 100644 --- a/frontend/src/preview/PdfViewer.tsx +++ b/frontend/src/preview/PdfViewer.tsx @@ -7,7 +7,7 @@ import React, { useRef, useState, } from 'react'; -import type { JSX, MutableRefObject, RefObject } from 'react'; +import type { CSSProperties, JSX, MutableRefObject, RefObject } from 'react'; import { GlobalWorkerOptions, getDocument, @@ -17,7 +17,6 @@ import type { PDFDocumentProxy, RenderTask, } from 'pdfjs-dist/types/src/display/api'; -import { TextLayerBuilder } from 'pdfjs-dist/web/pdf_viewer.mjs'; GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url, @@ -27,9 +26,11 @@ interface PdfViewerProps { src: string; title?: string; className?: string; + viewportRef?: RefObject; } type RenderStatus = 'idle' | 'loading' | 'ready' | 'error'; +type ViewMode = 'fit-width' | 'contain'; interface PageDescriptor { number: number; @@ -42,6 +43,24 @@ const MIN_RENDER_WIDTH = 1200; const MAX_RENDER_WIDTH = 2200; const RERENDER_DELTA = 48; +const getAvailableViewportSize = (viewportNode: Element, stackNode: Element) => { + const viewportStyle = window.getComputedStyle(viewportNode); + const viewportPaddingX = parseFloat(viewportStyle.paddingLeft || '0') + + parseFloat(viewportStyle.paddingRight || '0'); + const viewportPaddingY = parseFloat(viewportStyle.paddingTop || '0') + + parseFloat(viewportStyle.paddingBottom || '0'); + + const stackStyle = window.getComputedStyle(stackNode); + const stackPaddingX = parseFloat(stackStyle.paddingLeft || '0') + + parseFloat(stackStyle.paddingRight || '0'); + const stackPaddingY = parseFloat(stackStyle.paddingTop || '0') + + parseFloat(stackStyle.paddingBottom || '0'); + + const width = Math.max(0, viewportNode.clientWidth - viewportPaddingX - stackPaddingX); + const height = Math.max(0, viewportNode.clientHeight - viewportPaddingY - stackPaddingY); + return { width, height }; +}; + const resolvePdfWasmBaseUrl = (): string => { if (typeof document !== 'undefined') { const base = document.baseURI || (typeof window !== 'undefined' ? window.location.href : '/'); @@ -53,16 +72,25 @@ const resolvePdfWasmBaseUrl = (): string => { return '/pdfjs/wasm/'; }; -const PdfViewer = ({ src, title, className }: PdfViewerProps): JSX.Element => { +const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX.Element => { const containerRef = useRef(null); - const [viewportWidth, setViewportWidth] = useState(0); + const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); + const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight); const [renderWidth, setRenderWidth] = useState(0); const [status, setStatus] = useState('idle'); const [errorMessage, setErrorMessage] = useState(null); const [pages, setPages] = useState([]); const [pixelRatio, setPixelRatio] = useState(1); + const [viewMode, setViewMode] = useState('contain'); const pdfRef = useRef(null); const wasmUrlRef = useRef(null); + const focusTargetRef = useRef<{ + ratioX: number; + ratioY: number; + pointerOffsetX: number; + pointerOffsetY: number; + } | null>(null); + const focusRatioRef = useRef<{ x: number; y: number } | null>(null); const ensureWasmUrl = useCallback(() => { if (!wasmUrlRef.current) { wasmUrlRef.current = resolvePdfWasmBaseUrl(); @@ -71,45 +99,61 @@ const PdfViewer = ({ src, title, className }: PdfViewerProps): JSX.Element => { }, []); useLayoutEffect(() => { - const container = containerRef.current; - if (!container) { - return undefined; - } + let frameId: number | null = null; + let observer: ResizeObserver | null = null; - const updateWidth = () => { - const measurementTarget = ( - container.closest('.document-viewer__viewport') as HTMLElement | null - ) || container.parentElement || container; - const nextWidth = Math.max(1, Math.round(measurementTarget.clientWidth)); - setViewportWidth((prev) => (prev === nextWidth ? prev : nextWidth)); + const attach = () => { + const stackElement = containerRef.current; + const viewportElement = viewportRef?.current; + if (!stackElement || !viewportElement) { + frameId = requestAnimationFrame(attach); + return; + } + + const updateBounds = () => { + const { width, height } = getAvailableViewportSize(viewportElement, stackElement); + const nextWidth = Math.max(1, Math.round(width || 0)); + const nextHeight = Math.max(1, Math.round(height || 0)); + setViewportWidth((prev) => (prev === nextWidth ? prev : nextWidth)); + setViewportHeight((prev) => (prev === nextHeight ? prev : nextHeight)); + }; + + updateBounds(); + + observer = new ResizeObserver(() => { + updateBounds(); + }); + + const observedNodes = new Set(); + const observeNode = (node: Element | null) => { + if (!node || observedNodes.has(node)) { + return; + } + observer?.observe(node); + observedNodes.add(node); + }; + + observeNode(stackElement); + observeNode(stackElement.parentElement); + observeNode(viewportElement); + observeNode(stackElement.closest('.document-viewer__viewport')); }; - updateWidth(); + attach(); - const observer = new ResizeObserver(() => { - updateWidth(); - }); - observer.observe(container); - const parent = container.parentElement; - if (parent) { - observer.observe(parent); - } - const viewportNode = container.closest('.document-viewer__viewport') as HTMLElement | null; - if (viewportNode) { - observer.observe(viewportNode); - } - - return () => observer.disconnect(); - }, []); + return () => { + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + observer?.disconnect(); + }; + }, [viewportRef]); useEffect(() => { if (viewportWidth <= 0) { return; } - const desiredWidth = Math.min( - MAX_RENDER_WIDTH, - Math.max(viewportWidth, MIN_RENDER_WIDTH), - ); + const desiredWidth = Math.min(MAX_RENDER_WIDTH, Math.max(viewportWidth, MIN_RENDER_WIDTH)); setRenderWidth((current) => { if (current === 0 || desiredWidth > current + RERENDER_DELTA) { return desiredWidth; @@ -118,6 +162,31 @@ const PdfViewer = ({ src, title, className }: PdfViewerProps): JSX.Element => { }); }, [viewportWidth]); + useEffect(() => { + const focus = focusTargetRef.current; + if (!focus) { + return; + } + const viewportElement = viewportRef?.current || containerRef.current?.closest('.document-viewer__viewport'); + const stackElement = containerRef.current; + if (!viewportElement || !stackElement) { + focusTargetRef.current = null; + return; + } + const contentWidth = Math.max(1, stackElement.scrollWidth || stackElement.clientWidth); + const contentHeight = Math.max(1, stackElement.scrollHeight || stackElement.clientHeight); + const targetX = focus.ratioX * contentWidth; + const targetY = focus.ratioY * contentHeight; + const nextScrollLeft = Math.max(0, targetX - focus.pointerOffsetX); + const nextScrollTop = Math.max(0, targetY - focus.pointerOffsetY); + viewportElement.scrollTo({ + left: nextScrollLeft, + top: nextScrollTop, + behavior: 'auto', + }); + focusTargetRef.current = null; + }, [viewMode, viewportRef]); + useEffect(() => { if (!src || renderWidth <= 0) { return undefined; @@ -146,6 +215,7 @@ const PdfViewer = ({ src, title, className }: PdfViewerProps): JSX.Element => { setPages([]); setStatus('loading'); setErrorMessage(null); + console.log('[PdfViewer] rendering pages', { renderWidth, mode: viewMode, src }); const renderDocument = async () => { try { @@ -223,6 +293,36 @@ const PdfViewer = ({ src, title, className }: PdfViewerProps): JSX.Element => { .filter(Boolean) .join(' '); const showStatus = status !== 'ready' || pages.length === 0; + const stackStyle = useMemo(() => ({ + '--pdf-viewer-viewport-width': `${viewportWidth}px`, + '--pdf-viewer-viewport-height': `${viewportHeight}px`, + }), [viewportHeight, viewportWidth]); + + const toggleViewMode = useCallback(() => { + setViewMode((prev) => (prev === 'fit-width' ? 'contain' : 'fit-width')); + }, []); + + const handlePageClick = useCallback((event: React.MouseEvent) => { + const stack = containerRef.current; + const viewportElement = viewportRef?.current || stack?.closest('.document-viewer__viewport'); + if (stack && viewportElement) { + const viewportRect = viewportElement.getBoundingClientRect(); + const pointerOffsetX = event.clientX - viewportRect.left; + const pointerOffsetY = event.clientY - viewportRect.top; + const contentWidth = Math.max(1, stack.scrollWidth || stack.clientWidth); + const contentHeight = Math.max(1, stack.scrollHeight || stack.clientHeight); + const ratioX = (viewportElement.scrollLeft + pointerOffsetX) / contentWidth; + const ratioY = (viewportElement.scrollTop + pointerOffsetY) / contentHeight; + focusTargetRef.current = { + ratioX: Math.max(0, Math.min(1, ratioX)), + ratioY: Math.max(0, Math.min(1, ratioY)), + pointerOffsetX: Math.max(0, Math.min(viewportElement.clientWidth, pointerOffsetX)), + pointerOffsetY: Math.max(0, Math.min(viewportElement.clientHeight, pointerOffsetY)), + }; + } + event.stopPropagation(); + toggleViewMode(); + }, [toggleViewMode, viewportRef]); const pageElements = useMemo(() => ( pages.map((page) => ( @@ -232,26 +332,21 @@ const PdfViewer = ({ src, title, className }: PdfViewerProps): JSX.Element => { pdfRef={pdfRef} pixelRatio={pixelRatio} rootRef={containerRef} + viewportRef={viewportRef} + onPageClick={handlePageClick} /> )) - ), [pages, pixelRatio]); - - const handleCanvasStackClick = useCallback((event: React.MouseEvent) => { - const target = event.target as HTMLElement; - if (target.closest('.pdf-viewer__page-wrapper')) { - event.stopPropagation(); - } - }, []); + ), [handlePageClick, pages, pixelRatio, viewportRef]); return (
{pageElements}
@@ -271,6 +366,8 @@ interface PdfPageCanvasProps { pdfRef: MutableRefObject; pixelRatio: number; rootRef: RefObject; + viewportRef?: RefObject; + onPageClick: (event: React.MouseEvent) => void; } function PdfPageCanvas({ @@ -278,12 +375,13 @@ function PdfPageCanvas({ pdfRef, pixelRatio, rootRef, + viewportRef, + onPageClick, }: PdfPageCanvasProps): JSX.Element { const wrapperRef = useRef(null); const canvasRef = useRef(null); const renderTaskRef = useRef(null); const hasRenderedRef = useRef(false); - const textLayerBuilderRef = useRef(null); const renderPage = useCallback(async () => { if (renderTaskRef.current || hasRenderedRef.current) { @@ -311,14 +409,7 @@ function PdfPageCanvas({ const wrapper = wrapperRef.current; if (wrapper) { - textLayerBuilderRef.current?.cancel(); - const textLayerBuilder = new TextLayerBuilder({ - pdfPage: page, - }); - textLayerBuilderRef.current = textLayerBuilder; wrapper.querySelector('.textLayer')?.remove(); - wrapper.appendChild(textLayerBuilder.div); - await textLayerBuilder.render({ viewport }); } } catch (error) { console.error('Failed to render PDF page', descriptor.number, error); @@ -332,40 +423,55 @@ function PdfPageCanvas({ if (!canvas) { return undefined; } - canvas.style.width = '100%'; - canvas.style.height = 'auto'; + canvas.width = descriptor.width; + canvas.height = descriptor.height; canvas.style.setProperty('aspect-ratio', `${descriptor.width} / ${descriptor.height}`); const wrapperNode = wrapperRef.current; return () => { renderTaskRef.current?.cancel(); - textLayerBuilderRef.current?.cancel(); wrapperNode?.querySelector('.textLayer')?.remove(); }; }, [descriptor.height, descriptor.width]); useEffect(() => { - const node = canvasRef.current; - if (!node) { + const target = wrapperRef.current; + if (!target) { return undefined; } - const root = rootRef.current || undefined; + const root = viewportRef?.current + || rootRef.current?.closest?.('.document-viewer__viewport') as HTMLElement | null + || undefined; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { - if (entry.isIntersecting) { - void renderPage(); + console.log('[PdfViewer] intersection', { + page: descriptor.number, + isIntersecting: entry.isIntersecting, + ratio: entry.intersectionRatio, + }); + if (!entry.isIntersecting || hasRenderedRef.current) { + return; } + console.log('[PdfViewer] rendering page', descriptor.number); + void renderPage(); }); }, { root, rootMargin: '200px 0px', threshold: 0.1, }); - observer.observe(node); + console.log('[PdfViewer] observing page', descriptor.number, { rootMargin: '200px 0px', threshold: 0.1 }); + observer.observe(target); return () => observer.disconnect(); - }, [renderPage, rootRef]); + }, [renderPage, rootRef, viewportRef]); return ( -
+
{ + onPageClick(event); + }} + >