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 | null; draggedFolderId?: Identifier | 'root' | null; ensureAssetUrl?: (...args: any[]) => unknown; getDocumentAsset?: (...args: any[]) => unknown; onFolderClick?: FolderEventHandler; onFolderSelect?: (folderId: Identifier | 'root') => void; onFolderDragOver?: (event: DragEvent, folderId: Identifier | 'root') => void; onFolderDragLeave?: (event: DragEvent) => void; onFolderDrop?: (event: DragEvent, folderId: Identifier | 'root') => void; onFolderDragStart?: (event: DragEvent, folderId: Identifier | 'root') => void; onFolderDragEnd?: (event: DragEvent) => void; onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise | boolean; onDocumentClick?: DocumentEventHandler; onDocumentActivate?: DocumentEventHandler; onDocumentDragStart?: (event: DragEvent, document: DocumentLike) => void; onDocumentDragEnd?: (event: DragEvent) => void; onDocumentTagDragOver?: (event: DragEvent) => void; onDocumentTagDragLeave?: (event: DragEvent) => void; onDocumentTagDrop?: (documentId: Identifier, tag: any) => void; onDocumentRename?: (documentId: Identifier, nextTitle: string) => Promise | boolean; tagLookupById?: Map | null; onTagClick?: (tagId: Identifier) => void; onCorrespondentClick?: (correspondentId: Identifier) => void; activeCorrespondentIdSet?: Set | null; scrollRef?: RefObject; // Desk specific (optional for now or handled via intersection) tenantId?: Identifier | null; viewId?: string | null; documentLinks?: Map | null; ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise; onDocumentStackSelect?: (docIds: Identifier[], event?: any) => void; onPromoteSelection?: (docId: Identifier | null) => void; activeTagFilters?: Array; } const DocumentsPanelInner: React.FC = ({ 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 => 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) => { 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(() => ( ), [ 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(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(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(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 (
No documents match the current filters.
); } if (!hasEntries) { return (
No documents to show here yet. Drop files to make this space come alive.
); } switch (viewMode) { case 'desk': return ; case 'grid': return ; case 'list': default: return ; } }; const panelVariant = isDeskView ? 'desk' : isGridView ? 'grid' : 'list'; const shouldHandlePanelInteractions = !isDeskView && entries.length > 0; const handleSectionFocus = useCallback((event: React.FocusEvent) => { if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) { return; } handlePanelFocus(); }, [shouldHandlePanelInteractions, handlePanelFocus]); const handleSectionKeyDown = useCallback((event: React.KeyboardEvent) => { if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) { return; } handlePanelKeyDown(event); }, [shouldHandlePanelInteractions, handlePanelKeyDown]); const handleSectionClick = useCallback((event: React.MouseEvent) => { if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) { return; } clearSelection(); }, [shouldHandlePanelInteractions, clearSelection]); return ( <>
{renderBody()}
); }; const DocumentsPanel: React.FC = ({ selectionValue, ...rest }) => ( ); export default DocumentsPanel;