From 6ae497323ec683046e6722ce997bc83a5f4d9eea Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 11 Nov 2025 10:09:15 +0100 Subject: [PATCH] viewer --- frontend/src/app/useWorkspaceSurface.js | 2 - frontend/src/preview/DocumentViewerPanel.jsx | 163 ++++++++++++++++--- frontend/src/styles/documents/viewer.css | 46 +++++- 3 files changed, 182 insertions(+), 29 deletions(-) diff --git a/frontend/src/app/useWorkspaceSurface.js b/frontend/src/app/useWorkspaceSurface.js index 81fcb80..bb5d513 100644 --- a/frontend/src/app/useWorkspaceSurface.js +++ b/frontend/src/app/useWorkspaceSurface.js @@ -83,7 +83,6 @@ export const useWorkspaceSurface = ({ onFolderNavigate, } = detailExtras; return createDocumentViewerSurface({ - documentId: previewDocumentId, document: previewWorkspaceDocument, previewEntry: previewWorkspaceEntry, ensureAssetUrl, @@ -108,7 +107,6 @@ export const useWorkspaceSurface = ({ }, [ showPreviewWorkspace, previewWorkspaceDocument, - previewDocumentId, previewWorkspaceEntry, ensureAssetUrl, ensurePreviewData, diff --git a/frontend/src/preview/DocumentViewerPanel.jsx b/frontend/src/preview/DocumentViewerPanel.jsx index 245aee6..2c8cf73 100644 --- a/frontend/src/preview/DocumentViewerPanel.jsx +++ b/frontend/src/preview/DocumentViewerPanel.jsx @@ -1,4 +1,11 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { DownloadIcon, CloseIcon } from '../ui/icons'; import { buildCorrespondentOptions, @@ -9,10 +16,50 @@ import { extractDocumentMetadataPayload } from '../documents/documentMetadata'; import { createDocumentActionState } from '../documents/documentActions'; import { resolveDocumentAssetUrl } from '../asset_manager'; +const PORTRAIT_WIDTH_TO_HEIGHT = 1 / Math.sqrt(2); // ≈0.707 (A-series aspect ratio) +const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.6; +const PORTRAIT_RATIO_STYLE_ID = 'document-viewer-portrait-ratio-style'; + +const ensurePortraitRatioStyle = () => { + if (typeof document === 'undefined') { + return; + } + + const cssValue = String(DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO); + const cssText = `:root { --document-viewer-portrait-height-ratio: ${cssValue}; }`; + + let styleEl = document.getElementById(PORTRAIT_RATIO_STYLE_ID); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = PORTRAIT_RATIO_STYLE_ID; + document.head.appendChild(styleEl); + } + + if (styleEl.textContent !== cssText) { + styleEl.textContent = cssText; + } +}; + +if (typeof document !== 'undefined') { + ensurePortraitRatioStyle(); +} + +const computeStackedLayoutBreakpoint = () => { + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 900; + const portraitViewportWidth = viewportHeight + * DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO + * PORTRAIT_WIDTH_TO_HEIGHT; + const detailsColumnWidth = 320; // px ~ 20rem for metadata & tabs + const gutterAllowance = 48; // padding + grid gap + + const desiredWidth = portraitViewportWidth + detailsColumnWidth + gutterAllowance; + return desiredWidth; +}; + const DocumentViewerPanel = ({ document, - documentId, previewEntry, + hydrateDocument, tagLookupById, tagOptions, onTagAdd, @@ -182,13 +229,81 @@ const DocumentViewerPanel = ({ [hasOcr, loadOcrContent], ); + const viewerRef = useRef(null); + const [isStackedLayout, setIsStackedLayout] = useState(false); + + useEffect(() => { + if (typeof hydrateDocument === 'function' && document?.id) { + hydrateDocument(document.id); + } + }, [hydrateDocument, document?.id]); + + useLayoutEffect(() => { + ensurePortraitRatioStyle(); + }, []); + + useLayoutEffect(() => { + const node = viewerRef.current; + if (!node) { + setIsStackedLayout(false); + return undefined; + } + + let frame = null; + const commitMeasure = (width) => { + if (frame) { + cancelAnimationFrame(frame); + } + frame = requestAnimationFrame(() => { + const breakpoint = computeStackedLayoutBreakpoint(); + setIsStackedLayout(width < breakpoint); + }); + }; + + const measure = () => { + commitMeasure(node.getBoundingClientRect().width); + }; + + measure(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measure); + return () => { + if (frame) { + cancelAnimationFrame(frame); + } + window.removeEventListener('resize', measure); + }; + } + + const observer = new ResizeObserver((entries) => { + if (!entries.length) { + return; + } + commitMeasure(entries[0].contentRect.width); + }); + + observer.observe(node); + + return () => { + observer.disconnect(); + if (frame) { + cancelAnimationFrame(frame); + } + }; + }, [document?.id]); + + const viewerClassName = isStackedLayout + ? 'document-viewer document-viewer--stacked' + : 'document-viewer'; + if (!document) { return ( -
+
- Loading document{documentId ? ` ${documentId}` : ''}… + Loading document…
@@ -200,7 +315,7 @@ const DocumentViewerPanel = ({ } return ( -
+
{ - if (!document || !actionState) { + if (!document) { return null; } - const { downloadHref } = actionState; + const downloadHref = actionState?.downloadHref || previewEntry?.url; + if (!downloadHref) { + return null; + } return ( <> @@ -257,7 +376,6 @@ export const createDocumentViewerHeaderActions = ({ }; export const createDocumentViewerSurface = ({ - documentId, document, previewEntry, ensureAssetUrl, @@ -278,11 +396,11 @@ export const createDocumentViewerSurface = ({ onUpdateIssued, resolveFolderPath, }) => { - if (!documentId && !document) { + if (!document) { return null; } - const title = document?.title || 'Document preview'; + const title = document.title || 'Document preview'; const closeButton = onClose ? (