viewer
This commit is contained in:
@@ -83,7 +83,6 @@ export const useWorkspaceSurface = ({
|
|||||||
onFolderNavigate,
|
onFolderNavigate,
|
||||||
} = detailExtras;
|
} = detailExtras;
|
||||||
return createDocumentViewerSurface({
|
return createDocumentViewerSurface({
|
||||||
documentId: previewDocumentId,
|
|
||||||
document: previewWorkspaceDocument,
|
document: previewWorkspaceDocument,
|
||||||
previewEntry: previewWorkspaceEntry,
|
previewEntry: previewWorkspaceEntry,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
@@ -108,7 +107,6 @@ export const useWorkspaceSurface = ({
|
|||||||
}, [
|
}, [
|
||||||
showPreviewWorkspace,
|
showPreviewWorkspace,
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
previewDocumentId,
|
|
||||||
previewWorkspaceEntry,
|
previewWorkspaceEntry,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
ensurePreviewData,
|
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 { DownloadIcon, CloseIcon } from '../ui/icons';
|
||||||
import {
|
import {
|
||||||
buildCorrespondentOptions,
|
buildCorrespondentOptions,
|
||||||
@@ -9,10 +16,50 @@ import { extractDocumentMetadataPayload } from '../documents/documentMetadata';
|
|||||||
import { createDocumentActionState } from '../documents/documentActions';
|
import { createDocumentActionState } from '../documents/documentActions';
|
||||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
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 = ({
|
const DocumentViewerPanel = ({
|
||||||
document,
|
document,
|
||||||
documentId,
|
|
||||||
previewEntry,
|
previewEntry,
|
||||||
|
hydrateDocument,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
tagOptions,
|
tagOptions,
|
||||||
onTagAdd,
|
onTagAdd,
|
||||||
@@ -182,13 +229,81 @@ const DocumentViewerPanel = ({
|
|||||||
[hasOcr, loadOcrContent],
|
[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) {
|
if (!document) {
|
||||||
return (
|
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-pane">
|
||||||
<div className="document-viewer__details">
|
<div className="document-viewer__details">
|
||||||
<div className="document-viewer__message">
|
<div className="document-viewer__message">
|
||||||
Loading document{documentId ? ` ${documentId}` : ''}…
|
Loading document…
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,7 +315,7 @@ const DocumentViewerPanel = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="document-viewer">
|
<section className={viewerClassName} ref={viewerRef}>
|
||||||
<div className="document-viewer__details-pane">
|
<div className="document-viewer__details-pane">
|
||||||
<div className="document-viewer__details">
|
<div className="document-viewer__details">
|
||||||
<DocumentInfoPanel
|
<DocumentInfoPanel
|
||||||
@@ -231,12 +346,16 @@ export default DocumentViewerPanel;
|
|||||||
export const createDocumentViewerHeaderActions = ({
|
export const createDocumentViewerHeaderActions = ({
|
||||||
document,
|
document,
|
||||||
actionState,
|
actionState,
|
||||||
|
previewEntry,
|
||||||
}) => {
|
}) => {
|
||||||
if (!document || !actionState) {
|
if (!document) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { downloadHref } = actionState;
|
const downloadHref = actionState?.downloadHref || previewEntry?.url;
|
||||||
|
if (!downloadHref) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -257,7 +376,6 @@ export const createDocumentViewerHeaderActions = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createDocumentViewerSurface = ({
|
export const createDocumentViewerSurface = ({
|
||||||
documentId,
|
|
||||||
document,
|
document,
|
||||||
previewEntry,
|
previewEntry,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
@@ -278,11 +396,11 @@ export const createDocumentViewerSurface = ({
|
|||||||
onUpdateIssued,
|
onUpdateIssued,
|
||||||
resolveFolderPath,
|
resolveFolderPath,
|
||||||
}) => {
|
}) => {
|
||||||
if (!documentId && !document) {
|
if (!document) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = document?.title || 'Document preview';
|
const title = document.title || 'Document preview';
|
||||||
const closeButton = onClose
|
const closeButton = onClose
|
||||||
? (
|
? (
|
||||||
<button
|
<button
|
||||||
@@ -306,7 +424,7 @@ export const createDocumentViewerSurface = ({
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
let breadcrumbs = null;
|
let breadcrumbs = null;
|
||||||
if (document && typeof resolveFolderPath === 'function') {
|
if (typeof resolveFolderPath === 'function') {
|
||||||
const folderSegments = resolveFolderPath(document.folder_id);
|
const folderSegments = resolveFolderPath(document.folder_id);
|
||||||
const normalizedSegments = Array.isArray(folderSegments)
|
const normalizedSegments = Array.isArray(folderSegments)
|
||||||
? folderSegments
|
? folderSegments
|
||||||
@@ -320,17 +438,15 @@ export const createDocumentViewerSurface = ({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionState = document
|
const actionState = createDocumentActionState({
|
||||||
? createDocumentActionState({
|
document,
|
||||||
document,
|
resolveApiPath,
|
||||||
resolveApiPath,
|
ensurePreviewData,
|
||||||
ensurePreviewData,
|
ensureAssetUrl,
|
||||||
ensureAssetUrl,
|
getDocumentAsset,
|
||||||
getDocumentAsset,
|
notifyApiError,
|
||||||
notifyApiError,
|
ocrErrorMessage: 'Unable to open OCR text.',
|
||||||
ocrErrorMessage: 'Unable to open OCR text.',
|
});
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const header = {
|
const header = {
|
||||||
title,
|
title,
|
||||||
@@ -339,6 +455,7 @@ export const createDocumentViewerSurface = ({
|
|||||||
actions: createDocumentViewerHeaderActions({
|
actions: createDocumentViewerHeaderActions({
|
||||||
document,
|
document,
|
||||||
actionState,
|
actionState,
|
||||||
|
previewEntry,
|
||||||
}),
|
}),
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
};
|
};
|
||||||
@@ -350,8 +467,8 @@ export const createDocumentViewerSurface = ({
|
|||||||
content: (
|
content: (
|
||||||
<DocumentViewerPanel
|
<DocumentViewerPanel
|
||||||
document={document}
|
document={document}
|
||||||
documentId={documentId}
|
|
||||||
previewEntry={previewEntry}
|
previewEntry={previewEntry}
|
||||||
|
hydrateDocument={ensurePreviewData}
|
||||||
tagLookupById={tagLookupById}
|
tagLookupById={tagLookupById}
|
||||||
tagOptions={tagOptions}
|
tagOptions={tagOptions}
|
||||||
onTagAdd={onTagAdd}
|
onTagAdd={onTagAdd}
|
||||||
|
|||||||
@@ -1,12 +1,42 @@
|
|||||||
.document-viewer {
|
.document-viewer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
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;
|
gap: 1rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 1rem 1rem;
|
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 {
|
.document-drag-preview {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -125,6 +155,7 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
grid-area: details;
|
||||||
}
|
}
|
||||||
.document-viewer__tabs-wrapper {
|
.document-viewer__tabs-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -289,22 +320,29 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
grid-area: viewport;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__object {
|
.document-viewer__object {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-viewer__object:not(.document-viewer__object--image) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.document-viewer__object--image {
|
.document-viewer__object--image {
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
align-self: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__unsupported {
|
.document-viewer__unsupported {
|
||||||
|
|||||||
Reference in New Issue
Block a user