detailspanel, selection handling, single document details panel
This commit is contained in:
@@ -4358,17 +4358,12 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
detailPanelOpen,
|
detailPanelOpen,
|
||||||
detailPanelSelectedDocuments,
|
detailPanelDocument,
|
||||||
openDetailPanel,
|
openDetailPanel,
|
||||||
closeDetailPanel,
|
closeDetailPanel,
|
||||||
} = useDetailPanel({
|
} = useDetailPanel({
|
||||||
selectedDocumentIds,
|
|
||||||
documentLookup,
|
documentLookup,
|
||||||
orderedSelectedDocuments,
|
orderedSelectedDocuments,
|
||||||
selectionOrder,
|
|
||||||
documentsViewMode,
|
|
||||||
getRowId,
|
|
||||||
isDocumentRowKey,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
detailPanelControlRef.current = {
|
detailPanelControlRef.current = {
|
||||||
@@ -4900,17 +4895,13 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
const detailPanelProps = useMemo(
|
const detailPanelProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
selectedDocuments: detailPanelSelectedDocuments,
|
document: detailPanelDocument,
|
||||||
tags,
|
tags,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
onTagAdd: handleTagAdd,
|
onTagAdd: handleTagAdd,
|
||||||
onTagRemove: handleTagRemove,
|
onTagRemove: handleTagRemove,
|
||||||
previewEntry: selectedPreviewEntry,
|
previewEntry: selectedPreviewEntry,
|
||||||
onOpenPreview: openDocumentPreview,
|
onOpenPreview: openDocumentPreview,
|
||||||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
|
||||||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
|
||||||
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
|
||||||
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
|
||||||
onPromoteSelection: promoteSelectionOrder,
|
onPromoteSelection: promoteSelectionOrder,
|
||||||
activePreviewId,
|
activePreviewId,
|
||||||
onUpdateTitle: handleDocumentTitleUpdate,
|
onUpdateTitle: handleDocumentTitleUpdate,
|
||||||
@@ -4929,14 +4920,10 @@ const AppLayout = () => {
|
|||||||
[
|
[
|
||||||
activePreviewId,
|
activePreviewId,
|
||||||
correspondents,
|
correspondents,
|
||||||
detailPanelSelectedDocuments,
|
detailPanelDocument,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
handleBulkCorrespondentAdd,
|
|
||||||
handleBulkCorrespondentRemove,
|
|
||||||
handleBulkTagAddFromDetail,
|
|
||||||
handleBulkTagRemoveFromDetail,
|
|
||||||
handleCorrespondentAdd,
|
handleCorrespondentAdd,
|
||||||
handleCorrespondentRemove,
|
handleCorrespondentRemove,
|
||||||
handleDetailPanelClose,
|
handleDetailPanelClose,
|
||||||
|
|||||||
@@ -3,147 +3,74 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
export const useDetailPanel = ({
|
export const useDetailPanel = ({
|
||||||
documentLookup,
|
documentLookup,
|
||||||
orderedSelectedDocuments,
|
orderedSelectedDocuments,
|
||||||
selectionOrder,
|
|
||||||
documentsViewMode,
|
|
||||||
getRowId,
|
|
||||||
isDocumentRowKey,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
|
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
|
||||||
const [detailPanelDocIds, setDetailPanelDocIds] = useState([]);
|
const [detailPanelDocId, setDetailPanelDocId] = useState(null);
|
||||||
const [detailPanelDocs, setDetailPanelDocs] = useState([]);
|
const [detailPanelDocument, setDetailPanelDocument] = useState(null);
|
||||||
const lastScrolledDetailDocRef = useRef(null);
|
|
||||||
const latestOrderedDocsRef = useRef([]);
|
const latestOrderedDocsRef = useRef([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latestOrderedDocsRef.current = orderedSelectedDocuments;
|
latestOrderedDocsRef.current = orderedSelectedDocuments;
|
||||||
if (detailPanelOpen && orderedSelectedDocuments.length) {
|
if (detailPanelOpen && orderedSelectedDocuments.length) {
|
||||||
const snapshotIds = orderedSelectedDocuments
|
const nextDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1];
|
||||||
.map((doc) => doc?.id)
|
if (nextDoc?.id) {
|
||||||
.filter((id) => typeof id === 'string' || typeof id === 'number');
|
setDetailPanelDocId(nextDoc.id);
|
||||||
if (snapshotIds.length) {
|
setDetailPanelDocument(nextDoc);
|
||||||
setDetailPanelDocIds(snapshotIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [orderedSelectedDocuments, detailPanelOpen]);
|
}, [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;
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
});
|
|
||||||
}, [detailPanelDocIds, resolveDocsForIds]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!detailPanelDocId) {
|
||||||
if (!detailPanelOpen) {
|
if (!detailPanelOpen) {
|
||||||
lastScrolledDetailDocRef.current = null;
|
setDetailPanelDocument(null);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const resolved = documentLookup.get(detailPanelDocId);
|
||||||
if (!orderedSelectedDocuments.length) {
|
if (resolved && resolved !== detailPanelDocument) {
|
||||||
lastScrolledDetailDocRef.current = null;
|
setDetailPanelDocument(resolved);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]);
|
||||||
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;
|
|
||||||
|
|
||||||
const openDetailPanel = useCallback(
|
const openDetailPanel = useCallback(
|
||||||
({ documentIds: explicitIds, documents: explicitDocs } = {}) => {
|
({ documentId, document, documentIds, documents } = {}) => {
|
||||||
let sourceDocs = Array.isArray(explicitDocs) ? explicitDocs : null;
|
let targetDoc = document || null;
|
||||||
let snapshotIds = Array.isArray(explicitIds)
|
let targetId = documentId ?? document?.id ?? null;
|
||||||
? explicitIds.filter((id) => typeof id === 'string' || typeof id === 'number')
|
|
||||||
|
if (!targetDoc && Array.isArray(documents) && documents.length) {
|
||||||
|
targetDoc = documents[documents.length - 1];
|
||||||
|
targetId = targetDoc?.id ?? targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
: null;
|
||||||
|
if (fallbackDoc) {
|
||||||
if (!snapshotIds?.length) {
|
targetDoc = fallbackDoc;
|
||||||
if (!sourceDocs || !sourceDocs.length) {
|
targetId = fallbackDoc.id;
|
||||||
sourceDocs = latestOrderedDocsRef.current;
|
|
||||||
}
|
}
|
||||||
snapshotIds = Array.isArray(sourceDocs)
|
|
||||||
? sourceDocs
|
|
||||||
.map((doc) => doc?.id)
|
|
||||||
.filter((id) => typeof id === 'string' || typeof id === 'number')
|
|
||||||
: [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueIds = [];
|
if (!targetDoc && targetId == null) {
|
||||||
snapshotIds.forEach((id) => {
|
return;
|
||||||
if (!uniqueIds.includes(id)) {
|
|
||||||
uniqueIds.push(id);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const resolvedDocs = resolveDocsForIds(uniqueIds, sourceDocs || latestOrderedDocsRef.current);
|
setDetailPanelDocId(targetDoc?.id || targetId || null);
|
||||||
|
setDetailPanelDocument(targetDoc || null);
|
||||||
setDetailPanelDocIds(uniqueIds);
|
setDetailPanelOpen(Boolean(targetDoc || targetId));
|
||||||
setDetailPanelDocs(resolvedDocs);
|
|
||||||
setDetailPanelOpen(true);
|
|
||||||
},
|
},
|
||||||
[resolveDocsForIds],
|
[documentLookup],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeDetailPanel = useCallback(() => {
|
const closeDetailPanel = useCallback(() => {
|
||||||
@@ -152,7 +79,7 @@ export const useDetailPanel = ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
detailPanelOpen,
|
detailPanelOpen,
|
||||||
detailPanelSelectedDocuments,
|
detailPanelDocument,
|
||||||
openDetailPanel,
|
openDetailPanel,
|
||||||
closeDetailPanel,
|
closeDetailPanel,
|
||||||
setDetailPanelOpen,
|
setDetailPanelOpen,
|
||||||
|
|||||||
+123
-538
@@ -7,23 +7,15 @@ import {
|
|||||||
WindowMaximizeIcon,
|
WindowMaximizeIcon,
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
import PanelHeader from '../ui/PanelHeader';
|
import PanelHeader from '../ui/PanelHeader';
|
||||||
import { formatFileSize } from '../utils/format';
|
|
||||||
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||||
import { describeDocumentSummary } from '../documents/documentSummary';
|
import { describeDocumentSummary } from '../documents/documentSummary';
|
||||||
import { createDocumentActionState } from '../documents/documentActions';
|
import { createDocumentActionState } from '../documents/documentActions';
|
||||||
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
||||||
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
||||||
import {
|
import { sortCorrespondents, buildCorrespondentOptions } from '../documents/DocumentSummarySection';
|
||||||
TagSection,
|
|
||||||
CorrespondentSection,
|
|
||||||
sortCorrespondents,
|
|
||||||
buildCorrespondentOptions,
|
|
||||||
} from '../documents/DocumentSummarySection';
|
|
||||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||||
|
|
||||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
|
||||||
|
|
||||||
const derivePreviewOrientation = (metadata) => {
|
const derivePreviewOrientation = (metadata) => {
|
||||||
const width = Number(metadata?.width);
|
const width = Number(metadata?.width);
|
||||||
const height = Number(metadata?.height);
|
const height = Number(metadata?.height);
|
||||||
@@ -33,122 +25,107 @@ const derivePreviewOrientation = (metadata) => {
|
|||||||
return 'landscape';
|
return 'landscape';
|
||||||
};
|
};
|
||||||
|
|
||||||
const computeStackAngle = (docId, index) => {
|
const PreviewImage = ({
|
||||||
if (index === 0) return 0;
|
item,
|
||||||
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',
|
emptyMessage = 'Preview unavailable',
|
||||||
emptyContent = null,
|
emptyContent = null,
|
||||||
onItemActivate,
|
onActivate,
|
||||||
onOpenPreview,
|
onOpenPreview,
|
||||||
onZoomPreview,
|
onZoomPreview,
|
||||||
|
showNav = false,
|
||||||
|
canGoPrev = false,
|
||||||
|
canGoNext = false,
|
||||||
|
onGoPrev = null,
|
||||||
|
onGoNext = null,
|
||||||
}) => {
|
}) => {
|
||||||
const limited = useMemo(() => items.slice(0, maxItems), [items, maxItems]);
|
if (!item) {
|
||||||
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 (
|
return (
|
||||||
<div className="preview-stack preview-stack--empty">
|
<div className="preview-image preview-image--empty">
|
||||||
{emptyContent || <span className="meta">{emptyMessage}</span>}
|
{emptyContent || <span className="meta">{emptyMessage}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const handleActivate = (event) => {
|
||||||
<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();
|
event.stopPropagation();
|
||||||
if (isFront) {
|
|
||||||
if (onZoomPreview) {
|
if (onZoomPreview) {
|
||||||
onZoomPreview(entry);
|
onZoomPreview(item);
|
||||||
} else if (onOpenPreview) {
|
} else if (onOpenPreview) {
|
||||||
onOpenPreview(entry.id);
|
onOpenPreview(item.id);
|
||||||
} else if (onItemActivate) {
|
} else if (onActivate) {
|
||||||
onItemActivate(entry.id);
|
onActivate(item.id);
|
||||||
}
|
}
|
||||||
} else if (onItemActivate) {
|
};
|
||||||
onItemActivate(entry.id);
|
|
||||||
}
|
const interceptNavPointer = (event) => {
|
||||||
}}
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="preview-image">
|
||||||
|
<img
|
||||||
|
src={item.url}
|
||||||
|
alt={item.alt}
|
||||||
|
className={`preview-image__content orientation-${item.orientation || 'landscape'}`}
|
||||||
|
onClick={handleActivate}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (!onItemActivate && !onOpenPreview && !onZoomPreview) return;
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (isFront) {
|
handleActivate(event);
|
||||||
if (onZoomPreview) {
|
|
||||||
onZoomPreview(entry);
|
|
||||||
} else if (onOpenPreview) {
|
|
||||||
onOpenPreview(entry.id);
|
|
||||||
} else {
|
|
||||||
onItemActivate?.(entry.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onItemActivate?.(entry.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{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?.();
|
||||||
|
}}
|
||||||
|
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();
|
||||||
|
onGoNext?.();
|
||||||
|
}}
|
||||||
|
onPointerDown={interceptNavPointer}
|
||||||
|
onPointerUp={interceptNavPointer}
|
||||||
|
onMouseDown={interceptNavPointer}
|
||||||
|
onMouseUp={interceptNavPointer}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
aria-label="Next preview"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
) : null}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DetailPanel = ({
|
const DetailPanel = ({
|
||||||
selectedDocuments = [],
|
document = null,
|
||||||
tags = [],
|
tags = [],
|
||||||
tagLookupById = new Map(),
|
tagLookupById = new Map(),
|
||||||
onTagAdd,
|
onTagAdd,
|
||||||
onTagRemove,
|
onTagRemove,
|
||||||
onOpenPreview,
|
onOpenPreview,
|
||||||
onBulkTagAdd,
|
|
||||||
onBulkTagRemove,
|
|
||||||
onBulkCorrespondentAdd,
|
|
||||||
onBulkCorrespondentRemove,
|
|
||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
onUpdateTitle = async () => false,
|
onUpdateTitle = async () => false,
|
||||||
onUpdateIssued = async () => false,
|
onUpdateIssued = async () => false,
|
||||||
@@ -163,13 +140,9 @@ const DetailPanel = ({
|
|||||||
resolveFolderPath = null,
|
resolveFolderPath = null,
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}) => {
|
}) => {
|
||||||
const selectedCount = selectedDocuments.length;
|
const singleDoc = document || null;
|
||||||
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
|
||||||
const singleDocId = singleDoc?.id || null;
|
const singleDocId = singleDoc?.id || null;
|
||||||
const selectionKey = useMemo(
|
const selectionKey = singleDocId || 'none';
|
||||||
() => selectedDocuments.map((doc) => doc?.id ?? '').join('|'),
|
|
||||||
[selectedDocuments],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { downloadHref: singleDownloadHref } = useMemo(
|
const { downloadHref: singleDownloadHref } = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -185,18 +158,10 @@ const DetailPanel = ({
|
|||||||
|
|
||||||
const detailSummary = useMemo(() => describeDocumentSummary(singleDoc), [singleDoc]);
|
const detailSummary = useMemo(() => describeDocumentSummary(singleDoc), [singleDoc]);
|
||||||
|
|
||||||
const headerTitle = useMemo(() => {
|
const headerTitle = singleDoc ? detailSummary.title : 'Document details';
|
||||||
if (selectedCount === 0) {
|
|
||||||
return 'Document details';
|
|
||||||
}
|
|
||||||
if (selectedCount === 1) {
|
|
||||||
return detailSummary.title;
|
|
||||||
}
|
|
||||||
return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
|
||||||
}, [selectedCount, detailSummary]);
|
|
||||||
|
|
||||||
const headerBreadcrumbs = useMemo(() => {
|
const headerBreadcrumbs = useMemo(() => {
|
||||||
if (selectedCount !== 1 || !singleDoc || typeof resolveFolderPath !== 'function') {
|
if (!singleDoc || typeof resolveFolderPath !== 'function') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,15 +190,10 @@ const DetailPanel = ({
|
|||||||
label: detailSummary.title,
|
label: detailSummary.title,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [selectedCount, singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]);
|
}, [singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]);
|
||||||
|
|
||||||
const [zoomedPreview, setZoomedPreview] = useState(null);
|
const [zoomedPreview, setZoomedPreview] = useState(null);
|
||||||
|
|
||||||
const bulkDocumentIds = useMemo(
|
|
||||||
() => selectedDocuments.map((doc) => doc?.id).filter(Boolean),
|
|
||||||
[selectedDocuments],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setZoomedPreview(null);
|
setZoomedPreview(null);
|
||||||
}, [selectionKey]);
|
}, [selectionKey]);
|
||||||
@@ -284,77 +244,22 @@ const DetailPanel = ({
|
|||||||
[ensureAssetUrl, getDocumentAsset],
|
[ensureAssetUrl, getDocumentAsset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stackDocuments = useMemo(() => {
|
const singlePreviewItem = useMemo(() => {
|
||||||
if (!selectedDocuments.length) return [];
|
if (!singleDoc) return null;
|
||||||
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;
|
const url = singlePreviewNavigator.currentUrl;
|
||||||
if (!url) {
|
if (url) {
|
||||||
return [];
|
return {
|
||||||
}
|
|
||||||
const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata);
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: singleDoc.id,
|
id: singleDoc.id,
|
||||||
url,
|
url,
|
||||||
orientation,
|
orientation: derivePreviewOrientation(singlePreviewNavigator.currentMetadata),
|
||||||
alt: singleDoc.title,
|
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);
|
return makePreviewItem(singleDoc, 1);
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}, [
|
}, [
|
||||||
stackDocuments,
|
singleDoc,
|
||||||
stackTopDocument,
|
singlePreviewNavigator.currentUrl,
|
||||||
stackPreviewNavigator.currentUrl,
|
singlePreviewNavigator.currentMetadata,
|
||||||
stackPreviewNavigator.currentMetadata,
|
|
||||||
makePreviewItem,
|
makePreviewItem,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -362,42 +267,6 @@ const DetailPanel = ({
|
|||||||
const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
|
const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
|
||||||
const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl);
|
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(
|
const correspondentOptions = useMemo(
|
||||||
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
|
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
|
||||||
[correspondents],
|
[correspondents],
|
||||||
@@ -496,77 +365,9 @@ const DetailPanel = ({
|
|||||||
[singleHasOcr, loadSingleOcrContent],
|
[singleHasOcr, loadSingleOcrContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const bulkCorrespondents = useMemo(() => {
|
const openZoomPreview = useCallback((docId) => {
|
||||||
if (selectedDocuments.length <= 1) {
|
if (!docId) return;
|
||||||
const doc = selectedDocuments[0];
|
setZoomedPreview({ docId });
|
||||||
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(() => {
|
const closeZoomPreview = useCallback(() => {
|
||||||
@@ -578,32 +379,22 @@ const DetailPanel = ({
|
|||||||
if (!singleHasPreview) return;
|
if (!singleHasPreview) return;
|
||||||
const targetId = entry?.id ?? singleDocId;
|
const targetId = entry?.id ?? singleDocId;
|
||||||
if (!targetId) return;
|
if (!targetId) return;
|
||||||
openZoomPreview({ mode: 'single', docId: targetId });
|
openZoomPreview(targetId);
|
||||||
},
|
},
|
||||||
[openZoomPreview, singleHasPreview, singleDocId],
|
[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(() => {
|
const zoomDisplay = useMemo(() => {
|
||||||
if (!zoomedPreview) {
|
if (
|
||||||
|
!zoomedPreview
|
||||||
|
|| !singleDoc
|
||||||
|
|| !singleDocId
|
||||||
|
|| zoomedPreview.docId !== singleDocId
|
||||||
|
|| !singleHasPreview
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
zoomedPreview.mode === 'single' &&
|
|
||||||
singleDocId &&
|
|
||||||
singleDoc &&
|
|
||||||
singleHasPreview &&
|
|
||||||
zoomedPreview.docId === singleDocId
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
url: singlePreviewNavigator.currentUrl,
|
url: singlePreviewNavigator.currentUrl,
|
||||||
alt: singleDoc.title,
|
alt: singleDoc.title,
|
||||||
@@ -614,28 +405,6 @@ const DetailPanel = ({
|
|||||||
goPrev: singlePreviewNavigator.goPrev,
|
goPrev: singlePreviewNavigator.goPrev,
|
||||||
goNext: singlePreviewNavigator.goNext,
|
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,
|
zoomedPreview,
|
||||||
singleDoc,
|
singleDoc,
|
||||||
@@ -647,15 +416,6 @@ const DetailPanel = ({
|
|||||||
singlePreviewNavigator.goPrev,
|
singlePreviewNavigator.goPrev,
|
||||||
singlePreviewNavigator.goNext,
|
singlePreviewNavigator.goNext,
|
||||||
singleEffectiveCardinality,
|
singleEffectiveCardinality,
|
||||||
stackTopDocument,
|
|
||||||
stackTopDocId,
|
|
||||||
topHasPreview,
|
|
||||||
stackPreviewNavigator.currentUrl,
|
|
||||||
stackPreviewNavigator.canGoPrev,
|
|
||||||
stackPreviewNavigator.canGoNext,
|
|
||||||
stackPreviewNavigator.goPrev,
|
|
||||||
stackPreviewNavigator.goNext,
|
|
||||||
topEffectiveCardinality,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -673,39 +433,20 @@ const DetailPanel = ({
|
|||||||
cardinality: singleNavigatorCardinality,
|
cardinality: singleNavigatorCardinality,
|
||||||
} = singlePreviewNavigator;
|
} = singlePreviewNavigator;
|
||||||
|
|
||||||
const {
|
|
||||||
documentId: stackNavigatorDocId,
|
|
||||||
asset: stackNavigatorAsset,
|
|
||||||
ordinal: stackNavigatorOrdinal,
|
|
||||||
canGoPrev: stackNavigatorCanGoPrev,
|
|
||||||
canGoNext: stackNavigatorCanGoNext,
|
|
||||||
cardinality: stackNavigatorCardinality,
|
|
||||||
} = stackPreviewNavigator;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof ensureAssetUrl !== 'function') {
|
if (typeof ensureAssetUrl !== 'function') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!singleNavigatorDocId || !singleNavigatorAsset || !Number.isFinite(singleNavigatorOrdinal)) {
|
||||||
const warmNavigator = (navigator) => {
|
|
||||||
const {
|
|
||||||
documentId,
|
|
||||||
asset,
|
|
||||||
ordinal,
|
|
||||||
canGoPrev,
|
|
||||||
canGoNext,
|
|
||||||
cardinality,
|
|
||||||
} = navigator;
|
|
||||||
if (!documentId || !asset || !Number.isFinite(ordinal)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requests = [];
|
const requests = [];
|
||||||
if (canGoPrev) {
|
if (singleNavigatorCanGoPrev) {
|
||||||
const prevOrdinal = Math.max(1, ordinal - 1);
|
const prevOrdinal = Math.max(1, singleNavigatorOrdinal - 1);
|
||||||
if (!cardinality || prevOrdinal <= cardinality) {
|
if (!singleNavigatorCardinality || prevOrdinal <= singleNavigatorCardinality) {
|
||||||
requests.push(
|
requests.push(
|
||||||
ensureAssetUrl(documentId, asset, {
|
ensureAssetUrl(singleNavigatorDocId, singleNavigatorAsset, {
|
||||||
start: prevOrdinal,
|
start: prevOrdinal,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
objectOrdinal: prevOrdinal,
|
objectOrdinal: prevOrdinal,
|
||||||
@@ -713,11 +454,11 @@ const DetailPanel = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (canGoNext) {
|
if (singleNavigatorCanGoNext) {
|
||||||
const nextOrdinal = ordinal + 1;
|
const nextOrdinal = singleNavigatorOrdinal + 1;
|
||||||
if (!cardinality || nextOrdinal <= cardinality) {
|
if (!singleNavigatorCardinality || nextOrdinal <= singleNavigatorCardinality) {
|
||||||
requests.push(
|
requests.push(
|
||||||
ensureAssetUrl(documentId, asset, {
|
ensureAssetUrl(singleNavigatorDocId, singleNavigatorAsset, {
|
||||||
start: nextOrdinal,
|
start: nextOrdinal,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
objectOrdinal: nextOrdinal,
|
objectOrdinal: nextOrdinal,
|
||||||
@@ -727,28 +468,6 @@ const DetailPanel = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
requests.forEach((promise) => promise?.catch?.(() => {}));
|
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,
|
ensureAssetUrl,
|
||||||
singleNavigatorDocId,
|
singleNavigatorDocId,
|
||||||
@@ -757,25 +476,18 @@ const DetailPanel = ({
|
|||||||
singleNavigatorCanGoPrev,
|
singleNavigatorCanGoPrev,
|
||||||
singleNavigatorCanGoNext,
|
singleNavigatorCanGoNext,
|
||||||
singleNavigatorCardinality,
|
singleNavigatorCardinality,
|
||||||
stackNavigatorDocId,
|
|
||||||
stackNavigatorAsset,
|
|
||||||
stackNavigatorOrdinal,
|
|
||||||
stackNavigatorCanGoPrev,
|
|
||||||
stackNavigatorCanGoNext,
|
|
||||||
stackNavigatorCardinality,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const renderSingle = () => {
|
const renderContent = () => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveCardinality =
|
const effectiveCardinality = singleEffectiveCardinality;
|
||||||
singlePreviewNavigator.cardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
|
const navCanGoPrev = Boolean(singlePreviewNavigator.canGoPrev);
|
||||||
const canGoPrev = singlePreviewNavigator.canGoPrev;
|
const navCanGoNext = Boolean(singlePreviewNavigator.canGoNext);
|
||||||
const canGoNext = singlePreviewNavigator.canGoNext;
|
|
||||||
const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl);
|
const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl);
|
||||||
const previewMissingAsset = !singleNavigatorAsset;
|
const previewMissingAsset = !singlePreviewNavigator.currentUrl;
|
||||||
const displayContentType = singleDoc.content_type || 'this file type';
|
const displayContentType = singleDoc.content_type || 'this file type';
|
||||||
const displayFilename =
|
const displayFilename =
|
||||||
singleDoc.filename || singleDoc.original_name || singleDoc.title || 'download';
|
singleDoc.filename || singleDoc.original_name || singleDoc.title || 'download';
|
||||||
@@ -799,61 +511,24 @@ const DetailPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
const emptyMessage = previewMissingAsset ? 'Preview unavailable' : 'Preview loading…';
|
const emptyMessage = previewMissingAsset ? 'Preview unavailable' : 'Preview loading…';
|
||||||
const interceptNavPointer = (event) => {
|
const showNav = hasPreviewImage && (effectiveCardinality > 1 || navCanGoPrev || navCanGoNext);
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="preview-pane">
|
||||||
<div className="preview-pane preview-pane--stack">
|
<div className="preview-pane__media">
|
||||||
<PreviewStack
|
<PreviewImage
|
||||||
items={singlePreviewItems}
|
item={singlePreviewItem}
|
||||||
maxItems={1}
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
emptyContent={previewFallback}
|
emptyContent={previewFallback}
|
||||||
onItemActivate={handlePreviewActivate}
|
onActivate={handlePreviewActivate}
|
||||||
onOpenPreview={onOpenPreview}
|
onOpenPreview={onOpenPreview}
|
||||||
onZoomPreview={handleSingleZoom}
|
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>
|
||||||
<div className="document-viewer__details">
|
<div className="document-viewer__details">
|
||||||
<DocumentInfoPanel
|
<DocumentInfoPanel
|
||||||
@@ -866,99 +541,7 @@ const DetailPanel = ({
|
|||||||
hideTabNavWhenSingle={false}
|
hideTabNavWhenSingle={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1035,14 +618,16 @@ const DetailPanel = ({
|
|||||||
actions={headerActions.length ? headerActions : null}
|
actions={headerActions.length ? headerActions : null}
|
||||||
/>
|
/>
|
||||||
<div className="panel-body">
|
<div className="panel-body">
|
||||||
{selectedCount <= 1 ? renderSingle() : renderBulk()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
{singleDoc && (
|
||||||
<PreviewZoomOverlay
|
<PreviewZoomOverlay
|
||||||
open={Boolean(zoomDisplay?.url)}
|
open={Boolean(zoomDisplay)}
|
||||||
display={zoomDisplay}
|
display={zoomDisplay}
|
||||||
onClose={closeZoomPreview}
|
onClose={closeZoomPreview}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -185,11 +185,13 @@ const DocumentViewerPanel = ({
|
|||||||
if (!document) {
|
if (!document) {
|
||||||
return (
|
return (
|
||||||
<section className="document-viewer document-viewer--loading">
|
<section className="document-viewer document-viewer--loading">
|
||||||
|
<div className="document-viewer__details-pane">
|
||||||
<div className="document-viewer__details">
|
<div className="document-viewer__details">
|
||||||
<div className="document-viewer__message">
|
<div className="document-viewer__message">
|
||||||
Loading document{documentId ? ` ${documentId}` : ''}…
|
Loading document{documentId ? ` ${documentId}` : ''}…
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="document-viewer__viewport">
|
<div className="document-viewer__viewport">
|
||||||
<div className="document-viewer__message">Preparing preview…</div>
|
<div className="document-viewer__message">Preparing preview…</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,6 +201,7 @@ const DocumentViewerPanel = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="document-viewer">
|
<section className="document-viewer">
|
||||||
|
<div className="document-viewer__details-pane">
|
||||||
<div className="document-viewer__details">
|
<div className="document-viewer__details">
|
||||||
<DocumentInfoPanel
|
<DocumentInfoPanel
|
||||||
document={document}
|
document={document}
|
||||||
@@ -211,6 +214,7 @@ const DocumentViewerPanel = ({
|
|||||||
resetKey={document?.id}
|
resetKey={document?.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="document-viewer__viewport">
|
<div className="document-viewer__viewport">
|
||||||
{!previewEntry?.url ? (
|
{!previewEntry?.url ? (
|
||||||
<div className="document-viewer__message">Loading preview…</div>
|
<div className="document-viewer__message">Loading preview…</div>
|
||||||
|
|||||||
+61
-33
@@ -864,7 +864,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 30em) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 30em) minmax(0, 1fr);
|
||||||
gap: 1.5rem;
|
gap: 1rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 1rem 1rem;
|
padding: 1rem 1rem;
|
||||||
}
|
}
|
||||||
@@ -973,7 +973,14 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
}
|
||||||
|
|
||||||
|
.document-viewer__details-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.document-viewer__tabs-wrapper {
|
.document-viewer__tabs-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -987,7 +994,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1123,7 +1129,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: auto;
|
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@@ -2882,7 +2887,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 1.25rem;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-section__header {
|
.detail-section__header {
|
||||||
@@ -3134,6 +3139,18 @@ button.danger:hover:not([disabled]) {
|
|||||||
|
|
||||||
.preview-pane {
|
.preview-pane {
|
||||||
margin-top: 0.4rem;
|
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;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
@@ -3144,6 +3161,44 @@ button.danger:hover:not([disabled]) {
|
|||||||
position: relative;
|
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 {
|
.thumbnail-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -3381,33 +3436,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
height: 80%;
|
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 {
|
.preview-pane__unsupported {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
@@ -3501,7 +3529,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
transform: scale(calc(1 / var(--preview-nav-scale, 1)));
|
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 {
|
.desk-item__card:hover .preview-pane__nav--overlay {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user