feat: Introduce PreviewContext and document a new spatial workspace architecture.

This commit is contained in:
2025-11-27 01:14:18 +01:00
parent 9ba581f64e
commit 82243b4170
6 changed files with 131 additions and 144 deletions
+3
View File
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { import {
DocumentsFilterProvider, DocumentsFilterProvider,
} from '../documents/context/DocumentsFilterContext'; } from '../documents/context/DocumentsFilterContext';
import { PreviewProvider } from '../preview/PreviewContext';
import { useWorkspaceSurface } from './useWorkspaceSurface'; import { useWorkspaceSurface } from './useWorkspaceSurface';
import { DocumentsHeaderBreadcrumb } from '../documents/panel/DocumentsPanelHeader'; import { DocumentsHeaderBreadcrumb } from '../documents/panel/DocumentsPanelHeader';
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext'; import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
@@ -75,7 +76,9 @@ const DocumentsRouteContent: React.FC = () => {
const content = renderSurface(); const content = renderSurface();
return ( return (
<DocumentsFilterProvider value={documentsFilter}> <DocumentsFilterProvider value={documentsFilter}>
<PreviewProvider ensureDownloadUrl={surfaceConfig.documentsTableProps?.ensureDownloadUrl}>
{content} {content}
</PreviewProvider>
</DocumentsFilterProvider> </DocumentsFilterProvider>
); );
}; };
+5 -5
View File
@@ -18,6 +18,7 @@ import { createDocumentEntryKey } from '../app/entryKey';
import { PointerTrackingProvider, usePointerTracking } from './PointerTrackingContext'; import { PointerTrackingProvider, usePointerTracking } from './PointerTrackingContext';
import type { Identifier } from '../types/identifiers'; import type { Identifier } from '../types/identifiers';
import type { DocumentsListEntry, Document } from '../types/documents'; import type { DocumentsListEntry, Document } from '../types/documents';
import { usePreviewContext } from '../preview/PreviewContext';
type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null; type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null;
type OverlaySource = { url: string; alt?: string | null; mimeType?: string | null; }; type OverlaySource = { url: string; alt?: string | null; mimeType?: string | null; };
@@ -50,7 +51,6 @@ 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
@@ -84,8 +84,8 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
onDocumentTagDrop, onDocumentTagDrop,
tenantId, tenantId,
viewId, viewId,
onPreview,
}) => { }) => {
const { openPreview } = usePreviewContext();
const { addPointer, removePointer } = usePointerTracking(); const { addPointer, removePointer } = usePointerTracking();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isLayoutReady, setIsLayoutReady] = useState(false); const [isLayoutReady, setIsLayoutReady] = useState(false);
@@ -207,16 +207,16 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
// Preview the last selected document // Preview the last selected document
const lastId = selectedDocumentIds[selectedDocumentIds.length - 1]; const lastId = selectedDocumentIds[selectedDocumentIds.length - 1];
const doc = items.find(i => String(i.id) === lastId); const doc = items.find(i => String(i.id) === lastId);
if (doc && onPreview) { if (doc) {
e.preventDefault(); e.preventDefault();
onPreview(doc); openPreview(doc);
} }
} }
}; };
window.addEventListener('keydown', handleWindowKeyDown); window.addEventListener('keydown', handleWindowKeyDown);
return () => window.removeEventListener('keydown', handleWindowKeyDown); return () => window.removeEventListener('keydown', handleWindowKeyDown);
}, [selectedDocumentIds, items, onPreview]); }, [selectedDocumentIds, items, openPreview]);
return ( return (
<> <>
+5 -2
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useCallback } from 'react';
import { useDocumentViewLogic, DocumentViewLogic } from './hooks/useDocumentViewLogic'; import { useDocumentViewLogic, DocumentViewLogic } from './hooks/useDocumentViewLogic';
import { useDocumentsNavigation } from './hooks/useDocumentsNavigation'; import { useDocumentsNavigation } from './hooks/useDocumentsNavigation';
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
import { usePreviewContext } from '../preview/PreviewContext';
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';
@@ -21,7 +22,8 @@ const AbstractDocumentsView = <CProps extends { clearSelection: () => void; chil
containerProps, containerProps,
...props ...props
}: AbstractDocumentsViewProps<CProps>) => { }: AbstractDocumentsViewProps<CProps>) => {
const { entries, onDocumentRename, onFolderRename, onFolderSelect, onPreview, scrollRef, viewId } = props; const { entries, onDocumentRename, onFolderRename, onFolderSelect, scrollRef, viewId } = props;
const { openPreview } = usePreviewContext();
const viewLogic = useDocumentViewLogic({ const viewLogic = useDocumentViewLogic({
onDocumentRename, onDocumentRename,
onFolderRename, onFolderRename,
@@ -29,7 +31,7 @@ const AbstractDocumentsView = <CProps extends { clearSelection: () => void; chil
const { handleKeyDown, handleFocus } = useDocumentsNavigation({ const { handleKeyDown, handleFocus } = useDocumentsNavigation({
entries, entries,
onFolderSelect, onFolderSelect,
onPreview, onPreview: openPreview,
}); });
const { clearSelection } = viewLogic; const { clearSelection } = viewLogic;
@@ -80,6 +82,7 @@ const AbstractDocumentsView = <CProps extends { clearSelection: () => void; chil
entry={entry} entry={entry}
viewLogic={viewLogic} viewLogic={viewLogic}
{...props} {...props}
onPreview={openPreview}
/> />
))} ))}
</ContainerComponent> </ContainerComponent>
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { DocumentsList, DocumentsGrid } from '../DocumentsView'; import { DocumentsList, DocumentsGrid } from '../DocumentsView';
import type { DragEvent, ReactNode, RefObject } from 'react'; import type { DragEvent, ReactNode, RefObject } from 'react';
import type { import type {
@@ -10,7 +10,7 @@ import type {
} from '../../types/documents'; } from '../../types/documents';
import DesktopWorkspace from '../../desktop/DesktopWorkspace'; import DesktopWorkspace from '../../desktop/DesktopWorkspace';
import { isTagTransferEvent } from '../tagTransfer'; import { isTagTransferEvent } from '../tagTransfer';
import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay'; import { usePreviewContext } from '../../preview/PreviewContext';
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer'; import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer';
import { import {
WorkspaceSelectionProvider, WorkspaceSelectionProvider,
@@ -109,7 +109,6 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
isSearchLoading = false, isSearchLoading = false,
viewMode = 'list', viewMode = 'list',
onViewModeChange, onViewModeChange,
ensureDownloadUrl,
onRefresh = () => { }, onRefresh = () => { },
sortField, sortField,
sortDirection, sortDirection,
@@ -317,73 +316,7 @@ 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;
const [previewDoc, setPreviewDoc] = useState<Document | null>(null); const { openPreview } = usePreviewContext();
useEffect(() => {
if (previewDoc && !rows.find(d => d.id === previewDoc.id)) {
setPreviewDoc(null);
}
}, [previewDoc, rows]);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const overlayDocument = useMemo(() => {
if (!previewDoc || !previewUrl) {
return previewDoc;
}
return {
...previewDoc,
documentLink: {
url: previewUrl,
alt: previewDoc.title,
mimeType: previewDoc.mime_type,
},
};
}, [previewDoc, previewUrl]);
useEffect(() => {
if (!previewDoc) {
setPreviewUrl(null);
return;
}
let cancelled = false;
if (ensureDownloadUrl) {
ensureDownloadUrl(previewDoc.id)
.then((entry) => {
if (!cancelled && entry?.url) {
setPreviewUrl(entry.url);
}
})
.catch(() => {
if (!cancelled) {
setPreviewUrl(null);
}
});
}
return () => {
cancelled = true;
};
}, [previewDoc, ensureDownloadUrl]);
const closePreviewOverlay = useCallback(() => {
setPreviewDoc(null);
setPreviewUrl(null);
}, []);
const handleDocumentPreviewZoom = useCallback(
(doc) => {
if (!doc || !doc.id) {
return;
}
if (!ensureDownloadUrl) {
return;
}
setPreviewDoc(doc);
},
[ensureDownloadUrl],
);
const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []); const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
@@ -431,17 +364,15 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
if (!doc) { if (!doc) {
return; return;
} }
if (event) { if (event && (event.altKey || (event.button === 1))) {
event.preventDefault(); openPreview(doc);
event.stopPropagation();
}
if (event?.altKey) {
handleDocumentPreviewZoom(doc);
return; return;
} }
onDocumentActivate?.(doc.id); if (onDocumentActivate) {
onDocumentActivate(doc);
}
}, },
[handleDocumentPreviewZoom, onDocumentActivate], [onDocumentActivate, openPreview],
); );
const handleFolderClick = useCallback( const handleFolderClick = useCallback(
@@ -521,7 +452,6 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
onCorrespondentClick: toggleCorrespondentFilter, onCorrespondentClick: toggleCorrespondentFilter,
tenantId: currentTenantId, tenantId: currentTenantId,
viewId, viewId,
onPreview: handleDocumentPreviewZoom,
}; };
const renderBody = () => { const renderBody = () => {
@@ -578,11 +508,6 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
> >
{renderBody()} {renderBody()}
</section> </section>
<PreviewZoomOverlay
open={Boolean(previewUrl)}
onClose={closePreviewOverlay}
document={overlayDocument}
/>
</> </>
); );
}; };
+14 -52
View File
@@ -1,9 +1,7 @@
import React, { import React, {
useCallback, useCallback,
useEffect,
useMemo, useMemo,
useRef, useRef,
useState,
} from 'react'; } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -14,11 +12,11 @@ import {
IconX, IconX,
WindowMaximizeIcon, WindowMaximizeIcon,
} from '../ui/icons'; } from '../ui/icons';
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
import { import {
buildCorrespondentOptions, buildCorrespondentOptions,
sortCorrespondents, sortCorrespondents,
} from '../documents/DocumentSummarySection'; } from '../documents/DocumentSummarySection';
import { usePreviewContext } from '../preview/PreviewContext';
import type { DocumentSummarySectionProps } from '../documents/DocumentSummarySection'; import type { DocumentSummarySectionProps } from '../documents/DocumentSummarySection';
import { extractDocumentMetadataPayload } from '../documents/documentSummary'; import { extractDocumentMetadataPayload } from '../documents/documentSummary';
import { createDocumentActionState } from '../documents/documentActions'; import { createDocumentActionState } from '../documents/documentActions';
@@ -233,7 +231,17 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
[hasOcr, loadOcrContent], [hasOcr, loadOcrContent],
); );
const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false); const { openPreview } = usePreviewContext();
const handleZoomOpen = useCallback(() => {
if (!document) {
return;
}
openPreview(document);
}, [document, openPreview]);
const panelRef = useRef<HTMLDivElement | HTMLFormElement | HTMLElement | null>(null);
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
const resolvedDocumentLink = useMemo(() => { const resolvedDocumentLink = useMemo(() => {
if (!document) { if (!document) {
@@ -253,24 +261,6 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
}; };
}, [document]); }, [document]);
const handleZoomOpen = useCallback(() => {
if (!resolvedDocumentLink?.url) {
return;
}
setZoomOverlayOpen(true);
}, [resolvedDocumentLink?.url]);
const handleZoomClose = useCallback(() => {
setZoomOverlayOpen(false);
}, []);
useEffect(() => {
setZoomOverlayOpen(false);
}, [resolvedDocumentLink?.url, document?.id]);
const panelRef = useRef<HTMLDivElement | HTMLFormElement | HTMLElement | null>(null);
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
const { const {
panelStyle: managedDetailPanelStyle, panelStyle: managedDetailPanelStyle,
handleProps: managedResizeHandleProps, handleProps: managedResizeHandleProps,
@@ -333,23 +323,11 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
})); }));
}, [breadcrumbs, navigateToFolder]); }, [breadcrumbs, navigateToFolder]);
const zoomDisplay = useMemo(() => {
if (!resolvedDocumentLink?.url || !document) {
return null;
}
const normalizedMimeType = document.mime_type;
return {
url: resolvedDocumentLink.url,
alt: document.title,
mimeType: normalizedMimeType,
};
}, [document, resolvedDocumentLink?.url]);
const headerActions = createDocumentViewerHeaderActions({ const headerActions = createDocumentViewerHeaderActions({
document, document,
actionState, actionState,
onZoom: zoomDisplay ? handleZoomOpen : null, onZoom: handleZoomOpen,
canZoom: Boolean(zoomDisplay), canZoom: Boolean(document),
}); });
const collapseButton = isSidebarVariant && onCollapsePanel const collapseButton = isSidebarVariant && onCollapsePanel
@@ -469,20 +447,6 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
document?.title || 'Document preview' document?.title || 'Document preview'
); );
const overlayDocument = useMemo(() => (
document && zoomDisplay?.url
? { ...document, documentLink: zoomDisplay }
: document
), [document, zoomDisplay]);
const overlay = (
<PreviewZoomOverlay
open={Boolean(zoomOverlayOpen && zoomDisplay)}
document={overlayDocument}
onClose={handleZoomClose}
/>
);
if (isSidebarVariant) { if (isSidebarVariant) {
const sidebarClass = `detail-panel panel${sidebarMode === 'inline' ? ' detail-panel--inline' : ''}${isPanelResizing ? ' detail-panel--resizing' : ''}`; const sidebarClass = `detail-panel panel${sidebarMode === 'inline' ? ' detail-panel--inline' : ''}${isPanelResizing ? ' detail-panel--resizing' : ''}`;
return ( return (
@@ -501,7 +465,6 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
/> />
<div className="panel-body detail-panel__content">{viewerSection}</div> <div className="panel-body detail-panel__content">{viewerSection}</div>
</aside> </aside>
{overlay}
</> </>
); );
} }
@@ -517,7 +480,6 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
/> />
{viewerSection} {viewerSection}
</section> </section>
{overlay}
</> </>
); );
}; };
+94
View File
@@ -0,0 +1,94 @@
import React, { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
import type { Document } from '../types/documents';
import type { Identifier } from '../types/identifiers';
interface PreviewContextType {
openPreview: (doc: Document) => void;
closePreview: () => void;
}
const PreviewContext = createContext<PreviewContextType | null>(null);
export const usePreviewContext = () => {
const context = useContext(PreviewContext);
if (!context) {
throw new Error('usePreviewContext must be used within a PreviewProvider');
}
return context;
};
interface PreviewProviderProps {
children: React.ReactNode;
ensureDownloadUrl?: (docId: Identifier) => Promise<any>;
}
export const PreviewProvider: React.FC<PreviewProviderProps> = ({ children, ensureDownloadUrl }) => {
const [previewDoc, setPreviewDoc] = useState<Document | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const openPreview = useCallback((doc: Document) => {
setPreviewDoc(doc);
}, []);
const closePreview = useCallback(() => {
setPreviewDoc(null);
setPreviewUrl(null);
}, []);
useEffect(() => {
if (!previewDoc) {
setPreviewUrl(null);
return;
}
let cancelled = false;
if (ensureDownloadUrl) {
ensureDownloadUrl(previewDoc.id)
.then((entry) => {
if (!cancelled && entry?.url) {
setPreviewUrl(entry.url);
}
})
.catch(() => {
if (!cancelled) {
setPreviewUrl(null);
}
});
}
return () => {
cancelled = true;
};
}, [previewDoc, ensureDownloadUrl]);
const overlayDocument = useMemo(() => {
if (!previewDoc || !previewUrl) {
return previewDoc;
}
return {
...previewDoc,
documentLink: {
url: previewUrl,
alt: previewDoc.title,
mimeType: previewDoc.mime_type,
},
};
}, [previewDoc, previewUrl]);
const value = useMemo(() => ({
openPreview,
closePreview,
}), [openPreview, closePreview]);
return (
<PreviewContext.Provider value={value}>
{children}
<PreviewZoomOverlay
open={Boolean(previewUrl)}
onClose={closePreview}
document={overlayDocument}
/>
</PreviewContext.Provider>
);
};