refactor
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo } from 'react';
|
|||||||
import { SidebarExpandIcon } from '../ui/icons';
|
import { SidebarExpandIcon } from '../ui/icons';
|
||||||
import { createDocumentsSurface } from '../documents/DocumentsPanel';
|
import { createDocumentsSurface } from '../documents/DocumentsPanel';
|
||||||
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
||||||
import { createDesktopSurface } from '../desktop/DesktopWorkspace';
|
import createDesktopSurface from '../desktop/createDesktopSurface';
|
||||||
|
|
||||||
export const useWorkspaceSurface = ({
|
export const useWorkspaceSurface = ({
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
|
|||||||
@@ -8,12 +8,8 @@ import React, {
|
|||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||||
import { CloseIcon } from '../ui/icons';
|
import { CloseIcon } from '../ui/icons';
|
||||||
import SelectionFloatingActions from '../documents/SelectionFloatingActions';
|
|
||||||
import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel';
|
|
||||||
import createWorkspaceSurfaceConfig from '../documents/workspaceHeader';
|
|
||||||
import DetailPanel from '../detail/DetailPanel';
|
|
||||||
import { formatTransform } from './math';
|
import { formatTransform } from './math';
|
||||||
import useDocumentDrag from './useDocumentDrag';
|
import useDocumentDrag from './useDocumentDrag';
|
||||||
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
|
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
|
||||||
@@ -29,91 +25,11 @@ import {
|
|||||||
import useDeskPointer from './pointer/useDeskPointer';
|
import useDeskPointer from './pointer/useDeskPointer';
|
||||||
import useDeskTagInteractions from './tags/useDeskTagInteractions';
|
import useDeskTagInteractions from './tags/useDeskTagInteractions';
|
||||||
import DesktopDocumentCard from './DesktopDocumentCard';
|
import DesktopDocumentCard from './DesktopDocumentCard';
|
||||||
|
import usePreviewMetadata from './hooks/usePreviewMetadata';
|
||||||
import './DesktopWorkspace.css';
|
import './DesktopWorkspace.css';
|
||||||
|
|
||||||
const DEBUG_DRAG = false;
|
const DEBUG_DRAG = false;
|
||||||
const DEBUG_FOCUS = false;
|
const DEBUG_FOCUS = false;
|
||||||
|
|
||||||
const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
|
|
||||||
const [metadataMap, setMetadataMap] = useState(() => new Map());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const docs = Array.isArray(documents) ? documents : [];
|
|
||||||
if (!docs.length) {
|
|
||||||
setMetadataMap(new Map());
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchMetadataForDoc = async (doc) => {
|
|
||||||
if (!doc?.id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const docId = String(doc.id);
|
|
||||||
const resolveAsset = (type) => (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null);
|
|
||||||
|
|
||||||
let asset = resolveAsset('preview') || resolveAsset('thumbnail');
|
|
||||||
let view = createAssetView(asset);
|
|
||||||
let metadata = view.getPrimaryMetadata();
|
|
||||||
|
|
||||||
const hasDimensions = (meta) =>
|
|
||||||
Number.isFinite(Number(meta?.width)) && Number.isFinite(Number(meta?.height)) &&
|
|
||||||
Number(meta.width) > 0 && Number(meta.height) > 0;
|
|
||||||
|
|
||||||
if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) {
|
|
||||||
try {
|
|
||||||
const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 });
|
|
||||||
if (ensured) {
|
|
||||||
asset = ensured;
|
|
||||||
view = createAssetView(asset);
|
|
||||||
metadata = view.getPrimaryMetadata();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[desk] ensureDocumentSize metadata fetch failed', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasDimensions(metadata)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const width = Number(metadata.width);
|
|
||||||
const height = Number(metadata.height);
|
|
||||||
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [docId, { width, height }];
|
|
||||||
};
|
|
||||||
|
|
||||||
Promise.all(docs.map((doc) => fetchMetadataForDoc(doc)))
|
|
||||||
.then((entries) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const next = new Map();
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry) {
|
|
||||||
next.set(entry[0], entry[1]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setMetadataMap(next);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setMetadataMap(new Map());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [documents, getDocumentAsset, ensureAssetUrl]);
|
|
||||||
|
|
||||||
return metadataMap;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DesktopWorkspace = ({
|
const DesktopWorkspace = ({
|
||||||
documents = [],
|
documents = [],
|
||||||
searchResults = null,
|
searchResults = null,
|
||||||
@@ -1082,109 +998,3 @@ const DesktopHelpOverlay = ({ open = false, onClose = null }) => {
|
|||||||
portalTarget,
|
portalTarget,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createDesktopSurface = ({
|
|
||||||
workspaceProps,
|
|
||||||
renderSidebarToggle,
|
|
||||||
parentBreadcrumb,
|
|
||||||
onNavigateParent,
|
|
||||||
detailProps = null,
|
|
||||||
detailOpen = false,
|
|
||||||
}) => {
|
|
||||||
if (!workspaceProps) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
currentFolderName,
|
|
||||||
searchResults,
|
|
||||||
onRefresh,
|
|
||||||
viewMode,
|
|
||||||
onViewModeChange,
|
|
||||||
selectedDocumentIds,
|
|
||||||
selectedFolderIds,
|
|
||||||
onDeleteSelection,
|
|
||||||
onClearSelection,
|
|
||||||
tags,
|
|
||||||
correspondents,
|
|
||||||
documentLookup,
|
|
||||||
tagLookupById,
|
|
||||||
onBulkTagAdd,
|
|
||||||
onBulkTagRemove,
|
|
||||||
onBulkCorrespondentAdd,
|
|
||||||
onBulkCorrespondentRemove,
|
|
||||||
onBulkReanalyze,
|
|
||||||
folderOptions,
|
|
||||||
onMoveDocumentsToFolder,
|
|
||||||
searchIncludeDescendants,
|
|
||||||
onToggleSearchIncludeDescendants,
|
|
||||||
} = workspaceProps;
|
|
||||||
|
|
||||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
|
||||||
const subtitle = Array.isArray(searchResults)
|
|
||||||
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
|
|
||||||
const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
|
|
||||||
const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0;
|
|
||||||
const selectionCount = documentSelectionCount + folderSelectionCount;
|
|
||||||
const actions = createDocumentsTableHeaderActions({
|
|
||||||
viewMode: viewMode || 'desk',
|
|
||||||
onViewModeChange,
|
|
||||||
onRefresh,
|
|
||||||
onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null,
|
|
||||||
includeDescendants: searchIncludeDescendants,
|
|
||||||
onToggleIncludeDescendants: onToggleSearchIncludeDescendants,
|
|
||||||
});
|
|
||||||
|
|
||||||
const floatingActions = selectionCount > 0
|
|
||||||
? (
|
|
||||||
<SelectionFloatingActions
|
|
||||||
selectionCount={selectionCount}
|
|
||||||
selectedDocumentIds={selectedDocumentIds}
|
|
||||||
selectedFolderIds={selectedFolderIds}
|
|
||||||
documentLookup={documentLookup}
|
|
||||||
tags={tags}
|
|
||||||
tagLookupById={tagLookupById}
|
|
||||||
correspondents={correspondents}
|
|
||||||
onBulkTagAdd={onBulkTagAdd}
|
|
||||||
onBulkTagRemove={onBulkTagRemove}
|
|
||||||
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
|
|
||||||
onBulkCorrespondentRemove={onBulkCorrespondentRemove}
|
|
||||||
onBulkReanalyze={onBulkReanalyze}
|
|
||||||
onDeleteSelection={onDeleteSelection}
|
|
||||||
onClearSelection={onClearSelection}
|
|
||||||
folderOptions={folderOptions}
|
|
||||||
onMoveDocumentsToFolder={onMoveDocumentsToFolder}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
|
||||||
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
|
||||||
const surfaceConfig = createWorkspaceSurfaceConfig({
|
|
||||||
key: 'workspace',
|
|
||||||
variant: 'workspace',
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
sidebarToggle,
|
|
||||||
parentBreadcrumb,
|
|
||||||
onNavigateParent,
|
|
||||||
actions,
|
|
||||||
breadcrumbs: workspaceProps?.breadcrumbs || null,
|
|
||||||
selectionLabel: null,
|
|
||||||
floatingActions,
|
|
||||||
content: (
|
|
||||||
<DesktopWorkspace
|
|
||||||
{...workspaceProps}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
detail,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...surfaceConfig,
|
|
||||||
supportsDetail: Boolean(detailProps),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import SelectionFloatingActions from '../documents/SelectionFloatingActions';
|
||||||
|
import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel';
|
||||||
|
import createWorkspaceSurfaceConfig from '../documents/workspaceHeader';
|
||||||
|
import DetailPanel from '../detail/DetailPanel';
|
||||||
|
import DesktopWorkspace from './DesktopWorkspace';
|
||||||
|
|
||||||
|
const createDesktopSurface = ({
|
||||||
|
workspaceProps,
|
||||||
|
renderSidebarToggle,
|
||||||
|
parentBreadcrumb,
|
||||||
|
onNavigateParent,
|
||||||
|
detailProps = null,
|
||||||
|
detailOpen = false,
|
||||||
|
}) => {
|
||||||
|
if (!workspaceProps) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentFolderName,
|
||||||
|
searchResults,
|
||||||
|
onRefresh,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
onDeleteSelection,
|
||||||
|
onClearSelection,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
tagLookupById,
|
||||||
|
onBulkTagAdd,
|
||||||
|
onBulkTagRemove,
|
||||||
|
onBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze,
|
||||||
|
folderOptions,
|
||||||
|
onMoveDocumentsToFolder,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
onToggleSearchIncludeDescendants,
|
||||||
|
} = workspaceProps;
|
||||||
|
|
||||||
|
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||||
|
const subtitle = Array.isArray(searchResults)
|
||||||
|
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
|
||||||
|
const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0;
|
||||||
|
const selectionCount = documentSelectionCount + folderSelectionCount;
|
||||||
|
const actions = createDocumentsTableHeaderActions({
|
||||||
|
viewMode: viewMode || 'desk',
|
||||||
|
onViewModeChange,
|
||||||
|
onRefresh,
|
||||||
|
onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null,
|
||||||
|
includeDescendants: searchIncludeDescendants,
|
||||||
|
onToggleIncludeDescendants: onToggleSearchIncludeDescendants,
|
||||||
|
});
|
||||||
|
|
||||||
|
const floatingActions = selectionCount > 0
|
||||||
|
? (
|
||||||
|
<SelectionFloatingActions
|
||||||
|
selectionCount={selectionCount}
|
||||||
|
selectedDocumentIds={selectedDocumentIds}
|
||||||
|
selectedFolderIds={selectedFolderIds}
|
||||||
|
documentLookup={documentLookup}
|
||||||
|
tags={tags}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
correspondents={correspondents}
|
||||||
|
onBulkTagAdd={onBulkTagAdd}
|
||||||
|
onBulkTagRemove={onBulkTagRemove}
|
||||||
|
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
|
||||||
|
onBulkCorrespondentRemove={onBulkCorrespondentRemove}
|
||||||
|
onBulkReanalyze={onBulkReanalyze}
|
||||||
|
onDeleteSelection={onDeleteSelection}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
|
folderOptions={folderOptions}
|
||||||
|
onMoveDocumentsToFolder={onMoveDocumentsToFolder}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
|
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
||||||
|
const surfaceConfig = createWorkspaceSurfaceConfig({
|
||||||
|
key: 'workspace',
|
||||||
|
variant: 'workspace',
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
sidebarToggle,
|
||||||
|
parentBreadcrumb,
|
||||||
|
onNavigateParent,
|
||||||
|
actions,
|
||||||
|
breadcrumbs: workspaceProps?.breadcrumbs || null,
|
||||||
|
selectionLabel: null,
|
||||||
|
floatingActions,
|
||||||
|
content: (
|
||||||
|
<DesktopWorkspace
|
||||||
|
{...workspaceProps}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...surfaceConfig,
|
||||||
|
supportsDetail: Boolean(detailProps),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createDesktopSurface;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createAssetView } from '../../asset_manager';
|
||||||
|
|
||||||
|
const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
|
||||||
|
const [metadataMap, setMetadataMap] = useState(() => new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const docs = Array.isArray(documents) ? documents : [];
|
||||||
|
if (!docs.length) {
|
||||||
|
setMetadataMap(new Map());
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMetadataForDoc = async (doc) => {
|
||||||
|
if (!doc?.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docId = String(doc.id);
|
||||||
|
const resolveAsset = (type) =>
|
||||||
|
(typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null);
|
||||||
|
|
||||||
|
let asset = resolveAsset('preview') || resolveAsset('thumbnail');
|
||||||
|
let view = createAssetView(asset);
|
||||||
|
let metadata = view.getPrimaryMetadata();
|
||||||
|
|
||||||
|
const hasDimensions = (meta) =>
|
||||||
|
Number.isFinite(Number(meta?.width)) &&
|
||||||
|
Number.isFinite(Number(meta?.height)) &&
|
||||||
|
Number(meta.width) > 0 &&
|
||||||
|
Number(meta.height) > 0;
|
||||||
|
|
||||||
|
if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) {
|
||||||
|
try {
|
||||||
|
const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 });
|
||||||
|
if (ensured) {
|
||||||
|
asset = ensured;
|
||||||
|
view = createAssetView(asset);
|
||||||
|
metadata = view.getPrimaryMetadata();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[desk] ensureDocumentSize metadata fetch failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasDimensions(metadata)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
docId,
|
||||||
|
width: Number(metadata.width),
|
||||||
|
height: Number(metadata.height),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
const entries = await Promise.all(docs.map(fetchMetadataForDoc));
|
||||||
|
if (!mounted || cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = new Map();
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry && entry.docId) {
|
||||||
|
next.set(entry.docId, entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!cancelled) {
|
||||||
|
setMetadataMap(next);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [documents, getDocumentAsset, ensureAssetUrl]);
|
||||||
|
|
||||||
|
return metadataMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePreviewMetadata;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,725 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ViewListIcon, ViewGridIcon, IconFileStack } from '../../ui/icons';
|
||||||
|
import BreadcrumbTrail from '../../ui/BreadcrumbTrail';
|
||||||
|
import DocumentsGrid from '../DocumentsGrid';
|
||||||
|
import DocumentsList from '../DocumentsList';
|
||||||
|
import { isTagTransferEvent } from '../tagTransfer';
|
||||||
|
import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay';
|
||||||
|
import { useAssetNavigator } from '../../hooks/useAssetNavigator';
|
||||||
|
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer';
|
||||||
|
|
||||||
|
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||||
|
|
||||||
|
const EntryType = {
|
||||||
|
folder: 'folder',
|
||||||
|
document: 'document',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentsPanel = ({
|
||||||
|
currentFolderName,
|
||||||
|
breadcrumbs,
|
||||||
|
onRefresh,
|
||||||
|
subfolders,
|
||||||
|
documents,
|
||||||
|
searchResults,
|
||||||
|
isFilterActive = false,
|
||||||
|
onFolderSelect,
|
||||||
|
onFolderDrop,
|
||||||
|
onFolderDragOver,
|
||||||
|
onFolderDragLeave,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
|
onFolderRename,
|
||||||
|
selectedFolderIds = [],
|
||||||
|
selectedDocumentIds = [],
|
||||||
|
focusedRowKey,
|
||||||
|
draggingDocumentIds = [],
|
||||||
|
onDocumentDragStart,
|
||||||
|
onDocumentDragEnd,
|
||||||
|
onDocumentRename,
|
||||||
|
onEntryPointer = null,
|
||||||
|
onEntrySelection = null,
|
||||||
|
onInspectDocument = null,
|
||||||
|
tagLookupById,
|
||||||
|
activeCorrespondentIds = [],
|
||||||
|
onFocusedRowChange,
|
||||||
|
ensureAssetUrl = null,
|
||||||
|
getDocumentAsset = () => null,
|
||||||
|
onTagClick,
|
||||||
|
onCorrespondentClick,
|
||||||
|
isSearchLoading = false,
|
||||||
|
onDocumentTagDrop,
|
||||||
|
viewMode = 'list',
|
||||||
|
onViewModeChange,
|
||||||
|
onClearSelection,
|
||||||
|
selectedEntries = [],
|
||||||
|
showHeader = true,
|
||||||
|
}) => {
|
||||||
|
const showingSearchResults = searchResults !== null;
|
||||||
|
const rows = showingSearchResults ? searchResults : documents;
|
||||||
|
|
||||||
|
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 selectedSet = useMemo(
|
||||||
|
() => new Set(selectedDocumentIds),
|
||||||
|
[selectedDocumentIds],
|
||||||
|
);
|
||||||
|
const selectedFolderSet = useMemo(
|
||||||
|
() => new Set(selectedFolderIds || []),
|
||||||
|
[selectedFolderIds],
|
||||||
|
);
|
||||||
|
const draggingSet = useMemo(
|
||||||
|
() => new Set(draggingDocumentIds || []),
|
||||||
|
[draggingDocumentIds],
|
||||||
|
);
|
||||||
|
const activeCorrespondentIdSet = useMemo(
|
||||||
|
() => new Set(activeCorrespondentIds || []),
|
||||||
|
[activeCorrespondentIds],
|
||||||
|
);
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
const suppressDocumentClickRef = useRef(false);
|
||||||
|
const [, forceVisibilityTick] = useState(0);
|
||||||
|
const lastScrollNodeRef = useRef(null);
|
||||||
|
const assignScrollRef = useCallback((node) => {
|
||||||
|
if (lastScrollNodeRef.current === node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastScrollNodeRef.current = node;
|
||||||
|
scrollRef.current = node;
|
||||||
|
if (node) {
|
||||||
|
forceVisibilityTick((value) => value + 1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const isGridView = viewMode === 'grid';
|
||||||
|
const isDeskView = viewMode === 'desk';
|
||||||
|
|
||||||
|
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 previewNavigator = useAssetNavigator({
|
||||||
|
document: previewDoc,
|
||||||
|
assetType: 'preview',
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
prefetch: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentUrl: previewUrl,
|
||||||
|
canGoPrev: previewCanGoPrev,
|
||||||
|
canGoNext: previewCanGoNext,
|
||||||
|
goPrev: previewGoPrev,
|
||||||
|
goNext: previewGoNext,
|
||||||
|
} = previewNavigator;
|
||||||
|
|
||||||
|
const previewDisplay = useMemo(() => {
|
||||||
|
if (!previewDoc || !previewUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: previewUrl,
|
||||||
|
alt: previewDoc.title,
|
||||||
|
canGoPrev: Boolean(previewCanGoPrev),
|
||||||
|
canGoNext: Boolean(previewCanGoNext),
|
||||||
|
goPrev: previewGoPrev,
|
||||||
|
goNext: previewGoNext,
|
||||||
|
};
|
||||||
|
}, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]);
|
||||||
|
|
||||||
|
const closePreviewOverlay = useCallback(() => {
|
||||||
|
setPreviewDocId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDocumentPreviewZoom = useCallback(
|
||||||
|
(doc) => {
|
||||||
|
if (!doc || !doc.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previewAsset = typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, 'preview') : null;
|
||||||
|
if (!previewAsset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewDocId(doc.id);
|
||||||
|
},
|
||||||
|
[getDocumentAsset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentActivate = useCallback(
|
||||||
|
(doc, event) => {
|
||||||
|
if (!doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event) {
|
||||||
|
if (typeof event.preventDefault === 'function') {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
if (typeof event.stopPropagation === 'function') {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event?.altKey) {
|
||||||
|
handleDocumentPreviewZoom(doc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onInspectDocument?.(doc.id, event);
|
||||||
|
},
|
||||||
|
[handleDocumentPreviewZoom, onInspectDocument],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]);
|
||||||
|
const navigableRows = useMemo(
|
||||||
|
() => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
const navigableRowKeys = useMemo(() => navigableRows.map((row) => row.key), [navigableRows]);
|
||||||
|
|
||||||
|
const getEntryByKey = useCallback(
|
||||||
|
(rowKey) => entries.find((entry) => entry.key === rowKey) || null,
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePanelFocus = useCallback(() => {
|
||||||
|
let resolvedKey = null;
|
||||||
|
|
||||||
|
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
|
||||||
|
resolvedKey = focusedRowKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedKey) {
|
||||||
|
for (let index = selectedEntries.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = selectedEntries[index];
|
||||||
|
if (navigableRowKeys.includes(candidate)) {
|
||||||
|
resolvedKey = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedKey && navigableRows.length) {
|
||||||
|
resolvedKey = navigableRows[0].key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFocusedRowChange?.(resolvedKey);
|
||||||
|
|
||||||
|
if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') {
|
||||||
|
onEntrySelection(resolvedKey, {
|
||||||
|
shiftKey: false,
|
||||||
|
preventDefault: () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
focusedRowKey,
|
||||||
|
navigableRowKeys,
|
||||||
|
navigableRows,
|
||||||
|
onEntrySelection,
|
||||||
|
onFocusedRowChange,
|
||||||
|
selectedEntries,
|
||||||
|
selectedRowKeySet,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 =
|
||||||
|
focusedRowKey && navigableRowKeys.includes(focusedRowKey)
|
||||||
|
? focusedRowKey
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!activeKey) {
|
||||||
|
if (selectedEntries.length) {
|
||||||
|
for (let index = selectedEntries.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = selectedEntries[index];
|
||||||
|
if (navigableRowKeys.includes(candidate)) {
|
||||||
|
activeKey = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeKey) {
|
||||||
|
activeKey = key === 'ArrowUp' ? navigableRowKeys[navigableRowKeys.length - 1] : navigableRowKeys[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = navigableRowKeys.indexOf(activeKey);
|
||||||
|
const activeRow = currentIndex === -1 ? null : navigableRows[currentIndex];
|
||||||
|
|
||||||
|
if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') {
|
||||||
|
if (activeRow) {
|
||||||
|
onEntrySelection?.(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFocusedRowChange?.(targetRow.key);
|
||||||
|
onEntrySelection?.(targetRow.key, {
|
||||||
|
shiftKey,
|
||||||
|
preventDefault: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
focusedRowKey,
|
||||||
|
getEntryByKey,
|
||||||
|
navigableRowKeys,
|
||||||
|
navigableRows,
|
||||||
|
onEntrySelection,
|
||||||
|
onFocusedRowChange,
|
||||||
|
onFolderSelect,
|
||||||
|
selectedEntries,
|
||||||
|
handleDocumentPreviewZoom,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isListView = viewMode === 'list';
|
||||||
|
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
||||||
|
const handleSetViewMode = useCallback(
|
||||||
|
(nextMode) => {
|
||||||
|
if (!onViewModeChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onViewModeChange(nextMode);
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onViewModeChange],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [viewMode]);
|
||||||
|
const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
|
||||||
|
const ensureFocusedRowVisible = useCallback(() => {
|
||||||
|
if (!focusedRowKey) return;
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
let selector = null;
|
||||||
|
if (focusedRowKey.startsWith('document:')) {
|
||||||
|
selector = `#document-row-${focusedRowKey.slice('document:'.length)}`;
|
||||||
|
} else if (focusedRowKey.startsWith('folder:')) {
|
||||||
|
selector = `#folder-row-${focusedRowKey.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);
|
||||||
|
}
|
||||||
|
}, [focusedRowKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureFocusedRowVisible();
|
||||||
|
}, [ensureFocusedRowVisible]);
|
||||||
|
|
||||||
|
const activeDescendantId = useMemo(() => {
|
||||||
|
if (!focusedRowKey) return undefined;
|
||||||
|
if (focusedRowKey.startsWith('document:')) {
|
||||||
|
return `document-row-${focusedRowKey.slice('document:'.length)}`;
|
||||||
|
}
|
||||||
|
if (focusedRowKey.startsWith('folder:')) {
|
||||||
|
return `folder-row-${focusedRowKey.slice('folder:'.length)}`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [focusedRowKey]);
|
||||||
|
|
||||||
|
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 handleDocumentTagDrop = useCallback(
|
||||||
|
(event, documentId) => {
|
||||||
|
if (!isTagDragEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.currentTarget.classList.remove('tag-drop-target');
|
||||||
|
const payload =
|
||||||
|
event.dataTransfer.getData('application/x-papercrate-tag') ||
|
||||||
|
event.dataTransfer.getData('text/papercrate-tag');
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload);
|
||||||
|
if (parsed?.id && onDocumentTagDrop) {
|
||||||
|
onDocumentTagDrop(documentId, parsed);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[documents] Failed to parse tag drop payload', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isTagDragEvent, onDocumentTagDrop],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentClick = useCallback(
|
||||||
|
(doc, event) => {
|
||||||
|
if (!doc || suppressDocumentClickRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof onEntryPointer === 'function') {
|
||||||
|
onEntryPointer(
|
||||||
|
{ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc },
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onEntryPointer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderClick = useCallback(
|
||||||
|
(folder, event) => {
|
||||||
|
if (!folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof onEntryPointer === 'function') {
|
||||||
|
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 });
|
||||||
|
onFocusedRowChange?.(`folder:${folder.id}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onEntryPointer, onFocusedRowChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 showTableRows = entries.length > 0;
|
||||||
|
const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0;
|
||||||
|
const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading;
|
||||||
|
const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading;
|
||||||
|
const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]);
|
||||||
|
const trailEntries = useMemo(() => {
|
||||||
|
if (!breadcrumbEntries.length) {
|
||||||
|
return [{ id: 'current-folder', label: currentFolderName }];
|
||||||
|
}
|
||||||
|
const lastIndex = breadcrumbEntries.length - 1;
|
||||||
|
return breadcrumbEntries.map((crumb, index) => ({
|
||||||
|
id: crumb.id ?? index,
|
||||||
|
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
|
||||||
|
onClick: index < lastIndex && onFolderSelect
|
||||||
|
? () => onFolderSelect(crumb.id)
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}, [breadcrumbEntries, currentFolderName, onFolderSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||||
|
>
|
||||||
|
{showHeader ? (
|
||||||
|
<div className="panel-section__header">
|
||||||
|
<div className="panel-section__titles">
|
||||||
|
<h2 className="documents-panel__title">
|
||||||
|
<BreadcrumbTrail
|
||||||
|
entries={trailEntries}
|
||||||
|
className="documents-panel__breadcrumbs"
|
||||||
|
separator="/"
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
{showingSearchResults && (
|
||||||
|
<div className="panel-section__subtitle">Search results</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="header-actions">
|
||||||
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isListView ? ' active' : ''}`}
|
||||||
|
onClick={() => handleSetViewMode('list')}
|
||||||
|
aria-pressed={isListView}
|
||||||
|
title="List view"
|
||||||
|
>
|
||||||
|
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||||
|
onClick={() => handleSetViewMode('grid')}
|
||||||
|
aria-pressed={isGridView}
|
||||||
|
title="Icons view"
|
||||||
|
>
|
||||||
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
|
||||||
|
onClick={() => handleSetViewMode('desk')}
|
||||||
|
aria-pressed={isDeskView}
|
||||||
|
title="Desk view"
|
||||||
|
>
|
||||||
|
<IconFileStack className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="secondary" onClick={onRefresh}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showDefaultEmptyState ? (
|
||||||
|
<div className="panel-section__body">
|
||||||
|
<div className="empty-state">
|
||||||
|
Drop files anywhere or onto a folder to upload documents.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : showGridSearchEmptyState ? (
|
||||||
|
<div className="panel-section__body">
|
||||||
|
<div className="empty-state empty-state--global">
|
||||||
|
No documents match the current filters.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : showListSearchEmptyState ? (
|
||||||
|
<div className="panel-section__body">
|
||||||
|
<div className="empty-state">No documents match the current filters.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="panel-section__body">
|
||||||
|
<div
|
||||||
|
ref={assignScrollRef}
|
||||||
|
className="documents-scroll"
|
||||||
|
tabIndex={0}
|
||||||
|
onFocus={(event) => {
|
||||||
|
if (event.target === scrollRef.current) {
|
||||||
|
handlePanelFocus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.target !== scrollRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePanelKeyDown(event);
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
||||||
|
>
|
||||||
|
{isGridView ? (
|
||||||
|
<DocumentsGrid
|
||||||
|
entries={entries}
|
||||||
|
selectedDocumentIdsSet={selectedSet}
|
||||||
|
selectedFolderIdsSet={selectedFolderSet}
|
||||||
|
draggingDocumentIdsSet={draggingSet}
|
||||||
|
draggedFolderId={draggedFolderId}
|
||||||
|
onFolderClick={handleFolderClick}
|
||||||
|
onFolderSelect={onFolderSelect}
|
||||||
|
onFolderDragOver={onFolderDragOver}
|
||||||
|
onFolderDragLeave={onFolderDragLeave}
|
||||||
|
onFolderDrop={onFolderDrop}
|
||||||
|
onFolderDragStart={onFolderDragStart}
|
||||||
|
onFolderDragEnd={onFolderDragEnd}
|
||||||
|
onDocumentClick={handleDocumentClick}
|
||||||
|
onDocumentActivate={handleDocumentActivate}
|
||||||
|
onDocumentDragStart={handleDocumentDragStartLocal}
|
||||||
|
onDocumentDragEnd={handleDocumentDragEndLocal}
|
||||||
|
onDocumentTagDragOver={handleDocumentTagDragOver}
|
||||||
|
onDocumentTagDragLeave={handleDocumentTagDragLeave}
|
||||||
|
onDocumentTagDrop={handleDocumentTagDrop}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
gridIconSize={gridIconSize}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
|
onDocumentRename={onDocumentRename}
|
||||||
|
onFolderRename={onFolderRename}
|
||||||
|
/>
|
||||||
|
) : !showTableRows ? null : (
|
||||||
|
<DocumentsList
|
||||||
|
entries={entries}
|
||||||
|
focusedRowKey={focusedRowKey}
|
||||||
|
selectedDocumentIdsSet={selectedSet}
|
||||||
|
selectedFolderIdsSet={selectedFolderSet}
|
||||||
|
draggingDocumentIdsSet={draggingSet}
|
||||||
|
draggedFolderId={draggedFolderId}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
onFolderClick={handleFolderClick}
|
||||||
|
onFolderSelect={onFolderSelect}
|
||||||
|
onFolderDragOver={onFolderDragOver}
|
||||||
|
onFolderDragLeave={onFolderDragLeave}
|
||||||
|
onFolderDrop={onFolderDrop}
|
||||||
|
onFolderDragStart={onFolderDragStart}
|
||||||
|
onFolderDragEnd={onFolderDragEnd}
|
||||||
|
onFolderRename={onFolderRename}
|
||||||
|
onDocumentClick={handleDocumentClick}
|
||||||
|
onDocumentActivate={handleDocumentActivate}
|
||||||
|
onDocumentDragStart={handleDocumentDragStartLocal}
|
||||||
|
onDocumentDragEnd={handleDocumentDragEndLocal}
|
||||||
|
onDocumentTagDragOver={handleDocumentTagDragOver}
|
||||||
|
onDocumentTagDragLeave={handleDocumentTagDragLeave}
|
||||||
|
onDocumentTagDrop={handleDocumentTagDrop}
|
||||||
|
onDocumentRename={onDocumentRename}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<PreviewZoomOverlay
|
||||||
|
open={Boolean(previewDocId)}
|
||||||
|
display={previewDisplay}
|
||||||
|
onClose={closePreviewOverlay}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentsPanel;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
ViewListIcon,
|
||||||
|
ViewGridIcon,
|
||||||
|
IconFileStack,
|
||||||
|
RefreshIcon,
|
||||||
|
MinusVerticalIcon,
|
||||||
|
InfoIcon,
|
||||||
|
FoldersIcon,
|
||||||
|
FoldersOffIcon,
|
||||||
|
SortAscendingLettersIcon,
|
||||||
|
SortDescendingLettersIcon,
|
||||||
|
} from '../../ui/icons';
|
||||||
|
import SortFieldQuickMenu from './SortFieldQuickMenu';
|
||||||
|
|
||||||
|
export const createDocumentsTableHeaderActions = ({
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onRefresh,
|
||||||
|
onShowDeskHelp = null,
|
||||||
|
sortField = 'title',
|
||||||
|
onSortFieldChange = null,
|
||||||
|
sortDirection = 'asc',
|
||||||
|
onSortDirectionToggle = null,
|
||||||
|
isFilterActive = false,
|
||||||
|
includeDescendants = true,
|
||||||
|
onToggleIncludeDescendants = null,
|
||||||
|
}) => {
|
||||||
|
const isListView = viewMode === 'list';
|
||||||
|
const isGridView = viewMode === 'grid';
|
||||||
|
const isDeskView = viewMode === 'desk';
|
||||||
|
|
||||||
|
const sortDirectionIsDesc = sortDirection === 'desc';
|
||||||
|
const sortDirectionTitle = sortDirectionIsDesc
|
||||||
|
? 'Sorting Z → A. Click to switch to ascending.'
|
||||||
|
: 'Sorting A → Z. Click to switch to descending.';
|
||||||
|
|
||||||
|
const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function'
|
||||||
|
? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button documents-toolbar__toggle"
|
||||||
|
onClick={onToggleIncludeDescendants}
|
||||||
|
aria-pressed={!includeDescendants}
|
||||||
|
aria-label={includeDescendants ? 'Include subfolders' : 'Limit to current folder'}
|
||||||
|
title={includeDescendants
|
||||||
|
? 'Including subfolders. Click to limit the search to the current folder.'
|
||||||
|
: 'Limiting to the current folder. Click to include subfolders again.'}
|
||||||
|
>
|
||||||
|
{includeDescendants ? <FoldersIcon /> : <FoldersOffIcon />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sortControls = typeof onSortFieldChange === 'function'
|
||||||
|
? (
|
||||||
|
<div className="documents-actions__sort-group">
|
||||||
|
<SortFieldQuickMenu sortField={sortField} onChange={onSortFieldChange} />
|
||||||
|
{typeof onSortDirectionToggle === 'function' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button documents-toolbar__toggle documents-sort__direction"
|
||||||
|
onClick={onSortDirectionToggle}
|
||||||
|
aria-pressed={sortDirectionIsDesc}
|
||||||
|
aria-label={sortDirectionIsDesc ? 'Sort descending' : 'Sort ascending'}
|
||||||
|
title={sortDirectionTitle}
|
||||||
|
>
|
||||||
|
{sortDirectionIsDesc ? (
|
||||||
|
<SortDescendingLettersIcon size={18} />
|
||||||
|
) : (
|
||||||
|
<SortAscendingLettersIcon size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isDeskView && typeof onShowDeskHelp === 'function' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={onShowDeskHelp}
|
||||||
|
aria-label="Show desk view tips"
|
||||||
|
title="Show desk view tips"
|
||||||
|
>
|
||||||
|
<InfoIcon />
|
||||||
|
</button>
|
||||||
|
<span className="main-content__actions-divider" aria-hidden="true">
|
||||||
|
<MinusVerticalIcon />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{includeDescendantsToggle ? (
|
||||||
|
<>
|
||||||
|
{includeDescendantsToggle}
|
||||||
|
<span className="main-content__actions-divider" aria-hidden="true">
|
||||||
|
<MinusVerticalIcon />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{sortControls ? (
|
||||||
|
<>
|
||||||
|
{sortControls}
|
||||||
|
<span className="main-content__actions-divider" aria-hidden="true">
|
||||||
|
<MinusVerticalIcon />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isListView ? ' active' : ''}`}
|
||||||
|
onClick={() => onViewModeChange?.('list')}
|
||||||
|
aria-pressed={isListView}
|
||||||
|
title="List view"
|
||||||
|
>
|
||||||
|
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||||
|
onClick={() => onViewModeChange?.('grid')}
|
||||||
|
aria-pressed={isGridView}
|
||||||
|
title="Icons view"
|
||||||
|
>
|
||||||
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
|
||||||
|
onClick={() => onViewModeChange?.('desk')}
|
||||||
|
aria-pressed={isDeskView}
|
||||||
|
title="Desk view"
|
||||||
|
>
|
||||||
|
<IconFileStack className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="main-content__actions-divider" aria-hidden="true">
|
||||||
|
<MinusVerticalIcon />
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
aria-label="Refresh"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createDocumentsTableHeaderActions;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import QuickAddMenu from '../../ui/QuickAddMenu';
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ value: 'title', label: 'Title' },
|
||||||
|
{ value: 'issued_at', label: 'Issued date' },
|
||||||
|
{ value: 'created_at', label: 'Added' },
|
||||||
|
{ value: 'updated_at', label: 'Updated date' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => {
|
||||||
|
const next = acc;
|
||||||
|
next[option.value] = option.label;
|
||||||
|
return next;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const SortFieldQuickMenu = ({ sortField, onChange }) => {
|
||||||
|
const currentOption = useMemo(
|
||||||
|
() => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0],
|
||||||
|
[sortField],
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() => SORT_OPTIONS.map((option) => ({ id: option.value, label: option.label })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(value, option) => {
|
||||||
|
if (typeof onChange !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextValue = option?.id || option?.original?.id || value;
|
||||||
|
if (nextValue) {
|
||||||
|
onChange(nextValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = currentOption?.label || SORT_LABEL_LOOKUP[currentOption?.value] || 'Title';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QuickAddMenu
|
||||||
|
className="documents-sort__quickmenu"
|
||||||
|
options={options}
|
||||||
|
onSelectOption={handleSelect}
|
||||||
|
triggerClassName="view-toggle__button documents-sort__trigger quick-add__trigger"
|
||||||
|
triggerContent={(
|
||||||
|
<span className="documents-sort__trigger-content">
|
||||||
|
<span className="documents-sort__label">{label}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
triggerAriaLabel={`Sort by ${label}`}
|
||||||
|
triggerTitle={`Sort by ${label}`}
|
||||||
|
placeholder="Select sort field"
|
||||||
|
menuMinWidth={200}
|
||||||
|
align="start"
|
||||||
|
positionStrategy="absolute"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SortFieldQuickMenu;
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import DetailPanel from '../../detail/DetailPanel';
|
||||||
|
import SelectionFloatingActions from '../SelectionFloatingActions';
|
||||||
|
import createWorkspaceSurfaceConfig from '../workspaceHeader';
|
||||||
|
import DocumentsPanel from './DocumentsPanel';
|
||||||
|
import { createDocumentsTableHeaderActions } from './DocumentsToolbar';
|
||||||
|
|
||||||
|
const createDocumentsSurface = ({
|
||||||
|
tableProps,
|
||||||
|
parentBreadcrumb,
|
||||||
|
onNavigateParent,
|
||||||
|
renderSidebarToggle,
|
||||||
|
detailProps,
|
||||||
|
detailOpen = false,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
currentFolderName,
|
||||||
|
breadcrumbs,
|
||||||
|
searchResults,
|
||||||
|
isFilterActive,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onRefresh,
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
onSortFieldChange,
|
||||||
|
onSortDirectionToggle,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
onDeleteSelection,
|
||||||
|
onClearSelection,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
tagLookupById,
|
||||||
|
onBulkTagAdd,
|
||||||
|
onBulkTagRemove,
|
||||||
|
onBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze,
|
||||||
|
folderOptions,
|
||||||
|
onMoveDocumentsToFolder,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
onToggleSearchIncludeDescendants,
|
||||||
|
onInspectDocument,
|
||||||
|
} = tableProps;
|
||||||
|
|
||||||
|
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||||
|
const subtitle = Array.isArray(searchResults)
|
||||||
|
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
|
||||||
|
const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0;
|
||||||
|
const selectionCount = documentSelectionCount + folderSelectionCount;
|
||||||
|
|
||||||
|
const actions = createDocumentsTableHeaderActions({
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onRefresh,
|
||||||
|
sortField,
|
||||||
|
onSortFieldChange,
|
||||||
|
sortDirection,
|
||||||
|
onSortDirectionToggle,
|
||||||
|
isFilterActive,
|
||||||
|
includeDescendants: searchIncludeDescendants,
|
||||||
|
onToggleIncludeDescendants: onToggleSearchIncludeDescendants,
|
||||||
|
});
|
||||||
|
|
||||||
|
const floatingActions = selectionCount > 0
|
||||||
|
? (
|
||||||
|
<SelectionFloatingActions
|
||||||
|
selectionCount={selectionCount}
|
||||||
|
selectedDocumentIds={selectedDocumentIds}
|
||||||
|
selectedFolderIds={selectedFolderIds}
|
||||||
|
documentLookup={documentLookup}
|
||||||
|
tags={tags}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
correspondents={correspondents}
|
||||||
|
onBulkTagAdd={onBulkTagAdd}
|
||||||
|
onBulkTagRemove={onBulkTagRemove}
|
||||||
|
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
|
||||||
|
onBulkCorrespondentRemove={onBulkCorrespondentRemove}
|
||||||
|
onBulkReanalyze={onBulkReanalyze}
|
||||||
|
onDeleteSelection={onDeleteSelection}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
|
folderOptions={folderOptions}
|
||||||
|
onMoveDocumentsToFolder={onMoveDocumentsToFolder}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
|
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
||||||
|
|
||||||
|
return createWorkspaceSurfaceConfig({
|
||||||
|
key: 'documents',
|
||||||
|
variant: 'documents',
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
sidebarToggle,
|
||||||
|
parentBreadcrumb,
|
||||||
|
onNavigateParent,
|
||||||
|
actions,
|
||||||
|
breadcrumbs,
|
||||||
|
selectionLabel: null,
|
||||||
|
floatingActions,
|
||||||
|
content: (
|
||||||
|
<DocumentsPanel
|
||||||
|
{...tableProps}
|
||||||
|
showHeader={false}
|
||||||
|
onInspectDocument={onInspectDocument}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createDocumentsSurface;
|
||||||
Reference in New Issue
Block a user