viewer
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<section className="document-viewer document-viewer--loading">
|
||||
<section className="document-viewer document-viewer--loading" ref={viewerRef}>
|
||||
<div className="document-viewer__details-pane">
|
||||
<div className="document-viewer__details">
|
||||
<div className="document-viewer__message">
|
||||
Loading document{documentId ? ` ${documentId}` : ''}…
|
||||
Loading document…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,7 +315,7 @@ const DocumentViewerPanel = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="document-viewer">
|
||||
<section className={viewerClassName} ref={viewerRef}>
|
||||
<div className="document-viewer__details-pane">
|
||||
<div className="document-viewer__details">
|
||||
<DocumentInfoPanel
|
||||
@@ -231,12 +346,16 @@ export default DocumentViewerPanel;
|
||||
export const createDocumentViewerHeaderActions = ({
|
||||
document,
|
||||
actionState,
|
||||
previewEntry,
|
||||
}) => {
|
||||
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
|
||||
? (
|
||||
<button
|
||||
@@ -306,7 +424,7 @@ export const createDocumentViewerSurface = ({
|
||||
)
|
||||
: null;
|
||||
let breadcrumbs = null;
|
||||
if (document && typeof resolveFolderPath === 'function') {
|
||||
if (typeof resolveFolderPath === 'function') {
|
||||
const folderSegments = resolveFolderPath(document.folder_id);
|
||||
const normalizedSegments = Array.isArray(folderSegments)
|
||||
? folderSegments
|
||||
@@ -320,17 +438,15 @@ export const createDocumentViewerSurface = ({
|
||||
];
|
||||
}
|
||||
|
||||
const actionState = document
|
||||
? createDocumentActionState({
|
||||
document,
|
||||
resolveApiPath,
|
||||
ensurePreviewData,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
notifyApiError,
|
||||
ocrErrorMessage: 'Unable to open OCR text.',
|
||||
})
|
||||
: null;
|
||||
const actionState = createDocumentActionState({
|
||||
document,
|
||||
resolveApiPath,
|
||||
ensurePreviewData,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
notifyApiError,
|
||||
ocrErrorMessage: 'Unable to open OCR text.',
|
||||
});
|
||||
|
||||
const header = {
|
||||
title,
|
||||
@@ -339,6 +455,7 @@ export const createDocumentViewerSurface = ({
|
||||
actions: createDocumentViewerHeaderActions({
|
||||
document,
|
||||
actionState,
|
||||
previewEntry,
|
||||
}),
|
||||
breadcrumbs,
|
||||
};
|
||||
@@ -350,8 +467,8 @@ export const createDocumentViewerSurface = ({
|
||||
content: (
|
||||
<DocumentViewerPanel
|
||||
document={document}
|
||||
documentId={documentId}
|
||||
previewEntry={previewEntry}
|
||||
hydrateDocument={ensurePreviewData}
|
||||
tagLookupById={tagLookupById}
|
||||
tagOptions={tagOptions}
|
||||
onTagAdd={onTagAdd}
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
.document-viewer {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 30em) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, clamp(18rem, 30vw, 26rem)) minmax(0, 1fr);
|
||||
grid-template-areas: 'details viewport';
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
padding: 1rem 1rem;
|
||||
}
|
||||
|
||||
.document-viewer--stacked {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows:
|
||||
minmax(auto, 1fr)
|
||||
auto;
|
||||
grid-template-areas:
|
||||
'viewport'
|
||||
'details';
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.document-viewer--stacked .document-viewer__viewport {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
max-height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
|
||||
}
|
||||
|
||||
.document-viewer--stacked .document-viewer__details-pane {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.document-drag-preview {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
@@ -125,6 +155,7 @@
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
grid-area: details;
|
||||
}
|
||||
.document-viewer__tabs-wrapper {
|
||||
display: flex;
|
||||
@@ -289,22 +320,29 @@
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
grid-area: viewport;
|
||||
}
|
||||
|
||||
.document-viewer__object {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.document-viewer__object:not(.document-viewer__object--image) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.document-viewer__object--image {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.document-viewer__unsupported {
|
||||
|
||||
Reference in New Issue
Block a user