detailspanel, selection handling, single document details panel

This commit is contained in:
2025-11-07 10:46:04 +01:00
parent 5b88ecf2dd
commit 30b90a5bf5
5 changed files with 292 additions and 761 deletions
+3 -16
View File
@@ -4358,17 +4358,12 @@ const AppLayout = () => {
const {
detailPanelOpen,
detailPanelSelectedDocuments,
detailPanelDocument,
openDetailPanel,
closeDetailPanel,
} = useDetailPanel({
selectedDocumentIds,
documentLookup,
orderedSelectedDocuments,
selectionOrder,
documentsViewMode,
getRowId,
isDocumentRowKey,
});
detailPanelControlRef.current = {
@@ -4900,17 +4895,13 @@ const AppLayout = () => {
const detailPanelProps = useMemo(
() => ({
selectedDocuments: detailPanelSelectedDocuments,
document: detailPanelDocument,
tags,
tagLookupById,
onTagAdd: handleTagAdd,
onTagRemove: handleTagRemove,
previewEntry: selectedPreviewEntry,
onOpenPreview: openDocumentPreview,
onBulkTagAdd: handleBulkTagAddFromDetail,
onBulkTagRemove: handleBulkTagRemoveFromDetail,
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
onPromoteSelection: promoteSelectionOrder,
activePreviewId,
onUpdateTitle: handleDocumentTitleUpdate,
@@ -4929,14 +4920,10 @@ const AppLayout = () => {
[
activePreviewId,
correspondents,
detailPanelSelectedDocuments,
detailPanelDocument,
ensureAssetUrl,
ensurePreviewData,
getDocumentAsset,
handleBulkCorrespondentAdd,
handleBulkCorrespondentRemove,
handleBulkTagAddFromDetail,
handleBulkTagRemoveFromDetail,
handleCorrespondentAdd,
handleCorrespondentRemove,
handleDetailPanelClose,
+44 -117
View File
@@ -3,147 +3,74 @@ import { useCallback, useEffect, useRef, useState } from 'react';
export const useDetailPanel = ({
documentLookup,
orderedSelectedDocuments,
selectionOrder,
documentsViewMode,
getRowId,
isDocumentRowKey,
}) => {
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
const [detailPanelDocIds, setDetailPanelDocIds] = useState([]);
const [detailPanelDocs, setDetailPanelDocs] = useState([]);
const lastScrolledDetailDocRef = useRef(null);
const [detailPanelDocId, setDetailPanelDocId] = useState(null);
const [detailPanelDocument, setDetailPanelDocument] = useState(null);
const latestOrderedDocsRef = useRef([]);
useEffect(() => {
latestOrderedDocsRef.current = orderedSelectedDocuments;
if (detailPanelOpen && orderedSelectedDocuments.length) {
const snapshotIds = orderedSelectedDocuments
.map((doc) => doc?.id)
.filter((id) => typeof id === 'string' || typeof id === 'number');
if (snapshotIds.length) {
setDetailPanelDocIds(snapshotIds);
const nextDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1];
if (nextDoc?.id) {
setDetailPanelDocId(nextDoc.id);
setDetailPanelDocument(nextDoc);
}
}
}, [orderedSelectedDocuments, detailPanelOpen]);
const resolveDocsForIds = useCallback(
(ids, fallbackDocs = []) => {
if (!ids?.length) {
return [];
}
const fallbackMap = new Map((fallbackDocs || []).map((doc) => [doc?.id, doc]));
return ids
.map((id) => documentLookup.get(id) || fallbackMap.get(id) || null)
.filter(Boolean);
},
[documentLookup],
);
useEffect(() => {
if (!detailPanelDocIds.length) {
setDetailPanelDocs((prev) => (prev.length ? [] : prev));
return;
}
setDetailPanelDocs((prevDocs) => {
const resolved = resolveDocsForIds(detailPanelDocIds, prevDocs);
if (resolved.length === prevDocs.length && resolved.every((doc, index) => doc === prevDocs[index])) {
return prevDocs;
if (!detailPanelDocId) {
if (!detailPanelOpen) {
setDetailPanelDocument(null);
}
return resolved;
});
}, [detailPanelDocIds, resolveDocsForIds]);
useEffect(() => {
if (!detailPanelOpen) {
lastScrolledDetailDocRef.current = null;
return;
}
if (!orderedSelectedDocuments.length) {
lastScrolledDetailDocRef.current = null;
return;
const resolved = documentLookup.get(detailPanelDocId);
if (resolved && resolved !== detailPanelDocument) {
setDetailPanelDocument(resolved);
}
let lastSelectedId = null;
for (let index = selectionOrder.length - 1; index >= 0; index -= 1) {
const key = selectionOrder[index];
if (isDocumentRowKey(key)) {
lastSelectedId = getRowId(key);
if (lastSelectedId) {
break;
}
}
}
if (!lastSelectedId && orderedSelectedDocuments.length) {
const fallbackDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1];
lastSelectedId = fallbackDoc?.id || null;
}
if (!lastSelectedId || lastScrolledDetailDocRef.current === lastSelectedId) {
return;
}
if (typeof document === 'undefined') {
return;
}
const targetElement =
document.getElementById(`document-row-${lastSelectedId}`)
|| document.getElementById(`document-card-${lastSelectedId}`);
if (!targetElement) {
return;
}
lastScrolledDetailDocRef.current = lastSelectedId;
requestAnimationFrame(() => {
targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
}, [
detailPanelOpen,
orderedSelectedDocuments,
selectionOrder,
documentsViewMode,
getRowId,
isDocumentRowKey,
]);
const detailPanelSelectedDocuments = detailPanelDocs;
}, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]);
const openDetailPanel = useCallback(
({ documentIds: explicitIds, documents: explicitDocs } = {}) => {
let sourceDocs = Array.isArray(explicitDocs) ? explicitDocs : null;
let snapshotIds = Array.isArray(explicitIds)
? explicitIds.filter((id) => typeof id === 'string' || typeof id === 'number')
: null;
({ documentId, document, documentIds, documents } = {}) => {
let targetDoc = document || null;
let targetId = documentId ?? document?.id ?? null;
if (!snapshotIds?.length) {
if (!sourceDocs || !sourceDocs.length) {
sourceDocs = latestOrderedDocsRef.current;
}
snapshotIds = Array.isArray(sourceDocs)
? sourceDocs
.map((doc) => doc?.id)
.filter((id) => typeof id === 'string' || typeof id === 'number')
: [];
if (!targetDoc && Array.isArray(documents) && documents.length) {
targetDoc = documents[documents.length - 1];
targetId = targetDoc?.id ?? targetId;
}
const uniqueIds = [];
snapshotIds.forEach((id) => {
if (!uniqueIds.includes(id)) {
uniqueIds.push(id);
if (!targetDoc && Array.isArray(documentIds) && documentIds.length) {
targetId = documentIds[documentIds.length - 1];
}
if (!targetDoc && targetId != null) {
targetDoc = documentLookup.get(String(targetId)) || null;
}
if (!targetDoc) {
const fallbackDocs = latestOrderedDocsRef.current;
const fallbackDoc = Array.isArray(fallbackDocs) && fallbackDocs.length
? fallbackDocs[fallbackDocs.length - 1]
: null;
if (fallbackDoc) {
targetDoc = fallbackDoc;
targetId = fallbackDoc.id;
}
});
}
const resolvedDocs = resolveDocsForIds(uniqueIds, sourceDocs || latestOrderedDocsRef.current);
if (!targetDoc && targetId == null) {
return;
}
setDetailPanelDocIds(uniqueIds);
setDetailPanelDocs(resolvedDocs);
setDetailPanelOpen(true);
setDetailPanelDocId(targetDoc?.id || targetId || null);
setDetailPanelDocument(targetDoc || null);
setDetailPanelOpen(Boolean(targetDoc || targetId));
},
[resolveDocsForIds],
[documentLookup],
);
const closeDetailPanel = useCallback(() => {
@@ -152,7 +79,7 @@ export const useDetailPanel = ({
return {
detailPanelOpen,
detailPanelSelectedDocuments,
detailPanelDocument,
openDetailPanel,
closeDetailPanel,
setDetailPanelOpen,
+166 -581
View File
@@ -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}
/>
)}
</>
);
};
+18 -14
View File
@@ -185,9 +185,11 @@ const DocumentViewerPanel = ({
if (!document) {
return (
<section className="document-viewer document-viewer--loading">
<div className="document-viewer__details">
<div className="document-viewer__message">
Loading document{documentId ? ` ${documentId}` : ''}
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<div className="document-viewer__message">
Loading document{documentId ? ` ${documentId}` : ''}
</div>
</div>
</div>
<div className="document-viewer__viewport">
@@ -199,17 +201,19 @@ const DocumentViewerPanel = ({
return (
<section className="document-viewer">
<div className="document-viewer__details">
<DocumentInfoPanel
document={document}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentConfig={contentTabConfig}
defaultTabId="details"
classNamePrefix="document-viewer"
hideTabNavWhenSingle={false}
resetKey={document?.id}
/>
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<DocumentInfoPanel
document={document}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentConfig={contentTabConfig}
defaultTabId="details"
classNamePrefix="document-viewer"
hideTabNavWhenSingle={false}
resetKey={document?.id}
/>
</div>
</div>
<div className="document-viewer__viewport">
{!previewEntry?.url ? (
+61 -33
View File
@@ -864,7 +864,7 @@ button.danger:hover:not([disabled]) {
flex: 1;
display: grid;
grid-template-columns: minmax(0, 30em) minmax(0, 1fr);
gap: 1.5rem;
gap: 1rem;
min-height: 0;
padding: 1rem 1rem;
}
@@ -973,7 +973,14 @@ button.danger:hover:not([disabled]) {
gap: 0.5rem;
min-height: 0;
flex: 1;
overflow: hidden;
}
.document-viewer__details-pane {
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
flex: 1;
}
.document-viewer__tabs-wrapper {
display: flex;
@@ -987,7 +994,6 @@ button.danger:hover:not([disabled]) {
flex-direction: column;
flex: 1;
min-height: 0;
overflow: auto;
padding-top: 1rem;
}
@@ -1123,7 +1129,6 @@ button.danger:hover:not([disabled]) {
width: 100%;
height: 100%;
margin: 0;
overflow: auto;
padding: 1rem 0;
font-size: 1rem;
white-space: pre-wrap;
@@ -2882,7 +2887,7 @@ button.danger:hover:not([disabled]) {
flex-direction: column;
overflow-y: auto;
min-height: 0;
padding: 1.25rem;
padding: 0;
}
.detail-section__header {
@@ -3134,6 +3139,18 @@ button.danger:hover:not([disabled]) {
.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;
@@ -3144,6 +3161,44 @@ button.danger:hover:not([disabled]) {
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;
@@ -3381,33 +3436,6 @@ button.danger:hover:not([disabled]) {
height: 80%;
}
.preview-stack__image {
display: block;
width: auto;
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;
pointer-events: auto;
}
.preview-stack__image:hover,
.preview-stack__image:focus-visible {
outline-color: var(--accent-focus);
box-shadow:
inset 0 0 0 999px var(--accent-elevated),
0 6px 18px var(--accent-elevated-strong);
}
.preview-pane__unsupported {
width: 100%;
max-width: 320px;
@@ -3501,7 +3529,7 @@ button.danger:hover:not([disabled]) {
transform: scale(calc(1 / var(--preview-nav-scale, 1)));
}
.preview-pane--stack:hover .preview-pane__nav--overlay,
.preview-pane__media:hover .preview-pane__nav--overlay,
.desk-item__card:hover .preview-pane__nav--overlay {
opacity: 1;
}