954 lines
28 KiB
React
954 lines
28 KiB
React
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
DownloadIcon,
|
|
ArrowLeftIcon,
|
|
ArrowRightIcon,
|
|
ChevronsRightIcon,
|
|
AnalyzeIcon,
|
|
WindowMaximizeIcon,
|
|
TextScanIcon,
|
|
} from '../ui/icons';
|
|
import PanelHeader from '../ui/PanelHeader';
|
|
import { formatFileSize } from '../utils/format';
|
|
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
|
import { describeDocumentSummary } from '../documents/documentSummary';
|
|
import { createDocumentActionState } from '../documents/documentActions';
|
|
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
|
import DocumentSummarySection, {
|
|
TagSection,
|
|
CorrespondentSection,
|
|
sortCorrespondents,
|
|
buildCorrespondentOptions,
|
|
} from '../documents/DocumentSummarySection';
|
|
|
|
const MAX_PREVIEW_STACK_ITEMS = 15;
|
|
|
|
const derivePreviewOrientation = (metadata) => {
|
|
const width = Number(metadata?.width);
|
|
const height = Number(metadata?.height);
|
|
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
|
|
return width >= height ? 'landscape' : 'portrait';
|
|
}
|
|
return 'landscape';
|
|
};
|
|
|
|
const computeStackAngle = (docId, index) => {
|
|
if (index === 0) return 0;
|
|
let hash = 0;
|
|
const source = docId || `stack-${index}`;
|
|
for (let i = 0; i < source.length; i += 1) {
|
|
hash = (hash * 31 + source.charCodeAt(i)) % 997;
|
|
}
|
|
const magnitude = Math.max(3, (hash % 13) + 3);
|
|
const sign = index % 2 === 0 ? 1 : -1;
|
|
return magnitude * sign;
|
|
};
|
|
|
|
const PreviewStack = ({
|
|
items = [],
|
|
maxItems = MAX_PREVIEW_STACK_ITEMS,
|
|
emptyMessage = 'Preview unavailable',
|
|
onItemActivate,
|
|
onOpenPreview,
|
|
onZoomPreview,
|
|
}) => {
|
|
const limited = useMemo(() => items.slice(0, maxItems), [items, maxItems]);
|
|
const hasMultiple = limited.length > 1;
|
|
const preparedItems = useMemo(
|
|
() =>
|
|
limited.map((entry, index) => ({
|
|
entry,
|
|
angle: index === 0 ? 0 : computeStackAngle(entry.id, index),
|
|
})),
|
|
[limited],
|
|
);
|
|
|
|
if (!limited.length) {
|
|
return <span className="meta">{emptyMessage}</span>;
|
|
}
|
|
|
|
return (
|
|
<div className="preview-stack preview-stack--stacked">
|
|
{preparedItems.map(({ entry, angle }, index) => {
|
|
const transform = hasMultiple
|
|
? `translate(-50%, -50%) rotate(${angle}deg)`
|
|
: 'translate(-50%, -50%)';
|
|
const isFront = index === 0;
|
|
return (
|
|
<div
|
|
key={entry.id || index}
|
|
className={`preview-stack__item orientation-${entry.orientation || 'landscape'}`}
|
|
style={{
|
|
zIndex: preparedItems.length - index,
|
|
transform,
|
|
}}
|
|
aria-hidden={
|
|
hasMultiple && !onItemActivate && !onOpenPreview && !onZoomPreview
|
|
? 'true'
|
|
: undefined
|
|
}
|
|
>
|
|
<img
|
|
src={entry.url}
|
|
alt={entry.alt}
|
|
className="preview-stack__image"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
if (isFront) {
|
|
if (onZoomPreview) {
|
|
onZoomPreview(entry);
|
|
} else if (onOpenPreview) {
|
|
onOpenPreview(entry.id);
|
|
} else if (onItemActivate) {
|
|
onItemActivate(entry.id);
|
|
}
|
|
} else if (onItemActivate) {
|
|
onItemActivate(entry.id);
|
|
}
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (!onItemActivate && !onOpenPreview && !onZoomPreview) return;
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (isFront) {
|
|
if (onZoomPreview) {
|
|
onZoomPreview(entry);
|
|
} else if (onOpenPreview) {
|
|
onOpenPreview(entry.id);
|
|
} else {
|
|
onItemActivate?.(entry.id);
|
|
}
|
|
} else {
|
|
onItemActivate?.(entry.id);
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DetailPanel = ({
|
|
selectedDocuments = [],
|
|
tags = [],
|
|
tagLookupById = new Map(),
|
|
onTagAdd,
|
|
onTagRemove,
|
|
onRegenerateThumbnails,
|
|
onOpenPreview,
|
|
onBulkTagAdd,
|
|
onBulkTagRemove,
|
|
onBulkReanalyze,
|
|
onBulkCorrespondentAdd,
|
|
onBulkCorrespondentRemove,
|
|
onPromoteSelection,
|
|
onUpdateTitle = async () => false,
|
|
onUpdateIssued = async () => false,
|
|
ensureAssetUrl = null,
|
|
getDocumentAsset = () => null,
|
|
ensurePreviewData = () => Promise.resolve(),
|
|
correspondents = [],
|
|
onCorrespondentAdd,
|
|
onCorrespondentRemove,
|
|
resolveApiPath,
|
|
onFolderNavigate = null,
|
|
resolveFolderPath = null,
|
|
onClose = () => {},
|
|
}) => {
|
|
const selectedCount = selectedDocuments.length;
|
|
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
|
const singleDocId = singleDoc?.id || null;
|
|
const selectionKey = useMemo(
|
|
() => selectedDocuments.map((doc) => doc?.id ?? '').join('|'),
|
|
[selectedDocuments],
|
|
);
|
|
|
|
const { downloadHref: singleDownloadHref, hasOcr: singleHasOcr, openOcr } = useMemo(
|
|
() =>
|
|
createDocumentActionState({
|
|
document: singleDoc,
|
|
resolveApiPath,
|
|
ensurePreviewData,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
}),
|
|
[singleDoc, resolveApiPath, ensurePreviewData, ensureAssetUrl, getDocumentAsset],
|
|
);
|
|
|
|
const detailSummary = useMemo(() => describeDocumentSummary(singleDoc), [singleDoc]);
|
|
|
|
const headerTitle = useMemo(() => {
|
|
if (selectedCount === 0) {
|
|
return 'Document details';
|
|
}
|
|
if (selectedCount === 1) {
|
|
return detailSummary.title;
|
|
}
|
|
return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
|
}, [selectedCount, detailSummary]);
|
|
|
|
const [zoomedPreview, setZoomedPreview] = useState(null);
|
|
|
|
const bulkDocumentIds = useMemo(
|
|
() => selectedDocuments.map((doc) => doc?.id).filter(Boolean),
|
|
[selectedDocuments],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setZoomedPreview(null);
|
|
}, [selectionKey]);
|
|
|
|
const handlePreviewActivate = useCallback(
|
|
(docId) => {
|
|
if (!docId) return;
|
|
onPromoteSelection?.(docId);
|
|
},
|
|
[onPromoteSelection],
|
|
);
|
|
|
|
const singlePreviewNavigator = useAssetNavigator({
|
|
document: singleDoc,
|
|
assetType: 'preview',
|
|
ensureAssetUrl,
|
|
getAsset: getDocumentAsset,
|
|
prefetch: 3,
|
|
});
|
|
|
|
const makePreviewItem = useCallback(
|
|
(doc, ordinal = 1) => {
|
|
if (!doc) return null;
|
|
const asset = getDocumentAsset(doc, 'preview');
|
|
const assetView = createAssetView(asset);
|
|
const object = assetView.getObject(ordinal);
|
|
let url = object?.url || null;
|
|
if (!url) {
|
|
url = resolveDocumentAssetUrl(doc, 'preview', {
|
|
ensureAssetUrl,
|
|
getAsset: getDocumentAsset,
|
|
ensureOptions: { start: ordinal, limit: 1 },
|
|
objectOrdinal: ordinal,
|
|
});
|
|
}
|
|
if (!url) {
|
|
return null;
|
|
}
|
|
const metadata = object?.metadata || assetView.getPrimaryMetadata() || {};
|
|
const orientation = derivePreviewOrientation(metadata);
|
|
return {
|
|
id: doc.id,
|
|
url,
|
|
orientation,
|
|
alt: doc.title,
|
|
};
|
|
},
|
|
[ensureAssetUrl, getDocumentAsset],
|
|
);
|
|
|
|
const stackDocuments = useMemo(() => {
|
|
if (!selectedDocuments.length) return [];
|
|
const seen = new Set();
|
|
const ordered = [];
|
|
for (let index = selectedDocuments.length - 1; index >= 0; index -= 1) {
|
|
const doc = selectedDocuments[index];
|
|
if (!doc?.id || seen.has(doc.id)) continue;
|
|
seen.add(doc.id);
|
|
ordered.push(doc);
|
|
if (ordered.length >= MAX_PREVIEW_STACK_ITEMS) {
|
|
break;
|
|
}
|
|
}
|
|
return ordered;
|
|
}, [selectedDocuments]);
|
|
|
|
const stackTopDocument = stackDocuments[0] || null;
|
|
const stackTopDocId = stackTopDocument?.id || null;
|
|
const stackPreviewNavigator = useAssetNavigator({
|
|
document: stackTopDocument,
|
|
assetType: 'preview',
|
|
ensureAssetUrl,
|
|
getAsset: getDocumentAsset,
|
|
prefetch: 3,
|
|
});
|
|
|
|
const singlePreviewItems = useMemo(() => {
|
|
if (!singleDoc) return [];
|
|
const url = singlePreviewNavigator.currentUrl;
|
|
if (!url) {
|
|
return [];
|
|
}
|
|
const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata);
|
|
return [
|
|
{
|
|
id: singleDoc.id,
|
|
url,
|
|
orientation,
|
|
alt: singleDoc.title,
|
|
},
|
|
];
|
|
}, [singleDoc, singlePreviewNavigator.currentUrl, singlePreviewNavigator.currentMetadata]);
|
|
|
|
const stackPreviews = useMemo(() => {
|
|
if (!stackDocuments.length) {
|
|
return [];
|
|
}
|
|
return stackDocuments
|
|
.map((doc) => {
|
|
if (!doc) return null;
|
|
if (stackTopDocument && doc.id === stackTopDocument.id) {
|
|
const url = stackPreviewNavigator.currentUrl;
|
|
if (!url) {
|
|
return null;
|
|
}
|
|
const orientation = derivePreviewOrientation(stackPreviewNavigator.currentMetadata);
|
|
return {
|
|
id: doc.id,
|
|
url,
|
|
orientation,
|
|
alt: doc.title,
|
|
};
|
|
}
|
|
return makePreviewItem(doc, 1);
|
|
})
|
|
.filter(Boolean);
|
|
}, [
|
|
stackDocuments,
|
|
stackTopDocument,
|
|
stackPreviewNavigator.currentUrl,
|
|
stackPreviewNavigator.currentMetadata,
|
|
makePreviewItem,
|
|
]);
|
|
|
|
const singleCardinality = singlePreviewNavigator.cardinality;
|
|
const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
|
|
const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl);
|
|
|
|
const topDocId = stackTopDocument?.id || null;
|
|
const topCardinality = stackPreviewNavigator.cardinality;
|
|
const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0);
|
|
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
|
|
|
const bulkTagUnion = useMemo(() => {
|
|
if (!selectedDocuments.length) return [];
|
|
const tagMap = new Map();
|
|
selectedDocuments.forEach((doc) => {
|
|
(doc.tags || []).forEach((tag) => {
|
|
if (!tag?.label) return;
|
|
const label = tag.label.trim();
|
|
if (!label) return;
|
|
if (!tagMap.has(label)) {
|
|
const fallback = tagLookupById.get(tag.id);
|
|
tagMap.set(label, {
|
|
id: tag.id,
|
|
label,
|
|
color: tag.color ?? fallback?.color ?? null,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
return [...tagMap.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
}, [selectedDocuments, tagLookupById]);
|
|
|
|
const stackTotalSizeBytes = useMemo(() => {
|
|
if (!stackPreviews.length) return 0;
|
|
const byId = new Map(selectedDocuments.map((doc) => [doc.id, doc]));
|
|
return stackPreviews.reduce((sum, item) => {
|
|
const source = byId.get(item.id);
|
|
const bytes = source?.current_version?.size_bytes;
|
|
return sum + (typeof bytes === 'number' ? bytes : 0);
|
|
}, 0);
|
|
}, [stackPreviews, selectedDocuments]);
|
|
|
|
const correspondentOptions = useMemo(
|
|
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
|
|
[correspondents],
|
|
);
|
|
|
|
const singleCorrespondents = useMemo(() => {
|
|
if (!singleDoc) return [];
|
|
return sortCorrespondents(singleDoc.correspondents || []);
|
|
}, [singleDoc]);
|
|
|
|
const bulkCorrespondents = useMemo(() => {
|
|
if (selectedDocuments.length <= 1) {
|
|
const doc = selectedDocuments[0];
|
|
return doc ? sortCorrespondents(doc.correspondents || []) : [];
|
|
}
|
|
|
|
const map = new Map();
|
|
selectedDocuments.forEach((doc) => {
|
|
if (!doc?.id) return;
|
|
(doc.correspondents || []).forEach((entry) => {
|
|
if (!entry?.id || typeof entry.name !== 'string') return;
|
|
if (!map.has(entry.id)) {
|
|
map.set(entry.id, {
|
|
id: entry.id,
|
|
name: entry.name,
|
|
documentIds: new Set(),
|
|
});
|
|
}
|
|
map.get(entry.id).documentIds.add(doc.id);
|
|
});
|
|
});
|
|
|
|
return [...map.values()]
|
|
.map((entry) => ({
|
|
id: entry.id,
|
|
name: entry.name,
|
|
documentIds: [...entry.documentIds],
|
|
count: entry.documentIds.size,
|
|
}))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}, [selectedDocuments]);
|
|
|
|
const handleBulkCorrespondentRemove = useCallback(
|
|
(entry) => {
|
|
if (!entry?.id) return;
|
|
if (onBulkCorrespondentRemove) {
|
|
return onBulkCorrespondentRemove({
|
|
assignments: [
|
|
{
|
|
correspondent_id: entry.id,
|
|
},
|
|
],
|
|
documentIds: entry.documentIds,
|
|
});
|
|
}
|
|
|
|
if (!onCorrespondentRemove) return;
|
|
const targets = entry.documentIds && entry.documentIds.length
|
|
? entry.documentIds
|
|
: selectedDocuments
|
|
.filter((doc) => (doc.correspondents || []).some((item) => item.id === entry.id))
|
|
.map((doc) => doc.id);
|
|
|
|
return Promise.all(
|
|
targets.map((documentId) =>
|
|
onCorrespondentRemove({
|
|
documentId,
|
|
correspondentId: entry.id,
|
|
}),
|
|
),
|
|
).catch(() => {});
|
|
},
|
|
[onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
|
);
|
|
|
|
const openZoomPreview = useCallback((config) => {
|
|
if (!config) return;
|
|
setZoomedPreview({
|
|
mode: config.mode,
|
|
docId: config.docId ?? null,
|
|
});
|
|
}, []);
|
|
|
|
const closeZoomPreview = useCallback(() => {
|
|
setZoomedPreview(null);
|
|
}, []);
|
|
|
|
const handleSingleZoom = useCallback(
|
|
(entry) => {
|
|
if (!singleHasPreview) return;
|
|
const targetId = entry?.id ?? singleDocId;
|
|
if (!targetId) return;
|
|
openZoomPreview({ mode: 'single', docId: targetId });
|
|
},
|
|
[openZoomPreview, singleHasPreview, singleDocId],
|
|
);
|
|
|
|
const handleStackZoom = useCallback(
|
|
(entry) => {
|
|
if (!stackTopDocId || entry?.id !== stackTopDocId) return;
|
|
if (!topHasPreview) return;
|
|
openZoomPreview({ mode: 'stack', docId: stackTopDocId });
|
|
},
|
|
[openZoomPreview, stackTopDocId, topHasPreview],
|
|
);
|
|
|
|
const zoomDisplay = useMemo(() => {
|
|
if (!zoomedPreview) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
zoomedPreview.mode === 'single' &&
|
|
singleDocId &&
|
|
singleDoc &&
|
|
singleHasPreview &&
|
|
zoomedPreview.docId === singleDocId
|
|
) {
|
|
return {
|
|
url: singlePreviewNavigator.currentUrl,
|
|
alt: singleDoc.title,
|
|
canGoPrev:
|
|
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev),
|
|
canGoNext:
|
|
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext),
|
|
goPrev: singlePreviewNavigator.goPrev,
|
|
goNext: singlePreviewNavigator.goNext,
|
|
};
|
|
}
|
|
|
|
if (
|
|
zoomedPreview.mode === 'stack' &&
|
|
stackTopDocId &&
|
|
stackTopDocument &&
|
|
zoomedPreview.docId === stackTopDocId &&
|
|
topHasPreview
|
|
) {
|
|
return {
|
|
url: stackPreviewNavigator.currentUrl,
|
|
alt: stackTopDocument.title,
|
|
canGoPrev:
|
|
topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoPrev),
|
|
canGoNext:
|
|
topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoNext),
|
|
goPrev: stackPreviewNavigator.goPrev,
|
|
goNext: stackPreviewNavigator.goNext,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}, [
|
|
zoomedPreview,
|
|
singleDoc,
|
|
singleDocId,
|
|
singleHasPreview,
|
|
singlePreviewNavigator.currentUrl,
|
|
singlePreviewNavigator.canGoPrev,
|
|
singlePreviewNavigator.canGoNext,
|
|
singlePreviewNavigator.goPrev,
|
|
singlePreviewNavigator.goNext,
|
|
singleEffectiveCardinality,
|
|
stackTopDocument,
|
|
stackTopDocId,
|
|
topHasPreview,
|
|
stackPreviewNavigator.currentUrl,
|
|
stackPreviewNavigator.canGoPrev,
|
|
stackPreviewNavigator.canGoNext,
|
|
stackPreviewNavigator.goPrev,
|
|
stackPreviewNavigator.goNext,
|
|
topEffectiveCardinality,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (zoomedPreview && !zoomDisplay) {
|
|
setZoomedPreview(null);
|
|
}
|
|
}, [zoomedPreview, zoomDisplay]);
|
|
|
|
const {
|
|
documentId: singleNavigatorDocId,
|
|
asset: singleNavigatorAsset,
|
|
ordinal: singleNavigatorOrdinal,
|
|
canGoPrev: singleNavigatorCanGoPrev,
|
|
canGoNext: singleNavigatorCanGoNext,
|
|
cardinality: singleNavigatorCardinality,
|
|
} = singlePreviewNavigator;
|
|
|
|
const {
|
|
documentId: stackNavigatorDocId,
|
|
asset: stackNavigatorAsset,
|
|
ordinal: stackNavigatorOrdinal,
|
|
canGoPrev: stackNavigatorCanGoPrev,
|
|
canGoNext: stackNavigatorCanGoNext,
|
|
cardinality: stackNavigatorCardinality,
|
|
} = stackPreviewNavigator;
|
|
|
|
useEffect(() => {
|
|
if (typeof ensureAssetUrl !== 'function') {
|
|
return;
|
|
}
|
|
|
|
const warmNavigator = (navigator) => {
|
|
const {
|
|
documentId,
|
|
asset,
|
|
ordinal,
|
|
canGoPrev,
|
|
canGoNext,
|
|
cardinality,
|
|
} = navigator;
|
|
if (!documentId || !asset || !Number.isFinite(ordinal)) {
|
|
return;
|
|
}
|
|
|
|
const requests = [];
|
|
if (canGoPrev) {
|
|
const prevOrdinal = Math.max(1, ordinal - 1);
|
|
if (!cardinality || prevOrdinal <= cardinality) {
|
|
requests.push(
|
|
ensureAssetUrl(documentId, asset, {
|
|
start: prevOrdinal,
|
|
limit: 1,
|
|
objectOrdinal: prevOrdinal,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
if (canGoNext) {
|
|
const nextOrdinal = ordinal + 1;
|
|
if (!cardinality || nextOrdinal <= cardinality) {
|
|
requests.push(
|
|
ensureAssetUrl(documentId, asset, {
|
|
start: nextOrdinal,
|
|
limit: 1,
|
|
objectOrdinal: nextOrdinal,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
requests.forEach((promise) => promise?.catch?.(() => {}));
|
|
};
|
|
|
|
const singleWarmState = {
|
|
documentId: singleNavigatorDocId,
|
|
asset: singleNavigatorAsset,
|
|
ordinal: singleNavigatorOrdinal,
|
|
canGoPrev: singleNavigatorCanGoPrev,
|
|
canGoNext: singleNavigatorCanGoNext,
|
|
cardinality: singleNavigatorCardinality,
|
|
};
|
|
|
|
const stackWarmState = {
|
|
documentId: stackNavigatorDocId,
|
|
asset: stackNavigatorAsset,
|
|
ordinal: stackNavigatorOrdinal,
|
|
canGoPrev: stackNavigatorCanGoPrev,
|
|
canGoNext: stackNavigatorCanGoNext,
|
|
cardinality: stackNavigatorCardinality,
|
|
};
|
|
|
|
warmNavigator(singleWarmState);
|
|
warmNavigator(stackWarmState);
|
|
}, [
|
|
ensureAssetUrl,
|
|
singleNavigatorDocId,
|
|
singleNavigatorAsset,
|
|
singleNavigatorOrdinal,
|
|
singleNavigatorCanGoPrev,
|
|
singleNavigatorCanGoNext,
|
|
singleNavigatorCardinality,
|
|
stackNavigatorDocId,
|
|
stackNavigatorAsset,
|
|
stackNavigatorOrdinal,
|
|
stackNavigatorCanGoPrev,
|
|
stackNavigatorCanGoNext,
|
|
stackNavigatorCardinality,
|
|
]);
|
|
|
|
const renderSingle = () => {
|
|
if (!singleDoc) {
|
|
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
|
}
|
|
|
|
const effectiveCardinality =
|
|
singlePreviewNavigator.cardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
|
|
const canGoPrev = singlePreviewNavigator.canGoPrev;
|
|
const canGoNext = singlePreviewNavigator.canGoNext;
|
|
const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl);
|
|
const interceptNavPointer = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="preview-pane preview-pane--stack">
|
|
<PreviewStack
|
|
items={singlePreviewItems}
|
|
maxItems={1}
|
|
emptyMessage="Preview loading…"
|
|
onItemActivate={handlePreviewActivate}
|
|
onOpenPreview={onOpenPreview}
|
|
onZoomPreview={handleSingleZoom}
|
|
/>
|
|
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
|
<div className="preview-pane__nav preview-pane__nav--overlay">
|
|
<button
|
|
type="button"
|
|
className="preview-pane__nav-button preview-pane__nav-button--prev"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
singlePreviewNavigator.goPrev();
|
|
}}
|
|
onPointerDown={interceptNavPointer}
|
|
onPointerUp={interceptNavPointer}
|
|
onMouseDown={interceptNavPointer}
|
|
onMouseUp={interceptNavPointer}
|
|
disabled={!canGoPrev}
|
|
aria-label="Previous preview"
|
|
>
|
|
<ArrowLeftIcon />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="preview-pane__nav-button preview-pane__nav-button--next"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
singlePreviewNavigator.goNext();
|
|
}}
|
|
onPointerDown={interceptNavPointer}
|
|
onPointerUp={interceptNavPointer}
|
|
onMouseDown={interceptNavPointer}
|
|
onMouseUp={interceptNavPointer}
|
|
disabled={!canGoNext}
|
|
aria-label="Next preview"
|
|
>
|
|
<ArrowRightIcon />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<DocumentSummarySection
|
|
document={singleDoc}
|
|
tagLookupById={tagLookupById}
|
|
tagOptions={tags}
|
|
onTagAdd={(doc, value, extras) => onTagAdd(doc, value, extras)}
|
|
onTagRemove={(docId, tagId) => onTagRemove(docId, tagId)}
|
|
correspondents={singleCorrespondents}
|
|
correspondentOptions={correspondentOptions}
|
|
onCorrespondentAdd={onCorrespondentAdd}
|
|
onCorrespondentRemove={onCorrespondentRemove}
|
|
onUpdateTitle={onUpdateTitle}
|
|
onUpdateIssued={onUpdateIssued}
|
|
resolveFolderPath={resolveFolderPath}
|
|
onFolderNavigate={onFolderNavigate}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderBulk = () => {
|
|
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
|
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
|
const headerLabel = `${countLabel}${sizeLabel ? ` (${sizeLabel})` : ''}`;
|
|
const topDocIdLocal = topDocId;
|
|
const topCardinalityLocal = topEffectiveCardinality;
|
|
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
|
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
|
|
const topCanGoNext = stackPreviewNavigator.canGoNext;
|
|
const interceptTopNavPointer = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
const documentIds = bulkDocumentIds;
|
|
|
|
return (
|
|
<>
|
|
<div className="preview-pane preview-pane--stack">
|
|
<PreviewStack
|
|
items={stackPreviews}
|
|
emptyMessage="No previews available."
|
|
onItemActivate={handlePreviewActivate}
|
|
onOpenPreview={onOpenPreview}
|
|
onZoomPreview={handleStackZoom}
|
|
/>
|
|
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
|
|
<div className="preview-pane__nav preview-pane__nav--overlay">
|
|
<button
|
|
type="button"
|
|
className="preview-pane__nav-button preview-pane__nav-button--prev"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
stackPreviewNavigator.goPrev();
|
|
}}
|
|
onPointerDown={interceptTopNavPointer}
|
|
onPointerUp={interceptTopNavPointer}
|
|
onMouseDown={interceptTopNavPointer}
|
|
onMouseUp={interceptTopNavPointer}
|
|
disabled={!topCanGoPrev}
|
|
aria-label="Previous preview"
|
|
>
|
|
<ArrowLeftIcon />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="preview-pane__nav-button preview-pane__nav-button--next"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
stackPreviewNavigator.goNext();
|
|
}}
|
|
onPointerDown={interceptTopNavPointer}
|
|
onPointerUp={interceptTopNavPointer}
|
|
onMouseDown={interceptTopNavPointer}
|
|
onMouseUp={interceptTopNavPointer}
|
|
disabled={!topCanGoNext}
|
|
aria-label="Next preview"
|
|
>
|
|
<ArrowRightIcon />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<h3 style={{ margin: 0 }}>{headerLabel}</h3>
|
|
<TagSection
|
|
tags={bulkTagUnion}
|
|
emptyMessage="No tags assigned."
|
|
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label, documentIds })}
|
|
onAdd={({ value }) =>
|
|
onBulkTagAdd?.({ label: value, input: null, documentIds })
|
|
}
|
|
addPlaceholder="Add tag to selection"
|
|
addButtonLabel="Add tag"
|
|
datalistOptions={tags}
|
|
className="bulk-tags"
|
|
/>
|
|
<CorrespondentSection
|
|
entries={bulkCorrespondents}
|
|
onRemove={handleBulkCorrespondentRemove}
|
|
onAdd={({ name }) =>
|
|
onBulkCorrespondentAdd?.({ name, input: null, documentIds })
|
|
}
|
|
addPlaceholder="Add correspondent to selection"
|
|
datalistOptions={correspondentOptions}
|
|
showCount
|
|
className="bulk-correspondents"
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const isBulkSelection = selectedCount > 1;
|
|
const showOcrAction = Boolean(singleDoc && singleHasOcr);
|
|
|
|
const headerLeading = [
|
|
(
|
|
<button
|
|
key="close"
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={onClose}
|
|
aria-label="Close detail panel"
|
|
title="Close detail panel"
|
|
>
|
|
<ChevronsRightIcon />
|
|
</button>
|
|
),
|
|
];
|
|
|
|
if (singleDoc) {
|
|
headerLeading.push(
|
|
<button
|
|
key="preview"
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onOpenPreview(singleDoc.id);
|
|
}}
|
|
aria-label="Open preview"
|
|
title="Open preview"
|
|
disabled={!singleHasPreview}
|
|
>
|
|
<WindowMaximizeIcon />
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
const headerActions = [];
|
|
|
|
if (isBulkSelection && onBulkReanalyze) {
|
|
headerActions.push(
|
|
<button
|
|
key="bulk-reanalyze"
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onBulkReanalyze(bulkDocumentIds);
|
|
}}
|
|
aria-label="Re-run analysis for selection"
|
|
title="Re-run analysis for selection"
|
|
>
|
|
<AnalyzeIcon />
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
if (singleDoc && singleDownloadHref) {
|
|
headerActions.push(
|
|
<a
|
|
key="download"
|
|
className="icon-button"
|
|
href={singleDownloadHref}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
aria-label="Download document"
|
|
title="Download document"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<DownloadIcon />
|
|
</a>,
|
|
);
|
|
}
|
|
|
|
if (showOcrAction) {
|
|
headerActions.push(
|
|
<button
|
|
key="ocr"
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
openOcr().catch(() => {});
|
|
}}
|
|
aria-label="View OCR text"
|
|
title="View OCR text"
|
|
>
|
|
<TextScanIcon />
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
if (singleDoc) {
|
|
headerActions.push(
|
|
<button
|
|
key="reanalyze"
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onRegenerateThumbnails(singleDoc.id);
|
|
}}
|
|
aria-label="Re-run analysis"
|
|
title="Re-run analysis"
|
|
>
|
|
<AnalyzeIcon />
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<aside className="detail-panel panel">
|
|
<PanelHeader
|
|
leading={headerLeading}
|
|
title={headerTitle}
|
|
titleTag="h3"
|
|
actions={headerActions.length ? headerActions : null}
|
|
/>
|
|
<div className="panel-body">
|
|
{selectedCount <= 1 ? renderSingle() : renderBulk()}
|
|
</div>
|
|
</aside>
|
|
<PreviewZoomOverlay
|
|
open={Boolean(zoomDisplay?.url)}
|
|
display={zoomDisplay}
|
|
onClose={closeZoomPreview}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default DetailPanel;
|