feat: add keyboard navigation for document views, externalize document preview handling, and include physics model documentation.
This commit is contained in:
@@ -6,7 +6,6 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { LayoutStore, LayoutCard } from './LayoutSystem';
|
import { LayoutStore, LayoutCard } from './LayoutSystem';
|
||||||
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
|
|
||||||
import DesktopDocumentCard from './DesktopDocumentCard';
|
import DesktopDocumentCard from './DesktopDocumentCard';
|
||||||
import usePreviewMetadata from './hooks/usePreviewMetadata';
|
import usePreviewMetadata from './hooks/usePreviewMetadata';
|
||||||
import useDeskTagInteractions from './tags/useDeskTagInteractions';
|
import useDeskTagInteractions from './tags/useDeskTagInteractions';
|
||||||
@@ -31,12 +30,6 @@ export interface DeskDocument {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OverlayDisplay {
|
|
||||||
url: string;
|
|
||||||
alt?: string | null;
|
|
||||||
mimeType?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DocumentSizeInfo {
|
interface DocumentSizeInfo {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -57,6 +50,7 @@ export interface DesktopWorkspaceProps {
|
|||||||
onDocumentTagDrop?: (docId: Identifier, tag: any) => void;
|
onDocumentTagDrop?: (docId: Identifier, tag: any) => void;
|
||||||
tenantId?: Identifier | null;
|
tenantId?: Identifier | null;
|
||||||
viewId?: string | null;
|
viewId?: string | null;
|
||||||
|
onPreview?: (doc: DeskDocument) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrapper to handle hooks per card
|
// Wrapper to handle hooks per card
|
||||||
@@ -90,6 +84,7 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
onDocumentTagDrop,
|
onDocumentTagDrop,
|
||||||
tenantId,
|
tenantId,
|
||||||
viewId,
|
viewId,
|
||||||
|
onPreview,
|
||||||
}) => {
|
}) => {
|
||||||
const { addPointer, removePointer } = usePointerTracking();
|
const { addPointer, removePointer } = usePointerTracking();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -206,9 +201,22 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
requestCanvasFocus: focusShell,
|
requestCanvasFocus: focusShell,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Overlay State
|
useEffect(() => {
|
||||||
const [overlayDisplay, setOverlayDisplay] = useState<OverlayDisplay | null>(null);
|
const handleWindowKeyDown = (e: KeyboardEvent) => {
|
||||||
const closeOverlay = useCallback(() => setOverlayDisplay(null), []);
|
if (e.code === 'Space' && selectedDocumentIds.length > 0) {
|
||||||
|
// Preview the last selected document
|
||||||
|
const lastId = selectedDocumentIds[selectedDocumentIds.length - 1];
|
||||||
|
const doc = items.find(i => String(i.id) === lastId);
|
||||||
|
if (doc && onPreview) {
|
||||||
|
e.preventDefault();
|
||||||
|
onPreview(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleWindowKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleWindowKeyDown);
|
||||||
|
}, [selectedDocumentIds, items, onPreview]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -305,11 +313,6 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PreviewZoomOverlay
|
|
||||||
open={Boolean(overlayDisplay?.url)}
|
|
||||||
onClose={closeOverlay}
|
|
||||||
document={null}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useCallback } from 'react';
|
||||||
import { useDocumentViewLogic, DocumentViewLogic } from './hooks/useDocumentViewLogic';
|
import { useDocumentViewLogic, DocumentViewLogic } from './hooks/useDocumentViewLogic';
|
||||||
|
import { useDocumentsNavigation } from './hooks/useDocumentsNavigation';
|
||||||
|
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
|
||||||
import DocumentsListRow from './components/DocumentsListRow';
|
import DocumentsListRow from './components/DocumentsListRow';
|
||||||
import DocumentsGridCard from './components/DocumentsGridCard';
|
import DocumentsGridCard from './components/DocumentsGridCard';
|
||||||
import DocumentsListContainer from './components/DocumentsListContainer';
|
import DocumentsListContainer from './components/DocumentsListContainer';
|
||||||
@@ -19,15 +21,59 @@ const AbstractDocumentsView = <CProps extends { clearSelection: () => void; chil
|
|||||||
containerProps,
|
containerProps,
|
||||||
...props
|
...props
|
||||||
}: AbstractDocumentsViewProps<CProps>) => {
|
}: AbstractDocumentsViewProps<CProps>) => {
|
||||||
const { entries, onDocumentRename, onFolderRename } = props;
|
const { entries, onDocumentRename, onFolderRename, onFolderSelect, onPreview, scrollRef, viewId } = props;
|
||||||
const viewLogic = useDocumentViewLogic({
|
const viewLogic = useDocumentViewLogic({
|
||||||
onDocumentRename,
|
onDocumentRename,
|
||||||
onFolderRename,
|
onFolderRename,
|
||||||
});
|
});
|
||||||
|
const { handleKeyDown, handleFocus } = useDocumentsNavigation({
|
||||||
|
entries,
|
||||||
|
onFolderSelect,
|
||||||
|
onPreview,
|
||||||
|
});
|
||||||
const { clearSelection } = viewLogic;
|
const { clearSelection } = viewLogic;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef?.current) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [scrollRef, viewId]);
|
||||||
|
|
||||||
|
const { focusedEntryKey } = useWorkspaceSelectionContext();
|
||||||
|
|
||||||
|
const ensureFocusedEntryVisible = useCallback(() => {
|
||||||
|
if (!focusedEntryKey) return;
|
||||||
|
const container = scrollRef?.current;
|
||||||
|
if (!container) return;
|
||||||
|
let selector = null;
|
||||||
|
if (focusedEntryKey.startsWith('document:')) {
|
||||||
|
selector = `#document-${focusedEntryKey.slice('document:'.length)}`;
|
||||||
|
} else if (focusedEntryKey.startsWith('folder:')) {
|
||||||
|
selector = `#folder-${focusedEntryKey.slice('folder:'.length)}`;
|
||||||
|
}
|
||||||
|
if (!selector) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = container.querySelector(selector) as HTMLElement;
|
||||||
|
if (!entry || !container.contains(entry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [focusedEntryKey, scrollRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureFocusedEntryVisible();
|
||||||
|
}, [ensureFocusedEntryVisible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContainerComponent clearSelection={clearSelection} {...(containerProps as any)}>
|
<ContainerComponent
|
||||||
|
clearSelection={clearSelection}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
tabIndex={0}
|
||||||
|
{...(containerProps as any)}
|
||||||
|
>
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<ItemComponent
|
<ItemComponent
|
||||||
key={entry.key}
|
key={entry.key}
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ interface DocumentsGridContainerProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
gridIconSize?: number;
|
gridIconSize?: number;
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DocumentsGridContainer: React.FC<DocumentsGridContainerProps> = ({
|
const DocumentsGridContainer: React.FC<DocumentsGridContainerProps> = ({
|
||||||
children,
|
children,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
gridIconSize,
|
gridIconSize,
|
||||||
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="documents-grid"
|
className="documents-grid"
|
||||||
role="list"
|
role="list"
|
||||||
|
{...props}
|
||||||
style={
|
style={
|
||||||
gridIconSize
|
gridIconSize
|
||||||
? ({ '--documents-grid-icon-size': `${gridIconSize}px` } as React.CSSProperties)
|
? ({ '--documents-grid-icon-size': `${gridIconSize}px` } as React.CSSProperties)
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import React from 'react';
|
|||||||
interface DocumentsListContainerProps {
|
interface DocumentsListContainerProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DocumentsListContainer: React.FC<DocumentsListContainerProps> = ({
|
const DocumentsListContainer: React.FC<DocumentsListContainerProps> = ({
|
||||||
children,
|
children,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<table aria-multiselectable="true">
|
<table aria-multiselectable="true" {...props}>
|
||||||
<thead
|
<thead
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearSelection();
|
clearSelection();
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import type { DocumentsListEntry } from '../../types/documents';
|
||||||
|
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
|
||||||
|
|
||||||
|
interface UseDocumentsNavigationProps {
|
||||||
|
entries: DocumentsListEntry[];
|
||||||
|
onFolderSelect?: (folderId: string) => void;
|
||||||
|
onPreview?: (doc: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDocumentsNavigation = ({
|
||||||
|
entries,
|
||||||
|
onFolderSelect,
|
||||||
|
onPreview,
|
||||||
|
}: UseDocumentsNavigationProps) => {
|
||||||
|
const {
|
||||||
|
selectedEntries,
|
||||||
|
focusedEntryKey,
|
||||||
|
setFocusedEntryKey,
|
||||||
|
handleEntrySelection,
|
||||||
|
} = useWorkspaceSelectionContext();
|
||||||
|
|
||||||
|
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: string) => entries.find((entry) => entry.key === entryKey) || null,
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent) => {
|
||||||
|
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 === 'folder') {
|
||||||
|
onFolderSelect?.(activeRow.id as string);
|
||||||
|
} else {
|
||||||
|
const entry = getEntryByKey(activeRow.key);
|
||||||
|
// @ts-ignore
|
||||||
|
if (entry?.document) {
|
||||||
|
// @ts-ignore
|
||||||
|
onPreview?.(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,
|
||||||
|
onPreview,
|
||||||
|
handleEntrySelection,
|
||||||
|
setFocusedEntryKey,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = 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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleKeyDown,
|
||||||
|
handleFocus,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -22,7 +22,6 @@ import DocumentsPanelHeader, {
|
|||||||
DocumentsHeaderBreadcrumb,
|
DocumentsHeaderBreadcrumb,
|
||||||
} from './DocumentsPanelHeader';
|
} from './DocumentsPanelHeader';
|
||||||
import { SelectionFloatingPanel } from '../SelectionFloatingActions';
|
import { SelectionFloatingPanel } from '../SelectionFloatingActions';
|
||||||
import { createDocumentEntryKey } from '../../app/entryKey';
|
|
||||||
import { createDocumentsTableHeaderActions } from './DocumentsToolbar';
|
import { createDocumentsTableHeaderActions } from './DocumentsToolbar';
|
||||||
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
|
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
|
||||||
import { DEFAULT_GRID_ICON_SIZE } from '../../constants/documents';
|
import { DEFAULT_GRID_ICON_SIZE } from '../../constants/documents';
|
||||||
@@ -77,10 +76,7 @@ export interface DocumentsViewProps {
|
|||||||
// Desk specific (optional for now or handled via intersection)
|
// Desk specific (optional for now or handled via intersection)
|
||||||
tenantId?: Identifier | null;
|
tenantId?: Identifier | null;
|
||||||
viewId?: string | null;
|
viewId?: string | null;
|
||||||
documentLinks?: Map<Identifier, any> | null;
|
|
||||||
ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise<any>;
|
ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise<any>;
|
||||||
onDocumentStackSelect?: (docIds: Identifier[], event?: any) => void;
|
|
||||||
onPromoteSelection?: (docId: Identifier | null) => void;
|
|
||||||
activeTagFilters?: Array<Identifier | null>;
|
activeTagFilters?: Array<Identifier | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +109,6 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
isSearchLoading = false,
|
isSearchLoading = false,
|
||||||
viewMode = 'list',
|
viewMode = 'list',
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
documentLinks,
|
|
||||||
ensureDownloadUrl,
|
ensureDownloadUrl,
|
||||||
onRefresh = () => { },
|
onRefresh = () => { },
|
||||||
sortField,
|
sortField,
|
||||||
@@ -135,18 +130,12 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
activeTagFilters = [],
|
activeTagFilters = [],
|
||||||
activeCorrespondentFilters = [],
|
activeCorrespondentFilters = [],
|
||||||
selectedFolder = null,
|
selectedFolder = null,
|
||||||
promoteSelectionOrder,
|
|
||||||
onDocumentTagDrop,
|
onDocumentTagDrop,
|
||||||
currentTenantId,
|
currentTenantId,
|
||||||
}): ReactNode => {
|
}): ReactNode => {
|
||||||
const {
|
const {
|
||||||
selectedEntries,
|
|
||||||
focusedEntryKey,
|
|
||||||
setFocusedEntryKey,
|
setFocusedEntryKey,
|
||||||
handleEntrySelection,
|
|
||||||
clearSelection,
|
clearSelection,
|
||||||
selectionAnchorRef,
|
|
||||||
applySelection,
|
|
||||||
} = useWorkspaceSelectionContext();
|
} = useWorkspaceSelectionContext();
|
||||||
const {
|
const {
|
||||||
isActive: isFilterActive,
|
isActive: isFilterActive,
|
||||||
@@ -167,43 +156,10 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
|
|
||||||
const showingSearchResults = Array.isArray(searchResultIds);
|
const showingSearchResults = Array.isArray(searchResultIds);
|
||||||
const rows = showingSearchResults && searchDocuments ? searchDocuments : documents;
|
const rows = showingSearchResults && searchDocuments ? searchDocuments : documents;
|
||||||
const documentLinkMap = documentLinks instanceof Map ? documentLinks : null;
|
|
||||||
const searchResultCount = Array.isArray(searchResultIds) ? searchResultIds.length : 0;
|
const searchResultCount = Array.isArray(searchResultIds) ? searchResultIds.length : 0;
|
||||||
|
|
||||||
const handleDeskDocumentStackSelect = useCallback(
|
const viewId = useMemo(() => {
|
||||||
(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) {
|
if (showingSearchResults) {
|
||||||
const trimmedQuery = searchQuery.trim();
|
const trimmedQuery = searchQuery.trim();
|
||||||
const tagsKey = [...activeTagFilters].sort().join(',');
|
const tagsKey = [...activeTagFilters].sort().join(',');
|
||||||
@@ -361,89 +317,59 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
const isDeskView = viewMode === 'desk';
|
const isDeskView = viewMode === 'desk';
|
||||||
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
||||||
|
|
||||||
type ZoomSource = { url: string; alt?: string | null; mimeType?: string | null };
|
const [previewDoc, setPreviewDoc] = useState<Document | null>(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(() => {
|
useEffect(() => {
|
||||||
if (previewDocId && !previewDoc) {
|
if (previewDoc && !rows.find(d => d.id === previewDoc.id)) {
|
||||||
setPreviewDocId(null);
|
setPreviewDoc(null);
|
||||||
}
|
}
|
||||||
}, [previewDocId, previewDoc]);
|
}, [previewDoc, rows]);
|
||||||
|
|
||||||
const [previewZoomSource, setPreviewZoomSource] = useState<ZoomSource | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const zoomDisplay = previewZoomSource;
|
|
||||||
const overlayDocument = useMemo(() => (
|
|
||||||
previewDoc && zoomDisplay?.url
|
|
||||||
? { ...previewDoc, documentLink: zoomDisplay }
|
|
||||||
: previewDoc
|
|
||||||
), [previewDoc, zoomDisplay]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const overlayDocument = useMemo(() => {
|
||||||
let cancelled = false;
|
if (!previewDoc || !previewUrl) {
|
||||||
if (!previewDocId || !previewDoc) {
|
return previewDoc;
|
||||||
setPreviewZoomSource(null);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const documentMimeType = previewDoc.mime_type;
|
return {
|
||||||
|
...previewDoc,
|
||||||
const applyEntry = (entry?: DocumentLinkLike | null) => {
|
documentLink: {
|
||||||
if (!entry?.url) {
|
url: previewUrl,
|
||||||
setPreviewZoomSource(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPreviewZoomSource({
|
|
||||||
url: entry.url,
|
|
||||||
alt: previewDoc.title,
|
alt: previewDoc.title,
|
||||||
mimeType: documentMimeType,
|
mimeType: previewDoc.mime_type,
|
||||||
});
|
},
|
||||||
};
|
};
|
||||||
|
}, [previewDoc, previewUrl]);
|
||||||
|
|
||||||
const cachedEntry = documentLinkMap?.get(previewDocId) || null;
|
useEffect(() => {
|
||||||
if (cachedEntry?.url) {
|
if (!previewDoc) {
|
||||||
applyEntry(cachedEntry);
|
setPreviewUrl(null);
|
||||||
return () => {
|
return;
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ensureDownloadUrl) {
|
let cancelled = false;
|
||||||
setPreviewZoomSource(null);
|
if (ensureDownloadUrl) {
|
||||||
return () => {
|
ensureDownloadUrl(previewDoc.id)
|
||||||
cancelled = true;
|
.then((entry) => {
|
||||||
};
|
if (!cancelled && entry?.url) {
|
||||||
|
setPreviewUrl(entry.url);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureDownloadUrl(previewDocId)
|
|
||||||
.then((entry) => {
|
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
applyEntry(entry);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setPreviewZoomSource(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [previewDocId, previewDoc, documentLinkMap, ensureDownloadUrl]);
|
}, [previewDoc, ensureDownloadUrl]);
|
||||||
|
|
||||||
const closePreviewOverlay = useCallback(() => {
|
const closePreviewOverlay = useCallback(() => {
|
||||||
setPreviewDocId(null);
|
setPreviewDoc(null);
|
||||||
setPreviewZoomSource(null);
|
setPreviewUrl(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDocumentPreviewZoom = useCallback(
|
const handleDocumentPreviewZoom = useCallback(
|
||||||
@@ -451,251 +377,15 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
if (!doc || !doc.id) {
|
if (!doc || !doc.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ensureDownloadUrl && !(documentLinkMap?.get(doc.id)?.url)) {
|
if (!ensureDownloadUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPreviewDocId(doc.id);
|
setPreviewDoc(doc);
|
||||||
},
|
},
|
||||||
[ensureDownloadUrl, documentLinkMap],
|
[ensureDownloadUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
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 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-${focusedEntryKey.slice('document:'.length)}`;
|
|
||||||
} else if (focusedEntryKey.startsWith('folder:')) {
|
|
||||||
selector = `#folder-${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-${focusedEntryKey.slice('document:'.length)}`;
|
|
||||||
}
|
|
||||||
if (focusedEntryKey.startsWith('folder:')) {
|
|
||||||
return `folder-${focusedEntryKey.slice('folder:'.length)}`;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [focusedEntryKey]);
|
|
||||||
|
|
||||||
const handleDocumentTagDragOver = useCallback(
|
const handleDocumentTagDragOver = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
@@ -736,6 +426,24 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
[onEntryPointer],
|
[onEntryPointer],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 handleFolderClick = useCallback(
|
const handleFolderClick = useCallback(
|
||||||
(folder, event) => {
|
(folder, event) => {
|
||||||
if (!folder) {
|
if (!folder) {
|
||||||
@@ -812,9 +520,8 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
activeCorrespondentIdSet: activeCorrespondentIdSet,
|
activeCorrespondentIdSet: activeCorrespondentIdSet,
|
||||||
onCorrespondentClick: toggleCorrespondentFilter,
|
onCorrespondentClick: toggleCorrespondentFilter,
|
||||||
tenantId: currentTenantId,
|
tenantId: currentTenantId,
|
||||||
viewId: deskViewId,
|
viewId,
|
||||||
onDocumentStackSelect: handleDeskDocumentStackSelect,
|
onPreview: handleDocumentPreviewZoom,
|
||||||
onPromoteSelection: promoteSelectionOrder,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBody = () => {
|
const renderBody = () => {
|
||||||
@@ -839,7 +546,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
|
|
||||||
switch (viewMode) {
|
switch (viewMode) {
|
||||||
case 'desk':
|
case 'desk':
|
||||||
return <DesktopWorkspace {...viewProps} tenantId={currentTenantId} viewId={deskViewId} />;
|
return <DesktopWorkspace {...viewProps} />;
|
||||||
case 'grid':
|
case 'grid':
|
||||||
return <DocumentsGrid {...viewProps} gridIconSize={gridIconSize} />;
|
return <DocumentsGrid {...viewProps} gridIconSize={gridIconSize} />;
|
||||||
case 'list':
|
case 'list':
|
||||||
@@ -851,20 +558,6 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
const panelVariant = isDeskView ? 'desk' : isGridView ? 'grid' : 'list';
|
const panelVariant = isDeskView ? 'desk' : isGridView ? 'grid' : 'list';
|
||||||
const shouldHandlePanelInteractions = !isDeskView && entries.length > 0;
|
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>) => {
|
const handleSectionClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||||
if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) {
|
if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) {
|
||||||
return;
|
return;
|
||||||
@@ -881,16 +574,12 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
<section
|
<section
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={`documents-panel documents-panel--view-${panelVariant}`}
|
className={`documents-panel documents-panel--view-${panelVariant}`}
|
||||||
tabIndex={shouldHandlePanelInteractions ? 0 : undefined}
|
|
||||||
onFocus={handleSectionFocus}
|
|
||||||
onKeyDown={handleSectionKeyDown}
|
|
||||||
onClick={handleSectionClick}
|
onClick={handleSectionClick}
|
||||||
aria-activedescendant={shouldHandlePanelInteractions && !isGridView ? activeDescendantId : undefined}
|
|
||||||
>
|
>
|
||||||
{renderBody()}
|
{renderBody()}
|
||||||
</section>
|
</section>
|
||||||
<PreviewZoomOverlay
|
<PreviewZoomOverlay
|
||||||
open={Boolean(zoomDisplay?.url)}
|
open={Boolean(previewUrl)}
|
||||||
onClose={closePreviewOverlay}
|
onClose={closePreviewOverlay}
|
||||||
document={overlayDocument}
|
document={overlayDocument}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user