913 lines
26 KiB
TypeScript
913 lines
26 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import DocumentsGrid from '../DocumentsGrid';
|
|
import DocumentsList from '../DocumentsList';
|
|
import type { DragEvent, ReactNode, RefObject } from 'react';
|
|
import type {
|
|
DocumentsListEntry,
|
|
FolderEventHandler,
|
|
DocumentEventHandler,
|
|
DocumentLike,
|
|
DocumentTag,
|
|
} from '../DocumentsList';
|
|
import DesktopWorkspace from '../../desktop/DesktopWorkspace';
|
|
import { isTagTransferEvent } from '../tagTransfer';
|
|
import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay';
|
|
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer';
|
|
import {
|
|
WorkspaceSelectionProvider,
|
|
useWorkspaceSelectionContext,
|
|
} from '../../app/WorkspaceSelectionContext';
|
|
import type { WorkspaceSelectionValue } from '../../app/WorkspaceSelectionContext';
|
|
import DocumentsPanelHeader, {
|
|
DocumentsPanelHeaderConfig,
|
|
DocumentsHeaderBreadcrumb,
|
|
} from './DocumentsPanelHeader';
|
|
import { SelectionFloatingPanel } from '../SelectionFloatingActions';
|
|
import { createDocumentEntryKey } from '../../app/entryKey';
|
|
import { createDocumentsTableHeaderActions } from './DocumentsToolbar';
|
|
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
|
|
import { DEFAULT_GRID_ICON_SIZE } from '../../constants/documents';
|
|
import type { Identifier } from '../../types/identifiers';
|
|
|
|
const EntryType = {
|
|
folder: 'folder',
|
|
document: 'document',
|
|
};
|
|
|
|
interface DocumentsPanelInnerProps {
|
|
headerLeading?: ReactNode;
|
|
onBreadcrumbNavigate?: (crumb: DocumentsHeaderBreadcrumb) => void;
|
|
[key: string]: any;
|
|
}
|
|
|
|
interface DocumentsPanelProps extends DocumentsPanelInnerProps {
|
|
selectionValue: WorkspaceSelectionValue;
|
|
}
|
|
|
|
const defaultGetDocumentAsset = (_doc?: unknown, _type?: string) => null;
|
|
|
|
export type DocumentLinkLike = { url?: string | null; mimeType?: string | null };
|
|
|
|
export interface DocumentsViewProps {
|
|
entries: DocumentsListEntry[];
|
|
draggingDocumentIdsSet?: Set<Identifier> | null;
|
|
draggedFolderId?: Identifier | 'root' | null;
|
|
ensureAssetUrl?: (...args: any[]) => unknown;
|
|
getDocumentAsset?: (...args: any[]) => unknown;
|
|
onFolderClick?: FolderEventHandler;
|
|
onFolderSelect?: (folderId: Identifier | 'root') => void;
|
|
onFolderDragOver?: (event: DragEvent<HTMLElement>, folderId: Identifier | 'root') => void;
|
|
onFolderDragLeave?: (event: DragEvent<HTMLElement>) => void;
|
|
onFolderDrop?: (event: DragEvent<HTMLElement>, folderId: Identifier | 'root') => void;
|
|
onFolderDragStart?: (event: DragEvent<HTMLElement>, folderId: Identifier | 'root') => void;
|
|
onFolderDragEnd?: (event: DragEvent<HTMLElement>) => void;
|
|
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
|
|
onDocumentClick?: DocumentEventHandler;
|
|
onDocumentActivate?: DocumentEventHandler;
|
|
onDocumentDragStart?: (event: DragEvent<HTMLElement>, document: DocumentLike) => void;
|
|
onDocumentDragEnd?: (event: DragEvent<HTMLElement>) => void;
|
|
onDocumentTagDragOver?: (event: DragEvent<HTMLElement>) => void;
|
|
onDocumentTagDragLeave?: (event: DragEvent<HTMLElement>) => void;
|
|
onDocumentTagDrop?: (documentId: Identifier, tag: any) => void;
|
|
onDocumentRename?: (documentId: Identifier, nextTitle: string) => Promise<boolean> | boolean;
|
|
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
|
onTagClick?: (tagId: Identifier) => void;
|
|
onCorrespondentClick?: (correspondentId: Identifier) => void;
|
|
activeCorrespondentIdSet?: Set<Identifier> | null;
|
|
scrollRef?: RefObject<HTMLElement | null>;
|
|
// Desk specific (optional for now or handled via intersection)
|
|
tenantId?: Identifier | null;
|
|
viewId?: string | null;
|
|
documentLinks?: Map<Identifier, any> | null;
|
|
ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise<any>;
|
|
onDocumentStackSelect?: (docIds: Identifier[], event?: any) => void;
|
|
onPromoteSelection?: (docId: Identifier | null) => void;
|
|
activeTagFilters?: Array<Identifier | null>;
|
|
}
|
|
|
|
const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|
headerLeading = null,
|
|
onBreadcrumbNavigate,
|
|
currentFolderName,
|
|
breadcrumbs,
|
|
subfolders,
|
|
documents,
|
|
searchResultIds,
|
|
onFolderSelect,
|
|
onFolderDrop,
|
|
onFolderDragOver,
|
|
onFolderDragLeave,
|
|
onFolderDragStart,
|
|
onFolderDragEnd,
|
|
draggedFolderId,
|
|
onFolderRename,
|
|
draggingDocumentIds = [],
|
|
onDocumentDragStart,
|
|
onDocumentDragEnd,
|
|
onDocumentRename,
|
|
onEntryPointer = null,
|
|
onDocumentActivate = null,
|
|
tagLookupById,
|
|
activeCorrespondentIds = [],
|
|
ensureAssetUrl = null,
|
|
getDocumentAsset = defaultGetDocumentAsset,
|
|
isSearchLoading = false,
|
|
viewMode = 'list',
|
|
onViewModeChange,
|
|
documentLinks,
|
|
ensureDownloadUrl,
|
|
onRefresh = () => { },
|
|
sortField,
|
|
sortDirection,
|
|
onSortFieldChange,
|
|
onSortDirectionToggle,
|
|
onDeleteSelection,
|
|
documentLookup,
|
|
tags,
|
|
correspondents,
|
|
onBulkTagAdd,
|
|
onBulkTagRemove,
|
|
onBulkCorrespondentAdd,
|
|
onBulkCorrespondentRemove,
|
|
onBulkReanalyze,
|
|
folderOptions,
|
|
onMoveDocumentsToFolder,
|
|
searchQuery = '',
|
|
activeTagFilters = [],
|
|
activeCorrespondentFilters = [],
|
|
selectedFolder = null,
|
|
promoteSelectionOrder,
|
|
onDocumentTagDrop,
|
|
currentTenantId,
|
|
}): ReactNode => {
|
|
const {
|
|
selectedEntries,
|
|
focusedEntryKey,
|
|
setFocusedEntryKey,
|
|
handleEntrySelection,
|
|
clearSelection,
|
|
selectionAnchorRef,
|
|
applySelection,
|
|
} = useWorkspaceSelectionContext();
|
|
const {
|
|
isActive: isFilterActive,
|
|
includeDescendants,
|
|
toggleIncludeDescendants,
|
|
toggleTag: toggleTagFilter,
|
|
toggleCorrespondent: toggleCorrespondentFilter,
|
|
} = useDocumentsFilter();
|
|
const searchDocuments = useMemo(
|
|
() =>
|
|
Array.isArray(searchResultIds)
|
|
? searchResultIds
|
|
.map((id) => documentLookup?.get?.(id) || null)
|
|
.filter((doc): doc is Record<string, unknown> => Boolean(doc))
|
|
: null,
|
|
[searchResultIds, documentLookup],
|
|
);
|
|
|
|
const showingSearchResults = Array.isArray(searchResultIds);
|
|
const rows = showingSearchResults && searchDocuments ? searchDocuments : documents;
|
|
const documentLinkMap = documentLinks instanceof Map ? documentLinks : null;
|
|
const searchResultCount = Array.isArray(searchResultIds) ? searchResultIds.length : 0;
|
|
|
|
const handleDeskDocumentStackSelect = useCallback(
|
|
(docIds: Array<Identifier | string>) => {
|
|
if (!Array.isArray(docIds) || docIds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const entryKeys = docIds
|
|
.map((id) => createDocumentEntryKey(id as Identifier))
|
|
.filter((value): value is string => typeof value === 'string');
|
|
|
|
if (!entryKeys.length) {
|
|
return;
|
|
}
|
|
|
|
const nextKeys = [...selectedEntries];
|
|
entryKeys.forEach((key) => {
|
|
if (!nextKeys.includes(key)) {
|
|
nextKeys.push(key);
|
|
}
|
|
});
|
|
|
|
const anchor = (entryKeys[0]
|
|
|| selectionAnchorRef.current
|
|
|| nextKeys[nextKeys.length - 1]) as string | null;
|
|
|
|
applySelection(nextKeys, {
|
|
anchor,
|
|
interactedKeys: entryKeys,
|
|
});
|
|
},
|
|
[applySelection, selectedEntries, selectionAnchorRef],
|
|
);
|
|
|
|
const deskViewId = useMemo(() => {
|
|
if (showingSearchResults) {
|
|
const trimmedQuery = searchQuery.trim();
|
|
const tagsKey = [...activeTagFilters].sort().join(',');
|
|
const correspondentsKey = [...activeCorrespondentFilters].sort().join(',');
|
|
return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`;
|
|
}
|
|
|
|
const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root';
|
|
return `folder:${folderKey}`;
|
|
}, [
|
|
showingSearchResults,
|
|
searchQuery,
|
|
activeTagFilters,
|
|
activeCorrespondentFilters,
|
|
selectedFolder,
|
|
]);
|
|
|
|
const headerTitle = showingSearchResults
|
|
? 'Search results'
|
|
: currentFolderName || 'Documents';
|
|
const headerSubtitle = showingSearchResults
|
|
? `${searchResultCount} matching document${searchResultCount === 1 ? '' : 's'}`
|
|
: null;
|
|
const headerActions = useMemo(
|
|
() => createDocumentsTableHeaderActions({
|
|
viewMode,
|
|
onViewModeChange,
|
|
onRefresh,
|
|
sortField,
|
|
onSortFieldChange,
|
|
sortDirection,
|
|
onSortDirectionToggle,
|
|
isFilterActive,
|
|
includeDescendants,
|
|
onToggleIncludeDescendants: toggleIncludeDescendants,
|
|
}),
|
|
[
|
|
viewMode,
|
|
onViewModeChange,
|
|
onRefresh,
|
|
sortField,
|
|
onSortFieldChange,
|
|
sortDirection,
|
|
onSortDirectionToggle,
|
|
isFilterActive,
|
|
includeDescendants,
|
|
toggleIncludeDescendants,
|
|
],
|
|
);
|
|
const floatingActions = useMemo(() => (
|
|
<SelectionFloatingPanel
|
|
documentLookup={documentLookup}
|
|
tags={tags}
|
|
tagLookupById={tagLookupById}
|
|
correspondents={correspondents}
|
|
onBulkTagAdd={onBulkTagAdd}
|
|
onBulkTagRemove={onBulkTagRemove}
|
|
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
|
|
onBulkCorrespondentRemove={onBulkCorrespondentRemove}
|
|
onBulkReanalyze={onBulkReanalyze}
|
|
onDeleteSelection={onDeleteSelection}
|
|
folderOptions={folderOptions}
|
|
onMoveDocumentsToFolder={onMoveDocumentsToFolder}
|
|
onClearSelection={clearSelection}
|
|
/>
|
|
), [
|
|
documentLookup,
|
|
tags,
|
|
tagLookupById,
|
|
correspondents,
|
|
onBulkTagAdd,
|
|
onBulkTagRemove,
|
|
onBulkCorrespondentAdd,
|
|
onBulkCorrespondentRemove,
|
|
onBulkReanalyze,
|
|
onDeleteSelection,
|
|
folderOptions,
|
|
onMoveDocumentsToFolder,
|
|
clearSelection,
|
|
]);
|
|
const headerConfig: DocumentsPanelHeaderConfig = useMemo(() => ({
|
|
title: headerTitle,
|
|
subtitle: headerSubtitle,
|
|
leading: headerLeading,
|
|
actions: headerActions,
|
|
breadcrumbs,
|
|
floatingActions,
|
|
}), [
|
|
headerTitle,
|
|
headerSubtitle,
|
|
headerLeading,
|
|
headerActions,
|
|
breadcrumbs,
|
|
floatingActions,
|
|
]);
|
|
|
|
const currentFolderId = useMemo(() => {
|
|
if (showingSearchResults) {
|
|
return null;
|
|
}
|
|
const trail = Array.isArray(breadcrumbs) ? breadcrumbs : [];
|
|
if (trail.length === 0) {
|
|
return 'root';
|
|
}
|
|
return trail[trail.length - 1]?.id || 'root';
|
|
}, [breadcrumbs, showingSearchResults]);
|
|
|
|
const selectionContextRef = useRef(null);
|
|
useEffect(() => {
|
|
const nextContext = showingSearchResults
|
|
? { type: 'search', marker: searchResultIds }
|
|
: { type: 'folder', marker: currentFolderId || 'root' };
|
|
const previous = selectionContextRef.current;
|
|
selectionContextRef.current = nextContext;
|
|
if (!previous) {
|
|
return;
|
|
}
|
|
const changed = previous.type !== nextContext.type
|
|
|| previous.marker !== nextContext.marker;
|
|
if (changed) {
|
|
clearSelection();
|
|
}
|
|
}, [showingSearchResults, currentFolderId, searchResultIds, clearSelection]);
|
|
|
|
const entries = useMemo(() => {
|
|
const list = [];
|
|
if (!showingSearchResults) {
|
|
subfolders.forEach((folder) => {
|
|
if (!folder || !folder.id) {
|
|
return;
|
|
}
|
|
list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder });
|
|
});
|
|
}
|
|
rows.forEach((doc) => {
|
|
if (!doc || !doc.id) {
|
|
return;
|
|
}
|
|
list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc });
|
|
});
|
|
return list;
|
|
}, [showingSearchResults, subfolders, rows]);
|
|
|
|
const draggingSet = useMemo(
|
|
() => new Set(draggingDocumentIds || []),
|
|
[draggingDocumentIds],
|
|
);
|
|
const activeCorrespondentIdSet = useMemo(
|
|
() => new Set(activeCorrespondentIds || []),
|
|
[activeCorrespondentIds],
|
|
);
|
|
const scrollRef = useRef<HTMLElement | null>(null);
|
|
const suppressDocumentClickRef = useRef(false);
|
|
const isGridView = viewMode === 'grid';
|
|
const isDeskView = viewMode === 'desk';
|
|
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
|
|
|
type ZoomSource = { url: string; alt?: string | null; mimeType?: string | null };
|
|
|
|
const [previewDocId, setPreviewDocId] = useState<Identifier | null>(null);
|
|
|
|
const previewDoc = useMemo(() => {
|
|
if (!previewDocId) {
|
|
return null;
|
|
}
|
|
return rows.find((doc) => doc?.id === previewDocId) || null;
|
|
}, [previewDocId, rows]);
|
|
|
|
useEffect(() => {
|
|
if (previewDocId && !previewDoc) {
|
|
setPreviewDocId(null);
|
|
}
|
|
}, [previewDocId, previewDoc]);
|
|
|
|
const [previewZoomSource, setPreviewZoomSource] = useState<ZoomSource | null>(null);
|
|
const zoomDisplay = previewZoomSource;
|
|
const overlayDocument = useMemo(() => (
|
|
previewDoc && zoomDisplay?.url
|
|
? { ...previewDoc, documentLink: zoomDisplay }
|
|
: previewDoc
|
|
), [previewDoc, zoomDisplay]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
if (!previewDocId || !previewDoc) {
|
|
setPreviewZoomSource(null);
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}
|
|
const documentMimeType = previewDoc.mime_type;
|
|
|
|
const applyEntry = (entry?: DocumentLinkLike | null) => {
|
|
if (!entry?.url) {
|
|
setPreviewZoomSource(null);
|
|
return;
|
|
}
|
|
setPreviewZoomSource({
|
|
url: entry.url,
|
|
alt: previewDoc.title,
|
|
mimeType: documentMimeType,
|
|
});
|
|
};
|
|
|
|
const cachedEntry = documentLinkMap?.get(previewDocId) || null;
|
|
if (cachedEntry?.url) {
|
|
applyEntry(cachedEntry);
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}
|
|
|
|
if (!ensureDownloadUrl) {
|
|
setPreviewZoomSource(null);
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}
|
|
|
|
ensureDownloadUrl(previewDocId)
|
|
.then((entry) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
applyEntry(entry);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setPreviewZoomSource(null);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [previewDocId, previewDoc, documentLinkMap, ensureDownloadUrl]);
|
|
|
|
const closePreviewOverlay = useCallback(() => {
|
|
setPreviewDocId(null);
|
|
setPreviewZoomSource(null);
|
|
}, []);
|
|
|
|
const handleDocumentPreviewZoom = useCallback(
|
|
(doc) => {
|
|
if (!doc || !doc.id) {
|
|
return;
|
|
}
|
|
if (!ensureDownloadUrl && !(documentLinkMap?.get(doc.id)?.url)) {
|
|
return;
|
|
}
|
|
setPreviewDocId(doc.id);
|
|
},
|
|
[ensureDownloadUrl, documentLinkMap],
|
|
);
|
|
|
|
const handleDocumentActivate = useCallback(
|
|
(doc, event?: React.MouseEvent | KeyboardEvent | null) => {
|
|
if (!doc) {
|
|
return;
|
|
}
|
|
if (event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
if (event?.altKey) {
|
|
handleDocumentPreviewZoom(doc);
|
|
return;
|
|
}
|
|
onDocumentActivate?.(doc.id);
|
|
},
|
|
[handleDocumentPreviewZoom, onDocumentActivate],
|
|
);
|
|
|
|
const navigableRows = useMemo(
|
|
() => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })),
|
|
[entries],
|
|
);
|
|
const navigableEntryKeys = useMemo(() => navigableRows.map((row) => row.key), [navigableRows]);
|
|
|
|
const getEntryByKey = useCallback(
|
|
(entryKey) => entries.find((entry) => entry.key === entryKey) || null,
|
|
[entries],
|
|
);
|
|
|
|
const handlePanelFocus = useCallback(() => {
|
|
let resolvedKey = null;
|
|
|
|
if (focusedEntryKey && navigableEntryKeys.includes(focusedEntryKey)) {
|
|
resolvedKey = focusedEntryKey;
|
|
}
|
|
|
|
if (!resolvedKey) {
|
|
for (let index = selectedEntries.length - 1; index >= 0; index -= 1) {
|
|
const candidate = selectedEntries[index];
|
|
if (navigableEntryKeys.includes(candidate)) {
|
|
resolvedKey = candidate;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!resolvedKey) {
|
|
if (!selectedEntries.length) {
|
|
return;
|
|
}
|
|
if (navigableRows.length) {
|
|
resolvedKey = navigableRows[0].key;
|
|
}
|
|
}
|
|
|
|
if (!resolvedKey) {
|
|
return;
|
|
}
|
|
|
|
setFocusedEntryKey(resolvedKey);
|
|
}, [
|
|
focusedEntryKey,
|
|
navigableEntryKeys,
|
|
navigableRows,
|
|
setFocusedEntryKey,
|
|
selectedEntries,
|
|
]);
|
|
|
|
const handlePanelKeyDown = useCallback(
|
|
(event) => {
|
|
const { key, shiftKey } = event;
|
|
const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar'];
|
|
if (!triggers.includes(key)) {
|
|
return;
|
|
}
|
|
|
|
if (!navigableRows.length) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
let activeKey =
|
|
focusedEntryKey && navigableEntryKeys.includes(focusedEntryKey)
|
|
? focusedEntryKey
|
|
: null;
|
|
|
|
if (!activeKey) {
|
|
if (selectedEntries.length) {
|
|
for (let index = selectedEntries.length - 1; index >= 0; index -= 1) {
|
|
const candidate = selectedEntries[index];
|
|
if (navigableEntryKeys.includes(candidate)) {
|
|
activeKey = candidate;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!activeKey) {
|
|
activeKey = key === 'ArrowUp' ? navigableEntryKeys[navigableEntryKeys.length - 1] : navigableEntryKeys[0];
|
|
}
|
|
}
|
|
|
|
const currentIndex = navigableEntryKeys.indexOf(activeKey);
|
|
const activeRow = currentIndex === -1 ? null : navigableRows[currentIndex];
|
|
|
|
if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') {
|
|
if (activeRow) {
|
|
handleEntrySelection(activeRow.key, event);
|
|
if (activeRow.type === EntryType.folder) {
|
|
onFolderSelect?.(activeRow.id);
|
|
} else {
|
|
const entry = getEntryByKey(activeRow.key);
|
|
if (entry?.document) {
|
|
handleDocumentPreviewZoom(entry.document);
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
let nextIndex = currentIndex;
|
|
if (key === 'ArrowDown') {
|
|
nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1);
|
|
} else if (key === 'ArrowUp') {
|
|
nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0);
|
|
} else if (key === 'Home') {
|
|
nextIndex = 0;
|
|
} else if (key === 'End') {
|
|
nextIndex = navigableRows.length - 1;
|
|
}
|
|
|
|
if (nextIndex === -1 || nextIndex >= navigableRows.length) {
|
|
return;
|
|
}
|
|
|
|
const targetRow = navigableRows[nextIndex];
|
|
if (!targetRow) {
|
|
return;
|
|
}
|
|
|
|
setFocusedEntryKey(targetRow.key);
|
|
handleEntrySelection(targetRow.key, {
|
|
shiftKey,
|
|
preventDefault: () => { },
|
|
});
|
|
},
|
|
[
|
|
focusedEntryKey,
|
|
getEntryByKey,
|
|
navigableEntryKeys,
|
|
navigableRows,
|
|
onFolderSelect,
|
|
selectedEntries,
|
|
handleDocumentPreviewZoom,
|
|
handleEntrySelection,
|
|
setFocusedEntryKey,
|
|
],
|
|
);
|
|
|
|
const scrollToTop = useCallback(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = 0;
|
|
}
|
|
}, []);
|
|
|
|
const searchKey = useMemo(
|
|
() => (Array.isArray(searchResultIds) ? searchResultIds.join(':') : 'none'),
|
|
[searchResultIds],
|
|
);
|
|
|
|
const breadcrumbKey = useMemo(
|
|
() => (Array.isArray(breadcrumbs) ? breadcrumbs.map((crumb) => crumb?.id ?? '').join(':') : 'none'),
|
|
[breadcrumbs],
|
|
);
|
|
|
|
useEffect(() => {
|
|
scrollToTop();
|
|
}, [
|
|
scrollToTop,
|
|
viewMode,
|
|
showingSearchResults,
|
|
searchKey,
|
|
breadcrumbKey,
|
|
]);
|
|
|
|
const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
|
|
const ensureFocusedRowVisible = useCallback(() => {
|
|
if (!focusedEntryKey) return;
|
|
const container = scrollRef.current;
|
|
if (!container) return;
|
|
let selector = null;
|
|
if (focusedEntryKey.startsWith('document:')) {
|
|
selector = `#document-row-${focusedEntryKey.slice('document:'.length)}`;
|
|
} else if (focusedEntryKey.startsWith('folder:')) {
|
|
selector = `#folder-row-${focusedEntryKey.slice('folder:'.length)}`;
|
|
}
|
|
if (!selector) {
|
|
return;
|
|
}
|
|
const row = container.querySelector(selector);
|
|
if (!row || !container.contains(row)) {
|
|
return;
|
|
}
|
|
|
|
const header = container.querySelector('thead');
|
|
const headerHeight = header ? header.getBoundingClientRect().height : 0;
|
|
const rowTop = row.offsetTop;
|
|
const rowBottom = rowTop + row.offsetHeight;
|
|
const visibleTop = container.scrollTop + headerHeight;
|
|
const visibleBottom = container.scrollTop + container.clientHeight;
|
|
|
|
if (rowTop < visibleTop) {
|
|
container.scrollTop = Math.max(rowTop - headerHeight, 0);
|
|
return;
|
|
}
|
|
|
|
if (rowBottom > visibleBottom) {
|
|
const nextScrollTop = rowBottom - container.clientHeight;
|
|
container.scrollTop = Math.max(nextScrollTop, 0);
|
|
}
|
|
}, [focusedEntryKey]);
|
|
|
|
useEffect(() => {
|
|
ensureFocusedRowVisible();
|
|
}, [ensureFocusedRowVisible]);
|
|
|
|
const activeDescendantId = useMemo(() => {
|
|
if (!focusedEntryKey) return undefined;
|
|
if (focusedEntryKey.startsWith('document:')) {
|
|
return `document-row-${focusedEntryKey.slice('document:'.length)}`;
|
|
}
|
|
if (focusedEntryKey.startsWith('folder:')) {
|
|
return `folder-row-${focusedEntryKey.slice('folder:'.length)}`;
|
|
}
|
|
return undefined;
|
|
}, [focusedEntryKey]);
|
|
|
|
const handleDocumentTagDragOver = useCallback(
|
|
(event) => {
|
|
if (!isTagDragEvent(event)) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'copy';
|
|
event.currentTarget.classList.add('tag-drop-target');
|
|
},
|
|
[isTagDragEvent],
|
|
);
|
|
|
|
const handleDocumentTagDragLeave = useCallback(
|
|
(event) => {
|
|
if (!isTagDragEvent(event)) {
|
|
return;
|
|
}
|
|
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
|
|
return;
|
|
}
|
|
event.currentTarget.classList.remove('tag-drop-target');
|
|
},
|
|
[isTagDragEvent],
|
|
);
|
|
|
|
const handleDocumentClick = useCallback(
|
|
(doc, event) => {
|
|
if (!doc || suppressDocumentClickRef.current || !onEntryPointer) {
|
|
return;
|
|
}
|
|
|
|
onEntryPointer(
|
|
{ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc },
|
|
event,
|
|
);
|
|
},
|
|
[onEntryPointer],
|
|
);
|
|
|
|
const handleFolderClick = useCallback(
|
|
(folder, event) => {
|
|
if (!folder) {
|
|
return;
|
|
}
|
|
|
|
if (onEntryPointer) {
|
|
onEntryPointer(
|
|
{ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder },
|
|
event,
|
|
);
|
|
}
|
|
|
|
if (
|
|
!isPointerModifierEvent(event)
|
|
&& isPrimaryPointerEvent(event)
|
|
&& scrollRef.current
|
|
) {
|
|
scrollRef.current.focus({ preventScroll: true });
|
|
setFocusedEntryKey(`folder:${folder.id}`);
|
|
}
|
|
},
|
|
[onEntryPointer, setFocusedEntryKey],
|
|
);
|
|
|
|
const handleDocumentDragStartLocal = useCallback(
|
|
(event, doc) => {
|
|
suppressDocumentClickRef.current = true;
|
|
onDocumentDragStart?.(event, doc);
|
|
},
|
|
[onDocumentDragStart],
|
|
);
|
|
|
|
const handleDocumentDragEndLocal = useCallback(
|
|
(event) => {
|
|
onDocumentDragEnd?.(event);
|
|
requestAnimationFrame(() => {
|
|
suppressDocumentClickRef.current = false;
|
|
});
|
|
},
|
|
[onDocumentDragEnd],
|
|
);
|
|
|
|
const hasDocumentEntries = useMemo(
|
|
() => entries.some((entry) => entry.type === EntryType.document),
|
|
[entries],
|
|
);
|
|
|
|
const viewProps: DocumentsViewProps = {
|
|
entries,
|
|
draggingDocumentIdsSet: draggingSet,
|
|
draggedFolderId,
|
|
onFolderClick: handleFolderClick,
|
|
onFolderSelect,
|
|
onFolderDragOver,
|
|
onFolderDragLeave,
|
|
onFolderDrop,
|
|
onFolderDragStart,
|
|
onFolderDragEnd,
|
|
onDocumentClick: handleDocumentClick,
|
|
onDocumentActivate: handleDocumentActivate,
|
|
onDocumentDragStart: handleDocumentDragStartLocal,
|
|
onDocumentDragEnd: handleDocumentDragEndLocal,
|
|
onDocumentTagDragOver: handleDocumentTagDragOver,
|
|
onDocumentTagDragLeave: handleDocumentTagDragLeave,
|
|
onDocumentTagDrop,
|
|
onDocumentRename,
|
|
onFolderRename,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
tagLookupById,
|
|
onTagClick: toggleTagFilter,
|
|
onCorrespondentClick: toggleCorrespondentFilter,
|
|
activeCorrespondentIdSet: activeCorrespondentIdSet,
|
|
activeTagFilters,
|
|
scrollRef,
|
|
// Desk specific
|
|
onDocumentStackSelect: handleDeskDocumentStackSelect,
|
|
onPromoteSelection: promoteSelectionOrder,
|
|
tenantId: currentTenantId,
|
|
viewId: deskViewId,
|
|
documentLinks: documentLinkMap,
|
|
ensureDownloadUrl,
|
|
};
|
|
|
|
const renderBody = () => {
|
|
const hasEntries = entries.length > 0;
|
|
const isSearchEmpty = (showingSearchResults || isFilterActive) && !hasDocumentEntries && !isSearchLoading;
|
|
|
|
if (isSearchEmpty) {
|
|
return (
|
|
<div className="empty-state">
|
|
No documents match the current filters.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!hasEntries) {
|
|
return (
|
|
<div className="empty-state">
|
|
No documents to show here yet. Drop files to make this space come alive.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
switch (viewMode) {
|
|
case 'desk':
|
|
return <DesktopWorkspace {...viewProps} />;
|
|
case 'grid':
|
|
return <DocumentsGrid {...viewProps} gridIconSize={gridIconSize} />;
|
|
case 'list':
|
|
default:
|
|
return <DocumentsList {...viewProps} />;
|
|
}
|
|
};
|
|
|
|
const panelVariant = isDeskView ? 'desk' : isGridView ? 'grid' : 'list';
|
|
const shouldHandlePanelInteractions = !isDeskView && entries.length > 0;
|
|
|
|
const handleSectionFocus = useCallback((event: React.FocusEvent<HTMLElement>) => {
|
|
if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) {
|
|
return;
|
|
}
|
|
handlePanelFocus();
|
|
}, [shouldHandlePanelInteractions, handlePanelFocus]);
|
|
|
|
const handleSectionKeyDown = useCallback((event: React.KeyboardEvent<HTMLElement>) => {
|
|
if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) {
|
|
return;
|
|
}
|
|
handlePanelKeyDown(event);
|
|
}, [shouldHandlePanelInteractions, handlePanelKeyDown]);
|
|
|
|
const handleSectionClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
|
if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) {
|
|
return;
|
|
}
|
|
clearSelection();
|
|
}, [shouldHandlePanelInteractions, clearSelection]);
|
|
|
|
return (
|
|
<>
|
|
<DocumentsPanelHeader
|
|
header={headerConfig}
|
|
onBreadcrumbClick={onBreadcrumbNavigate}
|
|
/>
|
|
<section
|
|
ref={scrollRef}
|
|
className={`documents-panel documents-panel--view-${panelVariant}`}
|
|
tabIndex={shouldHandlePanelInteractions ? 0 : undefined}
|
|
onFocus={handleSectionFocus}
|
|
onKeyDown={handleSectionKeyDown}
|
|
onClick={handleSectionClick}
|
|
aria-activedescendant={shouldHandlePanelInteractions && !isGridView ? activeDescendantId : undefined}
|
|
>
|
|
{renderBody()}
|
|
</section>
|
|
<PreviewZoomOverlay
|
|
open={Boolean(zoomDisplay?.url)}
|
|
onClose={closePreviewOverlay}
|
|
document={overlayDocument}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const DocumentsPanel: React.FC<DocumentsPanelProps> = ({ selectionValue, ...rest }) => (
|
|
<WorkspaceSelectionProvider value={selectionValue}>
|
|
<DocumentsPanelInner {...rest} />
|
|
</WorkspaceSelectionProvider>
|
|
);
|
|
|
|
export default DocumentsPanel;
|