Files
papercrate/frontend/src/detail/useDetailWorkspace.ts
T

342 lines
9.4 KiB
TypeScript

import { useCallback, useEffect, useMemo } from 'react';
import type { MutableRefObject } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { useDetailPanel } from '../app/useDetailPanel';
import {
DEFAULT_FOLDER_NAME,
getRowId,
isDocumentRowKey,
} from '../app/appLayoutUtils';
import type { DocumentInfoPanelProps } from '../documents/DocumentInfoPanel';
import type { EnsureAssetUrl, GetDocumentAsset } from '../utils/ocr';
type Identifier = string | number;
interface DocumentLike {
id?: Identifier;
folder_id?: Identifier | 'root';
title?: string;
[key: string]: unknown;
}
interface FolderNode {
id: Identifier | 'root';
name?: string;
parentId?: Identifier | 'root';
}
type DocumentLink = {
url?: string;
contentType?: string | null;
} | null;
interface UseDetailWorkspaceArgs {
documents: DocumentLike[];
selectionOrder: string[];
selectedDocumentIds: Identifier[];
documentLookup: Map<Identifier, DocumentLike>;
folderNodes: Map<Identifier | 'root', FolderNode>;
ensureFolderData: (folderId: Identifier | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise<void>;
detailPanelControlRef: MutableRefObject<{ open?: (args?: { documentIds?: Identifier[] }) => void; close?: () => void } | null>;
detailFolderFetchRef: MutableRefObject<Set<Identifier | 'root'>>;
documentLinks: Map<Identifier, DocumentLink>;
previewDocumentId?: Identifier | null;
activePreviewId?: Identifier | null;
openDocumentPreview?: (args: { documentIds: Identifier[] }) => void;
handleDocumentTitleUpdate?: (docId: Identifier, title: string) => Promise<boolean> | boolean;
handleDocumentIssuedUpdate?: (docId: Identifier, issued: number | null) => Promise<boolean> | boolean;
handleDocumentTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
handleTagRemove?: (...args: unknown[]) => void;
ensureAssetUrl?: EnsureAssetUrl;
getDocumentAsset?: GetDocumentAsset;
correspondents?: unknown[];
handleCorrespondentAdd?: (...args: unknown[]) => void;
handleCorrespondentRemove?: (...args: unknown[]) => void;
resolveApiPath?: (path: string) => string;
selectFolder?: (folderId?: Identifier | 'root') => void;
tags?: unknown[];
tagLookupById?: Map<Identifier, unknown> | null;
}
interface UseDetailWorkspaceResult {
detailPanelProps: DocumentInfoPanelProps;
detailPanelOpen: boolean;
openDetailPanel: ReturnType<typeof useDetailPanel>['openDetailPanel'];
closeDetailPanel: ReturnType<typeof useDetailPanel>['closeDetailPanel'];
handleDetailPanelClose: () => void;
inspectDocument: (docId: Identifier | null) => void;
previewActive: boolean;
previewWorkspaceDocument: DocumentLike | null;
documentLink: DocumentLink;
resolveThumbnailUrlForDoc: (doc: DocumentLike | null) => string | null;
resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>;
}
const useDetailWorkspace = ({
documents,
selectionOrder,
selectedDocumentIds,
documentLookup,
folderNodes,
ensureFolderData,
detailPanelControlRef,
detailFolderFetchRef,
documentLinks,
previewDocumentId,
activePreviewId,
openDocumentPreview,
handleDocumentTitleUpdate,
handleDocumentIssuedUpdate,
handleDocumentTagAdd,
handleTagRemove,
ensureAssetUrl,
getDocumentAsset,
correspondents,
handleCorrespondentAdd,
handleCorrespondentRemove,
resolveApiPath,
selectFolder,
tags,
tagLookupById,
}: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => {
const orderedSelectedDocuments = useMemo(() => {
const ordered = [];
const seen = new Set();
const pushDoc = (doc) => {
if (doc?.id && !seen.has(doc.id)) {
ordered.push(doc);
seen.add(doc.id);
}
};
selectionOrder.forEach((key) => {
if (!isDocumentRowKey(key)) {
return;
}
const docId = getRowId(key);
const doc = documentLookup.get(docId) || null;
pushDoc(doc);
});
selectedDocumentIds.forEach((docId) => {
if (seen.has(docId)) {
return;
}
const doc = documentLookup.get(docId) || null;
pushDoc(doc);
});
return ordered;
}, [selectionOrder, documentLookup, selectedDocumentIds]);
const {
detailPanelOpen,
detailPanelDocument,
openDetailPanel,
closeDetailPanel,
} = useDetailPanel({
documentLookup,
orderedSelectedDocuments,
});
useEffect(() => {
detailPanelControlRef.current = {
open: openDetailPanel,
close: closeDetailPanel,
};
}, [detailPanelControlRef, openDetailPanel, closeDetailPanel]);
useEffect(() => {
if (!orderedSelectedDocuments.length) {
return;
}
const visited = new Set();
orderedSelectedDocuments.forEach((doc) => {
const folderId = doc?.folder_id;
if (!folderId) {
return;
}
let currentId = folderId;
let guard = 0;
while (currentId && currentId !== 'root' && guard < 32) {
guard += 1;
if (visited.has(currentId)) {
break;
}
visited.add(currentId);
const node = folderNodes.get(currentId);
if (!node) {
if (!detailFolderFetchRef.current.has(currentId)) {
detailFolderFetchRef.current.add(currentId);
ensureFolderData(currentId, { force: false, includeDocuments: false })
.catch((error) => {
console.warn('Failed to preload folder metadata for detail path', currentId, error);
})
.finally(() => {
detailFolderFetchRef.current.delete(currentId);
});
}
break;
}
const parentId = node.parentId ?? 'root';
if (!parentId || parentId === 'root') {
break;
}
currentId = parentId;
}
});
}, [orderedSelectedDocuments, folderNodes, ensureFolderData, detailFolderFetchRef]);
const resolveFolderPath = useCallback(
(folderId) => {
if (!folderId || folderId === 'root') {
return [];
}
const segments = [];
const visited = new Set();
let currentId = folderId;
let guard = 0;
while (currentId && guard < 32 && !visited.has(currentId)) {
guard += 1;
visited.add(currentId);
if (currentId === 'root') {
break;
}
const node = folderNodes.get(currentId);
if (!node) {
segments.push({ id: currentId, name: '…' });
break;
}
segments.push({ id: node.id, name: node.name || 'Folder' });
const parentId = node.parentId ?? 'root';
if (!parentId || parentId === 'root') {
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
break;
}
currentId = parentId;
}
if (!segments.some((segment) => segment.id === 'root')) {
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
}
return segments.reverse();
},
[folderNodes],
);
const documentLink = useMemo(
() => (detailPanelDocument ? documentLinks.get(detailPanelDocument.id) || null : null),
[detailPanelDocument, documentLinks],
);
const previewWorkspaceDocument = useMemo(() => {
if (!previewDocumentId) {
return null;
}
return documentLookup.get(previewDocumentId)
|| documents.find((doc) => doc.id === previewDocumentId)
|| null;
}, [previewDocumentId, documentLookup, documents]);
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
const resolveThumbnailUrlForDoc = useCallback(
(doc) =>
resolveDocumentAssetUrl(doc, 'thumbnail', {
ensureAssetUrl,
getAsset: getDocumentAsset,
}),
[ensureAssetUrl, getDocumentAsset],
);
const inspectDocument = useCallback(
(documentId) => {
if (!documentId) {
return;
}
openDetailPanel({ documentIds: [documentId] });
},
[openDetailPanel],
);
const handleDetailPanelClose = useCallback(() => {
closeDetailPanel();
}, [closeDetailPanel]);
const detailPanelProps = useMemo(
() => ({
document: detailPanelDocument,
tags,
tagLookupById,
onTagAdd: handleDocumentTagAdd,
onTagRemove: handleTagRemove,
documentLink,
onOpenPreview: openDocumentPreview,
activePreviewId,
onUpdateTitle: handleDocumentTitleUpdate,
onUpdateIssued: handleDocumentIssuedUpdate,
ensureAssetUrl,
getDocumentAsset,
correspondents,
onCorrespondentAdd: handleCorrespondentAdd,
onCorrespondentRemove: handleCorrespondentRemove,
resolveApiPath,
onFolderNavigate: selectFolder,
onClose: handleDetailPanelClose,
resolveFolderPath,
}),
[
activePreviewId,
correspondents,
detailPanelDocument,
ensureAssetUrl,
getDocumentAsset,
handleCorrespondentAdd,
handleCorrespondentRemove,
handleDetailPanelClose,
handleDocumentTagAdd,
handleDocumentIssuedUpdate,
handleDocumentTitleUpdate,
handleTagRemove,
openDocumentPreview,
resolveApiPath,
resolveFolderPath,
selectFolder,
documentLink,
tags,
tagLookupById,
],
);
return {
detailPanelProps,
detailPanelOpen,
openDetailPanel,
closeDetailPanel,
handleDetailPanelClose,
inspectDocument,
previewActive,
previewWorkspaceDocument,
documentLink,
resolveThumbnailUrlForDoc,
resolveFolderPath,
};
};
export default useDetailWorkspace;