From a8589a864a8fae2a9c7c074b4f0ee83a5780edbf Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 11 Nov 2025 14:33:29 +0100 Subject: [PATCH] unify detailpanel and documentviewer --- frontend/src/desktop/createDesktopSurface.js | 11 +- frontend/src/detail/DetailPanel.jsx | 367 ------------------ frontend/src/detail/useDetailWorkspace.js | 2 +- .../documents/panel/createDocumentsSurface.js | 11 +- frontend/src/preview/DocumentViewerPanel.jsx | 112 +----- frontend/src/preview/useViewerLayoutMode.js | 99 +++++ frontend/src/styles/detail/detail-panels.css | 2 +- frontend/src/styles/documents/viewer.css | 15 +- 8 files changed, 139 insertions(+), 480 deletions(-) delete mode 100644 frontend/src/detail/DetailPanel.jsx create mode 100644 frontend/src/preview/useViewerLayoutMode.js diff --git a/frontend/src/desktop/createDesktopSurface.js b/frontend/src/desktop/createDesktopSurface.js index 1018c94..067b525 100644 --- a/frontend/src/desktop/createDesktopSurface.js +++ b/frontend/src/desktop/createDesktopSurface.js @@ -2,7 +2,7 @@ import React from 'react'; import SelectionFloatingActions from '../documents/SelectionFloatingActions'; import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel'; import createWorkspaceSurfaceConfig from '../documents/workspaceHeader'; -import DetailPanel from '../detail/DetailPanel'; +import DocumentViewerPanel from '../preview/DocumentViewerPanel'; import DesktopWorkspace from './DesktopWorkspace'; const createDesktopSurface = ({ @@ -82,7 +82,14 @@ const createDesktopSurface = ({ : null; const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; - const detail = detailOpen && detailProps ? : null; + const detail = detailOpen && detailProps + ? ( + + ) + : null; const surfaceConfig = createWorkspaceSurfaceConfig({ key: 'workspace', variant: 'workspace', diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx deleted file mode 100644 index 73fdee5..0000000 --- a/frontend/src/detail/DetailPanel.jsx +++ /dev/null @@ -1,367 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - DownloadIcon, - DetailPanelCollapseIcon, - WindowMaximizeIcon, - IconZoomInArea, -} from '../ui/icons'; -import PanelHeader from '../ui/PanelHeader'; -import { resolveDocumentAssetUrl } from '../asset_manager'; -import { describeDocumentSummary } from '../documents/documentSummary'; -import { createDocumentActionState } from '../documents/documentActions'; -import { sortCorrespondents, buildCorrespondentOptions } from '../documents/DocumentSummarySection'; -import BreadcrumbTrail from '../ui/BreadcrumbTrail'; -import { extractDocumentMetadataPayload } from '../documents/documentMetadata'; -import DocumentViewerLayout from '../preview/DocumentViewerLayout'; -import PreviewZoomOverlay from './PreviewZoomOverlay'; -import { useAssetNavigator } from '../hooks/useAssetNavigator'; - -const DetailPanel = ({ - document = null, - tags = [], - tagLookupById = new Map(), - onTagAdd, - onTagRemove, - onOpenPreview, - onUpdateTitle = async () => false, - onUpdateIssued = async () => false, - ensureAssetUrl = null, - getDocumentAsset = () => null, - ensurePreviewData = () => Promise.resolve(), - correspondents = [], - onCorrespondentAdd, - onCorrespondentRemove, - resolveApiPath, - onFolderNavigate = null, - resolveFolderPath = null, - previewEntry = null, - onClose = () => {}, -}) => { - const singleDoc = document || null; - const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false); - const previewNavigator = useAssetNavigator({ - document: singleDoc, - assetType: 'preview', - ensureAssetUrl, - getAsset: getDocumentAsset, - prefetch: 3, - }); - const navigatorUrl = previewNavigator?.currentUrl; - const navigatorCanGoPrev = Boolean(previewNavigator?.canGoPrev); - const navigatorCanGoNext = Boolean(previewNavigator?.canGoNext); - const navigatorGoPrev = previewNavigator?.goPrev; - const navigatorGoNext = previewNavigator?.goNext; - - const { downloadHref: singleDownloadHref } = useMemo( - () => - createDocumentActionState({ - document: singleDoc, - resolveApiPath, - ensurePreviewData, - ensureAssetUrl, - getDocumentAsset, - }), - [singleDoc, resolveApiPath, ensurePreviewData, ensureAssetUrl, getDocumentAsset], - ); - - const detailSummary = useMemo(() => describeDocumentSummary(singleDoc), [singleDoc]); - - const headerTitle = singleDoc ? detailSummary.title : 'Document details'; - - const headerBreadcrumbs = useMemo(() => { - if (!singleDoc || typeof resolveFolderPath !== 'function') { - return null; - } - - const handleNavigate = (folderId) => { - if (!folderId || typeof onFolderNavigate !== 'function') { - return; - } - onFolderNavigate(folderId); - }; - - const folderSegments = resolveFolderPath(singleDoc.folder_id); - const normalizedSegments = Array.isArray(folderSegments) - ? folderSegments - .filter((segment) => segment && segment.id && segment.name) - .map((segment) => ({ - id: segment.id, - label: segment.name, - onClick: segment.id ? () => handleNavigate(segment.id) : null, - })) - : []; - - return [ - ...normalizedSegments, - { - id: singleDoc.id || 'current-document', - label: detailSummary.title, - }, - ]; - }, [singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]); - - const correspondentOptions = useMemo( - () => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []), - [correspondents], - ); - - const singleCorrespondents = useMemo(() => { - if (!singleDoc) return []; - return sortCorrespondents(singleDoc.correspondents || []); - }, [singleDoc]); - - useEffect(() => { - if (typeof ensurePreviewData === 'function' && singleDoc?.id) { - ensurePreviewData(singleDoc.id); - } - }, [ensurePreviewData, singleDoc?.id]); - - const singleSummaryProps = useMemo( - () => ({ - tagLookupById, - tagOptions: tags, - onTagAdd: (doc, value, extras) => onTagAdd(doc, value, extras), - onTagRemove: (docId, tagId) => onTagRemove(docId, tagId), - correspondents: singleCorrespondents, - correspondentOptions, - onCorrespondentAdd, - onCorrespondentRemove, - onUpdateTitle, - onUpdateIssued, - }), - [ - tagLookupById, - tags, - onTagAdd, - onTagRemove, - singleCorrespondents, - correspondentOptions, - onCorrespondentAdd, - onCorrespondentRemove, - onUpdateTitle, - onUpdateIssued, - ], - ); - - const singleMetadataPayload = useMemo( - () => extractDocumentMetadataPayload(singleDoc), - [singleDoc], - ); - - const singleHasOcr = useMemo(() => { - if (!singleDoc || typeof getDocumentAsset !== 'function') { - return false; - } - return Boolean(getDocumentAsset(singleDoc, 'ocr-text')); - }, [singleDoc, getDocumentAsset]); - - const loadSingleOcrContent = useCallback(async ({ signal } = {}) => { - if (!singleDoc || !singleHasOcr || typeof getDocumentAsset !== 'function') { - return ''; - } - - const updateUrl = () => - resolveDocumentAssetUrl(singleDoc, 'ocr-text', { - ensureAssetUrl, - getAsset: getDocumentAsset, - }); - - const asset = getDocumentAsset(singleDoc, 'ocr-text'); - let url = updateUrl(); - - if (!url && singleDoc.id && asset?.id && typeof ensureAssetUrl === 'function') { - await ensureAssetUrl(singleDoc.id, asset, { start: 1, limit: 1 }); - if (signal?.aborted) { - throw new DOMException('Aborted', 'AbortError'); - } - url = updateUrl(); - } - - if (!url) { - return ''; - } - - const response = await fetch(url, { - method: 'GET', - mode: 'cors', - credentials: 'omit', - signal, - }); - - if (!response.ok) { - throw new Error(`Unexpected status: ${response.status}`); - } - - return response.text(); - }, [singleDoc, singleHasOcr, getDocumentAsset, ensureAssetUrl]); - - const singleContentConfig = useMemo( - () => ({ - enabled: singleHasOcr, - id: 'content', - label: 'Content', - loadContent: loadSingleOcrContent, - loadingMessage: 'Loading OCR content…', - emptyMessage: 'No OCR content available.', - unavailableMessage: 'No OCR content available.', - errorMessage: 'Failed to load OCR content.', - }), - [singleHasOcr, loadSingleOcrContent], - ); - - const renderContent = () => { - if (!singleDoc) { - return

Select a document to view metadata, tags and actions.

; - } - - return ( -
-
-
- -
-
-
- ); - }; - - const headerLeading = [ - ( - - ), - ]; - - if (singleDoc) { - headerLeading.push( - , - ); - } - - const headerActions = []; - - if (singleDoc && singleDownloadHref) { - headerActions.push( - event.stopPropagation()} - > - - , - ); - } - - const zoomDisplay = useMemo(() => { - if (navigatorUrl && singleDoc) { - return { - url: navigatorUrl, - alt: singleDoc.title, - canGoPrev: navigatorCanGoPrev, - canGoNext: navigatorCanGoNext, - goPrev: navigatorCanGoPrev ? navigatorGoPrev : undefined, - goNext: navigatorCanGoNext ? navigatorGoNext : undefined, - }; - } - if (singleDoc && previewEntry?.url) { - return { - url: previewEntry.url, - alt: singleDoc.title, - canGoPrev: false, - canGoNext: false, - }; - } - return null; - }, [ - navigatorUrl, - navigatorCanGoPrev, - navigatorCanGoNext, - navigatorGoPrev, - navigatorGoNext, - singleDoc, - previewEntry?.url, - ]); - - if (singleDoc && zoomDisplay) { - headerActions.push( - , - ); - } - - useEffect(() => { - setZoomOverlayOpen(false); - }, [previewEntry?.url, singleDoc?.id]); - - return ( - <> - - setZoomOverlayOpen(false)} - /> - - ); -}; - -export default DetailPanel; diff --git a/frontend/src/detail/useDetailWorkspace.js b/frontend/src/detail/useDetailWorkspace.js index c4c6c66..e5212ef 100644 --- a/frontend/src/detail/useDetailWorkspace.js +++ b/frontend/src/detail/useDetailWorkspace.js @@ -237,7 +237,7 @@ const useDetailWorkspace = ({ onUpdateIssued: handleDocumentIssuedUpdate, ensureAssetUrl, getDocumentAsset, - ensurePreviewData, + hydrateDocument: ensurePreviewData, correspondents, onCorrespondentAdd: handleCorrespondentAdd, onCorrespondentRemove: handleCorrespondentRemove, diff --git a/frontend/src/documents/panel/createDocumentsSurface.js b/frontend/src/documents/panel/createDocumentsSurface.js index 1124db1..e14f2ea 100644 --- a/frontend/src/documents/panel/createDocumentsSurface.js +++ b/frontend/src/documents/panel/createDocumentsSurface.js @@ -1,5 +1,5 @@ import React from 'react'; -import DetailPanel from '../../detail/DetailPanel'; +import DocumentViewerPanel from '../../preview/DocumentViewerPanel'; import SelectionFloatingActions from '../SelectionFloatingActions'; import createWorkspaceSurfaceConfig from '../workspaceHeader'; import DocumentsPanel from './DocumentsPanel'; @@ -91,7 +91,14 @@ const createDocumentsSurface = ({ : null; const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; - const detail = detailOpen && detailProps ? : null; + const detail = detailOpen && detailProps + ? ( + + ) + : null; return createWorkspaceSurfaceConfig({ key: 'documents', diff --git a/frontend/src/preview/DocumentViewerPanel.jsx b/frontend/src/preview/DocumentViewerPanel.jsx index f72e7ed..74d02d9 100644 --- a/frontend/src/preview/DocumentViewerPanel.jsx +++ b/frontend/src/preview/DocumentViewerPanel.jsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -20,46 +19,7 @@ import PanelHeader from '../ui/PanelHeader'; import BreadcrumbTrail from '../ui/BreadcrumbTrail'; import { useAssetNavigator } from '../hooks/useAssetNavigator'; import DocumentViewerLayout from './DocumentViewerLayout'; - -const PORTRAIT_WIDTH_TO_HEIGHT = 1 / Math.sqrt(2); // ≈0.707 (A-series aspect ratio) -const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.4; -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; -}; +import useViewerLayoutMode from './useViewerLayoutMode'; export const createDocumentViewerHeaderActions = ({ document, @@ -226,8 +186,6 @@ const DocumentViewerPanel = ({ [hasOcr, loadOcrContent], ); - const viewerRef = useRef(null); - const [isStackedLayout, setIsStackedLayout] = useState(false); const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false); const previewNavigator = useAssetNavigator({ document, @@ -263,60 +221,8 @@ const DocumentViewerPanel = ({ } }, [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 panelRef = useRef(null); + const isStackedLayout = useViewerLayoutMode(panelRef, document?.id); const viewerClassName = isStackedLayout ? 'document-viewer document-viewer--stacked' @@ -450,15 +356,13 @@ const DocumentViewerPanel = ({ if (!document) { return ( -
+
-
+
-
- Loading document… -
+
Loading document…
@@ -482,7 +386,7 @@ const DocumentViewerPanel = ({ return ( <> -
+
-
+
{ + 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; + } +}; + +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 + + return portraitViewportWidth + detailsColumnWidth + gutterAllowance; +}; + +export const useViewerLayoutMode = (ref, dependency) => { + const [isStacked, setIsStacked] = useState(false); + + useLayoutEffect(() => { + ensurePortraitRatioStyle(); + }, []); + + useLayoutEffect(() => { + const node = ref?.current; + if (!node) { + setIsStacked(false); + return undefined; + } + + let frame = null; + const commitMeasure = (width) => { + if (frame) { + cancelAnimationFrame(frame); + } + frame = requestAnimationFrame(() => { + const breakpoint = computeStackedLayoutBreakpoint(); + setIsStacked(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); + } + }; + }, [ref, dependency]); + + return isStacked; +}; + +export default useViewerLayoutMode; diff --git a/frontend/src/styles/detail/detail-panels.css b/frontend/src/styles/detail/detail-panels.css index dd76a19..c30b43a 100644 --- a/frontend/src/styles/detail/detail-panels.css +++ b/frontend/src/styles/detail/detail-panels.css @@ -75,7 +75,7 @@ } .detail-panel__content .document-viewer-panel__body { - padding: 0.5rem 1rem; + padding: 0.5rem 0.5rem 0; } .detail-section__header { diff --git a/frontend/src/styles/documents/viewer.css b/frontend/src/styles/documents/viewer.css index f415d16..5cfccab 100644 --- a/frontend/src/styles/documents/viewer.css +++ b/frontend/src/styles/documents/viewer.css @@ -14,6 +14,15 @@ overflow: auto; } +.document-viewer-panel__body--stacked { + flex-direction: column; +} + +.document-viewer-panel__body--stacked .document-viewer, +.document-viewer-panel__body--stacked .document-viewer--stacked { + min-height: auto; +} + .document-viewer-panel__body > .document-viewer, .document-viewer-panel__body > .document-viewer--loading { flex: 1; @@ -27,14 +36,14 @@ align-items: stretch; gap: 1rem; min-height: 0; - padding: 1rem 1rem; + padding: 0.25rem 1rem 1rem; } .document-viewer--stacked { display: flex; flex-direction: column; gap: 0.5rem; - padding: 0.5rem; + padding: 0.5rem 1rem 1rem; overflow: auto; } @@ -51,7 +60,7 @@ .document-viewer--stacked .document-viewer__details-pane { flex: 1 1 auto; - overflow: auto; + overflow: visible; order: 0; }