509 lines
15 KiB
TypeScript
509 lines
15 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
import { DocumentsList, DocumentsGrid } from '../DocumentsView';
|
|
import type { DragEvent, ReactNode, RefObject } from 'react';
|
|
import type {
|
|
DocumentsListEntry,
|
|
FolderEventHandler,
|
|
DocumentEventHandler,
|
|
Document,
|
|
DocumentTag,
|
|
} from '../../types/documents';
|
|
import DesktopWorkspace from '../../desktop/DesktopWorkspace';
|
|
import { isTagTransferEvent } from '../tagTransfer';
|
|
import { usePreviewContext } from '../../preview/PreviewContext';
|
|
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 { 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;
|
|
}
|
|
|
|
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;
|
|
onDocumentOpen?: DocumentEventHandler;
|
|
onDocumentDragStart?: (event: DragEvent<HTMLElement>, document: Document) => 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;
|
|
ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise<any>;
|
|
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,
|
|
isSearchLoading = false,
|
|
viewMode = 'list',
|
|
onViewModeChange,
|
|
onRefresh = () => { },
|
|
sortField,
|
|
sortDirection,
|
|
onSortFieldChange,
|
|
onSortDirectionToggle,
|
|
onDeleteSelection,
|
|
documentLookup,
|
|
tags,
|
|
correspondents,
|
|
onBulkTagAdd,
|
|
onBulkTagRemove,
|
|
onBulkCorrespondentAdd,
|
|
onBulkCorrespondentRemove,
|
|
onBulkReanalyze,
|
|
folderOptions,
|
|
onMoveDocumentsToFolder,
|
|
searchQuery = '',
|
|
activeTagFilters = [],
|
|
activeCorrespondentFilters = [],
|
|
selectedFolder = null,
|
|
onDocumentTagDrop,
|
|
currentTenantId,
|
|
}): ReactNode => {
|
|
const {
|
|
setFocusedEntryKey,
|
|
clearSelection,
|
|
} = 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 searchResultCount = Array.isArray(searchResultIds) ? searchResultIds.length : 0;
|
|
|
|
const viewId = 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;
|
|
|
|
const { openPreview } = usePreviewContext();
|
|
|
|
const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
|
|
|
|
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 handleDocumentActivate = useCallback(
|
|
(doc, event?: React.MouseEvent | KeyboardEvent | null) => {
|
|
if (!doc) {
|
|
return;
|
|
}
|
|
|
|
// Handle Preview (Alt+Click or Middle Click)
|
|
if (event && (event.altKey || ((event as React.MouseEvent).button === 1))) {
|
|
openPreview(doc);
|
|
return;
|
|
}
|
|
|
|
// Handle Activation (Double Click, Enter, or explicit call)
|
|
if (onDocumentActivate) {
|
|
onDocumentActivate(doc);
|
|
}
|
|
},
|
|
[onDocumentActivate, openPreview],
|
|
);
|
|
|
|
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 = {
|
|
entries,
|
|
draggingDocumentIdsSet: draggingSet,
|
|
draggedFolderId,
|
|
onFolderClick: handleFolderClick,
|
|
onFolderSelect,
|
|
onFolderDragOver,
|
|
onFolderDragLeave,
|
|
onFolderDrop,
|
|
onFolderDragStart,
|
|
onFolderDragEnd,
|
|
onFolderRename,
|
|
|
|
onDocumentActivate: handleDocumentActivate,
|
|
onDocumentDragStart: handleDocumentDragStartLocal,
|
|
onDocumentDragEnd: handleDocumentDragEndLocal,
|
|
onDocumentTagDragOver: handleDocumentTagDragOver,
|
|
onDocumentTagDragLeave: handleDocumentTagDragLeave,
|
|
onDocumentTagDrop,
|
|
onDocumentRename,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
tagLookupById,
|
|
onTagClick: toggleTagFilter,
|
|
scrollRef,
|
|
activeCorrespondentIdSet: activeCorrespondentIdSet,
|
|
onCorrespondentClick: toggleCorrespondentFilter,
|
|
tenantId: currentTenantId,
|
|
viewId,
|
|
};
|
|
|
|
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 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}`}
|
|
onClick={handleSectionClick}
|
|
>
|
|
{renderBody()}
|
|
</section>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const DocumentsPanel: React.FC<DocumentsPanelProps> = ({ selectionValue, ...rest }) => (
|
|
<WorkspaceSelectionProvider value={selectionValue}>
|
|
<DocumentsPanelInner {...rest} />
|
|
</WorkspaceSelectionProvider>
|
|
);
|
|
|
|
export default DocumentsPanel;
|