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;
}