unify detailpanel and documentviewer

This commit is contained in:
2025-11-11 14:45:19 +01:00
parent 10f9f3be2f
commit a8589a864a
8 changed files with 139 additions and 480 deletions
+9 -2
View File
@@ -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 ? <DetailPanel {...detailProps} /> : null;
const detail = detailOpen && detailProps
? (
<DocumentViewerPanel
variant="sidebar"
{...detailProps}
/>
)
: null;
const surfaceConfig = createWorkspaceSurfaceConfig({
key: 'workspace',
variant: 'workspace',
-367
View File
@@ -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 <p className="meta">Select a document to view metadata, tags and actions.</p>;
}
return (
<section className="document-viewer-panel document-viewer-panel--stacked">
<div className="document-viewer-panel__body">
<section className="document-viewer document-viewer--stacked">
<DocumentViewerLayout
document={singleDoc}
previewEntry={previewEntry}
summaryProps={singleSummaryProps}
metadataPayload={singleMetadataPayload}
contentTabConfig={singleContentConfig}
previewLoadingMessage="Loading preview…"
/>
</section>
</div>
</section>
);
};
const headerLeading = [
(
<button
key="close"
type="button"
className="icon-button"
onClick={onClose}
aria-label="Close detail panel"
title="Close detail panel"
>
<DetailPanelCollapseIcon />
</button>
),
];
if (singleDoc) {
headerLeading.push(
<button
key="preview"
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
onOpenPreview(singleDoc.id);
}}
aria-label="Maximize"
title="Maximize"
>
<WindowMaximizeIcon className="icon--flip-y" />
</button>,
);
}
const headerActions = [];
if (singleDoc && singleDownloadHref) {
headerActions.push(
<a
key="download"
className="icon-button"
href={singleDownloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
onClick={(event) => event.stopPropagation()}
>
<DownloadIcon />
</a>,
);
}
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(
<button
key="zoom"
type="button"
className="icon-button"
onClick={() => setZoomOverlayOpen(true)}
aria-label="Open zoom preview"
title="Open zoom preview"
>
<IconZoomInArea />
</button>,
);
}
useEffect(() => {
setZoomOverlayOpen(false);
}, [previewEntry?.url, singleDoc?.id]);
return (
<>
<aside className="detail-panel panel">
<PanelHeader
leading={headerLeading}
title={
headerBreadcrumbs ? (
<BreadcrumbTrail
entries={headerBreadcrumbs}
separator="/"
className="panel-header__breadcrumbs"
truncateFromStart
/>
) : (
headerTitle
)
}
titleTag="h3"
actions={headerActions.length ? headerActions : null}
/>
<div className="panel-body detail-panel__content">{renderContent()}</div>
</aside>
<PreviewZoomOverlay
open={Boolean(zoomOverlayOpen && zoomDisplay)}
display={zoomDisplay}
onClose={() => setZoomOverlayOpen(false)}
/>
</>
);
};
export default DetailPanel;
+1 -1
View File
@@ -237,7 +237,7 @@ const useDetailWorkspace = ({
onUpdateIssued: handleDocumentIssuedUpdate,
ensureAssetUrl,
getDocumentAsset,
ensurePreviewData,
hydrateDocument: ensurePreviewData,
correspondents,
onCorrespondentAdd: handleCorrespondentAdd,
onCorrespondentRemove: handleCorrespondentRemove,
@@ -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 ? <DetailPanel {...detailProps} /> : null;
const detail = detailOpen && detailProps
? (
<DocumentViewerPanel
variant="sidebar"
{...detailProps}
/>
)
: null;
return createWorkspaceSurfaceConfig({
key: 'documents',
+8 -104
View File
@@ -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 (
<section className="document-viewer-panel">
<section className="document-viewer-panel" ref={panelRef}>
<PanelHeader title="Document preview" />
<div className="document-viewer-panel__body">
<section className="document-viewer document-viewer--loading" ref={viewerRef}>
<section className="document-viewer document-viewer--loading">
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<div className="document-viewer__message">
Loading document
</div>
<div className="document-viewer__message">Loading document</div>
</div>
</div>
<div className="document-viewer__viewport">
@@ -482,7 +386,7 @@ const DocumentViewerPanel = ({
return (
<>
<section className="document-viewer-panel">
<section className="document-viewer-panel" ref={panelRef}>
<PanelHeader
leading={headerLeadingContent}
title={headerTitle}
@@ -490,7 +394,7 @@ const DocumentViewerPanel = ({
actions={headerActions}
/>
<div className="document-viewer-panel__body">
<section className={viewerClassName} ref={viewerRef}>
<section className={viewerClassName}>
<DocumentViewerLayout
document={document}
previewEntry={previewEntry}
@@ -0,0 +1,99 @@
import { useLayoutEffect, useState } from 'react';
const PORTRAIT_WIDTH_TO_HEIGHT = 1 / Math.sqrt(2); // ≈0.707 (A-series aspect ratio)
export 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;
}
};
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;
+1 -1
View File
@@ -75,7 +75,7 @@
}
.detail-panel__content .document-viewer-panel__body {
padding: 0.5rem 1rem;
padding: 0.5rem 0.5rem 0;
}
.detail-section__header {
+12 -3
View File
@@ -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;
}