detailspanel, selection handling, single document details panel
This commit is contained in:
+166
-581
@@ -7,23 +7,15 @@ import {
|
||||
WindowMaximizeIcon,
|
||||
} 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 DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
||||
import {
|
||||
TagSection,
|
||||
CorrespondentSection,
|
||||
sortCorrespondents,
|
||||
buildCorrespondentOptions,
|
||||
} from '../documents/DocumentSummarySection';
|
||||
import { sortCorrespondents, buildCorrespondentOptions } from '../documents/DocumentSummarySection';
|
||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||
|
||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||
|
||||
const derivePreviewOrientation = (metadata) => {
|
||||
const width = Number(metadata?.width);
|
||||
const height = Number(metadata?.height);
|
||||
@@ -33,122 +25,107 @@ const derivePreviewOrientation = (metadata) => {
|
||||
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,
|
||||
const PreviewImage = ({
|
||||
item,
|
||||
emptyMessage = 'Preview unavailable',
|
||||
emptyContent = null,
|
||||
onItemActivate,
|
||||
onActivate,
|
||||
onOpenPreview,
|
||||
onZoomPreview,
|
||||
showNav = false,
|
||||
canGoPrev = false,
|
||||
canGoNext = false,
|
||||
onGoPrev = null,
|
||||
onGoNext = null,
|
||||
}) => {
|
||||
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) {
|
||||
if (!item) {
|
||||
return (
|
||||
<div className="preview-stack preview-stack--empty">
|
||||
<div className="preview-image preview-image--empty">
|
||||
{emptyContent || <span className="meta">{emptyMessage}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleActivate = (event) => {
|
||||
event.stopPropagation();
|
||||
if (onZoomPreview) {
|
||||
onZoomPreview(item);
|
||||
} else if (onOpenPreview) {
|
||||
onOpenPreview(item.id);
|
||||
} else if (onActivate) {
|
||||
onActivate(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const interceptNavPointer = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
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,
|
||||
<div className="preview-image">
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.alt}
|
||||
className={`preview-image__content orientation-${item.orientation || 'landscape'}`}
|
||||
onClick={handleActivate}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleActivate(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{showNav ? (
|
||||
<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();
|
||||
onGoPrev?.();
|
||||
}}
|
||||
aria-hidden={
|
||||
hasMultiple && !onItemActivate && !onOpenPreview && !onZoomPreview
|
||||
? 'true'
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={interceptNavPointer}
|
||||
onPointerUp={interceptNavPointer}
|
||||
onMouseDown={interceptNavPointer}
|
||||
onMouseUp={interceptNavPointer}
|
||||
disabled={!canGoPrev}
|
||||
aria-label="Previous preview"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="preview-pane__nav-button preview-pane__nav-button--next"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onGoNext?.();
|
||||
}}
|
||||
onPointerDown={interceptNavPointer}
|
||||
onPointerUp={interceptNavPointer}
|
||||
onMouseDown={interceptNavPointer}
|
||||
onMouseUp={interceptNavPointer}
|
||||
disabled={!canGoNext}
|
||||
aria-label="Next preview"
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DetailPanel = ({
|
||||
selectedDocuments = [],
|
||||
document = null,
|
||||
tags = [],
|
||||
tagLookupById = new Map(),
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
onOpenPreview,
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
onBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove,
|
||||
onPromoteSelection,
|
||||
onUpdateTitle = async () => false,
|
||||
onUpdateIssued = async () => false,
|
||||
@@ -163,13 +140,9 @@ const DetailPanel = ({
|
||||
resolveFolderPath = null,
|
||||
onClose = () => {},
|
||||
}) => {
|
||||
const selectedCount = selectedDocuments.length;
|
||||
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
||||
const singleDoc = document || null;
|
||||
const singleDocId = singleDoc?.id || null;
|
||||
const selectionKey = useMemo(
|
||||
() => selectedDocuments.map((doc) => doc?.id ?? '').join('|'),
|
||||
[selectedDocuments],
|
||||
);
|
||||
const selectionKey = singleDocId || 'none';
|
||||
|
||||
const { downloadHref: singleDownloadHref } = useMemo(
|
||||
() =>
|
||||
@@ -185,18 +158,10 @@ const DetailPanel = ({
|
||||
|
||||
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 headerTitle = singleDoc ? detailSummary.title : 'Document details';
|
||||
|
||||
const headerBreadcrumbs = useMemo(() => {
|
||||
if (selectedCount !== 1 || !singleDoc || typeof resolveFolderPath !== 'function') {
|
||||
if (!singleDoc || typeof resolveFolderPath !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -225,15 +190,10 @@ const DetailPanel = ({
|
||||
label: detailSummary.title,
|
||||
},
|
||||
];
|
||||
}, [selectedCount, singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]);
|
||||
}, [singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]);
|
||||
|
||||
const [zoomedPreview, setZoomedPreview] = useState(null);
|
||||
|
||||
const bulkDocumentIds = useMemo(
|
||||
() => selectedDocuments.map((doc) => doc?.id).filter(Boolean),
|
||||
[selectedDocuments],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setZoomedPreview(null);
|
||||
}, [selectionKey]);
|
||||
@@ -284,77 +244,22 @@ const DetailPanel = ({
|
||||
[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 singlePreviewItem = useMemo(() => {
|
||||
if (!singleDoc) return null;
|
||||
const url = singlePreviewNavigator.currentUrl;
|
||||
if (!url) {
|
||||
return [];
|
||||
}
|
||||
const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata);
|
||||
return [
|
||||
{
|
||||
if (url) {
|
||||
return {
|
||||
id: singleDoc.id,
|
||||
url,
|
||||
orientation,
|
||||
orientation: derivePreviewOrientation(singlePreviewNavigator.currentMetadata),
|
||||
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);
|
||||
return makePreviewItem(singleDoc, 1);
|
||||
}, [
|
||||
stackDocuments,
|
||||
stackTopDocument,
|
||||
stackPreviewNavigator.currentUrl,
|
||||
stackPreviewNavigator.currentMetadata,
|
||||
singleDoc,
|
||||
singlePreviewNavigator.currentUrl,
|
||||
singlePreviewNavigator.currentMetadata,
|
||||
makePreviewItem,
|
||||
]);
|
||||
|
||||
@@ -362,42 +267,6 @@ const DetailPanel = ({
|
||||
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],
|
||||
@@ -496,77 +365,9 @@ const DetailPanel = ({
|
||||
[singleHasOcr, loadSingleOcrContent],
|
||||
);
|
||||
|
||||
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 openZoomPreview = useCallback((docId) => {
|
||||
if (!docId) return;
|
||||
setZoomedPreview({ docId });
|
||||
}, []);
|
||||
|
||||
const closeZoomPreview = useCallback(() => {
|
||||
@@ -578,64 +379,32 @@ const DetailPanel = ({
|
||||
if (!singleHasPreview) return;
|
||||
const targetId = entry?.id ?? singleDocId;
|
||||
if (!targetId) return;
|
||||
openZoomPreview({ mode: 'single', docId: targetId });
|
||||
openZoomPreview(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) {
|
||||
if (
|
||||
!zoomedPreview
|
||||
|| !singleDoc
|
||||
|| !singleDocId
|
||||
|| zoomedPreview.docId !== singleDocId
|
||||
|| !singleHasPreview
|
||||
) {
|
||||
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;
|
||||
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,
|
||||
};
|
||||
}, [
|
||||
zoomedPreview,
|
||||
singleDoc,
|
||||
@@ -647,15 +416,6 @@ const DetailPanel = ({
|
||||
singlePreviewNavigator.goPrev,
|
||||
singlePreviewNavigator.goNext,
|
||||
singleEffectiveCardinality,
|
||||
stackTopDocument,
|
||||
stackTopDocId,
|
||||
topHasPreview,
|
||||
stackPreviewNavigator.currentUrl,
|
||||
stackPreviewNavigator.canGoPrev,
|
||||
stackPreviewNavigator.canGoNext,
|
||||
stackPreviewNavigator.goPrev,
|
||||
stackPreviewNavigator.goNext,
|
||||
topEffectiveCardinality,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -673,82 +433,41 @@ const DetailPanel = ({
|
||||
cardinality: singleNavigatorCardinality,
|
||||
} = singlePreviewNavigator;
|
||||
|
||||
const {
|
||||
documentId: stackNavigatorDocId,
|
||||
asset: stackNavigatorAsset,
|
||||
ordinal: stackNavigatorOrdinal,
|
||||
canGoPrev: stackNavigatorCanGoPrev,
|
||||
canGoNext: stackNavigatorCanGoNext,
|
||||
cardinality: stackNavigatorCardinality,
|
||||
} = stackPreviewNavigator;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof ensureAssetUrl !== 'function') {
|
||||
return;
|
||||
}
|
||||
if (!singleNavigatorDocId || !singleNavigatorAsset || !Number.isFinite(singleNavigatorOrdinal)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const warmNavigator = (navigator) => {
|
||||
const {
|
||||
documentId,
|
||||
asset,
|
||||
ordinal,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
cardinality,
|
||||
} = navigator;
|
||||
if (!documentId || !asset || !Number.isFinite(ordinal)) {
|
||||
return;
|
||||
const requests = [];
|
||||
if (singleNavigatorCanGoPrev) {
|
||||
const prevOrdinal = Math.max(1, singleNavigatorOrdinal - 1);
|
||||
if (!singleNavigatorCardinality || prevOrdinal <= singleNavigatorCardinality) {
|
||||
requests.push(
|
||||
ensureAssetUrl(singleNavigatorDocId, singleNavigatorAsset, {
|
||||
start: prevOrdinal,
|
||||
limit: 1,
|
||||
objectOrdinal: prevOrdinal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (singleNavigatorCanGoNext) {
|
||||
const nextOrdinal = singleNavigatorOrdinal + 1;
|
||||
if (!singleNavigatorCardinality || nextOrdinal <= singleNavigatorCardinality) {
|
||||
requests.push(
|
||||
ensureAssetUrl(singleNavigatorDocId, singleNavigatorAsset, {
|
||||
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);
|
||||
requests.forEach((promise) => promise?.catch?.(() => {}));
|
||||
}, [
|
||||
ensureAssetUrl,
|
||||
singleNavigatorDocId,
|
||||
@@ -757,25 +476,18 @@ const DetailPanel = ({
|
||||
singleNavigatorCanGoPrev,
|
||||
singleNavigatorCanGoNext,
|
||||
singleNavigatorCardinality,
|
||||
stackNavigatorDocId,
|
||||
stackNavigatorAsset,
|
||||
stackNavigatorOrdinal,
|
||||
stackNavigatorCanGoPrev,
|
||||
stackNavigatorCanGoNext,
|
||||
stackNavigatorCardinality,
|
||||
]);
|
||||
|
||||
const renderSingle = () => {
|
||||
const renderContent = () => {
|
||||
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 effectiveCardinality = singleEffectiveCardinality;
|
||||
const navCanGoPrev = Boolean(singlePreviewNavigator.canGoPrev);
|
||||
const navCanGoNext = Boolean(singlePreviewNavigator.canGoNext);
|
||||
const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl);
|
||||
const previewMissingAsset = !singleNavigatorAsset;
|
||||
const previewMissingAsset = !singlePreviewNavigator.currentUrl;
|
||||
const displayContentType = singleDoc.content_type || 'this file type';
|
||||
const displayFilename =
|
||||
singleDoc.filename || singleDoc.original_name || singleDoc.title || 'download';
|
||||
@@ -799,61 +511,24 @@ const DetailPanel = ({
|
||||
</div>
|
||||
) : null;
|
||||
const emptyMessage = previewMissingAsset ? 'Preview unavailable' : 'Preview loading…';
|
||||
const interceptNavPointer = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
const showNav = hasPreviewImage && (effectiveCardinality > 1 || navCanGoPrev || navCanGoNext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="preview-pane preview-pane--stack">
|
||||
<PreviewStack
|
||||
items={singlePreviewItems}
|
||||
maxItems={1}
|
||||
<div className="preview-pane">
|
||||
<div className="preview-pane__media">
|
||||
<PreviewImage
|
||||
item={singlePreviewItem}
|
||||
emptyMessage={emptyMessage}
|
||||
emptyContent={previewFallback}
|
||||
onItemActivate={handlePreviewActivate}
|
||||
onActivate={handlePreviewActivate}
|
||||
onOpenPreview={onOpenPreview}
|
||||
onZoomPreview={handleSingleZoom}
|
||||
showNav={showNav}
|
||||
canGoPrev={navCanGoPrev}
|
||||
canGoNext={navCanGoNext}
|
||||
onGoPrev={navCanGoPrev ? singlePreviewNavigator.goPrev : undefined}
|
||||
onGoNext={navCanGoNext ? singlePreviewNavigator.goNext : undefined}
|
||||
/>
|
||||
{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>
|
||||
<div className="document-viewer__details">
|
||||
<DocumentInfoPanel
|
||||
@@ -866,99 +541,7 @@ const DetailPanel = ({
|
||||
hideTabNavWhenSingle={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1035,14 +618,16 @@ const DetailPanel = ({
|
||||
actions={headerActions.length ? headerActions : null}
|
||||
/>
|
||||
<div className="panel-body">
|
||||
{selectedCount <= 1 ? renderSingle() : renderBulk()}
|
||||
{renderContent()}
|
||||
</div>
|
||||
</aside>
|
||||
<PreviewZoomOverlay
|
||||
open={Boolean(zoomDisplay?.url)}
|
||||
display={zoomDisplay}
|
||||
onClose={closeZoomPreview}
|
||||
/>
|
||||
{singleDoc && (
|
||||
<PreviewZoomOverlay
|
||||
open={Boolean(zoomDisplay)}
|
||||
display={zoomDisplay}
|
||||
onClose={closeZoomPreview}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user