feat: update frontend for document handling and styling.

This commit is contained in:
2025-12-04 23:26:22 +01:00
parent 2389c6f4bc
commit 22e39420bb
18 changed files with 37 additions and 37 deletions
@@ -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;
+452
View File
@@ -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;
+117
View File
@@ -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;
+871
View File
@@ -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;
+63
View File
@@ -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;
}
+634
View File
@@ -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;
};