unify detailpanel and documentviewer
This commit is contained in:
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import SelectionFloatingActions from '../documents/SelectionFloatingActions';
|
import SelectionFloatingActions from '../documents/SelectionFloatingActions';
|
||||||
import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel';
|
import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel';
|
||||||
import createWorkspaceSurfaceConfig from '../documents/workspaceHeader';
|
import createWorkspaceSurfaceConfig from '../documents/workspaceHeader';
|
||||||
import DetailPanel from '../detail/DetailPanel';
|
import DocumentViewerPanel from '../preview/DocumentViewerPanel';
|
||||||
import DesktopWorkspace from './DesktopWorkspace';
|
import DesktopWorkspace from './DesktopWorkspace';
|
||||||
|
|
||||||
const createDesktopSurface = ({
|
const createDesktopSurface = ({
|
||||||
@@ -82,7 +82,14 @@ const createDesktopSurface = ({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : 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({
|
const surfaceConfig = createWorkspaceSurfaceConfig({
|
||||||
key: 'workspace',
|
key: 'workspace',
|
||||||
variant: 'workspace',
|
variant: 'workspace',
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -237,7 +237,7 @@ const useDetailWorkspace = ({
|
|||||||
onUpdateIssued: handleDocumentIssuedUpdate,
|
onUpdateIssued: handleDocumentIssuedUpdate,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
ensurePreviewData,
|
hydrateDocument: ensurePreviewData,
|
||||||
correspondents,
|
correspondents,
|
||||||
onCorrespondentAdd: handleCorrespondentAdd,
|
onCorrespondentAdd: handleCorrespondentAdd,
|
||||||
onCorrespondentRemove: handleCorrespondentRemove,
|
onCorrespondentRemove: handleCorrespondentRemove,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import DetailPanel from '../../detail/DetailPanel';
|
import DocumentViewerPanel from '../../preview/DocumentViewerPanel';
|
||||||
import SelectionFloatingActions from '../SelectionFloatingActions';
|
import SelectionFloatingActions from '../SelectionFloatingActions';
|
||||||
import createWorkspaceSurfaceConfig from '../workspaceHeader';
|
import createWorkspaceSurfaceConfig from '../workspaceHeader';
|
||||||
import DocumentsPanel from './DocumentsPanel';
|
import DocumentsPanel from './DocumentsPanel';
|
||||||
@@ -91,7 +91,14 @@ const createDocumentsSurface = ({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
const detail = detailOpen && detailProps
|
||||||
|
? (
|
||||||
|
<DocumentViewerPanel
|
||||||
|
variant="sidebar"
|
||||||
|
{...detailProps}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return createWorkspaceSurfaceConfig({
|
return createWorkspaceSurfaceConfig({
|
||||||
key: 'documents',
|
key: 'documents',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -20,46 +19,7 @@ import PanelHeader from '../ui/PanelHeader';
|
|||||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||||
import DocumentViewerLayout from './DocumentViewerLayout';
|
import DocumentViewerLayout from './DocumentViewerLayout';
|
||||||
|
import useViewerLayoutMode from './useViewerLayoutMode';
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createDocumentViewerHeaderActions = ({
|
export const createDocumentViewerHeaderActions = ({
|
||||||
document,
|
document,
|
||||||
@@ -226,8 +186,6 @@ const DocumentViewerPanel = ({
|
|||||||
[hasOcr, loadOcrContent],
|
[hasOcr, loadOcrContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const viewerRef = useRef(null);
|
|
||||||
const [isStackedLayout, setIsStackedLayout] = useState(false);
|
|
||||||
const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false);
|
const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false);
|
||||||
const previewNavigator = useAssetNavigator({
|
const previewNavigator = useAssetNavigator({
|
||||||
document,
|
document,
|
||||||
@@ -263,60 +221,8 @@ const DocumentViewerPanel = ({
|
|||||||
}
|
}
|
||||||
}, [hydrateDocument, document?.id]);
|
}, [hydrateDocument, document?.id]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
const panelRef = useRef(null);
|
||||||
ensurePortraitRatioStyle();
|
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
|
||||||
}, []);
|
|
||||||
|
|
||||||
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
|
const viewerClassName = isStackedLayout
|
||||||
? 'document-viewer document-viewer--stacked'
|
? 'document-viewer document-viewer--stacked'
|
||||||
@@ -450,15 +356,13 @@ const DocumentViewerPanel = ({
|
|||||||
|
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return (
|
return (
|
||||||
<section className="document-viewer-panel">
|
<section className="document-viewer-panel" ref={panelRef}>
|
||||||
<PanelHeader title="Document preview" />
|
<PanelHeader title="Document preview" />
|
||||||
<div className="document-viewer-panel__body">
|
<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-pane">
|
||||||
<div className="document-viewer__details">
|
<div className="document-viewer__details">
|
||||||
<div className="document-viewer__message">
|
<div className="document-viewer__message">Loading document…</div>
|
||||||
Loading document…
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="document-viewer__viewport">
|
<div className="document-viewer__viewport">
|
||||||
@@ -482,7 +386,7 @@ const DocumentViewerPanel = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="document-viewer-panel">
|
<section className="document-viewer-panel" ref={panelRef}>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
leading={headerLeadingContent}
|
leading={headerLeadingContent}
|
||||||
title={headerTitle}
|
title={headerTitle}
|
||||||
@@ -490,7 +394,7 @@ const DocumentViewerPanel = ({
|
|||||||
actions={headerActions}
|
actions={headerActions}
|
||||||
/>
|
/>
|
||||||
<div className="document-viewer-panel__body">
|
<div className="document-viewer-panel__body">
|
||||||
<section className={viewerClassName} ref={viewerRef}>
|
<section className={viewerClassName}>
|
||||||
<DocumentViewerLayout
|
<DocumentViewerLayout
|
||||||
document={document}
|
document={document}
|
||||||
previewEntry={previewEntry}
|
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;
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel__content .document-viewer-panel__body {
|
.detail-panel__content .document-viewer-panel__body {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-section__header {
|
.detail-section__header {
|
||||||
|
|||||||
@@ -14,6 +14,15 @@
|
|||||||
overflow: auto;
|
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,
|
||||||
.document-viewer-panel__body > .document-viewer--loading {
|
.document-viewer-panel__body > .document-viewer--loading {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -27,14 +36,14 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 1rem 1rem;
|
padding: 0.25rem 1rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer--stacked {
|
.document-viewer--stacked {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem 1rem 1rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +60,7 @@
|
|||||||
|
|
||||||
.document-viewer--stacked .document-viewer__details-pane {
|
.document-viewer--stacked .document-viewer__details-pane {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow: auto;
|
overflow: visible;
|
||||||
order: 0;
|
order: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user