feat: update frontend for document handling and styling.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import DocumentInfoPanel from './components/DocumentInfoPanel';
|
||||
import UnifiedDocumentViewer from './UnifiedDocumentViewer';
|
||||
|
||||
import type { Document } from '../types/documents';
|
||||
|
||||
interface ContentTabConfig {
|
||||
id?: string;
|
||||
label?: string;
|
||||
enabled?: boolean;
|
||||
forceDisplay?: boolean;
|
||||
loadContent?: (options?: { signal?: AbortSignal }) => unknown | Promise<unknown>;
|
||||
loadingMessage?: string;
|
||||
emptyMessage?: string;
|
||||
unavailableMessage?: string;
|
||||
errorMessage?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type LayoutMode = 'split' | 'stacked' | (string & {});
|
||||
|
||||
interface DocumentViewerLayoutProps {
|
||||
document?: Document | null;
|
||||
summaryProps?: Record<string, unknown>;
|
||||
metadataPayload?: unknown;
|
||||
contentTabConfig?: ContentTabConfig | null;
|
||||
resetKey?: string | null;
|
||||
classNamePrefix?: string;
|
||||
defaultTabId?: string;
|
||||
infoPanelProps?: Record<string, unknown>;
|
||||
previewLoadingMessage?: string;
|
||||
layoutMode?: LayoutMode;
|
||||
}
|
||||
const DocumentViewerLayout = ({
|
||||
document,
|
||||
summaryProps = {},
|
||||
metadataPayload,
|
||||
contentTabConfig,
|
||||
resetKey,
|
||||
classNamePrefix = 'document-viewer',
|
||||
defaultTabId = 'details',
|
||||
infoPanelProps = {},
|
||||
previewLoadingMessage = 'Preparing preview…',
|
||||
layoutMode = 'split',
|
||||
}: DocumentViewerLayoutProps): JSX.Element => {
|
||||
const isStacked = layoutMode === 'stacked';
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const previewContent = useMemo(() => (
|
||||
<UnifiedDocumentViewer
|
||||
document={document}
|
||||
viewportRef={viewportRef}
|
||||
/>
|
||||
), [document, viewportRef]);
|
||||
|
||||
const renderViewportPane = useCallback(() => (
|
||||
<div className="document-viewer__viewport" ref={viewportRef}>
|
||||
{!document?.current_version?.download?.url ? (
|
||||
<div className="document-viewer__message">{previewLoadingMessage}</div>
|
||||
) : (
|
||||
previewContent
|
||||
)}
|
||||
</div>
|
||||
), [previewContent, document?.current_version?.download?.url, previewLoadingMessage, viewportRef]);
|
||||
|
||||
const viewportPane = renderViewportPane();
|
||||
|
||||
const stackedLeadingTabs = useMemo(() => (
|
||||
isStacked
|
||||
? [
|
||||
{
|
||||
id: 'preview',
|
||||
label: 'Preview',
|
||||
render: () => renderViewportPane(),
|
||||
},
|
||||
]
|
||||
: []
|
||||
), [isStacked, renderViewportPane]);
|
||||
|
||||
const resolvedDefaultTabId = isStacked ? 'preview' : defaultTabId;
|
||||
const summaryPlacement = 'tabs';
|
||||
const tabsPlacement = 'bottom';
|
||||
const summaryLayout = 'compact';
|
||||
|
||||
const detailsPane = (
|
||||
<div className="document-viewer__details-pane">
|
||||
<div className="document-viewer__details">
|
||||
<DocumentInfoPanel
|
||||
document={document}
|
||||
summaryProps={summaryProps}
|
||||
metadataPayload={metadataPayload}
|
||||
contentConfig={contentTabConfig}
|
||||
defaultTabId={resolvedDefaultTabId}
|
||||
classNamePrefix={classNamePrefix}
|
||||
hideTabNavWhenSingle={false}
|
||||
resetKey={resetKey || document?.id}
|
||||
summaryPlacement={summaryPlacement}
|
||||
summaryLayout={summaryLayout}
|
||||
leadingTabs={stackedLeadingTabs}
|
||||
tabsPlacement={tabsPlacement}
|
||||
{...infoPanelProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isStacked) {
|
||||
return detailsPane;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{detailsPane}
|
||||
{viewportPane}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentViewerLayout;
|
||||
@@ -0,0 +1,452 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
DownloadIcon,
|
||||
CloseIcon,
|
||||
IconZoomInArea,
|
||||
WindowMaximizeIcon,
|
||||
} from '../components/icons';
|
||||
import {
|
||||
buildCorrespondentOptions,
|
||||
sortCorrespondents,
|
||||
} from './components/DocumentSummarySection';
|
||||
import { usePreviewContext } from './PreviewContext';
|
||||
import type { DocumentSummarySectionProps } from './components/DocumentSummarySection';
|
||||
import { extractDocumentMetadataPayload } from './logic/documentSummary';
|
||||
import { createDocumentActionState } from '../documents/documentActions';
|
||||
import { resolveDocumentAssetUrl } from '../lib/assets/AssetManager';
|
||||
import PanelHeader from '../components/PanelHeader';
|
||||
import BreadcrumbTrail from '../components/BreadcrumbTrail';
|
||||
import DocumentViewerLayout from './DocumentViewerLayout';
|
||||
import { useViewerLayoutMode } from './useViewerLayoutMode';
|
||||
import { usePanelResizeBindings } from '../app/PanelManagerContext';
|
||||
import type { DocumentId, FolderId } from '../types/identifiers';
|
||||
import type { Document } from '../types/documents';
|
||||
import type { Asset } from '../types/assets';
|
||||
|
||||
type SidebarMode = 'overlay' | 'inline';
|
||||
|
||||
interface DocumentViewerPanelProps extends DocumentSummarySectionProps {
|
||||
document: Document | null;
|
||||
|
||||
ensureAssetUrl?: (documentId: DocumentId, asset: Asset, options?: { force?: boolean }) => Promise<unknown>;
|
||||
getDocumentAsset?: (doc: Document | null, type: string) => Asset | null;
|
||||
ensurePreviewData?: (docId: DocumentId, options?: { signal?: AbortSignal }) => Promise<Document | null>;
|
||||
notifyApiError?: (error: unknown, fallbackMessage?: string) => void;
|
||||
sidebarToggle?: ReactNode;
|
||||
onClose?: () => void;
|
||||
resolveFolderPath?: (doc: Document | null) => Array<{ id?: string; name?: string }>;
|
||||
variant?: 'viewer' | 'sidebar';
|
||||
onMaximize?: (args: { documentIds: Array<string> }) => void;
|
||||
sidebarMode?: SidebarMode;
|
||||
}
|
||||
|
||||
const createDocumentViewerHeaderActions = ({
|
||||
document,
|
||||
actionState,
|
||||
onZoom,
|
||||
canZoom = false,
|
||||
}) => {
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const downloadHref = actionState?.downloadHref;
|
||||
if (!downloadHref && !(canZoom && onZoom)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{downloadHref ? (
|
||||
<a
|
||||
className="icon-button"
|
||||
href={downloadHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Download document"
|
||||
title="Download document"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
|
||||
document,
|
||||
tagLookupById,
|
||||
tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
ensurePreviewData,
|
||||
notifyApiError,
|
||||
sidebarToggle = null,
|
||||
onClose,
|
||||
resolveFolderPath,
|
||||
variant = 'viewer',
|
||||
onMaximize,
|
||||
sidebarMode = 'overlay',
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const isSidebarVariant = variant === 'sidebar';
|
||||
const sortedCorrespondents = useMemo(
|
||||
() => sortCorrespondents(document?.correspondents || []),
|
||||
[document],
|
||||
);
|
||||
|
||||
const correspondentOptions = useMemo(
|
||||
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
|
||||
[correspondents],
|
||||
);
|
||||
|
||||
const metadataPayload = useMemo(
|
||||
() => extractDocumentMetadataPayload(document),
|
||||
[document],
|
||||
);
|
||||
|
||||
const hasOcr = useMemo(() => {
|
||||
if (!document || !getDocumentAsset) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(getDocumentAsset(document, 'text-content'));
|
||||
}, [document, getDocumentAsset]);
|
||||
|
||||
const navigateToFolder = useCallback(
|
||||
(folderId: FolderId | null) => {
|
||||
const target = folderId == null
|
||||
? '/documents'
|
||||
: `/documents/folder/${folderId}`;
|
||||
navigate(target);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const summaryProps = useMemo(
|
||||
() => ({
|
||||
tagLookupById,
|
||||
tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents: sortedCorrespondents,
|
||||
correspondentOptions,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
onFolderNavigate: navigateToFolder,
|
||||
}),
|
||||
[
|
||||
tagLookupById,
|
||||
tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
sortedCorrespondents,
|
||||
correspondentOptions,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
navigateToFolder,
|
||||
],
|
||||
);
|
||||
|
||||
const loadOcrContent = useCallback(async ({ signal }: { signal?: AbortSignal } = {}) => {
|
||||
if (!document || !hasOcr || !getDocumentAsset) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const updateUrl = () =>
|
||||
resolveDocumentAssetUrl(document, 'text-content', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
|
||||
const asset = getDocumentAsset(document, 'text-content');
|
||||
let url = updateUrl();
|
||||
|
||||
if (!url && document.id && asset?.id && ensureAssetUrl) {
|
||||
await ensureAssetUrl(document.id, asset, { force: true });
|
||||
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();
|
||||
}, [document, hasOcr, getDocumentAsset, ensureAssetUrl]);
|
||||
|
||||
const contentTabConfig = useMemo(
|
||||
() => ({
|
||||
enabled: hasOcr,
|
||||
id: 'content',
|
||||
label: 'Content',
|
||||
loadContent: loadOcrContent,
|
||||
loadingMessage: 'Loading text content…',
|
||||
emptyMessage: 'No text content available.',
|
||||
unavailableMessage: 'No text content available.',
|
||||
errorMessage: 'Failed to load text content.',
|
||||
}),
|
||||
[hasOcr, loadOcrContent],
|
||||
);
|
||||
|
||||
const { openPreview } = usePreviewContext();
|
||||
|
||||
const handleZoomOpen = useCallback(() => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
openPreview(document);
|
||||
}, [document, openPreview]);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement | HTMLFormElement | HTMLElement | null>(null);
|
||||
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
|
||||
|
||||
const {
|
||||
panelStyle: managedDetailPanelStyle,
|
||||
handleProps: managedResizeHandleProps,
|
||||
isPanelResizing,
|
||||
} = usePanelResizeBindings('detail', { enabled: isSidebarVariant, panelRef });
|
||||
const detailPanelStyle = isSidebarVariant ? managedDetailPanelStyle : undefined;
|
||||
const resizeHandleProps = isSidebarVariant ? managedResizeHandleProps : {};
|
||||
|
||||
const viewerClassName = isStackedLayout
|
||||
? 'document-viewer document-viewer--stacked'
|
||||
: 'document-viewer';
|
||||
|
||||
const actionState = useMemo(
|
||||
() =>
|
||||
document
|
||||
? createDocumentActionState({
|
||||
document,
|
||||
ensurePreviewData,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
notifyApiError,
|
||||
ocrErrorMessage: 'Unable to open text content.',
|
||||
})
|
||||
: null,
|
||||
[
|
||||
document,
|
||||
ensurePreviewData,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
notifyApiError,
|
||||
],
|
||||
);
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (!document || !resolveFolderPath) {
|
||||
return [];
|
||||
}
|
||||
const folderSegments = resolveFolderPath(document.folder_id);
|
||||
const normalizedSegments = Array.isArray(folderSegments)
|
||||
? folderSegments
|
||||
.filter((segment) => segment && segment.id && segment.name)
|
||||
.map((segment) => ({ id: segment.id, name: segment.name }))
|
||||
: [];
|
||||
|
||||
return [
|
||||
...normalizedSegments,
|
||||
{ id: document.id, name: document.title },
|
||||
];
|
||||
}, [document, resolveFolderPath]);
|
||||
|
||||
const breadcrumbTrailEntries = useMemo(() => {
|
||||
if (!breadcrumbs.length) {
|
||||
return [];
|
||||
}
|
||||
const lastIndex = breadcrumbs.length - 1;
|
||||
return breadcrumbs.map((crumb, index) => ({
|
||||
id: crumb.id,
|
||||
label: crumb.name,
|
||||
onClick: index < lastIndex ? () => navigateToFolder(crumb.id) : null,
|
||||
}));
|
||||
}, [breadcrumbs, navigateToFolder]);
|
||||
|
||||
const headerActions = createDocumentViewerHeaderActions({
|
||||
document,
|
||||
actionState,
|
||||
onZoom: handleZoomOpen,
|
||||
canZoom: Boolean(document),
|
||||
});
|
||||
|
||||
const maximizeButton = isSidebarVariant && onMaximize
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const targetId = document?.id;
|
||||
if (targetId == null) {
|
||||
return;
|
||||
}
|
||||
onMaximize?.({ documentIds: [targetId] });
|
||||
}}
|
||||
aria-label="Maximize"
|
||||
title="Maximize"
|
||||
>
|
||||
<WindowMaximizeIcon className="icon--flip-y" />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
|
||||
const closeButton = onClose
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => onClose?.()}
|
||||
aria-label="Close preview"
|
||||
title="Close preview"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
|
||||
const previewZoomButton = (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={handleZoomOpen}
|
||||
aria-label="Open zoom preview"
|
||||
title="Open zoom preview"
|
||||
>
|
||||
<IconZoomInArea />
|
||||
</button>
|
||||
);
|
||||
|
||||
const headerLeadingButtons = [
|
||||
sidebarToggle ? <React.Fragment key="sidebar-toggle">{sidebarToggle}</React.Fragment> : null,
|
||||
closeButton ? <React.Fragment key="close-button">{closeButton}</React.Fragment> : null,
|
||||
maximizeButton ? <React.Fragment key="maximize-button">{maximizeButton}</React.Fragment> : null,
|
||||
previewZoomButton ? <React.Fragment key="preview-zoom-button">{previewZoomButton}</React.Fragment> : null,
|
||||
].filter(Boolean);
|
||||
const headerLeadingContent = headerLeadingButtons.length ? headerLeadingButtons : null;
|
||||
|
||||
const resizeHandle = isSidebarVariant ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`resize-handle resize-handle--left${isPanelResizing ? ' is-active' : ''}`}
|
||||
aria-label="Resize detail panel"
|
||||
{...resizeHandleProps}
|
||||
>
|
||||
<span className="resize-handle__line" aria-hidden="true" />
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const loadingSection = (
|
||||
<div className="document-viewer-panel__body">
|
||||
<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>
|
||||
</div>
|
||||
<div className="document-viewer__viewport">
|
||||
<div className="document-viewer__message">Preparing preview…</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const viewerSection = document ? (
|
||||
<div
|
||||
className={isStackedLayout
|
||||
? 'document-viewer-panel__body document-viewer-panel__body--stacked'
|
||||
: 'document-viewer-panel__body'}
|
||||
>
|
||||
<section className={viewerClassName}>
|
||||
<DocumentViewerLayout
|
||||
document={document}
|
||||
summaryProps={summaryProps}
|
||||
metadataPayload={metadataPayload}
|
||||
contentTabConfig={contentTabConfig}
|
||||
previewLoadingMessage="Loading preview…"
|
||||
layoutMode={isStackedLayout ? 'stacked' : 'split'}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
) : loadingSection;
|
||||
|
||||
const headerTitle = breadcrumbTrailEntries.length ? (
|
||||
<div className="panel-header__breadcrumbs-wrapper">
|
||||
<BreadcrumbTrail
|
||||
entries={breadcrumbTrailEntries}
|
||||
separator="/"
|
||||
className="panel-header__breadcrumbs"
|
||||
truncateFromStart={isSidebarVariant}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
document?.title || 'Document preview'
|
||||
);
|
||||
|
||||
if (isSidebarVariant) {
|
||||
const sidebarClass = `detail-panel panel${sidebarMode === 'inline' ? ' detail-panel--inline' : ''}${isPanelResizing ? ' detail-panel--resizing' : ''}`;
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
className={sidebarClass}
|
||||
ref={panelRef}
|
||||
style={detailPanelStyle}
|
||||
>
|
||||
{resizeHandle}
|
||||
<PanelHeader
|
||||
leading={headerLeadingContent}
|
||||
title={headerTitle}
|
||||
titleTag="h3"
|
||||
actions={headerActions}
|
||||
/>
|
||||
<div className="panel-body detail-panel__content">{viewerSection}</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="document-viewer-panel" ref={panelRef}>
|
||||
<PanelHeader
|
||||
leading={headerLeadingContent}
|
||||
title={headerTitle}
|
||||
titleTag="h3"
|
||||
actions={headerActions}
|
||||
/>
|
||||
{viewerSection}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentViewerPanel;
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { DownloadIcon } from '../components/icons';
|
||||
import { AUDIO_EXTENSIONS, VIDEO_EXTENSIONS } from '../constants/preview';
|
||||
|
||||
interface MediaViewerProps {
|
||||
src: string;
|
||||
mimeType?: string;
|
||||
filename?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onLoad?: (event: React.SyntheticEvent<HTMLElement>) => void;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
mediaRef?: React.Ref<any>;
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
const getFileExtension = (filename?: string | null) => {
|
||||
if (!filename) {
|
||||
return '';
|
||||
}
|
||||
const match = filename.toLowerCase().match(/\.([a-z0-9]+)$/);
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
|
||||
const MediaViewer: React.FC<MediaViewerProps> = ({
|
||||
src,
|
||||
mimeType = '',
|
||||
filename = '',
|
||||
alt = 'Media preview',
|
||||
className,
|
||||
style,
|
||||
onLoad,
|
||||
onClick,
|
||||
mediaRef,
|
||||
draggable = false,
|
||||
}) => {
|
||||
const normalizedMimeType = mimeType.toLowerCase();
|
||||
const fileExtension = getFileExtension(filename);
|
||||
|
||||
const isImage = normalizedMimeType.startsWith('image/');
|
||||
const isAudio = normalizedMimeType.startsWith('audio/') || AUDIO_EXTENSIONS.has(fileExtension);
|
||||
const isVideo = normalizedMimeType.startsWith('video/') || VIDEO_EXTENSIONS.has(fileExtension);
|
||||
|
||||
const viewerClasses = ['document-viewer__object', className].filter(Boolean).join(' ');
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<img
|
||||
ref={mediaRef}
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`${viewerClasses} document-viewer__object--image`}
|
||||
style={style}
|
||||
onLoad={onLoad}
|
||||
onClick={onClick}
|
||||
draggable={draggable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
return (
|
||||
<audio
|
||||
ref={mediaRef}
|
||||
className={`${viewerClasses} document-viewer__object--audio`}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={src}
|
||||
aria-label={`Audio preview of ${alt}`}
|
||||
style={style}
|
||||
onLoadedMetadata={onLoad}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
return (
|
||||
<video
|
||||
ref={mediaRef}
|
||||
className={`${viewerClasses} document-viewer__object--video`}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={src}
|
||||
aria-label={`Video preview of ${alt}`}
|
||||
style={style}
|
||||
onLoadedMetadata={onLoad}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const displayMimeType = mimeType || 'this file type';
|
||||
const displayFilename = filename || 'download';
|
||||
|
||||
return (
|
||||
<div className="document-viewer__unsupported" style={style}>
|
||||
<div className="document-viewer__unsupported-message">
|
||||
Preview is not available for {displayMimeType} files.
|
||||
</div>
|
||||
<div className="document-viewer__unsupported-filename">{displayFilename}</div>
|
||||
<a
|
||||
className="button-link document-viewer__unsupported-download"
|
||||
href={src}
|
||||
download={displayFilename}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<DownloadIcon />
|
||||
<span>Download</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaViewer;
|
||||
@@ -0,0 +1,871 @@
|
||||
import React, {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { CSSProperties, JSX, MutableRefObject, RefObject } from 'react';
|
||||
import {
|
||||
GlobalWorkerOptions,
|
||||
PasswordResponses,
|
||||
getDocument,
|
||||
} from 'pdfjs-dist';
|
||||
import type {
|
||||
PDFDocumentLoadingTask,
|
||||
PDFDocumentProxy,
|
||||
RenderTask,
|
||||
} from 'pdfjs-dist/types/src/display/api';
|
||||
import {
|
||||
FAST_SCROLL_DWELL_THRESHOLD_MS,
|
||||
FAST_SCROLL_VELOCITY_THRESHOLD,
|
||||
MAX_PIXEL_RATIO,
|
||||
RERENDER_DELTA,
|
||||
SCROLL_VELOCITY_MIN_DELTA,
|
||||
} from '../constants/preview';
|
||||
GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
interface PdfViewerProps {
|
||||
src: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
viewportRef?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
type RenderStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
type ViewMode = 'fit-width' | 'contain';
|
||||
|
||||
interface PageDescriptor {
|
||||
number: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
interface RenderQueueRequest {
|
||||
pageNumber: number;
|
||||
resume: () => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
const getAvailableViewportSize = (viewportNode: Element, stackNode: Element) => {
|
||||
const viewportStyle = window.getComputedStyle(viewportNode);
|
||||
const viewportPaddingX = parseFloat(viewportStyle.paddingLeft || '0')
|
||||
+ parseFloat(viewportStyle.paddingRight || '0');
|
||||
const viewportPaddingY = parseFloat(viewportStyle.paddingTop || '0')
|
||||
+ parseFloat(viewportStyle.paddingBottom || '0');
|
||||
|
||||
const stackStyle = window.getComputedStyle(stackNode);
|
||||
const stackPaddingX = parseFloat(stackStyle.paddingLeft || '0')
|
||||
+ parseFloat(stackStyle.paddingRight || '0');
|
||||
const stackPaddingY = parseFloat(stackStyle.paddingTop || '0')
|
||||
+ parseFloat(stackStyle.paddingBottom || '0');
|
||||
|
||||
const width = Math.max(0, viewportNode.clientWidth - viewportPaddingX - stackPaddingX);
|
||||
const height = Math.max(0, viewportNode.clientHeight - viewportPaddingY - stackPaddingY);
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
const resolvePdfWasmBaseUrl = (): string => {
|
||||
if (typeof document !== 'undefined') {
|
||||
const base = document.baseURI || (typeof window !== 'undefined' ? window.location.href : '/');
|
||||
return new URL('./pdfjs/wasm/', base).toString();
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
return new URL('./pdfjs/wasm/', window.location.href).toString();
|
||||
}
|
||||
return '/pdfjs/wasm/';
|
||||
};
|
||||
|
||||
const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX.Element => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewportWidth, setViewportWidth] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [renderWidth, setRenderWidth] = useState(0);
|
||||
const [status, setStatus] = useState<RenderStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [pages, setPages] = useState<PageDescriptor[]>([]);
|
||||
const [pixelRatio, setPixelRatio] = useState(1);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('contain');
|
||||
const [visiblePages, setVisiblePages] = useState<Set<number>>(new Set());
|
||||
const [scrollVelocity, setScrollVelocity] = useState(0);
|
||||
const pdfRef = useRef<PDFDocumentProxy | null>(null);
|
||||
const wasmUrlRef = useRef<string | null>(null);
|
||||
const pageNodeMapRef = useRef<Map<number, HTMLElement>>(new Map());
|
||||
const intersectionObserverRef = useRef<IntersectionObserver | null>(null);
|
||||
const renderQueueRef = useRef<RenderQueueRequest[]>([]);
|
||||
const activeRendersRef = useRef(new Set<number>());
|
||||
const maxConcurrentRendersRef = useRef(2);
|
||||
const scrollVelocityRef = useRef({
|
||||
lastPosition: 0,
|
||||
lastTime: 0,
|
||||
velocity: 0,
|
||||
});
|
||||
const scrollElementRef = useRef<HTMLElement | null>(null);
|
||||
const [isEncrypted, setIsEncrypted] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState(false);
|
||||
const [passwordCallback, setPasswordCallback] = useState<((password: string) => void) | null>(null);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const requestQueueFlush = useCallback(() => {
|
||||
const queue = renderQueueRef.current;
|
||||
while (queue.length > 0 && activeRendersRef.current.size < maxConcurrentRendersRef.current) {
|
||||
const next = queue.pop();
|
||||
if (!next) {
|
||||
break;
|
||||
}
|
||||
if (activeRendersRef.current.has(next.pageNumber)) {
|
||||
next.cancel();
|
||||
continue;
|
||||
}
|
||||
activeRendersRef.current.add(next.pageNumber);
|
||||
next.resume();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const enqueueRender = useCallback((request: RenderQueueRequest) => {
|
||||
renderQueueRef.current.push(request);
|
||||
requestQueueFlush();
|
||||
}, [requestQueueFlush]);
|
||||
|
||||
const releaseRenderSlot = useCallback((pageNumber: number) => {
|
||||
if (activeRendersRef.current.delete(pageNumber)) {
|
||||
requestQueueFlush();
|
||||
}
|
||||
}, [requestQueueFlush]);
|
||||
|
||||
const cancelRenderRequest = useCallback((pageNumber: number) => {
|
||||
const queue = renderQueueRef.current;
|
||||
const index = queue.findIndex((entry) => entry.pageNumber === pageNumber);
|
||||
if (index >= 0) {
|
||||
const [entry] = queue.splice(index, 1);
|
||||
entry.cancel();
|
||||
}
|
||||
releaseRenderSlot(pageNumber);
|
||||
}, [releaseRenderSlot]);
|
||||
|
||||
const handleRenderFinished = useCallback((pageNumber: number) => {
|
||||
releaseRenderSlot(pageNumber);
|
||||
}, [releaseRenderSlot]);
|
||||
const focusTargetRef = useRef<{
|
||||
ratioX: number;
|
||||
ratioY: number;
|
||||
pointerOffsetX: number;
|
||||
pointerOffsetY: number;
|
||||
} | null>(null);
|
||||
const ensureWasmUrl = useCallback(() => {
|
||||
if (!wasmUrlRef.current) {
|
||||
wasmUrlRef.current = resolvePdfWasmBaseUrl();
|
||||
}
|
||||
return wasmUrlRef.current;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let frameId: number | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
const attach = () => {
|
||||
const stackElement = containerRef.current;
|
||||
const viewportElement = viewportRef?.current;
|
||||
const sizingElement = stackElement?.parentElement;
|
||||
|
||||
if (!stackElement || !sizingElement) {
|
||||
frameId = requestAnimationFrame(attach);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateBounds = () => {
|
||||
const { width, height } = getAvailableViewportSize(sizingElement, stackElement);
|
||||
const nextWidth = Math.max(1, Math.round(width || 0));
|
||||
const nextHeight = Math.max(1, Math.round(height || 0));
|
||||
setViewportWidth((prev) => (prev === nextWidth ? prev : nextWidth));
|
||||
setViewportHeight((prev) => (prev === nextHeight ? prev : nextHeight));
|
||||
};
|
||||
|
||||
updateBounds();
|
||||
|
||||
observer = new ResizeObserver(() => {
|
||||
updateBounds();
|
||||
});
|
||||
|
||||
const observedNodes = new Set<Element>();
|
||||
const observeNode = (node: Element | null) => {
|
||||
if (!node || observedNodes.has(node)) {
|
||||
return;
|
||||
}
|
||||
observer?.observe(node);
|
||||
observedNodes.add(node);
|
||||
};
|
||||
|
||||
observeNode(stackElement);
|
||||
observeNode(sizingElement);
|
||||
if (viewportElement) {
|
||||
observeNode(viewportElement);
|
||||
}
|
||||
observeNode(stackElement.closest('.document-viewer__viewport'));
|
||||
};
|
||||
|
||||
attach();
|
||||
|
||||
return () => {
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [viewportRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewportWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
const desiredWidth = viewportWidth;
|
||||
setRenderWidth((current) => {
|
||||
if (current === 0 || desiredWidth > current + RERENDER_DELTA) {
|
||||
return desiredWidth;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, [viewportWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const focus = focusTargetRef.current;
|
||||
if (!focus) {
|
||||
return;
|
||||
}
|
||||
const viewportElement = viewportRef?.current || containerRef.current?.closest('.document-viewer__viewport');
|
||||
const stackElement = containerRef.current;
|
||||
if (!viewportElement || !stackElement) {
|
||||
focusTargetRef.current = null;
|
||||
return;
|
||||
}
|
||||
const contentWidth = Math.max(1, stackElement.scrollWidth || stackElement.clientWidth);
|
||||
const contentHeight = Math.max(1, stackElement.scrollHeight || stackElement.clientHeight);
|
||||
const targetX = focus.ratioX * contentWidth;
|
||||
const targetY = focus.ratioY * contentHeight;
|
||||
const nextScrollLeft = Math.max(0, targetX - focus.pointerOffsetX);
|
||||
const nextScrollTop = Math.max(0, targetY - focus.pointerOffsetY);
|
||||
viewportElement.scrollTo({
|
||||
left: nextScrollLeft,
|
||||
top: nextScrollTop,
|
||||
behavior: 'auto',
|
||||
});
|
||||
focusTargetRef.current = null;
|
||||
}, [viewMode, viewportRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src || renderWidth <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let loadingTask: PDFDocumentLoadingTask | null = null;
|
||||
let activePdf: PDFDocumentProxy | null = null;
|
||||
const wasmUrl = ensureWasmUrl();
|
||||
|
||||
const disposeActivePdf = () => {
|
||||
if (activePdf) {
|
||||
void activePdf.destroy();
|
||||
if (pdfRef.current === activePdf) {
|
||||
pdfRef.current = null;
|
||||
}
|
||||
activePdf = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (pdfRef.current) {
|
||||
void pdfRef.current.destroy();
|
||||
pdfRef.current = null;
|
||||
}
|
||||
|
||||
setPages([]);
|
||||
setStatus('loading');
|
||||
setErrorMessage(null);
|
||||
const renderDocument = async () => {
|
||||
try {
|
||||
loadingTask = getDocument({
|
||||
url: src,
|
||||
wasmUrl,
|
||||
});
|
||||
loadingTask.onPassword = (callback, reason) => {
|
||||
setIsEncrypted(true);
|
||||
setPasswordCallback(() => callback);
|
||||
if (reason === PasswordResponses.INCORRECT_PASSWORD) {
|
||||
setPasswordError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const pdf = await loadingTask.promise;
|
||||
if (cancelled) {
|
||||
await pdf.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
|
||||
const descriptors: PageDescriptor[] = [];
|
||||
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||
if (cancelled) {
|
||||
break;
|
||||
}
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
if (cancelled) {
|
||||
break;
|
||||
}
|
||||
const baseViewport = page.getViewport({ scale: 1 });
|
||||
const desiredScale = Math.max(0.5, renderWidth / baseViewport.width);
|
||||
const viewport = page.getViewport({ scale: desiredScale });
|
||||
descriptors.push({
|
||||
number: pageNumber,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
scale: desiredScale,
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
await pdf.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
activePdf = pdf;
|
||||
pdfRef.current = pdf;
|
||||
setPixelRatio(ratio);
|
||||
setPages(descriptors);
|
||||
setStatus('ready');
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error('Failed to prepare PDF preview', error);
|
||||
setStatus('error');
|
||||
setErrorMessage('Unable to render PDF preview.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderDocument().catch((error) => {
|
||||
console.error('Unhandled PDF render error', error);
|
||||
setStatus('error');
|
||||
setErrorMessage('Unable to render PDF preview.');
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
loadingTask?.destroy();
|
||||
disposeActivePdf();
|
||||
};
|
||||
}, [ensureWasmUrl, renderWidth, src]);
|
||||
|
||||
const handlePasswordSubmit = useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (passwordCallback && passwordInputRef.current) {
|
||||
passwordCallback(passwordInputRef.current.value);
|
||||
}
|
||||
}, [passwordCallback]);
|
||||
|
||||
const viewerClasses = ['document-viewer__object', 'document-viewer__object--pdf', className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const statusMessage = status === 'error'
|
||||
? (errorMessage || 'Unable to render PDF preview.')
|
||||
: 'Preparing preview…';
|
||||
const statusClasses = ['pdf-viewer__status', status === 'error' ? 'pdf-viewer__status--error' : null]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const showStatus = status !== 'ready' || pages.length === 0;
|
||||
const stackStyle = useMemo<CSSProperties>(() => ({
|
||||
'--pdf-viewer-viewport-width': `${viewportWidth}px`,
|
||||
'--pdf-viewer-viewport-height': `${viewportHeight}px`,
|
||||
cursor: viewMode === 'fit-width' ? 'zoom-out' : 'zoom-in',
|
||||
}), [viewportHeight, viewportWidth, viewMode]);
|
||||
|
||||
const toggleViewMode = useCallback(() => {
|
||||
setViewMode((prev) => (prev === 'fit-width' ? 'contain' : 'fit-width'));
|
||||
}, []);
|
||||
|
||||
const handlePageClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const stack = containerRef.current;
|
||||
const viewportElement = viewportRef?.current || stack?.closest('.document-viewer__viewport');
|
||||
if (stack && viewportElement) {
|
||||
const viewportRect = viewportElement.getBoundingClientRect();
|
||||
const pointerOffsetX = event.clientX - viewportRect.left;
|
||||
const pointerOffsetY = event.clientY - viewportRect.top;
|
||||
const contentWidth = Math.max(1, stack.scrollWidth || stack.clientWidth);
|
||||
const contentHeight = Math.max(1, stack.scrollHeight || stack.clientHeight);
|
||||
const ratioX = (viewportElement.scrollLeft + pointerOffsetX) / contentWidth;
|
||||
const ratioY = (viewportElement.scrollTop + pointerOffsetY) / contentHeight;
|
||||
focusTargetRef.current = {
|
||||
ratioX: Math.max(0, Math.min(1, ratioX)),
|
||||
ratioY: Math.max(0, Math.min(1, ratioY)),
|
||||
pointerOffsetX: Math.max(0, Math.min(viewportElement.clientWidth, pointerOffsetX)),
|
||||
pointerOffsetY: Math.max(0, Math.min(viewportElement.clientHeight, pointerOffsetY)),
|
||||
};
|
||||
}
|
||||
event.stopPropagation();
|
||||
toggleViewMode();
|
||||
}, [toggleViewMode, viewportRef]);
|
||||
|
||||
const handlePageVisibilityChange = useCallback((pageNumber: number, isVisible: boolean) => {
|
||||
setVisiblePages((prev) => {
|
||||
const alreadyVisible = prev.has(pageNumber);
|
||||
if (alreadyVisible === isVisible) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Set(prev);
|
||||
if (isVisible) {
|
||||
next.add(pageNumber);
|
||||
} else {
|
||||
next.delete(pageNumber);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const shouldRenderPage = useCallback((pageNumber: number) => {
|
||||
if (visiblePages.size === 0) {
|
||||
return pageNumber === 1;
|
||||
}
|
||||
for (const visiblePage of visiblePages) {
|
||||
if (Math.abs(visiblePage - pageNumber) <= 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [visiblePages]);
|
||||
|
||||
const intersectionRoot = viewportRef?.current || null;
|
||||
|
||||
const updateScrollVelocityState = useCallback((nextVelocity: number) => {
|
||||
setScrollVelocity((previous) => {
|
||||
const wasFast = Math.abs(previous) > FAST_SCROLL_VELOCITY_THRESHOLD;
|
||||
const isFast = Math.abs(nextVelocity) > FAST_SCROLL_VELOCITY_THRESHOLD;
|
||||
if (!isFast && !wasFast && Math.abs(previous - nextVelocity) < SCROLL_VELOCITY_MIN_DELTA) {
|
||||
return previous;
|
||||
}
|
||||
if (Math.abs(previous - nextVelocity) < SCROLL_VELOCITY_MIN_DELTA && wasFast === isFast) {
|
||||
return previous;
|
||||
}
|
||||
return nextVelocity;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const registerPageNode = useCallback((pageNumber: number, node: HTMLElement | null) => {
|
||||
const map = pageNodeMapRef.current;
|
||||
const existing = map.get(pageNumber);
|
||||
if (existing === node) {
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
intersectionObserverRef.current?.unobserve(existing);
|
||||
map.delete(pageNumber);
|
||||
}
|
||||
if (node) {
|
||||
node.dataset.pageNumber = String(pageNumber);
|
||||
map.set(pageNumber, node);
|
||||
intersectionObserverRef.current?.observe(node);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let frame: number | null = null;
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let scrollElement: HTMLElement | null = null;
|
||||
let scrollListener: (() => void) | null = null;
|
||||
|
||||
const attachObserver = () => {
|
||||
const stackElement = containerRef.current;
|
||||
if (!stackElement) {
|
||||
frame = requestAnimationFrame(attachObserver);
|
||||
return;
|
||||
}
|
||||
const root = intersectionRoot
|
||||
|| stackElement.closest('.document-viewer__viewport')
|
||||
|| undefined;
|
||||
scrollElement = (viewportRef?.current || stackElement.closest('.document-viewer__viewport')) ?? null;
|
||||
scrollElementRef.current = scrollElement;
|
||||
if (scrollElement && !scrollListener) {
|
||||
scrollVelocityRef.current = {
|
||||
lastPosition: scrollElement.scrollTop,
|
||||
lastTime: performance.now(),
|
||||
velocity: 0,
|
||||
};
|
||||
scrollListener = () => {
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
const position = scrollElement.scrollTop;
|
||||
const elapsed = now - scrollVelocityRef.current.lastTime;
|
||||
if (elapsed <= 0) {
|
||||
return;
|
||||
}
|
||||
const velocity = (position - scrollVelocityRef.current.lastPosition) / elapsed;
|
||||
scrollVelocityRef.current = {
|
||||
lastPosition: position,
|
||||
lastTime: now,
|
||||
velocity,
|
||||
};
|
||||
updateScrollVelocityState(velocity);
|
||||
};
|
||||
scrollElement.addEventListener('scroll', scrollListener, { passive: true });
|
||||
}
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const attr = entry.target.getAttribute('data-page-number');
|
||||
if (!attr) {
|
||||
return;
|
||||
}
|
||||
const pageNumber = Number(attr);
|
||||
if (!(pageNumber > 0)) {
|
||||
return;
|
||||
}
|
||||
handlePageVisibilityChange(pageNumber, entry.isIntersecting);
|
||||
});
|
||||
}, {
|
||||
root: root as Element | undefined,
|
||||
rootMargin: '200px 0px',
|
||||
threshold: 0.1,
|
||||
});
|
||||
intersectionObserverRef.current = observer;
|
||||
pageNodeMapRef.current.forEach((node) => observer?.observe(node));
|
||||
};
|
||||
|
||||
attachObserver();
|
||||
|
||||
return () => {
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
observer?.disconnect();
|
||||
if (intersectionObserverRef.current === observer) {
|
||||
intersectionObserverRef.current = null;
|
||||
}
|
||||
if (scrollElement && scrollListener) {
|
||||
scrollElement.removeEventListener('scroll', scrollListener);
|
||||
}
|
||||
if (scrollElementRef.current === scrollElement) {
|
||||
scrollElementRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [handlePageVisibilityChange, intersectionRoot, updateScrollVelocityState, viewMode, viewportRef]);
|
||||
|
||||
const pageElements = useMemo(() => (
|
||||
pages.map((page) => (
|
||||
<Fragment key={`${page.number}-${Math.round(page.scale * 100)}-${Math.round(pixelRatio * 100)}`}>
|
||||
<PdfPageCanvas
|
||||
descriptor={page}
|
||||
pdfRef={pdfRef}
|
||||
pixelRatio={pixelRatio}
|
||||
onPageClick={handlePageClick}
|
||||
viewportWidth={viewportWidth}
|
||||
viewportHeight={viewportHeight}
|
||||
viewMode={viewMode}
|
||||
shouldRender={shouldRenderPage(page.number)}
|
||||
registerPageNode={registerPageNode}
|
||||
enqueueRender={enqueueRender}
|
||||
cancelRenderRequest={cancelRenderRequest}
|
||||
onRenderFinished={handleRenderFinished}
|
||||
scrollVelocity={scrollVelocity}
|
||||
/>
|
||||
</Fragment>
|
||||
))
|
||||
), [cancelRenderRequest, enqueueRender, handlePageClick, handleRenderFinished,
|
||||
pages, pixelRatio, registerPageNode, scrollVelocity, shouldRenderPage, viewportHeight, viewportWidth,
|
||||
viewMode]);
|
||||
|
||||
return (
|
||||
<div className={viewerClasses}>
|
||||
{isEncrypted && !pdfRef.current ? (
|
||||
<div
|
||||
className="pdf-viewer__password-container"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<form onSubmit={handlePasswordSubmit} className="pdf-viewer__password-form">
|
||||
<div className="pdf-viewer__password-message">
|
||||
This document is password protected.
|
||||
</div>
|
||||
<div className="pdf-viewer__password-input-group">
|
||||
<input
|
||||
ref={passwordInputRef}
|
||||
type="password"
|
||||
className="pdf-viewer__password-input"
|
||||
placeholder="Enter password"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" className="button button--primary">
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
{passwordError ? (
|
||||
<div className="pdf-viewer__password-error">
|
||||
Incorrect password. Please try again.
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`pdf-viewer__canvas-stack pdf-viewer__canvas-stack--${viewMode}`}
|
||||
role="document"
|
||||
aria-label={title || 'PDF document'}
|
||||
style={stackStyle}
|
||||
>
|
||||
{pageElements}
|
||||
</div>
|
||||
{showStatus ? (
|
||||
<div className={statusClasses}>
|
||||
<div className="document-viewer__message">
|
||||
{statusMessage}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PdfPageCanvasProps {
|
||||
descriptor: PageDescriptor;
|
||||
pdfRef: MutableRefObject<PDFDocumentProxy | null>;
|
||||
pixelRatio: number;
|
||||
onPageClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
viewMode: ViewMode;
|
||||
shouldRender: boolean;
|
||||
registerPageNode: (pageNumber: number, node: HTMLElement | null) => void;
|
||||
enqueueRender: (request: RenderQueueRequest) => void;
|
||||
cancelRenderRequest: (pageNumber: number) => void;
|
||||
onRenderFinished: (pageNumber: number) => void;
|
||||
scrollVelocity: number;
|
||||
}
|
||||
|
||||
function PdfPageCanvas({
|
||||
descriptor,
|
||||
pdfRef,
|
||||
pixelRatio,
|
||||
onPageClick,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
viewMode,
|
||||
shouldRender,
|
||||
registerPageNode,
|
||||
enqueueRender,
|
||||
cancelRenderRequest,
|
||||
onRenderFinished,
|
||||
scrollVelocity,
|
||||
}: PdfPageCanvasProps): JSX.Element {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const renderTaskRef = useRef<RenderTask | null>(null);
|
||||
const hasRenderedRef = useRef(false);
|
||||
const setWrapperNode = useCallback((node: HTMLDivElement | null) => {
|
||||
wrapperRef.current = node;
|
||||
registerPageNode(descriptor.number, node);
|
||||
}, [descriptor.number, registerPageNode]);
|
||||
const [canvasMounted, setCanvasMounted] = useState(false);
|
||||
const [allowRender, setAllowRender] = useState(false);
|
||||
|
||||
const resetCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
const context = canvas.getContext('2d');
|
||||
context?.clearRect(0, 0, context.canvas.width || 0, context.canvas.height || 0);
|
||||
}, []);
|
||||
|
||||
const renderPage = useCallback(async () => {
|
||||
if (renderTaskRef.current || hasRenderedRef.current) {
|
||||
return;
|
||||
}
|
||||
const pdf = pdfRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!pdf || !canvas) {
|
||||
onRenderFinished(descriptor.number);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const page = await pdf.getPage(descriptor.number);
|
||||
const viewport = page.getViewport({ scale: descriptor.scale });
|
||||
const qualityScale = 1;
|
||||
const renderContext = canvas.getContext('2d');
|
||||
if (!renderContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.width = viewport.width * pixelRatio * qualityScale;
|
||||
canvas.height = viewport.height * pixelRatio * qualityScale;
|
||||
renderContext.setTransform(pixelRatio * qualityScale, 0, 0, pixelRatio * qualityScale, 0, 0);
|
||||
|
||||
const renderTask = page.render({
|
||||
canvasContext: renderContext,
|
||||
viewport,
|
||||
canvas,
|
||||
});
|
||||
renderTaskRef.current = renderTask;
|
||||
await renderTask.promise;
|
||||
hasRenderedRef.current = true;
|
||||
|
||||
page.cleanup();
|
||||
|
||||
const wrapper = wrapperRef.current;
|
||||
if (wrapper) {
|
||||
wrapper.querySelector('.textLayer')?.remove();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'RenderingCancelledException') {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to render PDF page', descriptor.number, error);
|
||||
} finally {
|
||||
renderTaskRef.current = null;
|
||||
onRenderFinished(descriptor.number);
|
||||
}
|
||||
}, [descriptor.number, descriptor.scale, onRenderFinished, pdfRef, pixelRatio]);
|
||||
|
||||
const meetsVelocityRequirement = useMemo(() => {
|
||||
if (!shouldRender) {
|
||||
return true;
|
||||
}
|
||||
const absoluteVelocity = Math.abs(scrollVelocity);
|
||||
if (absoluteVelocity <= FAST_SCROLL_VELOCITY_THRESHOLD) {
|
||||
return true;
|
||||
}
|
||||
const estimatedMs = (descriptor.height + viewportHeight)
|
||||
/ Math.max(absoluteVelocity, 0.001);
|
||||
return estimatedMs >= FAST_SCROLL_DWELL_THRESHOLD_MS;
|
||||
}, [descriptor.height, scrollVelocity, shouldRender, viewportHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasMounted) {
|
||||
return undefined;
|
||||
}
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return undefined;
|
||||
}
|
||||
canvas.width = descriptor.width;
|
||||
canvas.height = descriptor.height;
|
||||
canvas.style.setProperty('aspect-ratio', `${descriptor.width} / ${descriptor.height}`);
|
||||
const wrapperNode = wrapperRef.current;
|
||||
return () => {
|
||||
renderTaskRef.current?.cancel();
|
||||
wrapperNode?.querySelector('.textLayer')?.remove();
|
||||
};
|
||||
}, [canvasMounted, descriptor.height, descriptor.width]);
|
||||
|
||||
useEffect(() => {
|
||||
setCanvasMounted(false);
|
||||
hasRenderedRef.current = false;
|
||||
renderTaskRef.current?.cancel();
|
||||
resetCanvas();
|
||||
}, [descriptor.number, resetCanvas]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRender || !meetsVelocityRequirement) {
|
||||
setAllowRender(false);
|
||||
cancelRenderRequest(descriptor.number);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const request: RenderQueueRequest = {
|
||||
pageNumber: descriptor.number,
|
||||
resume: () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setAllowRender(true);
|
||||
},
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
setAllowRender(false);
|
||||
},
|
||||
};
|
||||
enqueueRender(request);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setAllowRender(false);
|
||||
cancelRenderRequest(descriptor.number);
|
||||
};
|
||||
}, [cancelRenderRequest, descriptor.number, enqueueRender, meetsVelocityRequirement, shouldRender]);
|
||||
|
||||
useEffect(() => {
|
||||
if (allowRender) {
|
||||
setCanvasMounted((prev) => (prev ? prev : true));
|
||||
return;
|
||||
}
|
||||
setCanvasMounted((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
hasRenderedRef.current = false;
|
||||
renderTaskRef.current?.cancel();
|
||||
wrapperRef.current?.querySelector('.textLayer')?.remove();
|
||||
resetCanvas();
|
||||
return false;
|
||||
});
|
||||
}, [allowRender, resetCanvas]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasMounted || !allowRender) {
|
||||
return;
|
||||
}
|
||||
void renderPage();
|
||||
}, [allowRender, canvasMounted, renderPage]);
|
||||
|
||||
const limitAxis: 'width' | 'height' = useMemo(() => {
|
||||
if (viewMode === 'fit-width') {
|
||||
return 'width';
|
||||
}
|
||||
const safeViewportWidth = Math.max(1, viewportWidth);
|
||||
const safeViewportHeight = Math.max(1, viewportHeight);
|
||||
const safePageWidth = Math.max(1, descriptor.width);
|
||||
const safePageHeight = Math.max(1, descriptor.height);
|
||||
const widthScale = safeViewportWidth / safePageWidth;
|
||||
const heightScale = safeViewportHeight / safePageHeight;
|
||||
return widthScale <= heightScale ? 'width' : 'height';
|
||||
}, [descriptor.height, descriptor.width, viewMode, viewportHeight, viewportWidth]);
|
||||
|
||||
const wrapperClassName = useMemo(() => (
|
||||
['pdf-viewer__page-wrapper',
|
||||
limitAxis === 'width' ? 'pdf-viewer__page-wrapper--limit-width' : 'pdf-viewer__page-wrapper--limit-height',
|
||||
].filter(Boolean).join(' ')
|
||||
), [limitAxis]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setWrapperNode}
|
||||
className={wrapperClassName}
|
||||
data-page-number={descriptor.number}
|
||||
style={{
|
||||
aspectRatio: `${descriptor.width} / ${descriptor.height}`,
|
||||
'--pdf-viewer-page-width': `${descriptor.width}px`,
|
||||
'--pdf-viewer-page-height': `${descriptor.height}px`,
|
||||
'--pdf-viewer-page-aspect': `${descriptor.width / descriptor.height}`,
|
||||
}}
|
||||
onClick={(event) => {
|
||||
onPageClick(event);
|
||||
}}
|
||||
>
|
||||
{canvasMounted ? (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pdf-viewer__page"
|
||||
aria-label={`Page ${descriptor.number}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PdfViewer;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import PreviewZoomOverlay from './components/PreviewZoomOverlay';
|
||||
import type { Document } from '../types/documents';
|
||||
import type { Identifier } from '../types/identifiers';
|
||||
import { createSafeContext } from '../utils/createSafeContext';
|
||||
|
||||
interface PreviewContextType {
|
||||
openPreview: (doc: Document) => void;
|
||||
closePreview: () => void;
|
||||
}
|
||||
|
||||
const [PreviewContext, usePreviewContext] = createSafeContext<PreviewContextType>('Preview');
|
||||
|
||||
interface PreviewProviderProps {
|
||||
children: React.ReactNode;
|
||||
onNavigate?: (documentId: Identifier) => void;
|
||||
}
|
||||
|
||||
export const PreviewProvider: React.FC<PreviewProviderProps> = ({ children, onNavigate }) => {
|
||||
const [previewDoc, setPreviewDoc] = useState<Document | null>(null);
|
||||
const lastFocusedElement = useRef<HTMLElement | null>(null);
|
||||
|
||||
const openPreview = useCallback((doc: Document) => {
|
||||
if (!lastFocusedElement.current) {
|
||||
lastFocusedElement.current = document.activeElement as HTMLElement;
|
||||
}
|
||||
setPreviewDoc(doc);
|
||||
}, []);
|
||||
|
||||
const closePreview = useCallback(() => {
|
||||
setPreviewDoc(null);
|
||||
if (lastFocusedElement.current) {
|
||||
lastFocusedElement.current.focus();
|
||||
lastFocusedElement.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMaximize = useCallback(() => {
|
||||
if (previewDoc && onNavigate) {
|
||||
onNavigate(previewDoc.id);
|
||||
closePreview();
|
||||
}
|
||||
}, [previewDoc, onNavigate, closePreview]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
openPreview,
|
||||
closePreview,
|
||||
}), [openPreview, closePreview]);
|
||||
|
||||
return (
|
||||
<PreviewContext.Provider value={value}>
|
||||
{children}
|
||||
<PreviewZoomOverlay
|
||||
open={Boolean(previewDoc)}
|
||||
onClose={closePreview}
|
||||
onMaximize={onNavigate ? handleMaximize : undefined}
|
||||
document={previewDoc}
|
||||
/>
|
||||
</PreviewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { usePreviewContext };
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PdfViewer from './PdfViewer';
|
||||
import MediaViewer from './MediaViewer';
|
||||
import type { Document } from '../types/documents';
|
||||
|
||||
interface UnifiedDocumentViewerProps {
|
||||
document: Document | null;
|
||||
viewportRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const UnifiedDocumentViewer: React.FC<UnifiedDocumentViewerProps> = ({
|
||||
document,
|
||||
viewportRef,
|
||||
}) => {
|
||||
const content = useMemo(() => {
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const downloadUrl = document.current_version?.download?.url;
|
||||
if (!downloadUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedMimeType = (document.mime_type || '').toLowerCase();
|
||||
const normalizedFilename = document.filename;
|
||||
|
||||
const isPdf = normalizedMimeType === 'application/pdf'
|
||||
|| normalizedMimeType === 'application/x-pdf';
|
||||
|
||||
if (isPdf) {
|
||||
return (
|
||||
<PdfViewer
|
||||
src={downloadUrl}
|
||||
title={document.title}
|
||||
viewportRef={viewportRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaViewer
|
||||
src={downloadUrl}
|
||||
mimeType={normalizedMimeType}
|
||||
filename={normalizedFilename}
|
||||
alt={document.title}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
document,
|
||||
viewportRef,
|
||||
]);
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
export default UnifiedDocumentViewer;
|
||||
@@ -0,0 +1,493 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
|
||||
import { describeDocumentSummary, extractDocumentMetadataPayload, type DocumentSummaryRow } from '../logic/documentSummary';
|
||||
|
||||
type PanelTab = { id: string; label: string; render: (context?: Record<string, unknown>) => ReactNode };
|
||||
|
||||
type ContentState =
|
||||
| { status: 'idle'; data: null; error: null }
|
||||
| { status: 'loading'; data: null; error: null }
|
||||
| { status: 'loaded'; data: string; error: null }
|
||||
| { status: 'empty'; data: string; error: null }
|
||||
| { status: 'unavailable'; data: null; error: null }
|
||||
| { status: 'error'; data: null; error: unknown };
|
||||
|
||||
export interface DocumentInfoPanelProps {
|
||||
document: DocumentSummarySectionProps['document'];
|
||||
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'layout'>;
|
||||
metadataItems?: DocumentSummaryRow[];
|
||||
metadataPayload?: Record<string, unknown>;
|
||||
metadataTabLabel?: string;
|
||||
detailsTabLabel?: string;
|
||||
contentConfig?: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
enabled?: boolean;
|
||||
forceDisplay?: boolean;
|
||||
loadContent?: (args: { signal: AbortSignal }) => Promise<string>;
|
||||
onCancel?: () => void;
|
||||
loadingMessage?: string;
|
||||
emptyMessage?: string;
|
||||
unavailableMessage?: string;
|
||||
errorMessage?: string;
|
||||
renderContent?: (data: string) => ReactNode;
|
||||
} | null;
|
||||
activeTab?: string;
|
||||
onTabChange?: (tabId: string) => void;
|
||||
defaultTabId?: string;
|
||||
resetKey?: string | null;
|
||||
classNamePrefix?: string;
|
||||
hideTabNavWhenSingle?: boolean;
|
||||
summaryPlacement?: 'inline' | 'tabs';
|
||||
summaryTabLabel?: string;
|
||||
summaryTabId?: string;
|
||||
leadingTabs?: PanelTab[];
|
||||
trailingTabs?: PanelTab[];
|
||||
tabsPlacement?: 'top' | 'bottom';
|
||||
summaryLayout?: 'default' | 'compact';
|
||||
}
|
||||
|
||||
const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
|
||||
document,
|
||||
summaryProps = {},
|
||||
metadataItems: metadataItemsProp,
|
||||
metadataPayload: metadataPayloadProp,
|
||||
metadataTabLabel = 'Metadata',
|
||||
detailsTabLabel = 'Details',
|
||||
contentConfig: contentConfigProp = null,
|
||||
activeTab: controlledActiveTab,
|
||||
onTabChange,
|
||||
defaultTabId = 'details',
|
||||
resetKey = null,
|
||||
classNamePrefix = 'document-info',
|
||||
hideTabNavWhenSingle = true,
|
||||
summaryPlacement = 'inline',
|
||||
summaryTabLabel = 'Summary',
|
||||
summaryTabId = 'summary',
|
||||
leadingTabs = [],
|
||||
trailingTabs = [],
|
||||
tabsPlacement = 'top',
|
||||
summaryLayout = 'default',
|
||||
}) => {
|
||||
const base = classNamePrefix;
|
||||
|
||||
const metadataItems = useMemo(() => {
|
||||
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
|
||||
return metadataItemsProp;
|
||||
}
|
||||
return describeDocumentSummary(document);
|
||||
}, [metadataItemsProp, document]);
|
||||
|
||||
const metadataPayload = useMemo(() => {
|
||||
if (metadataPayloadProp !== undefined) {
|
||||
return metadataPayloadProp;
|
||||
}
|
||||
return extractDocumentMetadataPayload(document);
|
||||
}, [metadataPayloadProp, document]);
|
||||
|
||||
const contentConfig = contentConfigProp || null;
|
||||
const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true));
|
||||
const loadContent = contentConfig?.loadContent ?? null;
|
||||
const showContentTab = Boolean(contentConfig && (contentConfig.forceDisplay ?? contentEnabled));
|
||||
|
||||
const [contentState, setContentState] = useState<ContentState | null>(() => {
|
||||
if (!contentConfig) {
|
||||
return null;
|
||||
}
|
||||
if (!contentEnabled || !loadContent) {
|
||||
return { status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null };
|
||||
}
|
||||
return { status: 'idle', data: null, error: null };
|
||||
});
|
||||
|
||||
|
||||
|
||||
const renderSummarySection = useCallback(() => (
|
||||
<DocumentSummarySection
|
||||
document={document}
|
||||
layout={summaryLayout}
|
||||
{...summaryProps}
|
||||
/>
|
||||
), [document, summaryLayout, summaryProps]);
|
||||
|
||||
const renderDetailsSection = useCallback(() => (
|
||||
<section className={`${base}__section`}>
|
||||
{metadataItems.length ? (
|
||||
<dl className={`${base}__section-list`}>
|
||||
{metadataItems.map(({ key, label, value }) => (
|
||||
<div className={`${base}__section-item`} key={key || label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value || '—'}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<p className={`${base}__section-placeholder`}>No details available.</p>
|
||||
)}
|
||||
</section>
|
||||
), [base, metadataItems]);
|
||||
|
||||
const summaryInline = summaryPlacement !== 'tabs';
|
||||
|
||||
const summaryTab = useMemo(() => {
|
||||
if (summaryPlacement !== 'tabs') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: summaryTabId,
|
||||
label: summaryTabLabel,
|
||||
render: () => (
|
||||
<div className={`${base}__summary-tab-content`}>
|
||||
{renderSummarySection()}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}, [summaryPlacement, summaryTabId, summaryTabLabel, base, renderSummarySection]);
|
||||
|
||||
const normalizedLeadingTabs = useMemo(
|
||||
() => (Array.isArray(leadingTabs)
|
||||
? leadingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
|
||||
: []),
|
||||
[leadingTabs],
|
||||
);
|
||||
|
||||
const normalizedTrailingTabs = useMemo(
|
||||
() => (Array.isArray(trailingTabs)
|
||||
? trailingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
|
||||
: []),
|
||||
[trailingTabs],
|
||||
);
|
||||
|
||||
const summaryNode = summaryInline
|
||||
? (
|
||||
<>
|
||||
{renderSummarySection()}
|
||||
{renderDetailsSection()}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
|
||||
const visibleTabs = useMemo(() => {
|
||||
const tabsList: PanelTab[] = [];
|
||||
|
||||
if (normalizedLeadingTabs.length) {
|
||||
tabsList.push(...normalizedLeadingTabs);
|
||||
}
|
||||
|
||||
if (summaryTab) {
|
||||
tabsList.push(summaryTab);
|
||||
}
|
||||
|
||||
if (summaryPlacement !== 'tabs') {
|
||||
tabsList.push({
|
||||
id: 'details',
|
||||
label: detailsTabLabel,
|
||||
render: () => renderDetailsSection(),
|
||||
});
|
||||
}
|
||||
|
||||
if (showContentTab && contentConfig) {
|
||||
tabsList.push({
|
||||
id: contentConfig.id || 'content',
|
||||
label: contentConfig.label || 'Content',
|
||||
render: () => {
|
||||
const messageClass = `${base}__message`;
|
||||
const errorClass = `${base}__message ${base}__message--error`;
|
||||
const objectClass = `${base}__object ${base}__object--text-content`;
|
||||
|
||||
if (!contentEnabled || !contentConfig.loadContent) {
|
||||
return (
|
||||
<div className={messageClass}>
|
||||
{contentConfig.unavailableMessage || 'Content not available.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!contentState) {
|
||||
return (
|
||||
<div className={messageClass}>
|
||||
{contentConfig.emptyMessage || 'No content available.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (contentState.status) {
|
||||
case 'loading':
|
||||
return (
|
||||
<div className={messageClass}>
|
||||
{contentConfig.loadingMessage || 'Loading content…'}
|
||||
</div>
|
||||
);
|
||||
case 'error': {
|
||||
const errorMessage =
|
||||
contentConfig.errorMessage
|
||||
|| (contentState.error instanceof Error ? contentState.error.message : null)
|
||||
|| 'Failed to load content.';
|
||||
return <div className={errorClass}>{errorMessage}</div>;
|
||||
}
|
||||
case 'empty':
|
||||
return (
|
||||
<div className={messageClass}>
|
||||
{contentConfig.emptyMessage || 'No content available.'}
|
||||
</div>
|
||||
);
|
||||
case 'loaded':
|
||||
return (
|
||||
<pre className={objectClass}>{contentState.data}</pre>
|
||||
);
|
||||
case 'unavailable':
|
||||
return (
|
||||
<div className={messageClass}>
|
||||
{contentConfig.unavailableMessage || 'Content not available.'}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className={messageClass}>
|
||||
{contentConfig.emptyMessage || 'No content available.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (metadataPayload) {
|
||||
tabsList.push({
|
||||
id: 'metadata',
|
||||
label: metadataTabLabel,
|
||||
render: () => (
|
||||
<section className={`${base}__section ${base}__section--metadata-json`}>
|
||||
<pre className={`${base}__metadata-json`}>
|
||||
{JSON.stringify(metadataPayload, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedTrailingTabs.length) {
|
||||
tabsList.push(...normalizedTrailingTabs);
|
||||
}
|
||||
|
||||
return tabsList;
|
||||
}, [
|
||||
base,
|
||||
detailsTabLabel,
|
||||
contentConfig,
|
||||
contentEnabled,
|
||||
contentState,
|
||||
metadataPayload,
|
||||
metadataTabLabel,
|
||||
showContentTab,
|
||||
summaryTab,
|
||||
normalizedLeadingTabs,
|
||||
normalizedTrailingTabs,
|
||||
summaryPlacement,
|
||||
renderDetailsSection,
|
||||
]);
|
||||
|
||||
const fallbackTabId = useMemo(() => {
|
||||
if (!visibleTabs.length) {
|
||||
return null;
|
||||
}
|
||||
if (defaultTabId && visibleTabs.some((tab) => tab.id === defaultTabId)) {
|
||||
return defaultTabId;
|
||||
}
|
||||
return visibleTabs[0].id;
|
||||
}, [visibleTabs, defaultTabId]);
|
||||
|
||||
const renderTabContent = (tab?: PanelTab | null, context: Record<string, unknown> = {}) => {
|
||||
if (!tab) {
|
||||
return null;
|
||||
}
|
||||
if (!tab.render) {
|
||||
return null;
|
||||
}
|
||||
return tab.render(context);
|
||||
};
|
||||
|
||||
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
|
||||
const [uncontrolledTab, setUncontrolledTab] = useState(
|
||||
isControlled ? controlledActiveTab : fallbackTabId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlled) {
|
||||
setUncontrolledTab(fallbackTabId);
|
||||
}
|
||||
}, [fallbackTabId, isControlled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
|
||||
const nextTab = fallbackTabId;
|
||||
if (nextTab && nextTab !== controlledActiveTab) {
|
||||
onTabChange?.(nextTab);
|
||||
}
|
||||
}
|
||||
}, [isControlled, controlledActiveTab, visibleTabs, fallbackTabId, onTabChange]);
|
||||
|
||||
const activeTabId = isControlled ? controlledActiveTab : uncontrolledTab;
|
||||
|
||||
// Track previous ID/key to avoid unnecessary resets on prop reference changes
|
||||
const prevDocIdRef = React.useRef(document?.id);
|
||||
const prevResetKeyRef = React.useRef(resetKey);
|
||||
const activeControllerRef = React.useRef<AbortController | null>(null);
|
||||
|
||||
// Reset content state when document changes or contentConfig becomes available
|
||||
useEffect(() => {
|
||||
const docIdChanged = prevDocIdRef.current !== document?.id;
|
||||
const resetKeyChanged = prevResetKeyRef.current !== resetKey;
|
||||
// Check if we need to initialize state (e.g. contentConfig was loaded asynchronously)
|
||||
const needsInit = contentConfig && !contentState;
|
||||
|
||||
if (docIdChanged || resetKeyChanged || needsInit) {
|
||||
prevDocIdRef.current = document?.id;
|
||||
prevResetKeyRef.current = resetKey;
|
||||
|
||||
if (!contentConfig || !showContentTab) {
|
||||
setContentState(null);
|
||||
return;
|
||||
}
|
||||
if (!contentEnabled || !loadContent) {
|
||||
setContentState({ status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null });
|
||||
return;
|
||||
}
|
||||
// Reset to idle so the loading effect can trigger if needed
|
||||
setContentState({ status: 'idle', data: null, error: null });
|
||||
}
|
||||
}, [
|
||||
contentConfig,
|
||||
showContentTab,
|
||||
contentEnabled,
|
||||
loadContent,
|
||||
document?.id,
|
||||
resetKey,
|
||||
contentState,
|
||||
]);
|
||||
|
||||
// Cleanup effect: aborts when inputs change or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
activeControllerRef.current?.abort();
|
||||
contentConfig?.onCancel?.();
|
||||
};
|
||||
}, [activeTabId, contentConfig, loadContent, contentEnabled, document?.id]);
|
||||
|
||||
// Loading effect: triggers load when status is idle
|
||||
useEffect(() => {
|
||||
const contentTabId = contentConfig?.id || 'content';
|
||||
const isActive = activeTabId === contentTabId;
|
||||
|
||||
if (!isActive || !contentConfig || !loadContent || !contentEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentState?.status !== 'idle') {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
activeControllerRef.current = controller;
|
||||
|
||||
setContentState({ status: 'loading', data: null, error: null });
|
||||
|
||||
Promise.resolve(loadContent({ signal: controller.signal }))
|
||||
.then((result) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (result && result.length) {
|
||||
setContentState({ status: 'loaded', data: result, error: null });
|
||||
} else {
|
||||
setContentState({ status: 'empty', data: '', error: null });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted || error?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
setContentState({
|
||||
status: 'error',
|
||||
data: null,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}, [
|
||||
activeTabId,
|
||||
contentConfig,
|
||||
loadContent,
|
||||
contentEnabled,
|
||||
contentState?.status,
|
||||
document?.id,
|
||||
]);
|
||||
|
||||
const handleTabSelect = (tabId) => {
|
||||
if (!visibleTabs.some((tab) => tab.id === tabId)) {
|
||||
return;
|
||||
}
|
||||
if (!isControlled) {
|
||||
setUncontrolledTab(tabId);
|
||||
}
|
||||
if (tabId !== activeTabId) {
|
||||
onTabChange?.(tabId);
|
||||
}
|
||||
};
|
||||
|
||||
const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null;
|
||||
const shouldHideNav = hideTabNavWhenSingle && singleTab;
|
||||
|
||||
const tabNav = (
|
||||
<div className={`${base}__tabs`} role="tablist" aria-label="Document details">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab.id === activeTabId}
|
||||
className={`${base}__tab${tab.id === activeTabId ? ' is-active' : ''}`}
|
||||
onClick={() => handleTabSelect(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const tabPanels = (
|
||||
<div className={`${base}__tabpanes`}>
|
||||
{visibleTabs.map((tab) => (
|
||||
tab.id === activeTabId ? (
|
||||
<div key={tab.id} role="tabpanel" className={`${base}__tabpanel`}>
|
||||
{renderTabContent(tab, { document })}
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const tabsWrapperClass = `${base}__tabs-wrapper${tabsPlacement === 'bottom' ? ` ${base}__tabs-wrapper--bottom` : ''}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{summaryNode}
|
||||
{shouldHideNav ? (
|
||||
<div className={`${base}__tabpanes ${base}__tabpanes--single`}>
|
||||
<div className={`${base}__tabpanel`}>
|
||||
{renderTabContent(singleTab, { document })}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={tabsWrapperClass}>
|
||||
{tabsPlacement !== 'bottom' ? tabNav : null}
|
||||
{tabsPlacement === 'bottom' ? tabPanels : null}
|
||||
{tabsPlacement === 'bottom' ? tabNav : null}
|
||||
{tabsPlacement !== 'bottom' ? tabPanels : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentInfoPanel;
|
||||
@@ -0,0 +1,805 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { EditIcon, IconX, PlusIcon } from '../../components/icons';
|
||||
import InlineRenameInput from '../../documents/components/InlineRenameInput';
|
||||
import SelectionAssignmentMenu, {
|
||||
SelectionAssignmentMenuItem,
|
||||
type NormalizedSelectionAssignmentItem,
|
||||
} from '../../documents/features/selection/SelectionAssignmentMenu';
|
||||
import { getTagColorStyle } from '../../utils/colors';
|
||||
import {
|
||||
formatDate,
|
||||
toDateInputValue,
|
||||
toIssuedTimestamp,
|
||||
} from '../../utils/date';
|
||||
import { describeDocumentSummary, type DocumentSummaryRow } from '../logic/documentSummary';
|
||||
|
||||
import { useFolderManager } from '../../folders/FolderManagerContext';
|
||||
import type { FolderId, Identifier, TagId } from '../../types/identifiers';
|
||||
|
||||
interface TagEntry {
|
||||
id?: TagId;
|
||||
label?: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface CorrespondentEntry {
|
||||
id?: Identifier;
|
||||
name?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
import type { Document } from '../../types/documents';
|
||||
|
||||
interface TagSectionProps {
|
||||
tags?: TagEntry[];
|
||||
onRemove?: (tag: TagEntry) => void;
|
||||
onAdd?: (payload: { value: string; option?: unknown; input?: unknown }) => void;
|
||||
emptyMessage?: string;
|
||||
addPlaceholder?: string;
|
||||
addButtonLabel?: string;
|
||||
datalistOptions?: Array<SelectionAssignmentMenuItem | string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CorrespondentSectionProps {
|
||||
entries?: CorrespondentEntry[];
|
||||
onRemove?: (entry: CorrespondentEntry) => void;
|
||||
onAdd?: (payload: { name: string; option?: unknown; input?: unknown }) => void;
|
||||
showCount?: boolean;
|
||||
addPlaceholder?: string;
|
||||
addButtonLabel?: string;
|
||||
datalistOptions?: Array<SelectionAssignmentMenuItem | string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface DocumentSummarySectionProps {
|
||||
document?: Document | null;
|
||||
tagLookupById?: Map<TagId, TagEntry>;
|
||||
tagOptions?: SelectionAssignmentMenuItem[];
|
||||
onTagAdd?: (doc: Document, value: string, context?: { option?: unknown }) => void;
|
||||
onTagRemove?: (docId: Identifier | undefined, tagId: TagId | undefined) => void;
|
||||
correspondents?: CorrespondentEntry[];
|
||||
correspondentOptions?: SelectionAssignmentMenuItem[];
|
||||
onCorrespondentAdd?: (payload: { document: Document; name: string; option?: unknown }) => void;
|
||||
onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void;
|
||||
onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise<boolean> | boolean;
|
||||
onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise<boolean> | boolean;
|
||||
onFolderNavigate?: (folderId: FolderId | null) => void;
|
||||
layout?: 'default' | 'compact';
|
||||
}
|
||||
|
||||
interface MetaItem {
|
||||
key: string;
|
||||
label: string;
|
||||
valueContent?: React.ReactNode | null;
|
||||
fallbackValue?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const sortCorrespondents = (entries = []) =>
|
||||
entries
|
||||
.filter((entry) => entry && entry.name)
|
||||
.map(({ id, name, count }) => ({ id, name, count }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
export const buildCorrespondentOptions = (entries = []) => {
|
||||
const seen = new Set();
|
||||
return entries.reduce((options, entry) => {
|
||||
const name = entry?.name?.trim?.() || '';
|
||||
if (!name) {
|
||||
return options;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
return options;
|
||||
}
|
||||
seen.add(key);
|
||||
options.push(name);
|
||||
return options;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const normalizeOptions = <T,>(options?: T[] | null): T[] => (Array.isArray(options) ? options : []);
|
||||
|
||||
interface QuickAddOption {
|
||||
id?: Identifier;
|
||||
label?: string;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface QuickAddEntry {
|
||||
id: Identifier | string;
|
||||
label: string;
|
||||
original: QuickAddOption | string;
|
||||
}
|
||||
|
||||
const resolveOptionName = (source?: QuickAddOption | string | null): string => {
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
if (typeof source === 'string') {
|
||||
return source.trim();
|
||||
}
|
||||
const raw = source.name ?? source.label ?? '';
|
||||
return `${raw}`.trim();
|
||||
};
|
||||
|
||||
const normalizeQuickAddOption = (option?: QuickAddOption | string | null): QuickAddEntry | null => {
|
||||
if (option == null) {
|
||||
return null;
|
||||
}
|
||||
const label = (() => {
|
||||
if (typeof option === 'string') {
|
||||
return option.trim();
|
||||
}
|
||||
const sourceLabel = option.label ?? option.name ?? '';
|
||||
return `${sourceLabel}`.trim();
|
||||
})();
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: typeof option !== 'string' && option.id ? option.id : label,
|
||||
label,
|
||||
original: option,
|
||||
};
|
||||
};
|
||||
|
||||
const TagSection: React.FC<TagSectionProps> = ({
|
||||
tags = [],
|
||||
onRemove,
|
||||
onAdd,
|
||||
emptyMessage = 'No tags yet.',
|
||||
addPlaceholder = 'Add or create tag',
|
||||
addButtonLabel = 'Add',
|
||||
datalistOptions = [],
|
||||
className,
|
||||
}) => {
|
||||
const handleCreate = useCallback(
|
||||
(label: string) => onAdd?.({ value: label, input: null }),
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(option: { label?: string; name?: string } | string | null) => {
|
||||
if (!onAdd) return;
|
||||
const label = resolveOptionName(option as QuickAddOption | string | null);
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
onAdd({ value: label, option });
|
||||
},
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
const normalizedOptions = useMemo(
|
||||
() =>
|
||||
normalizeOptions(datalistOptions)
|
||||
.map((option) => normalizeQuickAddOption(option))
|
||||
.filter((option): option is QuickAddEntry => Boolean(option)),
|
||||
[datalistOptions],
|
||||
);
|
||||
const containerClass = className ? `tag-list ${className}` : 'tag-list';
|
||||
const showQuickAdd = Boolean(onAdd);
|
||||
|
||||
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||
const map = new Map<string, SelectionAssignmentMenuItem>();
|
||||
|
||||
normalizedOptions.forEach((option) => {
|
||||
const label = option?.label?.trim();
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
const key = label.toLowerCase();
|
||||
if (map.has(key)) {
|
||||
return;
|
||||
}
|
||||
map.set(key, {
|
||||
id: option.id ?? label,
|
||||
label,
|
||||
state: 'none',
|
||||
payload: option.original ?? { label },
|
||||
});
|
||||
});
|
||||
|
||||
tags.forEach((tag) => {
|
||||
const label = tag?.label?.trim?.() || '';
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
const key = label.toLowerCase();
|
||||
const payload = { id: tag.id, label, color: tag.color ?? null };
|
||||
if (map.has(key)) {
|
||||
const entry = map.get(key);
|
||||
if (entry) {
|
||||
entry.state = 'all';
|
||||
entry.payload = payload;
|
||||
}
|
||||
return;
|
||||
}
|
||||
map.set(key, {
|
||||
id: tag.id ?? label,
|
||||
label,
|
||||
state: 'all',
|
||||
payload,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
}, [normalizedOptions, tags]);
|
||||
|
||||
const handleAssignmentSelect = useCallback(
|
||||
(item: NormalizedSelectionAssignmentItem) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
if (item.state === 'all' && onRemove) {
|
||||
const payload = item.payload && typeof item.payload === 'object' && 'id' in item.payload
|
||||
? (item.payload as TagEntry)
|
||||
: tags.find((tag) => (tag.id ?? tag.label) === item.id) ?? { id: item.id, label: item.label };
|
||||
onRemove(payload);
|
||||
return;
|
||||
}
|
||||
const payload = item.payload ?? { label: item.label };
|
||||
handleSelect(payload);
|
||||
},
|
||||
[handleSelect, onRemove, tags],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{tags.map((tag) => {
|
||||
const key = tag.id ?? tag.label;
|
||||
const style = getTagColorStyle(tag.color);
|
||||
return (
|
||||
<span key={key} className="badge tag-chip" style={style || undefined}>
|
||||
<span className="tag-chip__label">{tag.label}</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-chip__remove"
|
||||
onClick={() => onRemove(tag)}
|
||||
aria-label={`Remove tag ${tag.label}`}
|
||||
>
|
||||
<IconX className="icon-inline" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{showQuickAdd ? (
|
||||
<SelectionAssignmentMenu
|
||||
label="Add tag"
|
||||
items={assignmentItems}
|
||||
placeholder={addPlaceholder}
|
||||
emptyMessage="No tags"
|
||||
createLabel={addButtonLabel}
|
||||
onToggle={handleAssignmentSelect}
|
||||
onCreate={handleCreate}
|
||||
showStateIndicators
|
||||
showCounts={false}
|
||||
positionStrategy="fixed"
|
||||
triggerClassName="quick-add__chip quick-add__trigger"
|
||||
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
|
||||
closeOnSelection={false}
|
||||
freezeSortOnOpen
|
||||
/>
|
||||
) : null}
|
||||
{!tags.length && !showQuickAdd ? <span className="tag-list__empty meta">{emptyMessage}</span> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
|
||||
entries = [],
|
||||
onRemove,
|
||||
onAdd,
|
||||
showCount = false,
|
||||
addPlaceholder = 'Add or create correspondent',
|
||||
addButtonLabel = 'Add',
|
||||
datalistOptions = [],
|
||||
className,
|
||||
}) => {
|
||||
const handleCreate = useCallback(
|
||||
(name: string) => onAdd?.({ name, input: null }),
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
const normalizedOptions = useMemo(
|
||||
() =>
|
||||
normalizeOptions(datalistOptions)
|
||||
.map((option) => normalizeQuickAddOption(option))
|
||||
.filter((option): option is QuickAddEntry => Boolean(option)),
|
||||
[datalistOptions],
|
||||
);
|
||||
const hasEntries = entries && entries.length > 0;
|
||||
const showQuickAdd = Boolean(onAdd);
|
||||
const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
|
||||
|
||||
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||
const map = new Map<string, SelectionAssignmentMenuItem>();
|
||||
|
||||
normalizedOptions.forEach((option) => {
|
||||
const label = option?.label?.trim();
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
const key = label.toLowerCase();
|
||||
if (map.has(key)) {
|
||||
return;
|
||||
}
|
||||
map.set(key, {
|
||||
id: option.id ?? label,
|
||||
label,
|
||||
state: 'none',
|
||||
payload: option.original ?? { name: label },
|
||||
});
|
||||
});
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const label = entry?.name?.trim?.() || '';
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
const key = label.toLowerCase();
|
||||
const payload = { id: entry.id, name: label };
|
||||
if (map.has(key)) {
|
||||
const item = map.get(key);
|
||||
if (item) {
|
||||
item.state = 'all';
|
||||
item.payload = payload;
|
||||
}
|
||||
return;
|
||||
}
|
||||
map.set(key, {
|
||||
id: entry.id ?? label,
|
||||
label,
|
||||
state: 'all',
|
||||
payload,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
}, [normalizedOptions, entries]);
|
||||
|
||||
const handleAssignmentSelect = useCallback(
|
||||
(item: NormalizedSelectionAssignmentItem) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
if (item.state === 'all' && onRemove) {
|
||||
const payload = item.payload && typeof item.payload === 'object' && 'id' in item.payload
|
||||
? (item.payload as CorrespondentEntry)
|
||||
: entries.find((entry) => (entry.id ?? entry.name) === item.id) ?? { id: item.id, name: item.label };
|
||||
onRemove(payload);
|
||||
return;
|
||||
}
|
||||
if (!onAdd) {
|
||||
return;
|
||||
}
|
||||
const source = (item.payload ?? item) as QuickAddOption | string | null;
|
||||
const resolvedName = resolveOptionName(source);
|
||||
if (!resolvedName) {
|
||||
return;
|
||||
}
|
||||
const payload = typeof source !== 'string'
|
||||
? { ...source, name: resolvedName }
|
||||
: { id: null, name: resolvedName };
|
||||
onAdd({ name: resolvedName, option: payload, input: null });
|
||||
},
|
||||
[entries, onAdd, onRemove],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{hasEntries
|
||||
? entries.map((entry) => {
|
||||
const key = entry.id ?? entry.name;
|
||||
return (
|
||||
<span key={key} className="correspondent-pill">
|
||||
<span className="correspondent-pill__label">
|
||||
{entry.name}
|
||||
{showCount && entry.count ? ` (${entry.count})` : ''}
|
||||
</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="correspondent-pill__remove"
|
||||
onClick={() => onRemove(entry)}
|
||||
aria-label={`Remove ${entry.name}`}
|
||||
>
|
||||
<IconX className="icon-inline" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
: !showQuickAdd && <span className="meta">No correspondents yet.</span>}
|
||||
{showQuickAdd ? (
|
||||
<SelectionAssignmentMenu
|
||||
label="Add correspondent"
|
||||
items={assignmentItems}
|
||||
placeholder={addPlaceholder}
|
||||
emptyMessage="No correspondents"
|
||||
createLabel={addButtonLabel}
|
||||
onToggle={handleAssignmentSelect}
|
||||
onCreate={handleCreate}
|
||||
showStateIndicators
|
||||
showCounts={false}
|
||||
positionStrategy="fixed"
|
||||
triggerClassName="quick-add__chip quick-add__trigger"
|
||||
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
|
||||
closeOnSelection={false}
|
||||
freezeSortOnOpen
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
||||
document,
|
||||
tagLookupById = new Map(),
|
||||
tagOptions = [],
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents,
|
||||
correspondentOptions = [],
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
onFolderNavigate,
|
||||
layout = 'default',
|
||||
}) => {
|
||||
const folderManager = useFolderManager();
|
||||
const isCompactLayout = layout === 'compact';
|
||||
const summaryRows = useMemo(() => describeDocumentSummary(document), [document]);
|
||||
const issuedDateLabel = useMemo(
|
||||
() => formatDate(document?.issued_at, { fallback: null }),
|
||||
[document?.issued_at],
|
||||
);
|
||||
|
||||
const editableTitle = Boolean(document && onUpdateTitle);
|
||||
const editableIssued = Boolean(document && onUpdateIssued);
|
||||
|
||||
const resolvedTags = useMemo(() => {
|
||||
if (!Array.isArray(document?.tags)) {
|
||||
return [];
|
||||
}
|
||||
return document.tags.map((tag) => ({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color ?? tagLookupById.get(tag.id)?.color ?? null,
|
||||
})).sort((a, b) => {
|
||||
const labelA = (a.label || '').toLowerCase();
|
||||
const labelB = (b.label || '').toLowerCase();
|
||||
return labelA.localeCompare(labelB);
|
||||
});
|
||||
}, [document?.tags, tagLookupById]);
|
||||
|
||||
const resolvedCorrespondents = useMemo(() => {
|
||||
if (Array.isArray(correspondents) && correspondents.length) {
|
||||
return correspondents;
|
||||
}
|
||||
return sortCorrespondents(document?.correspondents || []);
|
||||
}, [correspondents, document?.correspondents]);
|
||||
|
||||
const extraSummaryRows = useMemo(() => {
|
||||
const rows: DocumentSummaryRow[] = [];
|
||||
const currentVersionNumber = document?.current_version?.version_number;
|
||||
if (currentVersionNumber != null) {
|
||||
rows.push({
|
||||
key: 'current-version',
|
||||
label: 'Current version',
|
||||
value: `#${currentVersionNumber}`,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [document?.current_version?.version_number]);
|
||||
|
||||
const resolvedFolderId = document?.folder_id ?? null;
|
||||
|
||||
const [folderName, setFolderName] = useState<string | null>(() => folderManager.getNameSync(resolvedFolderId));
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const cached = folderManager.getNameSync(resolvedFolderId);
|
||||
setFolderName(cached);
|
||||
if (!cached && resolvedFolderId != null) {
|
||||
folderManager.resolveName(resolvedFolderId).then((name) => {
|
||||
if (active) {
|
||||
setFolderName(name);
|
||||
}
|
||||
}).catch(() => { });
|
||||
}
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [resolvedFolderId, folderManager]);
|
||||
|
||||
const folderHref = resolvedFolderId == null ? '/documents' : `/documents/folder/${resolvedFolderId}`;
|
||||
|
||||
const handleFolderClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (!onFolderNavigate) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onFolderNavigate(resolvedFolderId);
|
||||
},
|
||||
[onFolderNavigate, resolvedFolderId],
|
||||
);
|
||||
|
||||
const [titleDraft, setTitleDraft] = useState('');
|
||||
const [titleSaving, setTitleSaving] = useState(false);
|
||||
const [titleError, setTitleError] = useState(null);
|
||||
const [isTitleEditing, setIsTitleEditing] = useState(false);
|
||||
|
||||
const [issuedDraft, setIssuedDraft] = useState('');
|
||||
const [issuedSaving, setIssuedSaving] = useState(false);
|
||||
const [issuedError, setIssuedError] = useState(null);
|
||||
const [isIssuedEditing, setIsIssuedEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTitleEditing(false);
|
||||
setTitleDraft('');
|
||||
setTitleError(null);
|
||||
setTitleSaving(false);
|
||||
|
||||
setIsIssuedEditing(false);
|
||||
setIssuedDraft('');
|
||||
setIssuedError(null);
|
||||
setIssuedSaving(false);
|
||||
}, [document?.id]);
|
||||
|
||||
const startTitleEdit = useCallback(() => {
|
||||
if (!editableTitle || !document) return;
|
||||
setIsTitleEditing(true);
|
||||
setTitleDraft(document.title || '');
|
||||
setTitleError(null);
|
||||
}, [document, editableTitle]);
|
||||
|
||||
const cancelTitleEdit = useCallback(() => {
|
||||
setIsTitleEditing(false);
|
||||
setTitleDraft('');
|
||||
setTitleError(null);
|
||||
setTitleSaving(false);
|
||||
}, []);
|
||||
|
||||
const submitTitleEdit = useCallback(
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!editableTitle || !document || !onUpdateTitle) return;
|
||||
const trimmed = titleDraft.trim();
|
||||
if (!trimmed) {
|
||||
setTitleError('Title cannot be empty.');
|
||||
return;
|
||||
}
|
||||
setTitleSaving(true);
|
||||
try {
|
||||
const ok = await onUpdateTitle(document.id, trimmed);
|
||||
if (ok) {
|
||||
cancelTitleEdit();
|
||||
} else {
|
||||
setTitleError('Failed to update title.');
|
||||
}
|
||||
} finally {
|
||||
setTitleSaving(false);
|
||||
}
|
||||
},
|
||||
[cancelTitleEdit, document, editableTitle, onUpdateTitle, titleDraft],
|
||||
);
|
||||
|
||||
const startIssuedEdit = useCallback(() => {
|
||||
if (!editableIssued || !document) return;
|
||||
setIsIssuedEditing(true);
|
||||
setIssuedDraft(toDateInputValue(document.issued_at));
|
||||
setIssuedError(null);
|
||||
}, [document, editableIssued]);
|
||||
|
||||
const cancelIssuedEdit = useCallback(() => {
|
||||
setIsIssuedEditing(false);
|
||||
setIssuedDraft('');
|
||||
setIssuedError(null);
|
||||
setIssuedSaving(false);
|
||||
}, []);
|
||||
|
||||
const submitIssuedEdit = useCallback(
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!editableIssued || !document || !onUpdateIssued) return;
|
||||
const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null;
|
||||
setIssuedSaving(true);
|
||||
try {
|
||||
const ok = await onUpdateIssued(document.id, normalizedValue);
|
||||
if (ok) {
|
||||
cancelIssuedEdit();
|
||||
} else {
|
||||
setIssuedError('Failed to update issued date.');
|
||||
}
|
||||
} finally {
|
||||
setIssuedSaving(false);
|
||||
}
|
||||
},
|
||||
[cancelIssuedEdit, document, editableIssued, issuedDraft, onUpdateIssued],
|
||||
);
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderTitleEditForm = (extraClassName?: string) => (
|
||||
<InlineRenameInput
|
||||
value={titleDraft}
|
||||
onChange={(value) => {
|
||||
setTitleDraft(value);
|
||||
if (titleError) {
|
||||
setTitleError(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={() => submitTitleEdit({ preventDefault: () => { } } as any)}
|
||||
onCancel={cancelTitleEdit}
|
||||
isSaving={titleSaving}
|
||||
className={`doc-title-edit${extraClassName ? ` ${extraClassName}` : ''}`}
|
||||
aria-label="Document title"
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
|
||||
const titleMetaDisplay = editableTitle && isTitleEditing
|
||||
? renderTitleEditForm('doc-title-edit--inline')
|
||||
: (
|
||||
<>
|
||||
<span className="detail-meta__value">{document?.title}</span>
|
||||
{editableTitle ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={startTitleEdit}
|
||||
aria-label="Edit title"
|
||||
title="Edit title"
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const issuedDisplay = editableIssued && isIssuedEditing ? (
|
||||
<InlineRenameInput
|
||||
type="date"
|
||||
value={issuedDraft}
|
||||
onChange={(value) => {
|
||||
setIssuedDraft(value);
|
||||
if (issuedError) {
|
||||
setIssuedError(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={() => submitIssuedEdit({ preventDefault: () => { } } as any)}
|
||||
onCancel={cancelIssuedEdit}
|
||||
isSaving={issuedSaving}
|
||||
className="doc-issued-edit"
|
||||
aria-label="Issued on"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="detail-meta__value">{issuedDateLabel || 'Not set'}</span>
|
||||
{editableIssued ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={startIssuedEdit}
|
||||
aria-label={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
|
||||
title={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const tagsValueContent = (
|
||||
<TagSection
|
||||
tags={resolvedTags}
|
||||
onRemove={
|
||||
onTagRemove
|
||||
? (tag) => onTagRemove(document.id, tag.id)
|
||||
: undefined
|
||||
}
|
||||
onAdd={
|
||||
onTagAdd
|
||||
? ({ value, option }) => onTagAdd(document, value, { option })
|
||||
: undefined
|
||||
}
|
||||
datalistOptions={tagOptions}
|
||||
className="document-summary__tags"
|
||||
/>
|
||||
);
|
||||
|
||||
const correspondentsValueContent = (
|
||||
<CorrespondentSection
|
||||
entries={resolvedCorrespondents}
|
||||
onRemove={
|
||||
onCorrespondentRemove
|
||||
? (entry) =>
|
||||
onCorrespondentRemove({
|
||||
documentId: document.id,
|
||||
correspondentId: entry.id,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onAdd={
|
||||
onCorrespondentAdd
|
||||
? ({ name, option }) =>
|
||||
onCorrespondentAdd({
|
||||
document,
|
||||
name,
|
||||
option,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
showCount
|
||||
datalistOptions={correspondentOptions}
|
||||
className="document-summary__correspondents"
|
||||
/>
|
||||
);
|
||||
|
||||
const folderValueContent = (
|
||||
<Link
|
||||
className="document-summary__folder-link"
|
||||
to={folderHref}
|
||||
onClick={handleFolderClick}
|
||||
>
|
||||
{folderName}
|
||||
</Link>
|
||||
);
|
||||
|
||||
const summaryRowOverrides = {
|
||||
title: { valueContent: titleMetaDisplay, error: titleError },
|
||||
issued: { valueContent: issuedDisplay, error: issuedError },
|
||||
tags: { valueContent: tagsValueContent },
|
||||
correspondents: { valueContent: correspondentsValueContent },
|
||||
folder: { valueContent: folderValueContent },
|
||||
} as Record<string, { valueContent?: React.ReactNode | null; error?: string | null }>;
|
||||
|
||||
const baseRows: MetaItem[] = [...summaryRows, ...extraSummaryRows].map((row) => {
|
||||
const overrides = summaryRowOverrides[row.key] || {};
|
||||
return {
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
valueContent: overrides.valueContent ?? null,
|
||||
fallbackValue: overrides.valueContent ? row.value : row.value,
|
||||
error: overrides.error ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const allRows = baseRows;
|
||||
const summaryClass = `document-summary${isCompactLayout ? ' document-summary--compact' : ''}`;
|
||||
const sectionClass = `document-summary__section document-summary__meta${isCompactLayout ? ' document-summary__meta--compact' : ''}`;
|
||||
const listClass = `document-summary__details-list${isCompactLayout ? ' document-summary__details-list--meta' : ''}`;
|
||||
|
||||
return (
|
||||
<div className={summaryClass}>
|
||||
<section className={sectionClass}>
|
||||
<dl className={listClass}>
|
||||
{allRows.map((item) => (
|
||||
<div key={item.key} className="document-summary__details-row">
|
||||
<dt>{item.label}</dt>
|
||||
<dd>
|
||||
{item.valueContent != null && item.valueContent !== ''
|
||||
? item.valueContent
|
||||
: item.fallbackValue || '—'}
|
||||
</dd>
|
||||
{item.error ? <div className="status-inline error">{item.error}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentSummarySection;
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import UnifiedDocumentViewer from '../UnifiedDocumentViewer';
|
||||
import PanelHeader from '../../components/PanelHeader';
|
||||
import { IconX, DownloadIcon, FileInfoIcon } from '../../components/icons';
|
||||
|
||||
import type { Document } from '../../types/documents';
|
||||
|
||||
interface PreviewZoomOverlayProps {
|
||||
open?: boolean;
|
||||
onClose: () => void;
|
||||
onMaximize?: () => void;
|
||||
document?: Document | null;
|
||||
}
|
||||
|
||||
const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
||||
open = false,
|
||||
onClose,
|
||||
onMaximize,
|
||||
document: inputDocument = null,
|
||||
}) => {
|
||||
const [renderBackdrop, setRenderBackdrop] = useState(false);
|
||||
const [isBackdropVisible, setBackdropVisible] = useState(false);
|
||||
const lastDocumentRef = useRef<Document | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
if (inputDocument) {
|
||||
lastDocumentRef.current = inputDocument;
|
||||
}
|
||||
|
||||
const downloadUrl = lastDocumentRef.current?.current_version?.download?.url;
|
||||
const documentTitle = lastDocumentRef.current?.title || undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
cancelAnimationFrame(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (open) {
|
||||
setRenderBackdrop(true);
|
||||
timerRef.current = requestAnimationFrame(() => {
|
||||
timerRef.current = requestAnimationFrame(() => {
|
||||
setBackdropVisible(true);
|
||||
scrollRef.current?.focus?.({ preventScroll: true });
|
||||
timerRef.current = null;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setBackdropVisible(false);
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setRenderBackdrop(false);
|
||||
timerRef.current = null;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
cancelAnimationFrame(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key;
|
||||
|
||||
if (key === ' ' || key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const stageClassName = 'preview-zoom__stage';
|
||||
const containerClassName = 'preview-zoom__scroll';
|
||||
|
||||
const backdropClassName = [
|
||||
'preview-zoom-backdrop',
|
||||
isBackdropVisible ? 'preview-zoom-backdrop--visible' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
if (!renderBackdrop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
(
|
||||
<div
|
||||
className={backdropClassName}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Enlarged document preview"
|
||||
onClick={onClose}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<PanelHeader
|
||||
className="panel-header--dark"
|
||||
title={documentTitle}
|
||||
leading={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="icon-button"
|
||||
aria-label="Close preview"
|
||||
type="button"
|
||||
>
|
||||
<IconX />
|
||||
</button>
|
||||
{onMaximize && (
|
||||
<button
|
||||
onClick={onMaximize}
|
||||
className="icon-button"
|
||||
aria-label="Open document info"
|
||||
title="Open document info"
|
||||
type="button"
|
||||
>
|
||||
<FileInfoIcon />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="icon-button"
|
||||
aria-label="Download document"
|
||||
title="Download document"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={stageClassName}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={containerClassName}
|
||||
ref={scrollRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<UnifiedDocumentViewer
|
||||
document={lastDocumentRef.current}
|
||||
viewportRef={scrollRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewZoomOverlay;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { formatFileSize } from '../../utils/format';
|
||||
import { formatDateTime as defaultFormatDateTime } from '../../utils/date';
|
||||
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
|
||||
|
||||
import type { DocumentTag, DocumentCorrespondent, Document } from '../../types/documents';
|
||||
|
||||
interface DescribeSummaryOptions {
|
||||
formatDateTime?: typeof defaultFormatDateTime;
|
||||
}
|
||||
|
||||
type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents' | 'folder';
|
||||
|
||||
export interface DocumentSummaryRow {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
kind?: DocumentSummaryRowType;
|
||||
}
|
||||
|
||||
type DocumentSummary = DocumentSummaryRow[];
|
||||
|
||||
const coercePageCount = (metadata?: { page_count?: number | string | null } | null): number | null => {
|
||||
const raw = metadata?.page_count;
|
||||
if (raw == null || raw === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(String(raw), 10);
|
||||
return parsed >= 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const sanitizeArray = <T>(entries?: Array<T | null> | null): T[] =>
|
||||
Array.isArray(entries) ? entries.filter(Boolean) as T[] : [];
|
||||
|
||||
interface DocumentMetadataPayload {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const describeDocumentSummary = (document?: Document | null, options: DescribeSummaryOptions = {}): DocumentSummary => {
|
||||
const {
|
||||
formatDateTime = defaultFormatDateTime,
|
||||
} = options;
|
||||
|
||||
const formatDateLabel = (value?: string | number | null) => {
|
||||
if (typeof value === 'number') {
|
||||
return formatDateTime(new Date(value)) || '—';
|
||||
}
|
||||
return formatDateTime(value) || '—';
|
||||
};
|
||||
const doc = document ?? ({} as Document);
|
||||
const sizeBytes = doc.current_version?.size_bytes ?? null;
|
||||
const sizeLabel = sizeBytes !== null && sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
|
||||
const metadata = doc.current_version?.metadata || null;
|
||||
const pageCount = coercePageCount(metadata);
|
||||
const pageCountLabel = pageCount !== null ? String(pageCount) : '—';
|
||||
const folderLabel = doc.folder_id == null ? DEFAULT_FOLDER_NAME : `Folder ${doc.folder_id}`;
|
||||
const tags = sanitizeArray<DocumentTag>(doc.tags);
|
||||
const correspondents = sanitizeArray<DocumentCorrespondent>(doc.correspondents);
|
||||
const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[];
|
||||
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean) as string[];
|
||||
const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—';
|
||||
const correspondentsSummary = correspondentLabels.length ? correspondentLabels.join(', ') : '—';
|
||||
return [
|
||||
{ key: 'title', label: 'Title', value: doc.title ?? null, kind: 'editable-title' },
|
||||
{ key: 'tags', label: 'Tags', value: tagsSummary, kind: 'tags' },
|
||||
{ key: 'correspondents', label: 'Correspondents', value: correspondentsSummary, kind: 'correspondents' },
|
||||
{ key: 'issued', label: 'Issued', value: formatDateLabel(doc.issued_at), kind: 'editable-issued' },
|
||||
{ key: 'created', label: 'Created at', value: formatDateLabel(doc.created_at) },
|
||||
{ key: 'updated', label: 'Updated at', value: formatDateLabel(doc.updated_at) },
|
||||
{ key: 'folder', label: 'Folder', value: folderLabel, kind: 'folder' },
|
||||
{ key: 'size', label: 'Size', value: sizeLabel },
|
||||
{ key: 'mime-type', label: 'MIME type', value: doc.mime_type || 'Unknown' },
|
||||
{ key: 'pages', label: 'Pages', value: pageCountLabel },
|
||||
{ key: 'filename', label: 'Filename', value: doc.filename },
|
||||
{ key: 'original-filename', label: 'Original filename', value: doc.original_name },
|
||||
{ key: 'checksum', label: 'SHA-256 checksum', value: doc.current_version?.checksum },
|
||||
];
|
||||
};
|
||||
|
||||
export const extractDocumentMetadataPayload = (document?: Document | null): DocumentMetadataPayload | null => {
|
||||
const metadata = document?.['metadata'] as DocumentMetadataPayload | undefined;
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
const keys = Object.keys(metadata);
|
||||
if (!keys.length) {
|
||||
return null;
|
||||
}
|
||||
return metadata;
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { resolveDocumentAssetUrl } from '../../lib/assets/AssetManager';
|
||||
import { useDetailPanel } from '../../app/useDetailPanel';
|
||||
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
|
||||
import type { DocumentInfoPanelProps } from '../components/DocumentInfoPanel';
|
||||
import type { EnsureAssetUrl, GetDocumentAsset } from '../../utils/ocr';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
import type { Document } from '../../types/documents';
|
||||
|
||||
interface FolderNode {
|
||||
id: Identifier | 'root';
|
||||
name?: string;
|
||||
parentId?: Identifier | 'root';
|
||||
}
|
||||
|
||||
interface UseDetailWorkspaceArgs {
|
||||
documents: Document[];
|
||||
documentLookup: Map<Identifier, Document>;
|
||||
folderNodes: Map<Identifier | 'root', FolderNode>;
|
||||
ensureFolderData: (folderId: Identifier | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise<void>;
|
||||
detailPanelControlRef: MutableRefObject<{ open?: (documentId: Identifier) => void; close?: () => void } | null>;
|
||||
detailFolderFetchRef: MutableRefObject<Set<Identifier | 'root'>>;
|
||||
previewDocumentId?: Identifier | null;
|
||||
activePreviewId?: Identifier | null;
|
||||
openDocumentPreview?: (args: { documentIds: Identifier[] }) => void;
|
||||
handleDocumentTitleUpdate?: (docId: Identifier, title: string) => Promise<boolean> | boolean;
|
||||
handleDocumentIssuedUpdate?: (docId: Identifier, issued: number | null) => Promise<boolean> | boolean;
|
||||
handleDocumentTagAdd?: (doc: Document, value: string, context?: { option?: unknown }) => void;
|
||||
handleTagRemove?: (...args: unknown[]) => void;
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getDocumentAsset?: GetDocumentAsset;
|
||||
correspondents?: unknown[];
|
||||
handleCorrespondentAdd?: (...args: unknown[]) => void;
|
||||
handleCorrespondentRemove?: (...args: unknown[]) => void;
|
||||
selectFolder?: (folderId?: Identifier | 'root') => void;
|
||||
tags?: unknown[];
|
||||
tagLookupById?: Map<Identifier, unknown> | null;
|
||||
}
|
||||
|
||||
interface UseDetailWorkspaceResult {
|
||||
detailPanelProps: DocumentInfoPanelProps;
|
||||
detailPanelOpen: boolean;
|
||||
openDetailPanel: ReturnType<typeof useDetailPanel>['openDetailPanel'];
|
||||
closeDetailPanel: ReturnType<typeof useDetailPanel>['closeDetailPanel'];
|
||||
handleDetailPanelClose: () => void;
|
||||
inspectDocument: (docId: Identifier | null) => void;
|
||||
previewActive: boolean;
|
||||
previewWorkspaceDocument: Document | null;
|
||||
resolveThumbnailUrlForDoc: (doc: Document | null) => string | null;
|
||||
resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>;
|
||||
}
|
||||
|
||||
const useDetailWorkspace = ({
|
||||
documents,
|
||||
documentLookup,
|
||||
folderNodes,
|
||||
ensureFolderData,
|
||||
detailPanelControlRef,
|
||||
detailFolderFetchRef,
|
||||
previewDocumentId,
|
||||
activePreviewId,
|
||||
openDocumentPreview,
|
||||
handleDocumentTitleUpdate,
|
||||
handleDocumentIssuedUpdate,
|
||||
handleDocumentTagAdd,
|
||||
handleTagRemove,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
correspondents,
|
||||
handleCorrespondentAdd,
|
||||
handleCorrespondentRemove,
|
||||
selectFolder,
|
||||
tags,
|
||||
tagLookupById,
|
||||
}: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => {
|
||||
const {
|
||||
detailPanelOpen,
|
||||
detailPanelDocument,
|
||||
openDetailPanel,
|
||||
closeDetailPanel,
|
||||
} = useDetailPanel({
|
||||
documentLookup,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
detailPanelControlRef.current = {
|
||||
open: openDetailPanel,
|
||||
close: closeDetailPanel,
|
||||
};
|
||||
}, [detailPanelControlRef, openDetailPanel, closeDetailPanel]);
|
||||
|
||||
// Prefetch folder ancestors for breadcrumb display
|
||||
useEffect(() => {
|
||||
const folderId = detailPanelDocument?.folder_id;
|
||||
if (!folderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
let currentId = folderId;
|
||||
let guard = 0;
|
||||
|
||||
while (currentId && currentId !== 'root' && guard < 32) {
|
||||
guard += 1;
|
||||
if (visited.has(currentId)) {
|
||||
break;
|
||||
}
|
||||
visited.add(currentId);
|
||||
|
||||
const node = folderNodes.get(currentId);
|
||||
if (!node) {
|
||||
if (!detailFolderFetchRef.current.has(currentId)) {
|
||||
detailFolderFetchRef.current.add(currentId);
|
||||
ensureFolderData(currentId, { force: false, includeDocuments: false })
|
||||
.catch((error) => {
|
||||
console.warn('Failed to preload folder metadata for detail path', currentId, error);
|
||||
})
|
||||
.finally(() => {
|
||||
detailFolderFetchRef.current.delete(currentId);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const parentId = node.parentId ?? 'root';
|
||||
if (!parentId || parentId === 'root') {
|
||||
break;
|
||||
}
|
||||
currentId = parentId;
|
||||
}
|
||||
}, [detailPanelDocument, folderNodes, ensureFolderData, detailFolderFetchRef]);
|
||||
|
||||
const resolveFolderPath = useCallback(
|
||||
(folderId) => {
|
||||
if (!folderId || folderId === 'root') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segments = [];
|
||||
const visited = new Set();
|
||||
let currentId = folderId;
|
||||
let guard = 0;
|
||||
|
||||
while (currentId && guard < 32 && !visited.has(currentId)) {
|
||||
guard += 1;
|
||||
visited.add(currentId);
|
||||
|
||||
if (currentId === 'root') {
|
||||
break;
|
||||
}
|
||||
|
||||
const node = folderNodes.get(currentId);
|
||||
if (!node) {
|
||||
segments.push({ id: currentId, name: '…' });
|
||||
break;
|
||||
}
|
||||
|
||||
segments.push({ id: node.id, name: node.name || 'Folder' });
|
||||
|
||||
const parentId = node.parentId ?? 'root';
|
||||
if (!parentId || parentId === 'root') {
|
||||
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||||
break;
|
||||
}
|
||||
|
||||
currentId = parentId;
|
||||
}
|
||||
|
||||
if (!segments.some((segment) => segment.id === 'root')) {
|
||||
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||||
}
|
||||
|
||||
return segments.reverse();
|
||||
},
|
||||
[folderNodes],
|
||||
);
|
||||
|
||||
const previewWorkspaceDocument = useMemo(() => {
|
||||
if (!previewDocumentId) {
|
||||
return null;
|
||||
}
|
||||
return documentLookup.get(previewDocumentId)
|
||||
|| documents.find((doc) => doc.id === previewDocumentId)
|
||||
|| null;
|
||||
}, [previewDocumentId, documentLookup, documents]);
|
||||
|
||||
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
|
||||
|
||||
const resolveThumbnailUrlForDoc = useCallback(
|
||||
(doc) =>
|
||||
resolveDocumentAssetUrl(doc, 'thumbnail', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
}),
|
||||
[ensureAssetUrl, getDocumentAsset],
|
||||
);
|
||||
|
||||
const inspectDocument = useCallback(
|
||||
(documentId: Identifier) => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
openDetailPanel(documentId);
|
||||
},
|
||||
[openDetailPanel],
|
||||
);
|
||||
|
||||
const handleDetailPanelClose = useCallback(() => {
|
||||
closeDetailPanel();
|
||||
}, [closeDetailPanel]);
|
||||
|
||||
const detailPanelProps = useMemo(
|
||||
() => ({
|
||||
document: detailPanelDocument,
|
||||
tags,
|
||||
tagLookupById,
|
||||
onTagAdd: handleDocumentTagAdd,
|
||||
onTagRemove: handleTagRemove,
|
||||
onOpenPreview: openDocumentPreview,
|
||||
activePreviewId,
|
||||
onUpdateTitle: handleDocumentTitleUpdate,
|
||||
onUpdateIssued: handleDocumentIssuedUpdate,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
correspondents,
|
||||
onCorrespondentAdd: handleCorrespondentAdd,
|
||||
onCorrespondentRemove: handleCorrespondentRemove,
|
||||
onFolderNavigate: selectFolder,
|
||||
onClose: handleDetailPanelClose,
|
||||
resolveFolderPath,
|
||||
folderNodes,
|
||||
ensureFolderData,
|
||||
}),
|
||||
[
|
||||
activePreviewId,
|
||||
correspondents,
|
||||
detailPanelDocument,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
handleCorrespondentAdd,
|
||||
handleCorrespondentRemove,
|
||||
handleDetailPanelClose,
|
||||
handleDocumentTagAdd,
|
||||
handleDocumentIssuedUpdate,
|
||||
handleDocumentTitleUpdate,
|
||||
handleTagRemove,
|
||||
folderNodes,
|
||||
ensureFolderData,
|
||||
openDocumentPreview,
|
||||
resolveFolderPath,
|
||||
selectFolder,
|
||||
tags,
|
||||
tagLookupById,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
detailPanelProps,
|
||||
detailPanelOpen,
|
||||
openDetailPanel,
|
||||
closeDetailPanel,
|
||||
handleDetailPanelClose,
|
||||
inspectDocument,
|
||||
previewActive,
|
||||
previewWorkspaceDocument,
|
||||
resolveThumbnailUrlForDoc,
|
||||
resolveFolderPath,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDetailWorkspace;
|
||||
@@ -0,0 +1,845 @@
|
||||
.panel.detail-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: min(100vw, var(--detail-panel-width));
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
height: 100%;
|
||||
height: 100%;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 24px var(--shadow-soft);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000000;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.detail-panel.detail-panel--inline {
|
||||
position: relative;
|
||||
right: auto;
|
||||
top: auto;
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
z-index: auto;
|
||||
flex: 0 0 var(--detail-panel-width);
|
||||
}
|
||||
|
||||
.detail-panel--resizing {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-header .icon {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
}
|
||||
|
||||
.panel-header__leading,
|
||||
.panel-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.panel-header__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.panel-header>.panel-header__title:first-child {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-header__actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Dark theme variant for preview zoom overlay */
|
||||
.panel-header--dark {
|
||||
background: var(--overlay-dark);
|
||||
color: var(--text-on-dark);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel-header--dark .icon-button,
|
||||
.panel-header--dark button,
|
||||
.panel-header--dark a.icon-button {
|
||||
color: var(--text-on-dark);
|
||||
opacity: 0.75;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel-header--dark .icon-button:hover:not([disabled]),
|
||||
.panel-header--dark button:hover:not([disabled]),
|
||||
.panel-header--dark a.icon-button:hover {
|
||||
background: var(--surface-hover);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
|
||||
.detail-panel .panel-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.detail-panel__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.detail-panel__content .document-viewer-panel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-section__title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-add {
|
||||
--quick-add-menu-offset: 0.35rem;
|
||||
--quick-add-menu-min-width: 220px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quick-add__trigger {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.documents-sort__trigger.quick-add__trigger {
|
||||
padding: 0.25rem 0.5rem;
|
||||
min-height: 2.1rem;
|
||||
}
|
||||
|
||||
.quick-add__chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.18rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
min-width: 0;
|
||||
flex: 1 1 9rem;
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-add__chip:hover,
|
||||
.quick-add__chip:focus-visible {
|
||||
border-style: solid;
|
||||
background: var(--selection-soft);
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.quick-add__chip .icon-inline {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.quick-add__chip-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.quick-add__chip-label .icon-inline {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-add__chip-text {
|
||||
display: inline-block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-add__form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.quick-add__form input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-add__list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.quick-add__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-add__swatch {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.selection-assignment {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selection-assignment__menu {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
max-width: min(22rem, 90vw);
|
||||
}
|
||||
|
||||
.selection-assignment__header {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.selection-assignment__form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.selection-assignment__form input {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--fg);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.selection-assignment__form input:focus-visible {
|
||||
outline: none;
|
||||
border-color: color-mix(in oklch, var(--accent) 60%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent) 35%, transparent);
|
||||
}
|
||||
|
||||
.selection-assignment__add {
|
||||
align-self: stretch;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
}
|
||||
|
||||
.selection-assignment__list {
|
||||
max-height: max(240px, 50vh);
|
||||
overflow-y: auto;
|
||||
padding: 0 0.25rem 0.25rem;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.selection-assignment__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-weight: 400;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.selection-assignment__label {
|
||||
flex: 1 1 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.selection-assignment__spinner {
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.selection-assignment__label--nowrap {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.selection-assignment__indent {
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.selection-assignment__folder-label {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.selection-assignment__folder-name {
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.selection-assignment__folder-path {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.selection-assignment__slash {
|
||||
color: var(--muted);
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.selection-assignment__segment {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.selection-assignment__item--all .selection-assignment__icon {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.selection-assignment__item--partial .selection-assignment__icon {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.selection-assignment__icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selection-assignment__icon--empty {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 999px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.selection-assignment__label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.selection-assignment__count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.selection-assignment__empty {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.selection-assignment__create {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.preview-pane {
|
||||
margin-top: 0.4rem;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.preview-pane__media {
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-image__content {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
outline-color 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
filter 120ms ease,
|
||||
background-color 120ms ease;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.preview-image__content:hover,
|
||||
.preview-image__content:focus-visible {
|
||||
outline-color: var(--accent-focus);
|
||||
box-shadow:
|
||||
inset 0 0 0 999px var(--accent-elevated),
|
||||
0 6px 18px var(--accent-elevated-strong);
|
||||
}
|
||||
|
||||
.thumbnail-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.32rem;
|
||||
margin: 0.4rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.thumbnail-image {
|
||||
max-width: 100%;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.detail-panel .meta,
|
||||
.document-summary .meta {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path,
|
||||
.document-summary .detail-folder-path {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-folder-path--block {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__link,
|
||||
.document-summary .detail-folder-path__link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__link:hover,
|
||||
.detail-panel .detail-folder-path__link:focus-visible,
|
||||
.document-summary .detail-folder-path__link:hover,
|
||||
.document-summary .detail-folder-path__link:focus-visible {
|
||||
text-decoration: underline;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__separator,
|
||||
.document-summary .detail-folder-path__separator {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__segment,
|
||||
.document-summary .detail-folder-path__segment {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-row,
|
||||
.document-summary .doc-title-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin: 0.25rem 0 1.25rem;
|
||||
max-width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.doc-title-row__primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.doc-title-row__title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.doc-title-row__path {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
.document-summary--compact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.document-summary__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.document-summary__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.document-summary__meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.document-summary__meta-label {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.document-summary__meta-value {
|
||||
color: var(--fg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.document-summary__meta-value .detail-meta__value {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.document-summary__section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tag-list.document-summary__tags,
|
||||
.correspondent-list.document-summary__correspondents {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.document-summary__details-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.document-summary__details-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.document-summary__details-row dt {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.document-summary__details-row dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
text-align: right;
|
||||
flex: 1 1 auto;
|
||||
display: inline-flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.document-summary__details-row .icon-button {
|
||||
margin: -0.25rem 0;
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-edit,
|
||||
.document-summary .doc-title-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.document-summary__details-row .doc-title-edit,
|
||||
.document-summary__details-row .doc-title-edit--inline {
|
||||
width: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.doc-title-edit--inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.doc-title-edit--inline input {
|
||||
width: auto;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-edit input,
|
||||
.document-summary .doc-title-edit input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-edit input:focus-visible,
|
||||
.document-summary .doc-title-edit input:focus-visible {
|
||||
outline: 2px solid var(--selection-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.status-inline {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.detail-meta__row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-meta__label {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.detail-meta__value {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.detail-meta__row>.icon-button {
|
||||
margin: -0.25rem;
|
||||
}
|
||||
|
||||
.doc-issued-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.doc-issued-row__label {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.doc-issued-row__value {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.doc-issued-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.doc-issued-edit input[type='date'] {
|
||||
font: inherit;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.status-inline.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.bulk-detail-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
|
||||
.bulk-detail-actions .inline {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bulk-move {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
|
||||
.selection-assignment__header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.selection-assignment__nav-title .icon-button {
|
||||
margin-left: -0.25rem;
|
||||
}
|
||||
|
||||
.selection-assignment__nav-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.selection-assignment__nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.selection-assignment__item-content {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selection-assignment__item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.menu .selection-assignment__item--empty:hover,
|
||||
.menu .selection-assignment__item--empty:focus-visible {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.selection-assignment__item--empty .selection-assignment__item-content {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
.document-viewer-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.document-viewer-panel__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.document-viewer {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
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: 0 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.document-viewer--stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem 0.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.document-viewer--stacked .document-viewer__viewport {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.document-viewer--stacked .document-viewer__details-pane {
|
||||
overflow: visible;
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.document-drag-preview {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
top: -9999px;
|
||||
left: -9999px;
|
||||
width: var(--drag-preview-size, 96px);
|
||||
height: var(--drag-preview-size, 96px);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.document-drag-preview__item {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 12px var(--shadow-pop);
|
||||
overflow: hidden;
|
||||
background-color: var(--overlay-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
transform: translate(-50%, -50%) rotate(var(--rotation-deg, 0deg));
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.document-drag-preview__item--image {
|
||||
background-color: #000;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.document-drag-preview__item .document-thumbnail,
|
||||
.document-drag-preview__item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.document-drag-preview__item .thumb-placeholder,
|
||||
.document-drag-preview__item .thumb-placeholder * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.document-drag-preview__item--folder {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.document-drag-preview__folder-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.document-drag-preview__folder-thumb svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--accent-strong, var(--accent));
|
||||
}
|
||||
|
||||
.document-drag-preview__folder-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-on-dark);
|
||||
}
|
||||
|
||||
.document-drag-preview__count {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 8px var(--shadow-pop);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.document-viewer__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.document-viewer__details-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
grid-area: details;
|
||||
}
|
||||
|
||||
.document-viewer__tabs-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.document-viewer__tabs-wrapper--bottom .document-viewer__tabpanes {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.document-viewer__tabs-wrapper--bottom .document-viewer__tabs {
|
||||
order: 2;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.document-viewer__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.document-viewer__section-title {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.document-viewer__section-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.document-viewer__section-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.document-viewer__section-item dt {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.document-viewer__section-item dd {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-panel .document-viewer__section-item dt {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.detail-panel .document-viewer__section-item dd {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.document-viewer__section-placeholder {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.document-viewer__section-payload {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.document-viewer__section-payload summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: var(--accent-strong, var(--accent));
|
||||
}
|
||||
|
||||
.document-viewer__section-payload pre {
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--surface);
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.document-viewer__section--metadata-json {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.document-viewer__metadata-json {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--surface);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
overflow: auto;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.document-viewer__tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-overlay, var(--surface));
|
||||
border: 1px solid var(--outline-subtle);
|
||||
box-shadow: var(--panel-shadow-soft, none);
|
||||
}
|
||||
|
||||
.document-viewer__tab {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.35rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 120ms ease,
|
||||
background-color 120ms ease;
|
||||
border-radius: 999px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-viewer__tab:hover,
|
||||
.document-viewer__tab:focus-visible {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.document-viewer__tab.is-active {
|
||||
color: var(--accent-strong, var(--accent));
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.document-viewer__tabpanes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.document-viewer__tabpanes--single {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.document-viewer__tabpanel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.document-viewer__object--ocr {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.document-viewer__object--text-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 1rem 0;
|
||||
font-size: 1rem;
|
||||
white-space: pre-wrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.document-viewer__message--error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.document-viewer__viewport {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
max-height: 100%;
|
||||
grid-area: viewport;
|
||||
background: var(--surface-subtle);
|
||||
padding: 1.5vmin;
|
||||
box-sizing: border-box;
|
||||
--pdf-viewer-stack-padding: 1.5vmin;
|
||||
}
|
||||
|
||||
.document-viewer__object {
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.document-viewer__object--pdf {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.document-viewer__object--audio,
|
||||
.document-viewer__object--video {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: auto;
|
||||
background: var(--surface-overlay, #000);
|
||||
}
|
||||
|
||||
.document-viewer__object--audio {
|
||||
height: auto;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.document-viewer__object--video {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.document-viewer--stacked .document-viewer__object--image {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.document-viewer__unsupported {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.pdf-viewer__canvas-stack {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--pdf-viewer-stack-padding, 0);
|
||||
align-items: center;
|
||||
--pdf-viewer-viewport-width: 100%;
|
||||
--pdf-viewer-viewport-height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pdf-viewer__page-wrapper {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pdf-viewer__page-wrapper:not(:has(> canvas)) {
|
||||
background-color: transparent;
|
||||
|
||||
background-image: url("data:image/svg+xml,\
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' fill='white' fill-opacity='0.05'>\
|
||||
<rect x='200' width='200' height='200' />\
|
||||
<rect y='200' width='200' height='200' />\
|
||||
</svg>");
|
||||
|
||||
background-size: 30px 30px;
|
||||
}
|
||||
|
||||
.pdf-viewer__canvas-stack--fit-width .pdf-viewer__page-wrapper--limit-width {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: var(--pdf-viewer-page-aspect, calc(var(--pdf-viewer-page-width) / var(--pdf-viewer-page-height)));
|
||||
}
|
||||
|
||||
.pdf-viewer__canvas-stack--contain .pdf-viewer__page-wrapper--limit-height {
|
||||
width: auto;
|
||||
height: min(var(--pdf-viewer-page-height, 100%), var(--pdf-viewer-viewport-height, 100vh));
|
||||
max-width: min(var(--pdf-viewer-page-width, 100%), var(--pdf-viewer-viewport-width, 100vw));
|
||||
aspect-ratio: calc(var(--pdf-viewer-page-width) / var(--pdf-viewer-page-height));
|
||||
}
|
||||
|
||||
.pdf-viewer__canvas-stack--contain .pdf-viewer__page-wrapper--limit-width {
|
||||
width: var(--pdf-viewer-page-width, 100%);
|
||||
height: auto;
|
||||
max-width: min(var(--pdf-viewer-page-width, 100%), var(--pdf-viewer-viewport-width, 100vw));
|
||||
max-height: var(--pdf-viewer-viewport-height, 100vh);
|
||||
aspect-ratio: calc(var(--pdf-viewer-page-width) / var(--pdf-viewer-page-height));
|
||||
}
|
||||
|
||||
.pdf-viewer__page {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.pdf-viewer__page-wrapper .textLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
line-height: 1;
|
||||
transform-origin: 0 0;
|
||||
pointer-events: auto;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.pdf-viewer__page-wrapper .textLayer span,
|
||||
.pdf-viewer__page-wrapper .textLayer br {
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
transform-origin: 0 0;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.pdf-viewer__page-wrapper .textLayer .endOfContent {
|
||||
position: absolute;
|
||||
inset: 100% 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pdf-viewer__status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: color-mix(in oklch, var(--surface) 85%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pdf-viewer__status--error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.document-viewer__unsupported-filename {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.document-viewer__unsupported-message {
|
||||
font-size: 0.95rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.document-viewer__unsupported-download {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.document-viewer__unsupported-download svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.document-viewer__summary-tab-content {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.pdf-viewer__page-wrapper--contain .pdf-viewer__page {
|
||||
width: auto;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.pdf-viewer__password-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.pdf-viewer__password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-pop);
|
||||
max-width: 400px;
|
||||
margin: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pdf-viewer__password-message {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pdf-viewer__password-input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pdf-viewer__password-input-group .button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pdf-viewer__password-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--outline);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pdf-viewer__password-error {
|
||||
color: var(--danger);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MutableRefObject, useLayoutEffect, useState } from 'react';
|
||||
import {
|
||||
DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO,
|
||||
MIN_STACKED_BREAKPOINT,
|
||||
PORTRAIT_RATIO_STYLE_ID,
|
||||
} from '../constants/preview';
|
||||
|
||||
const ensurePortraitRatioStyle = () => {
|
||||
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 = () => Math.max(window.innerWidth / 2, MIN_STACKED_BREAKPOINT);
|
||||
|
||||
export const useViewerLayoutMode = (
|
||||
ref: MutableRefObject<HTMLElement | null> | null,
|
||||
dependency?: unknown,
|
||||
) => {
|
||||
const [isStacked, setIsStacked] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ensurePortraitRatioStyle();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref?.current;
|
||||
if (!node) {
|
||||
setIsStacked(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let frame: number | null = null;
|
||||
const commitMeasure = (width: number) => {
|
||||
if (frame) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
frame = requestAnimationFrame(() => {
|
||||
const breakpoint = computeStackedLayoutBreakpoint();
|
||||
setIsStacked(width < breakpoint);
|
||||
});
|
||||
};
|
||||
|
||||
const measure = () => {
|
||||
commitMeasure(node.getBoundingClientRect().width);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user