Merge remote-tracking branch 'ui/ui' into dev
This commit is contained in:
@@ -12,5 +12,6 @@ module.exports = {
|
|||||||
runtime: 'automatic',
|
runtime: 'automatic',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'@babel/preset-typescript',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import js from '@eslint/js';
|
|||||||
import pluginReact from 'eslint-plugin-react';
|
import pluginReact from 'eslint-plugin-react';
|
||||||
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import tsPluginImport from '@typescript-eslint/eslint-plugin';
|
||||||
|
|
||||||
|
const tsPlugin = tsPluginImport.default ?? tsPluginImport;
|
||||||
|
|
||||||
const sharedRules = {
|
const sharedRules = {
|
||||||
...js.configs.recommended.rules,
|
...js.configs.recommended.rules,
|
||||||
@@ -42,6 +46,7 @@ export default [
|
|||||||
plugins: {
|
plugins: {
|
||||||
react: pluginReact,
|
react: pluginReact,
|
||||||
'react-hooks': pluginReactHooks,
|
'react-hooks': pluginReactHooks,
|
||||||
|
'@typescript-eslint': tsPlugin,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
@@ -50,4 +55,32 @@ export default [
|
|||||||
},
|
},
|
||||||
rules: sharedRules,
|
rules: sharedRules,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
...sharedLanguageOptions,
|
||||||
|
parser: tsParser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
react: pluginReact,
|
||||||
|
'react-hooks': pluginReactHooks,
|
||||||
|
'@typescript-eslint': tsPlugin,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...sharedRules,
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+881
-18
File diff suppressed because it is too large
Load Diff
@@ -6,23 +6,29 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack serve --mode development --open",
|
"dev": "webpack serve --mode development --open",
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"lint": "eslint src --ext .js,.jsx",
|
"lint": "eslint src --ext .js,.jsx,.ts,.tsx",
|
||||||
"test:engine": "node --test tests/workspaceEngine.test.js"
|
"test:engine": "node --test tests/workspaceEngine.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@tabler/icons-react": "^3.35.0",
|
"@tabler/icons-react": "^3.35.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"pdfjs-dist": "^5.4.394",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^7.9.5"
|
"react-router-dom": "^7.9.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@babel/core": "^7.28.5",
|
||||||
"@babel/preset-env": "^7.28.5",
|
"@babel/preset-env": "^7.28.5",
|
||||||
"@babel/preset-react": "^7.28.5",
|
"@babel/preset-react": "^7.28.5",
|
||||||
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.46.4",
|
||||||
|
"@typescript-eslint/parser": "^8.18.1",
|
||||||
"babel-loader": "^10.0.0",
|
"babel-loader": "^10.0.0",
|
||||||
|
"copy-webpack-plugin": "^13.0.1",
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
@@ -31,6 +37,7 @@
|
|||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"html-webpack-plugin": "^5.6.4",
|
"html-webpack-plugin": "^5.6.4",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
"webpack": "^5.102.1",
|
"webpack": "^5.102.1",
|
||||||
"webpack-cli": "^6.0.1",
|
"webpack-cli": "^6.0.1",
|
||||||
"webpack-dev-server": "^5.2.2"
|
"webpack-dev-server": "^5.2.2"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace';
|
|||||||
import { useDocumentsPreferences } from './useDocumentsPreferences';
|
import { useDocumentsPreferences } from './useDocumentsPreferences';
|
||||||
import SettingsRoute from './SettingsRoute';
|
import SettingsRoute from './SettingsRoute';
|
||||||
|
|
||||||
const AppLayout = () => {
|
const AppLayout: React.FC = () => {
|
||||||
const documentsPreferences = useDocumentsPreferences();
|
const documentsPreferences = useDocumentsPreferences();
|
||||||
const {
|
const {
|
||||||
appStatus,
|
appStatus,
|
||||||
@@ -28,23 +28,17 @@ const AppLayout = () => {
|
|||||||
onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange,
|
onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange,
|
||||||
onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle,
|
onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle,
|
||||||
searchIncludeDescendants: documentsPreferences.searchIncludeDescendants,
|
searchIncludeDescendants: documentsPreferences.searchIncludeDescendants,
|
||||||
onToggleSearchIncludeDescendants: documentsPreferences.toggleSearchIncludeDescendants,
|
|
||||||
onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants,
|
onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants,
|
||||||
sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef,
|
sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef,
|
||||||
handleDeskExit: documentsPreferences.handleDeskExit,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
|
if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
|
||||||
const shouldRememberLastLocation = appStatus !== 'logged-out';
|
const redirectTarget = `${location.pathname}${location.search}${location.hash || ''}`;
|
||||||
return (
|
return (
|
||||||
<Navigate
|
<Navigate
|
||||||
to="/account/login"
|
to="/account/login"
|
||||||
replace
|
replace
|
||||||
state={
|
state={{ from: redirectTarget }}
|
||||||
shouldRememberLastLocation
|
|
||||||
? { from: location.pathname + location.search }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Sidebar from '../sidebar/Sidebar';
|
|
||||||
import { useSidebarContext } from '../sidebar/SidebarContext';
|
|
||||||
import { usePanelManager } from './PanelManagerContext';
|
|
||||||
|
|
||||||
const DocumentsLayout = ({ sidebarProps, children }) => {
|
|
||||||
const { collapsed } = useSidebarContext();
|
|
||||||
const { sidebarSuppressed } = usePanelManager();
|
|
||||||
const sidebarHidden = collapsed || sidebarSuppressed;
|
|
||||||
return (
|
|
||||||
<main className={`documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`}>
|
|
||||||
{!sidebarHidden ? <Sidebar {...sidebarProps} /> : null}
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DocumentsLayout;
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useAppShell } from '../appShellContext';
|
|
||||||
import DocumentsLayout from './DocumentsLayout';
|
|
||||||
import { useWorkspaceSurface } from './useWorkspaceSurface';
|
|
||||||
import PanelHeader from '../ui/PanelHeader';
|
|
||||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
|
||||||
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
|
|
||||||
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
|
|
||||||
|
|
||||||
const DocumentsRouteContent = () => {
|
|
||||||
const {
|
|
||||||
sidebarProps,
|
|
||||||
documentsTableProps,
|
|
||||||
detailPanelProps,
|
|
||||||
detailPanelOpen,
|
|
||||||
documentsViewMode,
|
|
||||||
deskWorkspaceProps,
|
|
||||||
openTagsModal,
|
|
||||||
openCorrespondentsModal,
|
|
||||||
previewWorkspaceDocument,
|
|
||||||
previewWorkspaceEntry,
|
|
||||||
previewDocumentId,
|
|
||||||
closeDocumentPreview,
|
|
||||||
ensurePreviewData,
|
|
||||||
resolveApiPath,
|
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
notifyApiError,
|
|
||||||
} = useAppShell();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { collapsed: sidebarCollapsed } = useSidebarContext();
|
|
||||||
const {
|
|
||||||
sidebarSuppressed,
|
|
||||||
expandSidebar,
|
|
||||||
} = usePanelManager();
|
|
||||||
|
|
||||||
const sidebarPropsWithActions = useMemo(
|
|
||||||
() => ({
|
|
||||||
...sidebarProps,
|
|
||||||
onManageTags: openTagsModal,
|
|
||||||
onManageCorrespondents: openCorrespondentsModal,
|
|
||||||
}),
|
|
||||||
[sidebarProps, openTagsModal, openCorrespondentsModal],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sidebarHidden = sidebarCollapsed || sidebarSuppressed;
|
|
||||||
|
|
||||||
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
|
|
||||||
const parentBreadcrumb = useMemo(() => {
|
|
||||||
if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return breadcrumbs[breadcrumbs.length - 2];
|
|
||||||
}, [breadcrumbs]);
|
|
||||||
|
|
||||||
const handleNavigateParent = useCallback(() => {
|
|
||||||
if (!parentBreadcrumb) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = parentBreadcrumb.id === 'root'
|
|
||||||
? '/documents'
|
|
||||||
: `/documents/folder/${parentBreadcrumb.id}`;
|
|
||||||
navigate(target);
|
|
||||||
}, [navigate, parentBreadcrumb]);
|
|
||||||
|
|
||||||
const handleHeaderBreadcrumbClick = useCallback((crumb) => {
|
|
||||||
if (!crumb || !crumb.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`;
|
|
||||||
navigate(target);
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const { surface } = useWorkspaceSurface({
|
|
||||||
sidebarHidden,
|
|
||||||
onExpandSidebar: expandSidebar,
|
|
||||||
documentsTableProps,
|
|
||||||
detailPanelProps,
|
|
||||||
detailPanelOpen,
|
|
||||||
viewMode: documentsViewMode,
|
|
||||||
deskWorkspaceProps,
|
|
||||||
previewWorkspaceDocument,
|
|
||||||
previewWorkspaceEntry,
|
|
||||||
previewDocumentId,
|
|
||||||
ensureAssetUrl,
|
|
||||||
ensurePreviewData,
|
|
||||||
getDocumentAsset,
|
|
||||||
resolveApiPath,
|
|
||||||
notifyApiError,
|
|
||||||
closeDocumentPreview,
|
|
||||||
parentBreadcrumb,
|
|
||||||
onNavigateParent: handleNavigateParent,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.classList.add('has-main-content');
|
|
||||||
return () => {
|
|
||||||
document.body.classList.remove('has-main-content');
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!surface) {
|
|
||||||
return (
|
|
||||||
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
|
||||||
<div className="main-content main-content--documents">
|
|
||||||
<div className="main-content__body main-content__body--documents" />
|
|
||||||
</div>
|
|
||||||
</DocumentsLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const variant = surface.variant || 'documents';
|
|
||||||
|
|
||||||
const mainContentClass = `main-content main-content--${variant}${
|
|
||||||
surface.detail ? ' main-content--has-detail' : ''
|
|
||||||
}`;
|
|
||||||
const bodyClass = `main-content__body main-content__body--${variant}${
|
|
||||||
surface.detail ? ' main-content__body--has-detail' : ''
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const header = surface.header || null;
|
|
||||||
|
|
||||||
let headerTitle = null;
|
|
||||||
if (header) {
|
|
||||||
const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : [];
|
|
||||||
const lastIndex = breadcrumbEntries.length - 1;
|
|
||||||
const trailEntries = breadcrumbEntries.length
|
|
||||||
? breadcrumbEntries.map((crumb, index) => ({
|
|
||||||
id: crumb.id ?? index,
|
|
||||||
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
|
|
||||||
onClick: index < lastIndex ? () => handleHeaderBreadcrumbClick(crumb) : null,
|
|
||||||
}))
|
|
||||||
: [{ id: 'current-location', label: header.title }];
|
|
||||||
|
|
||||||
headerTitle = (
|
|
||||||
<h2 className="main-content__title">
|
|
||||||
<BreadcrumbTrail
|
|
||||||
entries={trailEntries}
|
|
||||||
className="main-content__breadcrumbs"
|
|
||||||
separator="/"
|
|
||||||
/>
|
|
||||||
{header.subtitle ? (
|
|
||||||
<span className="main-content__subtitle">{header.subtitle}</span>
|
|
||||||
) : null}
|
|
||||||
</h2>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
|
||||||
<div className={mainContentClass}>
|
|
||||||
{header ? (
|
|
||||||
<div className="main-content__header-wrapper">
|
|
||||||
{(header.selectionLabel || header.floatingActions) ? (
|
|
||||||
<div className="panel-floating" aria-live="polite" aria-atomic="true">
|
|
||||||
{header.selectionLabel ? (
|
|
||||||
<span className="panel-floating__label">{header.selectionLabel}</span>
|
|
||||||
) : null}
|
|
||||||
{header.floatingActions || null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<PanelHeader
|
|
||||||
className="main-content__header"
|
|
||||||
leading={header.leading}
|
|
||||||
title={headerTitle}
|
|
||||||
titleTag="h2"
|
|
||||||
actions={header.actions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className={bodyClass}>{surface.content}</div>
|
|
||||||
{surface.detail || null}
|
|
||||||
</div>
|
|
||||||
</DocumentsLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DocumentsRoute = () => (
|
|
||||||
<SidebarProvider>
|
|
||||||
<PanelManagerProvider>
|
|
||||||
<DocumentsRouteContent />
|
|
||||||
</PanelManagerProvider>
|
|
||||||
</SidebarProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default DocumentsRoute;
|
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppShell } from '../appShellContext';
|
||||||
|
import {
|
||||||
|
DocumentsFilterProvider,
|
||||||
|
} from '../documents/context/DocumentsFilterContext';
|
||||||
|
import type { DocumentsFilterValue } from '../documents/context/DocumentsFilterContext';
|
||||||
|
import { useWorkspaceSurface } from './useWorkspaceSurface';
|
||||||
|
import { DocumentsHeaderBreadcrumb } from '../documents/panel/DocumentsPanelHeader';
|
||||||
|
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
|
||||||
|
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
|
||||||
|
import Sidebar from '../sidebar/Sidebar';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
|
||||||
|
type EnsureAssetUrl = (
|
||||||
|
docId: Identifier,
|
||||||
|
asset: unknown,
|
||||||
|
options?: Record<string, unknown>,
|
||||||
|
) => Promise<unknown> | void;
|
||||||
|
|
||||||
|
type EnsurePreviewData = (docId: Identifier, options?: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
type GetDocumentAsset = (document: unknown, assetType: string) => unknown;
|
||||||
|
type ResolveApiPath = (path: string) => string;
|
||||||
|
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||||
|
|
||||||
|
interface DocumentsTableProps {
|
||||||
|
breadcrumbs?: DocumentsHeaderBreadcrumb[] | null;
|
||||||
|
onBreadcrumbNavigate?: (crumb: DocumentsHeaderBreadcrumb) => void;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentsRouteAppShell {
|
||||||
|
sidebarProps?: Record<string, unknown> | null;
|
||||||
|
documentsTableProps?: DocumentsTableProps | null;
|
||||||
|
detailPanelProps?: Record<string, unknown> | null;
|
||||||
|
detailPanelOpen?: boolean;
|
||||||
|
openTagsModal?: () => void;
|
||||||
|
openCorrespondentsModal?: () => void;
|
||||||
|
previewWorkspaceDocument?: unknown;
|
||||||
|
documentLink?: unknown;
|
||||||
|
previewDocumentId?: Identifier | null;
|
||||||
|
closeDocumentPreview?: () => void;
|
||||||
|
ensurePreviewData?: EnsurePreviewData;
|
||||||
|
resolveApiPath?: ResolveApiPath;
|
||||||
|
ensureAssetUrl?: EnsureAssetUrl;
|
||||||
|
getDocumentAsset?: GetDocumentAsset;
|
||||||
|
notifyApiError?: NotifyApiError;
|
||||||
|
documentsFilter: DocumentsFilterValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentsRouteContent: React.FC = () => {
|
||||||
|
const {
|
||||||
|
sidebarProps,
|
||||||
|
documentsTableProps,
|
||||||
|
detailPanelProps,
|
||||||
|
detailPanelOpen,
|
||||||
|
openTagsModal,
|
||||||
|
openCorrespondentsModal,
|
||||||
|
previewWorkspaceDocument,
|
||||||
|
documentLink,
|
||||||
|
previewDocumentId,
|
||||||
|
closeDocumentPreview,
|
||||||
|
ensurePreviewData,
|
||||||
|
resolveApiPath,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
notifyApiError,
|
||||||
|
documentsFilter,
|
||||||
|
} = useAppShell() as unknown as DocumentsRouteAppShell;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { collapsed: sidebarCollapsed } = useSidebarContext();
|
||||||
|
const {
|
||||||
|
sidebarSuppressed,
|
||||||
|
expandSidebar,
|
||||||
|
} = usePanelManager();
|
||||||
|
|
||||||
|
const safeSidebarProps = useMemo<Record<string, unknown>>(
|
||||||
|
() => (sidebarProps && Object(sidebarProps) === sidebarProps ? sidebarProps : {}),
|
||||||
|
[sidebarProps],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarPropsWithActions = useMemo(
|
||||||
|
() => ({
|
||||||
|
...safeSidebarProps,
|
||||||
|
onManageTags: openTagsModal,
|
||||||
|
onManageCorrespondents: openCorrespondentsModal,
|
||||||
|
}),
|
||||||
|
[safeSidebarProps, openTagsModal, openCorrespondentsModal],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarHidden = sidebarCollapsed || sidebarSuppressed;
|
||||||
|
|
||||||
|
const handleHeaderBreadcrumbClick = useCallback((crumb: DocumentsHeaderBreadcrumb) => {
|
||||||
|
if (!crumb || !crumb.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`;
|
||||||
|
navigate(target);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const documentsTablePropsWithNav = useMemo(() => (
|
||||||
|
documentsTableProps
|
||||||
|
? { ...documentsTableProps, onBreadcrumbNavigate: handleHeaderBreadcrumbClick }
|
||||||
|
: null
|
||||||
|
), [documentsTableProps, handleHeaderBreadcrumbClick]);
|
||||||
|
|
||||||
|
const { surface } = useWorkspaceSurface({
|
||||||
|
sidebarHidden,
|
||||||
|
onExpandSidebar: expandSidebar,
|
||||||
|
documentsTableProps: documentsTablePropsWithNav,
|
||||||
|
detailPanelProps,
|
||||||
|
detailPanelOpen,
|
||||||
|
previewWorkspaceDocument,
|
||||||
|
documentLink,
|
||||||
|
previewDocumentId,
|
||||||
|
ensureAssetUrl,
|
||||||
|
ensurePreviewData,
|
||||||
|
getDocumentAsset,
|
||||||
|
resolveApiPath,
|
||||||
|
notifyApiError,
|
||||||
|
closeDocumentPreview,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.add('has-main-content');
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove('has-main-content');
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderSurface = () => {
|
||||||
|
if (!surface) {
|
||||||
|
return (
|
||||||
|
<main className={`documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`}>
|
||||||
|
{!sidebarHidden ? <Sidebar {...sidebarPropsWithActions} /> : null}
|
||||||
|
<div className="main-content">
|
||||||
|
<div className="main-content__body" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const surfaceDetail = (surface as { detail?: ReactNode }).detail || null;
|
||||||
|
return (
|
||||||
|
<main className={`documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`}>
|
||||||
|
{!sidebarHidden ? <Sidebar {...sidebarPropsWithActions} /> : null}
|
||||||
|
<div className="main-content">
|
||||||
|
<div className="main-content__body">{surface.content}</div>
|
||||||
|
{surfaceDetail}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = renderSurface();
|
||||||
|
return (
|
||||||
|
<DocumentsFilterProvider value={documentsFilter}>
|
||||||
|
{content}
|
||||||
|
</DocumentsFilterProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentsRoute: React.FC = () => (
|
||||||
|
<SidebarProvider>
|
||||||
|
<PanelManagerProvider>
|
||||||
|
<DocumentsRouteContent />
|
||||||
|
</PanelManagerProvider>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DocumentsRoute;
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const DropOverlay = ({ active, folderName }) => (
|
interface DropOverlayProps {
|
||||||
|
active?: boolean;
|
||||||
|
folderName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropOverlay: React.FC<DropOverlayProps> = ({ active = false, folderName }) => (
|
||||||
<div className={`drop-overlay${active ? ' active' : ''}`}>
|
<div className={`drop-overlay${active ? ' active' : ''}`}>
|
||||||
<div className="drop-overlay__content">
|
<div className="drop-overlay__content">
|
||||||
Drop files to upload to <strong>{folderName || 'this location'}</strong>
|
Drop files to upload to <strong>{folderName || 'this location'}</strong>
|
||||||
@@ -11,11 +11,31 @@ import {
|
|||||||
} from '../utils/webauthn';
|
} from '../utils/webauthn';
|
||||||
import { api, useAppDispatch, useAppState } from './appState';
|
import { api, useAppDispatch, useAppState } from './appState';
|
||||||
|
|
||||||
const LoginRoute = () => {
|
type StatusVariant = 'info' | 'success' | 'error';
|
||||||
const { status: appStatus, tenantSelection } = useAppState();
|
|
||||||
|
interface StatusMessage {
|
||||||
|
message: string;
|
||||||
|
variant: StatusVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantOption {
|
||||||
|
id?: string | number | null;
|
||||||
|
name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantSelectionState {
|
||||||
|
selectionToken?: string | null;
|
||||||
|
tenants?: TenantOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const LoginRoute: React.FC = () => {
|
||||||
|
const appState = useAppState();
|
||||||
|
const { status: appStatus } = appState;
|
||||||
|
const tenantSelection = (appState.tenantSelection ?? null) as TenantSelectionState | null;
|
||||||
const appDispatch = useAppDispatch();
|
const appDispatch = useAppDispatch();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState<StatusMessage | null>(null);
|
||||||
const [selectingTenantId, setSelectingTenantId] = useState(null);
|
const [selectingTenantId, setSelectingTenantId] = useState(null);
|
||||||
const passkeySupported = isWebAuthnAvailable();
|
const passkeySupported = isWebAuthnAvailable();
|
||||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||||
@@ -37,7 +57,6 @@ const LoginRoute = () => {
|
|||||||
|
|
||||||
let combined = extract(location.search);
|
let combined = extract(location.search);
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const hash = window.location.hash || '';
|
const hash = window.location.hash || '';
|
||||||
const queryIndex = hash.indexOf('?');
|
const queryIndex = hash.indexOf('?');
|
||||||
if (queryIndex !== -1) {
|
if (queryIndex !== -1) {
|
||||||
@@ -57,7 +76,6 @@ const LoginRoute = () => {
|
|||||||
preferredTenantId: combined.preferredTenantId || searchParams.preferredTenantId,
|
preferredTenantId: combined.preferredTenantId || searchParams.preferredTenantId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return combined;
|
return combined;
|
||||||
}, [location.search]);
|
}, [location.search]);
|
||||||
@@ -96,10 +114,6 @@ const LoginRoute = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const clearMagicParamsFromUrl = useCallback(() => {
|
const clearMagicParamsFromUrl = useCallback(() => {
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const removableKeys = ['magic_token', 'username', 'preferred_tenant_id'];
|
const removableKeys = ['magic_token', 'username', 'preferred_tenant_id'];
|
||||||
const currentSearch = new URLSearchParams(window.location.search);
|
const currentSearch = new URLSearchParams(window.location.search);
|
||||||
let searchChanged = false;
|
let searchChanged = false;
|
||||||
@@ -184,7 +198,7 @@ const LoginRoute = () => {
|
|||||||
|
|
||||||
const handlePasskeyLogin = useCallback(
|
const handlePasskeyLogin = useCallback(
|
||||||
async (rawUsername) => {
|
async (rawUsername) => {
|
||||||
const username = typeof rawUsername === 'string' ? rawUsername.trim() : '';
|
const username = rawUsername?.trim?.() || '';
|
||||||
if (!username) {
|
if (!username) {
|
||||||
setStatusMessage('Enter your username before using a passkey.', 'error');
|
setStatusMessage('Enter your username before using a passkey.', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -207,13 +221,18 @@ const LoginRoute = () => {
|
|||||||
|
|
||||||
const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions });
|
const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions });
|
||||||
setStatusMessage('Confirm the passkey prompt to continue.', 'info');
|
setStatusMessage('Confirm the passkey prompt to continue.', 'info');
|
||||||
const assertion = await navigator.credentials.get({ publicKey });
|
const assertion = await navigator.credentials.get({ publicKey }) as PublicKeyCredential | null;
|
||||||
|
|
||||||
if (!assertion) {
|
if (!assertion) {
|
||||||
setStatusMessage('Passkey login cancelled.', 'info');
|
setStatusMessage('Passkey login cancelled.', 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(assertion instanceof PublicKeyCredential)) {
|
||||||
|
setStatusMessage('Unexpected credential response.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const serialized = serializeAuthenticationCredential(assertion);
|
const serialized = serializeAuthenticationCredential(assertion);
|
||||||
const finishPayload = {
|
const finishPayload = {
|
||||||
challengeId,
|
challengeId,
|
||||||
@@ -289,7 +308,11 @@ const LoginRoute = () => {
|
|||||||
setStatusMessage('Signing you in…', 'info');
|
setStatusMessage('Signing you in…', 'info');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload: {
|
||||||
|
magic_token: string;
|
||||||
|
username?: string;
|
||||||
|
preferred_tenant_id?: string | number;
|
||||||
|
} = {
|
||||||
magic_token: magicToken,
|
magic_token: magicToken,
|
||||||
};
|
};
|
||||||
if (magicUsername) {
|
if (magicUsername) {
|
||||||
@@ -381,7 +404,7 @@ const LoginRoute = () => {
|
|||||||
|
|
||||||
const handleSignup = useCallback(
|
const handleSignup = useCallback(
|
||||||
async (rawUsername) => {
|
async (rawUsername) => {
|
||||||
const username = typeof rawUsername === 'string' ? rawUsername.trim() : '';
|
const username = rawUsername?.trim?.() || '';
|
||||||
if (!username) {
|
if (!username) {
|
||||||
setStatusMessage('Choose a username to create your account.', 'error');
|
setStatusMessage('Choose a username to create your account.', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -405,13 +428,18 @@ const LoginRoute = () => {
|
|||||||
|
|
||||||
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
|
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
|
||||||
setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info');
|
setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info');
|
||||||
const credential = await navigator.credentials.create({ publicKey });
|
const credential = await navigator.credentials.create({ publicKey }) as PublicKeyCredential | null;
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
setStatusMessage('Signup cancelled.', 'info');
|
setStatusMessage('Signup cancelled.', 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
|
setStatusMessage('Unexpected credential response.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const serialized = serializeRegistrationCredential(credential);
|
const serialized = serializeRegistrationCredential(credential);
|
||||||
const finishPayload = {
|
const finishPayload = {
|
||||||
signup_token: signupToken,
|
signup_token: signupToken,
|
||||||
@@ -459,8 +487,8 @@ const LoginRoute = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const redirectTarget = useMemo(() => {
|
const redirectTarget = useMemo(() => {
|
||||||
const target = location.state?.from;
|
const target = String(location.state?.from ?? '');
|
||||||
if (typeof target === 'string' && target.startsWith('/')) {
|
if (target.startsWith('/')) {
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
return '/documents';
|
return '/documents';
|
||||||
+154
-70
@@ -1,4 +1,5 @@
|
|||||||
import React, {
|
import React, {
|
||||||
|
ReactNode,
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
@@ -7,38 +8,85 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import type { CSSProperties, PointerEvent as ReactPointerEvent, RefObject } from 'react';
|
||||||
import { useSidebarContext } from '../sidebar/SidebarContext';
|
import { useSidebarContext } from '../sidebar/SidebarContext';
|
||||||
|
|
||||||
const PanelManagerContext = createContext(null);
|
type PanelKey = 'sidebar' | 'detail';
|
||||||
|
|
||||||
const PANEL_LIMITS = {
|
interface PanelLimits {
|
||||||
sidebar: { minRatio: 1 / 6, maxRatio: 1 / 4 },
|
maxRatio: number;
|
||||||
detail: { minRatio: 1 / 4, maxRatio: 3 / 4 },
|
minPx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetPanelWidthOptions {
|
||||||
|
commit?: boolean;
|
||||||
|
log?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PanelManagerContextValue {
|
||||||
|
sidebarWidth: number;
|
||||||
|
detailWidth: number;
|
||||||
|
sidebarSuppressed: boolean;
|
||||||
|
resizingPanel: PanelKey | null;
|
||||||
|
setPanelWidth: (panel: PanelKey, width: number, options?: SetPanelWidthOptions) => number;
|
||||||
|
startPanelResize: (panel: PanelKey) => void;
|
||||||
|
stopPanelResize: () => void;
|
||||||
|
getPanelWidth: (panel: PanelKey) => number;
|
||||||
|
setDetailActive: (isOpen: boolean) => void;
|
||||||
|
closeDetailPanel: () => void;
|
||||||
|
collapseSidebar: () => void;
|
||||||
|
expandSidebar: () => void;
|
||||||
|
registerDetailCloseHandler: (handler?: (() => void) | null) => void;
|
||||||
|
detailPanelOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PanelResizeBindings = {
|
||||||
|
panelStyle?: CSSProperties;
|
||||||
|
handleProps: {
|
||||||
|
onPointerDown?: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
|
};
|
||||||
|
isPanelResizing: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
const PanelManagerContext = createContext<PanelManagerContextValue | null>(null);
|
||||||
|
|
||||||
|
const PANEL_LIMITS: Record<PanelKey, PanelLimits> = {
|
||||||
|
sidebar: {
|
||||||
|
maxRatio: 1 / 3,
|
||||||
|
minPx: 280,
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
maxRatio: 2 / 3,
|
||||||
|
minPx: 320,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEYS: Record<PanelKey, string> = {
|
||||||
sidebar: 'papercrate_sidebar_width',
|
sidebar: 'papercrate_sidebar_width',
|
||||||
detail: 'papercrate_detail_width',
|
detail: 'papercrate_detail_width',
|
||||||
};
|
};
|
||||||
|
|
||||||
const MINIMAL_FREE_RATIO = 1 / 3;
|
const DEFAULT_SIDEBAR_WIDTH = 320;
|
||||||
|
const DEFAULT_DETAIL_WIDTH = 420;
|
||||||
|
|
||||||
const clampPanelWidth = (panel, value) => {
|
const MINIMAL_FREE_RATIO = 1 / 3;
|
||||||
|
const SIDEBAR_SOLO_THRESHOLD = 1 / 2;
|
||||||
|
const MINIMUM_MAIN_CONTENT_WIDTH = 160;
|
||||||
|
|
||||||
|
const clampPanelWidth = (panel: PanelKey, value: number): number => {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
const limits = PANEL_LIMITS[panel];
|
const limits = PANEL_LIMITS[panel];
|
||||||
if (!limits) {
|
|
||||||
return numeric;
|
|
||||||
}
|
|
||||||
const viewport = window.innerWidth;
|
const viewport = window.innerWidth;
|
||||||
const minLimit = Math.max(0, Math.round(viewport * limits.minRatio));
|
const minLimit = Math.max(0, limits.minPx);
|
||||||
const rawMax = Math.max(minLimit, Math.round(viewport * limits.maxRatio));
|
const ratioMax = Math.round(viewport * limits.maxRatio);
|
||||||
const maxAllowed = Math.min(rawMax, viewport - 160);
|
const rawMax = Math.max(ratioMax, minLimit);
|
||||||
const targetMax = Math.max(minLimit, maxAllowed);
|
const maxAllowed = Math.min(rawMax, viewport - MINIMUM_MAIN_CONTENT_WIDTH);
|
||||||
return Math.min(Math.max(numeric, minLimit), targetMax);
|
const targetMax = Math.min(viewport, Math.max(minLimit, maxAllowed));
|
||||||
|
return Math.min(Math.max(numeric, minLimit), Math.max(0, targetMax));
|
||||||
};
|
};
|
||||||
|
|
||||||
const readStoredWidth = (panel, fallback) => {
|
const readStoredWidth = (panel: PanelKey, fallback: number): number => {
|
||||||
const raw = window?.localStorage?.getItem(STORAGE_KEYS[panel]);
|
const raw = window.localStorage.getItem(STORAGE_KEYS[panel]);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -46,22 +94,27 @@ const readStoredWidth = (panel, fallback) => {
|
|||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const persistWidth = (panel, value) => {
|
const persistWidth = (panel: PanelKey, value: number): void => {
|
||||||
window?.localStorage?.setItem(STORAGE_KEYS[panel], String(Math.round(value)));
|
window.localStorage.setItem(STORAGE_KEYS[panel], String(Math.round(value)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPanelWidthToRoot = (panel, width) => {
|
const applyPanelWidthToRoot = (panel: PanelKey, width: number, active: boolean): void => {
|
||||||
if (!Number.isFinite(width)) {
|
if (!Number.isFinite(width)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width';
|
const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width';
|
||||||
document?.documentElement?.style.setProperty(varName, `${width}px`);
|
const resolvedValue = panel === 'detail' && !active ? '0px' : `${width}px`;
|
||||||
|
document.documentElement.style.setProperty(varName, resolvedValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PanelManagerProvider = ({ children }) => {
|
interface PanelManagerProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PanelManagerProvider: React.FC<PanelManagerProviderProps> = ({ children }) => {
|
||||||
const { collapsed, setCollapsed } = useSidebarContext();
|
const { collapsed, setCollapsed } = useSidebarContext();
|
||||||
const initialSidebarWidth = readStoredWidth('sidebar', 320);
|
const initialSidebarWidth = readStoredWidth('sidebar', DEFAULT_SIDEBAR_WIDTH);
|
||||||
const initialDetailWidth = readStoredWidth('detail', 420);
|
const initialDetailWidth = readStoredWidth('detail', DEFAULT_DETAIL_WIDTH);
|
||||||
|
|
||||||
const [sidebarWidth, setSidebarWidthState] = useState(() => clampPanelWidth('sidebar', initialSidebarWidth));
|
const [sidebarWidth, setSidebarWidthState] = useState(() => clampPanelWidth('sidebar', initialSidebarWidth));
|
||||||
const [detailWidth, setDetailWidthState] = useState(() => clampPanelWidth('detail', initialDetailWidth));
|
const [detailWidth, setDetailWidthState] = useState(() => clampPanelWidth('detail', initialDetailWidth));
|
||||||
@@ -71,6 +124,7 @@ export const PanelManagerProvider = ({ children }) => {
|
|||||||
|
|
||||||
const detailCloseHandlerRef = useRef(null);
|
const detailCloseHandlerRef = useRef(null);
|
||||||
const panelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
|
const panelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
|
||||||
|
const preferredPanelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
|
||||||
|
|
||||||
const closeDetailPanel = useCallback(() => {
|
const closeDetailPanel = useCallback(() => {
|
||||||
const handler = detailCloseHandlerRef.current;
|
const handler = detailCloseHandlerRef.current;
|
||||||
@@ -79,23 +133,20 @@ export const PanelManagerProvider = ({ children }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
panelWidthsRef.current.sidebar = sidebarWidth;
|
panelWidthsRef.current.sidebar = sidebarWidth;
|
||||||
applyPanelWidthToRoot('sidebar', sidebarWidth);
|
applyPanelWidthToRoot('sidebar', sidebarWidth, true);
|
||||||
}, [sidebarWidth]);
|
}, [sidebarWidth]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
panelWidthsRef.current.detail = detailWidth;
|
panelWidthsRef.current.detail = detailWidth;
|
||||||
applyPanelWidthToRoot('detail', detailWidth);
|
applyPanelWidthToRoot('detail', detailWidth, detailPanelOpen);
|
||||||
}, [detailWidth]);
|
}, [detailWidth, detailPanelOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handlePanelLayoutChange = useCallback((
|
||||||
persistWidth('sidebar', sidebarWidth);
|
panel: PanelKey,
|
||||||
}, [sidebarWidth]);
|
action: 'opened' | 'closed' | 'resized',
|
||||||
|
value?: number,
|
||||||
useEffect(() => {
|
{ detailOpen }: { detailOpen?: boolean } = {},
|
||||||
persistWidth('detail', detailWidth);
|
) => {
|
||||||
}, [detailWidth]);
|
|
||||||
|
|
||||||
const logPanelState = useCallback((panel, action, value) => {
|
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const sidebarWidth = panelWidthsRef.current.sidebar;
|
const sidebarWidth = panelWidthsRef.current.sidebar;
|
||||||
const detailWidth = panelWidthsRef.current.detail;
|
const detailWidth = panelWidthsRef.current.detail;
|
||||||
@@ -103,6 +154,7 @@ export const PanelManagerProvider = ({ children }) => {
|
|||||||
const freeRatio = viewportWidth > 0 ? freeSpace / viewportWidth : 0;
|
const freeRatio = viewportWidth > 0 ? freeSpace / viewportWidth : 0;
|
||||||
|
|
||||||
const meetsThreshold = freeRatio >= MINIMAL_FREE_RATIO;
|
const meetsThreshold = freeRatio >= MINIMAL_FREE_RATIO;
|
||||||
|
const effectiveDetailOpen = detailOpen ?? detailPanelOpen;
|
||||||
|
|
||||||
if (panel === 'detail' && !collapsed) {
|
if (panel === 'detail' && !collapsed) {
|
||||||
if (action === 'opened' || action === 'resized') {
|
if (action === 'opened' || action === 'resized') {
|
||||||
@@ -112,34 +164,20 @@ export const PanelManagerProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && detailPanelOpen) {
|
if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && effectiveDetailOpen) {
|
||||||
closeDetailPanel();
|
closeDetailPanel();
|
||||||
}
|
}
|
||||||
|
}, [collapsed, closeDetailPanel, detailPanelOpen]);
|
||||||
const normalizedAction = action === 'resized'
|
|
||||||
? `${panel} resized to ${value}px`
|
|
||||||
: `${panel} ${action}`;
|
|
||||||
console.log(normalizedAction, {
|
|
||||||
sidebar: sidebarWidth > 0 ? `${sidebarWidth}px` : 'closed',
|
|
||||||
detail: `${detailWidth}px`,
|
|
||||||
freeSpace,
|
|
||||||
viewportWidth,
|
|
||||||
freeRatio,
|
|
||||||
minimalFreeRatio: MINIMAL_FREE_RATIO,
|
|
||||||
meetsThreshold,
|
|
||||||
});
|
|
||||||
}, [collapsed, sidebarSuppressed, closeDetailPanel, detailPanelOpen]);
|
|
||||||
|
|
||||||
const collapseSidebar = useCallback(() => {
|
const collapseSidebar = useCallback(() => {
|
||||||
if (!collapsed) {
|
if (!collapsed) {
|
||||||
setCollapsed(true);
|
setCollapsed(true);
|
||||||
setSidebarSuppressed(false);
|
handlePanelLayoutChange('sidebar', 'closed');
|
||||||
logPanelState('sidebar', 'closed');
|
|
||||||
}
|
}
|
||||||
}, [collapsed, setCollapsed, logPanelState]);
|
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
|
||||||
|
|
||||||
const setPanelWidth = useCallback(
|
const setPanelWidth = useCallback(
|
||||||
(panel, width, commit = true) => {
|
(panel: PanelKey, width: number, { commit = true, log = true }: SetPanelWidthOptions = {}) => {
|
||||||
const clamped = clampPanelWidth(panel, width);
|
const clamped = clampPanelWidth(panel, width);
|
||||||
if (!Number.isFinite(clamped)) {
|
if (!Number.isFinite(clamped)) {
|
||||||
return panelWidthsRef.current[panel];
|
return panelWidthsRef.current[panel];
|
||||||
@@ -150,36 +188,79 @@ export const PanelManagerProvider = ({ children }) => {
|
|||||||
setDetailWidthState((prev) => (prev === clamped ? prev : clamped));
|
setDetailWidthState((prev) => (prev === clamped ? prev : clamped));
|
||||||
}
|
}
|
||||||
panelWidthsRef.current[panel] = clamped;
|
panelWidthsRef.current[panel] = clamped;
|
||||||
applyPanelWidthToRoot(panel, clamped);
|
|
||||||
if (commit) {
|
if (commit) {
|
||||||
|
preferredPanelWidthsRef.current[panel] = clamped;
|
||||||
persistWidth(panel, clamped);
|
persistWidth(panel, clamped);
|
||||||
}
|
}
|
||||||
|
|
||||||
logPanelState(panel, 'resized', clamped);
|
if (log) {
|
||||||
|
handlePanelLayoutChange(panel, 'resized', clamped);
|
||||||
|
}
|
||||||
return clamped;
|
return clamped;
|
||||||
},
|
},
|
||||||
[collapsed, logPanelState, sidebarSuppressed],
|
[handlePanelLayoutChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const registerDetailCloseHandler = useCallback((handler = null) => {
|
const resetSidebarPreferredWidth = useCallback(() => {
|
||||||
detailCloseHandlerRef.current = typeof handler === 'function' ? handler : null;
|
if (preferredPanelWidthsRef.current.sidebar === DEFAULT_SIDEBAR_WIDTH) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preferredPanelWidthsRef.current.sidebar = DEFAULT_SIDEBAR_WIDTH;
|
||||||
|
persistWidth('sidebar', DEFAULT_SIDEBAR_WIDTH);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clampPanelsWithinViewport = useCallback(() => {
|
||||||
|
const viewportWidth = Math.max(0, Number(window.innerWidth) || 0);
|
||||||
|
if (viewportWidth === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredSidebarWidth = preferredPanelWidthsRef.current.sidebar;
|
||||||
|
const desiredDetailWidth = preferredPanelWidthsRef.current.detail;
|
||||||
|
|
||||||
|
let sidebarDisplayWidth = Math.min(desiredSidebarWidth, viewportWidth);
|
||||||
|
let remainingWidth = Math.max(0, viewportWidth - sidebarDisplayWidth);
|
||||||
|
let detailDisplayWidth = Math.min(desiredDetailWidth, remainingWidth);
|
||||||
|
if (detailPanelOpen && sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) {
|
||||||
|
sidebarDisplayWidth = 0;
|
||||||
|
detailDisplayWidth = Math.min(desiredDetailWidth || viewportWidth, viewportWidth);
|
||||||
|
} else if (sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) {
|
||||||
|
sidebarDisplayWidth = viewportWidth;
|
||||||
|
detailDisplayWidth = 0;
|
||||||
|
resetSidebarPreferredWidth();
|
||||||
|
}
|
||||||
|
|
||||||
|
panelWidthsRef.current.sidebar = sidebarDisplayWidth;
|
||||||
|
panelWidthsRef.current.detail = detailDisplayWidth;
|
||||||
|
|
||||||
|
setSidebarWidthState((prev) => (prev === sidebarDisplayWidth ? prev : sidebarDisplayWidth));
|
||||||
|
setDetailWidthState((prev) => (prev === detailDisplayWidth ? prev : detailDisplayWidth));
|
||||||
|
}, [detailPanelOpen, resetSidebarPreferredWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clampPanelsWithinViewport();
|
||||||
|
window.addEventListener('resize', clampPanelsWithinViewport);
|
||||||
|
return () => window.removeEventListener('resize', clampPanelsWithinViewport);
|
||||||
|
}, [clampPanelsWithinViewport]);
|
||||||
|
|
||||||
|
const registerDetailCloseHandler = useCallback((handler: (() => void) | null = null) => {
|
||||||
|
detailCloseHandlerRef.current = handler ?? null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setDetailActive = useCallback(
|
const setDetailActive = useCallback(
|
||||||
(isOpen) => {
|
(isOpen) => {
|
||||||
setDetailPanelOpen(Boolean(isOpen));
|
setDetailPanelOpen(Boolean(isOpen));
|
||||||
logPanelState('detail', isOpen ? 'opened' : 'closed');
|
handlePanelLayoutChange('detail', isOpen ? 'opened' : 'closed');
|
||||||
},
|
},
|
||||||
[logPanelState],
|
[handlePanelLayoutChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const expandSidebar = useCallback(() => {
|
const expandSidebar = useCallback(() => {
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
setCollapsed(false);
|
setCollapsed(false);
|
||||||
}
|
}
|
||||||
setSidebarSuppressed(false);
|
handlePanelLayoutChange('sidebar', 'opened');
|
||||||
logPanelState('sidebar', 'opened');
|
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
|
||||||
}, [collapsed, setCollapsed, logPanelState]);
|
|
||||||
|
|
||||||
const startPanelResize = useCallback((panel) => {
|
const startPanelResize = useCallback((panel) => {
|
||||||
setResizingPanel(panel);
|
setResizingPanel(panel);
|
||||||
@@ -237,7 +318,10 @@ export const usePanelManager = () => {
|
|||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true } = {}) => {
|
export const usePanelResizeBindings = (
|
||||||
|
panel: PanelKey,
|
||||||
|
{ panelRef = null, enabled = true }: { panelRef?: RefObject<HTMLElement> | null; enabled?: boolean } = {},
|
||||||
|
): PanelResizeBindings => {
|
||||||
const {
|
const {
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
detailWidth,
|
detailWidth,
|
||||||
@@ -267,7 +351,7 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true
|
|||||||
useEffect(() => () => teardownListeners(), [teardownListeners]);
|
useEffect(() => () => teardownListeners(), [teardownListeners]);
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = useCallback(
|
||||||
(event) => {
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (!enabled || !panelRef?.current) {
|
if (!enabled || !panelRef?.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -284,18 +368,18 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true
|
|||||||
event.currentTarget?.setPointerCapture?.(pointerId);
|
event.currentTarget?.setPointerCapture?.(pointerId);
|
||||||
let lastWidth = startWidth;
|
let lastWidth = startWidth;
|
||||||
|
|
||||||
const handlePointerMove = (moveEvent) => {
|
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||||
if (moveEvent.pointerId !== pointerId) {
|
if (moveEvent.pointerId !== pointerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const delta = panel === 'sidebar'
|
const delta = panel === 'sidebar'
|
||||||
? moveEvent.clientX - startX
|
? moveEvent.clientX - startX
|
||||||
: startX - moveEvent.clientX;
|
: startX - moveEvent.clientX;
|
||||||
lastWidth = setPanelWidth(panel, startWidth + delta, false);
|
lastWidth = setPanelWidth(panel, startWidth + delta, { commit: false });
|
||||||
latestWidthRef.current = lastWidth;
|
latestWidthRef.current = lastWidth;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = (upEvent) => {
|
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||||
if (upEvent.pointerId !== pointerId) {
|
if (upEvent.pointerId !== pointerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,12 @@ import useCapabilitySets from '../settings/useCapabilitySets';
|
|||||||
import useCapabilities from '../settings/useCapabilities';
|
import useCapabilities from '../settings/useCapabilities';
|
||||||
import { api } from './appState';
|
import { api } from './appState';
|
||||||
|
|
||||||
const SettingsRoute = ({ open = true, onClose }) => {
|
interface SettingsRouteProps {
|
||||||
|
open?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsRoute: React.FC<SettingsRouteProps> = ({ open = true, onClose }) => {
|
||||||
const {
|
const {
|
||||||
token,
|
token,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
@@ -19,7 +24,7 @@ const SettingsRoute = ({ open = true, onClose }) => {
|
|||||||
refreshPasskeys,
|
refreshPasskeys,
|
||||||
registerPasskey,
|
registerPasskey,
|
||||||
revokePasskey,
|
revokePasskey,
|
||||||
} = useAppShell();
|
} = useAppShell() as Record<string, any>;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
tokens,
|
tokens,
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import React, { useMemo, useState, useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
CloseIcon,
|
|
||||||
LoaderIcon,
|
|
||||||
CheckIcon,
|
|
||||||
InfoIcon,
|
|
||||||
WarningIcon,
|
|
||||||
BottombarCollapseIcon,
|
|
||||||
BottombarExpandIcon,
|
|
||||||
ClearAllIcon,
|
|
||||||
} from '../ui/icons';
|
|
||||||
import PanelHeader from '../ui/PanelHeader';
|
|
||||||
|
|
||||||
const STATUS_META = {
|
|
||||||
pending: {
|
|
||||||
label: 'Queued',
|
|
||||||
tone: 'muted',
|
|
||||||
icon: <LoaderIcon className="icon icon--spin" size={16} />,
|
|
||||||
},
|
|
||||||
uploading: {
|
|
||||||
label: 'Uploading',
|
|
||||||
tone: 'accent',
|
|
||||||
icon: <LoaderIcon className="icon icon--spin" size={16} />,
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
label: 'Uploaded',
|
|
||||||
tone: 'success',
|
|
||||||
icon: <CheckIcon size={16} />,
|
|
||||||
},
|
|
||||||
duplicate: {
|
|
||||||
label: 'Duplicate',
|
|
||||||
tone: 'info',
|
|
||||||
icon: <InfoIcon size={16} />,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
label: 'Failed',
|
|
||||||
tone: 'danger',
|
|
||||||
icon: <WarningIcon size={16} />,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const UploadQueueOverlay = ({ queue = [], onClearQueue }) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const [dismissed, setDismissed] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (queue.length > 0) {
|
|
||||||
setDismissed(false);
|
|
||||||
}
|
|
||||||
}, [queue.length]);
|
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
|
||||||
if (!queue.length) {
|
|
||||||
return 'No uploads';
|
|
||||||
}
|
|
||||||
const uploadingCount = queue.filter((item) => item.status === 'uploading').length;
|
|
||||||
const pendingCount = queue.filter((item) => item.status === 'pending').length;
|
|
||||||
const errorCount = queue.filter((item) => item.status === 'error').length;
|
|
||||||
if (uploadingCount > 0 || pendingCount > 0) {
|
|
||||||
return `${uploadingCount} uploading · ${pendingCount} queued`;
|
|
||||||
}
|
|
||||||
if (errorCount > 0) {
|
|
||||||
return `${errorCount} failed · ${queue.length} total`;
|
|
||||||
}
|
|
||||||
return `${queue.length} completed`;
|
|
||||||
}, [queue]);
|
|
||||||
|
|
||||||
const hasActiveUploads = queue.some((item) => item.status === 'uploading' || item.status === 'pending');
|
|
||||||
|
|
||||||
const handleClearQueue = () => {
|
|
||||||
if (!onClearQueue || hasActiveUploads) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClearQueue();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!queue.length || dismissed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`upload-queue-overlay${collapsed ? ' upload-queue-overlay--collapsed' : ''}`}>
|
|
||||||
<PanelHeader
|
|
||||||
className="upload-queue-overlay__header"
|
|
||||||
title={(
|
|
||||||
<span className="upload-queue-overlay__title">
|
|
||||||
<span>Uploads</span>
|
|
||||||
<span className="upload-queue-overlay__summary">{summary}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
actions={(
|
|
||||||
<div className="upload-queue-overlay__controls">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost"
|
|
||||||
onClick={() => setCollapsed((value) => !value)}
|
|
||||||
aria-label={collapsed ? 'Expand upload queue' : 'Collapse upload queue'}
|
|
||||||
>
|
|
||||||
{collapsed ? <BottombarExpandIcon size={16} /> : <BottombarCollapseIcon size={16} />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost"
|
|
||||||
onClick={handleClearQueue}
|
|
||||||
disabled={hasActiveUploads || !queue.length}
|
|
||||||
aria-label="Clear completed uploads"
|
|
||||||
>
|
|
||||||
<ClearAllIcon size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={() => setDismissed(true)}
|
|
||||||
aria-label="Hide upload queue"
|
|
||||||
>
|
|
||||||
<CloseIcon size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{!collapsed ? (
|
|
||||||
<ul className="upload-queue-overlay__list">
|
|
||||||
{[...queue]
|
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.map((item) => {
|
|
||||||
const meta = STATUS_META[item.status] || STATUS_META.pending;
|
|
||||||
const fileLabel = item.name;
|
|
||||||
const duplicateLabel = item.status === 'duplicate' ? item.document?.title || null : null;
|
|
||||||
const documentId = item.document?.id || item.conflictDocumentId || null;
|
|
||||||
const hasLink = Boolean(documentId);
|
|
||||||
const handleNavigate = () => {
|
|
||||||
if (!documentId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(`/documents/${documentId}`);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<li key={item.id} className={`upload-queue-overlay__item upload-queue-overlay__item--${item.status}`}>
|
|
||||||
<span className={`upload-queue-overlay__status upload-queue-overlay__status--${meta.tone}`}>
|
|
||||||
{meta.icon}
|
|
||||||
</span>
|
|
||||||
<div className="upload-queue-overlay__details">
|
|
||||||
{item.status === 'success' && hasLink ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="upload-queue-overlay__name-link"
|
|
||||||
onClick={handleNavigate}
|
|
||||||
>
|
|
||||||
{fileLabel}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="upload-queue-overlay__name">
|
|
||||||
{fileLabel}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="upload-queue-overlay__meta-line">
|
|
||||||
{item.status === 'duplicate' && duplicateLabel ? (
|
|
||||||
<span className="upload-queue-overlay__meta-duplicate">
|
|
||||||
Duplicate of{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="upload-queue-overlay__meta-link"
|
|
||||||
onClick={handleNavigate}
|
|
||||||
>
|
|
||||||
{duplicateLabel}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
) : item.status === 'error' && item.error ? (
|
|
||||||
<span className="upload-queue-overlay__meta-error" title={item.error}>
|
|
||||||
{item.error}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span>{meta.label}</span>
|
|
||||||
{documentId ? (
|
|
||||||
<span className="upload-queue-overlay__meta-id">
|
|
||||||
(
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="upload-queue-overlay__meta-link"
|
|
||||||
onClick={handleNavigate}
|
|
||||||
disabled={!hasLink}
|
|
||||||
>
|
|
||||||
{documentId}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UploadQueueOverlay;
|
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useMemo, useState, useEffect } from 'react';
|
||||||
|
import type { JSX } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
CloseIcon,
|
||||||
|
LoaderIcon,
|
||||||
|
CheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
WarningIcon,
|
||||||
|
BottombarCollapseIcon,
|
||||||
|
BottombarExpandIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
|
import PanelHeader from '../ui/PanelHeader';
|
||||||
|
|
||||||
|
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error' | (string & {});
|
||||||
|
|
||||||
|
interface UploadQueueItem {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
status: UploadStatus;
|
||||||
|
error?: string | null;
|
||||||
|
document?: { id?: string | number; title?: string };
|
||||||
|
conflictDocumentId?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadQueueOverlayProps {
|
||||||
|
queue?: UploadQueueItem[];
|
||||||
|
onClearQueue?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_META: Record<string, { label: string; tone: string; icon: JSX.Element }> = {
|
||||||
|
pending: {
|
||||||
|
label: 'Queued',
|
||||||
|
tone: 'muted',
|
||||||
|
icon: <LoaderIcon className="icon icon--spin" size={16} />,
|
||||||
|
},
|
||||||
|
uploading: {
|
||||||
|
label: 'Uploading',
|
||||||
|
tone: 'accent',
|
||||||
|
icon: <LoaderIcon className="icon icon--spin" size={16} />,
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
label: 'Uploaded',
|
||||||
|
tone: 'success',
|
||||||
|
icon: <CheckIcon size={16} />,
|
||||||
|
},
|
||||||
|
duplicate: {
|
||||||
|
label: 'Duplicate',
|
||||||
|
tone: 'info',
|
||||||
|
icon: <InfoIcon size={16} />,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
label: 'Failed',
|
||||||
|
tone: 'danger',
|
||||||
|
icon: <WarningIcon size={16} />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProps): JSX.Element | null => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (queue.length > 0) {
|
||||||
|
setDismissed(false);
|
||||||
|
}
|
||||||
|
}, [queue.length]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
if (!queue.length) {
|
||||||
|
return 'No uploads';
|
||||||
|
}
|
||||||
|
const uploadingCount = queue.filter((item) => item.status === 'uploading').length;
|
||||||
|
const pendingCount = queue.filter((item) => item.status === 'pending').length;
|
||||||
|
const errorCount = queue.filter((item) => item.status === 'error').length;
|
||||||
|
if (uploadingCount > 0 || pendingCount > 0) {
|
||||||
|
return `${uploadingCount} uploading · ${pendingCount} queued`;
|
||||||
|
}
|
||||||
|
if (errorCount > 0) {
|
||||||
|
return `${errorCount} failed · ${queue.length} total`;
|
||||||
|
}
|
||||||
|
return `${queue.length} completed`;
|
||||||
|
}, [queue]);
|
||||||
|
|
||||||
|
const hasActiveUploads = queue.some((item) => item.status === 'uploading' || item.status === 'pending');
|
||||||
|
|
||||||
|
const handleDismissOverlay = () => {
|
||||||
|
if (!queue.length) {
|
||||||
|
setDismissed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasActiveUploads) {
|
||||||
|
const confirmed = window.confirm('Uploads are still running. Clear the queue and hide the overlay?');
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClearQueue?.();
|
||||||
|
setDismissed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!queue.length || dismissed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`upload-queue-overlay${collapsed ? ' upload-queue-overlay--collapsed' : ''}`}>
|
||||||
|
<PanelHeader
|
||||||
|
title={(
|
||||||
|
<span>
|
||||||
|
<span>Uploads</span>
|
||||||
|
<span className="panel-header__subtitle">{summary}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
actions={(
|
||||||
|
<div className="upload-queue-overlay__controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
aria-label={collapsed ? 'Expand upload queue' : 'Collapse upload queue'}
|
||||||
|
>
|
||||||
|
{collapsed ? <BottombarExpandIcon size={16} /> : <BottombarCollapseIcon size={16} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={handleDismissOverlay}
|
||||||
|
aria-label="Clear uploads and hide overlay"
|
||||||
|
>
|
||||||
|
<CloseIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!collapsed ? (
|
||||||
|
<div className="upload-queue-overlay__body">
|
||||||
|
<ul className="upload-queue-overlay__list">
|
||||||
|
{[...queue]
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((item) => {
|
||||||
|
const meta = STATUS_META[item.status] || STATUS_META.pending;
|
||||||
|
const fileLabel = item.name;
|
||||||
|
const documentTitle = item.document?.title || null;
|
||||||
|
const duplicateLabel = item.status === 'duplicate' ? documentTitle : null;
|
||||||
|
const documentId = item.document?.id || item.conflictDocumentId || null;
|
||||||
|
const hasLink = Boolean(documentId);
|
||||||
|
const handleNavigate = () => {
|
||||||
|
if (!documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(`/documents/${documentId}`);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<li key={item.id} className={`upload-queue-overlay__item upload-queue-overlay__item--${item.status}`}>
|
||||||
|
<span className={`upload-queue-overlay__status upload-queue-overlay__status--${meta.tone}`}>
|
||||||
|
{meta.icon}
|
||||||
|
</span>
|
||||||
|
<div className="upload-queue-overlay__details">
|
||||||
|
{item.status === 'success' && hasLink ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="upload-queue-overlay__name-link"
|
||||||
|
onClick={handleNavigate}
|
||||||
|
>
|
||||||
|
{fileLabel}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="upload-queue-overlay__name">
|
||||||
|
{fileLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="upload-queue-overlay__meta-line">
|
||||||
|
{item.status === 'duplicate' && duplicateLabel ? (
|
||||||
|
<span className="upload-queue-overlay__meta-duplicate">
|
||||||
|
Duplicate of{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="upload-queue-overlay__meta-link"
|
||||||
|
onClick={handleNavigate}
|
||||||
|
>
|
||||||
|
{duplicateLabel}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : item.status === 'error' && item.error ? (
|
||||||
|
<span className="upload-queue-overlay__meta-error" title={item.error}>
|
||||||
|
{item.error}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>{meta.label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UploadQueueOverlay;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React, { createContext, useContext } from 'react';
|
||||||
|
import type useWorkspaceSelection from './useWorkspaceSelection';
|
||||||
|
|
||||||
|
export type WorkspaceSelectionValue = ReturnType<typeof useWorkspaceSelection>;
|
||||||
|
|
||||||
|
const WorkspaceSelectionContext = createContext<WorkspaceSelectionValue | null>(null);
|
||||||
|
|
||||||
|
interface WorkspaceSelectionProviderProps {
|
||||||
|
value: WorkspaceSelectionValue;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceSelectionProvider: React.FC<WorkspaceSelectionProviderProps> = ({ value, children }) => (
|
||||||
|
<WorkspaceSelectionContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</WorkspaceSelectionContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useWorkspaceSelectionContext = () => {
|
||||||
|
const context = useContext(WorkspaceSelectionContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useWorkspaceSelectionContext must be used within a WorkspaceSelectionProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkspaceSelectionContext;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createAssetView } from '../asset_manager';
|
import { createAssetView, resolveAssetExpiresAt } from '../asset_manager';
|
||||||
|
|
||||||
export const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
export const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
||||||
export const DEFAULT_FOLDER_NAME = 'Documents';
|
export const DEFAULT_FOLDER_NAME = 'Documents';
|
||||||
@@ -16,13 +16,15 @@ export const resolveApiPath = (path = '') => path;
|
|||||||
const makeRowKey = (type, id) =>
|
const makeRowKey = (type, id) =>
|
||||||
id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`;
|
id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`;
|
||||||
|
|
||||||
const getRowType = (key) => (typeof key === 'string' ? key.split(ROW_KEY_SEPARATOR, 1)[0] : '');
|
const normalizeRowKey = (key: string | number | null) => String(key ?? '');
|
||||||
|
|
||||||
|
const getRowType = (key) => normalizeRowKey(key).split(ROW_KEY_SEPARATOR, 1)[0] ?? '';
|
||||||
|
|
||||||
export const getRowId = (key) => {
|
export const getRowId = (key) => {
|
||||||
if (typeof key !== 'string') return '';
|
const normalized = normalizeRowKey(key);
|
||||||
const separatorIndex = key.indexOf(ROW_KEY_SEPARATOR);
|
const separatorIndex = normalized.indexOf(ROW_KEY_SEPARATOR);
|
||||||
if (separatorIndex === -1) return key;
|
if (separatorIndex === -1) return normalized;
|
||||||
return key.slice(separatorIndex + 1);
|
return normalized.slice(separatorIndex + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX;
|
export const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX;
|
||||||
@@ -43,34 +45,17 @@ const isAssetEquivalent = (lhs, rhs) => {
|
|||||||
const rhsView = createAssetView(rhs);
|
const rhsView = createAssetView(rhs);
|
||||||
const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata;
|
const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata;
|
||||||
const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata;
|
const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata;
|
||||||
const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null;
|
const lhsExpiresAt = resolveAssetExpiresAt(lhs);
|
||||||
const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null;
|
const rhsExpiresAt = resolveAssetExpiresAt(rhs);
|
||||||
const lhsObjects = lhsView.getObjects();
|
|
||||||
const rhsObjects = rhsView.getObjects();
|
|
||||||
const objectsComparable =
|
|
||||||
lhsObjects.length === rhsObjects.length
|
|
||||||
&& lhsObjects.every((entry, index) => {
|
|
||||||
const other = rhsObjects[index];
|
|
||||||
if (!other) return false;
|
|
||||||
if (entry.ordinal !== other.ordinal) return false;
|
|
||||||
if (entry.url && other.url && entry.url === other.url) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!entry.url && !other.url) {
|
|
||||||
return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null);
|
|
||||||
}
|
|
||||||
return entry.url === other.url;
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
lhs.id === rhs.id
|
lhs.id === rhs.id
|
||||||
&& lhs.url === rhs.url
|
&& lhs.url === rhs.url
|
||||||
|
&& lhsExpiresAt === rhsExpiresAt
|
||||||
&& lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width
|
&& lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width
|
||||||
&& lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height
|
&& lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height
|
||||||
&& lhs.mime_type === rhs.mime_type
|
&& lhs.mime_type === rhs.mime_type
|
||||||
&& lhs.asset_type === rhs.asset_type
|
&& lhs.asset_type === rhs.asset_type
|
||||||
&& lhs.updated_at === rhs.updated_at
|
&& lhs.updated_at === rhs.updated_at
|
||||||
&& lhsCardinality === rhsCardinality
|
|
||||||
&& objectsComparable
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,10 +1,52 @@
|
|||||||
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
|
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
|
||||||
import api from '../lib/api';
|
import api from '../lib/api';
|
||||||
|
|
||||||
const storage = typeof window !== 'undefined' ? window.sessionStorage : undefined;
|
type Tenant = Record<string, unknown> | null;
|
||||||
|
|
||||||
|
interface TenantSelection {
|
||||||
|
selectionToken: string;
|
||||||
|
tenants: Tenant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppStatus =
|
||||||
|
| 'logged-out'
|
||||||
|
| 'authenticating'
|
||||||
|
| 'authenticated'
|
||||||
|
| 'selecting-tenant'
|
||||||
|
| 'bootstrapping'
|
||||||
|
| 'ready';
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
status: AppStatus;
|
||||||
|
token: string;
|
||||||
|
error: string | null;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
tenantSelection: TenantSelection | null;
|
||||||
|
tenant: Tenant;
|
||||||
|
tenants: Tenant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppAction =
|
||||||
|
| { type: 'LOGIN_REQUEST' }
|
||||||
|
| { type: 'LOGIN_SUCCESS'; token: string; tenant?: Tenant }
|
||||||
|
| { type: 'LOGIN_FAILURE'; error?: string | null }
|
||||||
|
| { type: 'TENANT_SELECTION_REQUIRED'; selectionToken: string; tenants: Tenant[] }
|
||||||
|
| { type: 'CLEAR_TENANT_SELECTION' }
|
||||||
|
| { type: 'LOGOUT_SUCCESS' }
|
||||||
|
| { type: 'BOOTSTRAP_START' }
|
||||||
|
| { type: 'BOOTSTRAP_SUCCESS' }
|
||||||
|
| { type: 'BOOTSTRAP_FAILURE'; error?: string | null }
|
||||||
|
| { type: 'TOKEN_REFRESH_START' }
|
||||||
|
| { type: 'TOKEN_REFRESH_SUCCESS'; token: string; tenant?: Tenant }
|
||||||
|
| { type: 'TOKEN_REFRESH_FAILURE'; error?: string | null }
|
||||||
|
| { type: 'LOGOUT' }
|
||||||
|
| { type: 'RESET_ERROR' }
|
||||||
|
| { type: 'SET_TENANTS'; tenants: Tenant[] };
|
||||||
|
|
||||||
|
const storage = window.sessionStorage;
|
||||||
|
|
||||||
const STORED_TOKEN = storage?.getItem('papercrate_token') ?? '';
|
const STORED_TOKEN = storage?.getItem('papercrate_token') ?? '';
|
||||||
let STORED_TENANT = null;
|
let STORED_TENANT: Tenant = null;
|
||||||
|
|
||||||
if (storage) {
|
if (storage) {
|
||||||
try {
|
try {
|
||||||
@@ -21,7 +63,7 @@ if (STORED_TOKEN) {
|
|||||||
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
|
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialAppState = {
|
const initialAppState: AppState = {
|
||||||
status: STORED_TOKEN ? 'authenticated' : 'logged-out',
|
status: STORED_TOKEN ? 'authenticated' : 'logged-out',
|
||||||
token: STORED_TOKEN,
|
token: STORED_TOKEN,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -31,10 +73,10 @@ const initialAppState = {
|
|||||||
tenants: [],
|
tenants: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppStateContext = React.createContext(null);
|
const AppStateContext = React.createContext<AppState | null>(null);
|
||||||
const AppDispatchContext = React.createContext(null);
|
const AppDispatchContext = React.createContext<React.Dispatch<AppAction> | null>(null);
|
||||||
|
|
||||||
const appStateReducer = (state, action) => {
|
const appStateReducer = (state: AppState, action: AppAction): AppState => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'LOGIN_REQUEST':
|
case 'LOGIN_REQUEST':
|
||||||
return {
|
return {
|
||||||
@@ -52,14 +94,14 @@ const appStateReducer = (state, action) => {
|
|||||||
token: action.token,
|
token: action.token,
|
||||||
error: null,
|
error: null,
|
||||||
tenantSelection: null,
|
tenantSelection: null,
|
||||||
tenant: action.tenant || null,
|
tenant: action.tenant ?? null,
|
||||||
tenants: state.tenants,
|
tenants: state.tenants,
|
||||||
};
|
};
|
||||||
case 'LOGIN_FAILURE':
|
case 'LOGIN_FAILURE':
|
||||||
return {
|
return {
|
||||||
status: 'logged-out',
|
status: 'logged-out',
|
||||||
token: '',
|
token: '',
|
||||||
error: action.error || null,
|
error: action.error ?? null,
|
||||||
isRefreshing: false,
|
isRefreshing: false,
|
||||||
tenantSelection: null,
|
tenantSelection: null,
|
||||||
tenant: null,
|
tenant: null,
|
||||||
@@ -89,6 +131,7 @@ const appStateReducer = (state, action) => {
|
|||||||
tenants: [],
|
tenants: [],
|
||||||
};
|
};
|
||||||
case 'LOGOUT_SUCCESS':
|
case 'LOGOUT_SUCCESS':
|
||||||
|
case 'LOGOUT':
|
||||||
return {
|
return {
|
||||||
status: 'logged-out',
|
status: 'logged-out',
|
||||||
token: '',
|
token: '',
|
||||||
@@ -103,7 +146,7 @@ const appStateReducer = (state, action) => {
|
|||||||
case 'BOOTSTRAP_SUCCESS':
|
case 'BOOTSTRAP_SUCCESS':
|
||||||
return { ...state, status: 'ready', error: null };
|
return { ...state, status: 'ready', error: null };
|
||||||
case 'BOOTSTRAP_FAILURE':
|
case 'BOOTSTRAP_FAILURE':
|
||||||
return { ...state, status: 'authenticated', error: action.error || null };
|
return { ...state, status: 'authenticated', error: action.error ?? null };
|
||||||
case 'TOKEN_REFRESH_START':
|
case 'TOKEN_REFRESH_START':
|
||||||
return { ...state, isRefreshing: true, error: null };
|
return { ...state, isRefreshing: true, error: null };
|
||||||
case 'TOKEN_REFRESH_SUCCESS':
|
case 'TOKEN_REFRESH_SUCCESS':
|
||||||
@@ -113,24 +156,14 @@ const appStateReducer = (state, action) => {
|
|||||||
isRefreshing: false,
|
isRefreshing: false,
|
||||||
status: state.status === 'logged-out' ? 'authenticated' : state.status,
|
status: state.status === 'logged-out' ? 'authenticated' : state.status,
|
||||||
tenantSelection: null,
|
tenantSelection: null,
|
||||||
tenant: action.tenant || state.tenant || null,
|
tenant: action.tenant ?? state.tenant ?? null,
|
||||||
tenants: state.tenants,
|
tenants: state.tenants,
|
||||||
};
|
};
|
||||||
case 'TOKEN_REFRESH_FAILURE':
|
case 'TOKEN_REFRESH_FAILURE':
|
||||||
return {
|
return {
|
||||||
status: 'logged-out',
|
status: 'logged-out',
|
||||||
token: '',
|
token: '',
|
||||||
error: action.error || null,
|
error: action.error ?? null,
|
||||||
isRefreshing: false,
|
|
||||||
tenantSelection: null,
|
|
||||||
tenant: null,
|
|
||||||
tenants: [],
|
|
||||||
};
|
|
||||||
case 'LOGOUT':
|
|
||||||
return {
|
|
||||||
status: 'logged-out',
|
|
||||||
token: '',
|
|
||||||
error: null,
|
|
||||||
isRefreshing: false,
|
isRefreshing: false,
|
||||||
tenantSelection: null,
|
tenantSelection: null,
|
||||||
tenant: null,
|
tenant: null,
|
||||||
@@ -148,7 +181,7 @@ const appStateReducer = (state, action) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppStateProvider = ({ children }) => {
|
const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||||
const [state, dispatch] = useReducer(appStateReducer, initialAppState);
|
const [state, dispatch] = useReducer(appStateReducer, initialAppState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -216,7 +249,7 @@ const AppStateProvider = ({ children }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useAppState = () => {
|
const useAppState = (): AppState => {
|
||||||
const context = useContext(AppStateContext);
|
const context = useContext(AppStateContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useAppState must be used within an AppStateProvider.');
|
throw new Error('useAppState must be used within an AppStateProvider.');
|
||||||
@@ -224,7 +257,7 @@ const useAppState = () => {
|
|||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useAppDispatch = () => {
|
const useAppDispatch = (): React.Dispatch<AppAction> => {
|
||||||
const context = useContext(AppDispatchContext);
|
const context = useContext(AppDispatchContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useAppDispatch must be used within an AppStateProvider.');
|
throw new Error('useAppDispatch must be used within an AppStateProvider.');
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export interface DetailDocument {
|
||||||
|
id?: string | number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDetailPanelOptions {
|
||||||
|
documentLookup: Map<string | number, DetailDocument>;
|
||||||
|
orderedSelectedDocuments: DetailDocument[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenDetailPanelArgs {
|
||||||
|
documentId?: string | number;
|
||||||
|
document?: DetailDocument | null;
|
||||||
|
documentIds?: Array<string | number>;
|
||||||
|
documents?: DetailDocument[];
|
||||||
|
}
|
||||||
|
|
||||||
export const useDetailPanel = ({
|
export const useDetailPanel = ({
|
||||||
documentLookup,
|
documentLookup,
|
||||||
orderedSelectedDocuments,
|
orderedSelectedDocuments,
|
||||||
}) => {
|
}: UseDetailPanelOptions) => {
|
||||||
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
|
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
|
||||||
const [detailPanelDocId, setDetailPanelDocId] = useState(null);
|
const [detailPanelDocId, setDetailPanelDocId] = useState<string | number | null>(null);
|
||||||
const [detailPanelDocument, setDetailPanelDocument] = useState(null);
|
const [detailPanelDocument, setDetailPanelDocument] = useState<DetailDocument | null>(null);
|
||||||
const latestOrderedDocsRef = useRef([]);
|
const latestOrderedDocsRef = useRef<DetailDocument[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latestOrderedDocsRef.current = orderedSelectedDocuments;
|
latestOrderedDocsRef.current = orderedSelectedDocuments;
|
||||||
@@ -34,7 +51,7 @@ export const useDetailPanel = ({
|
|||||||
}, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]);
|
}, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]);
|
||||||
|
|
||||||
const openDetailPanel = useCallback(
|
const openDetailPanel = useCallback(
|
||||||
({ documentId, document, documentIds, documents } = {}) => {
|
({ documentId, document, documentIds, documents }: OpenDetailPanelArgs = {}) => {
|
||||||
let targetDoc = document || null;
|
let targetDoc = document || null;
|
||||||
let targetId = documentId ?? document?.id ?? null;
|
let targetId = documentId ?? document?.id ?? null;
|
||||||
|
|
||||||
@@ -66,7 +83,7 @@ export const useDetailPanel = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDetailPanelDocId(targetDoc?.id || targetId || null);
|
setDetailPanelDocId(targetDoc?.id ?? targetId ?? null);
|
||||||
setDetailPanelDocument(targetDoc || null);
|
setDetailPanelDocument(targetDoc || null);
|
||||||
setDetailPanelOpen(Boolean(targetDoc || targetId));
|
setDetailPanelOpen(Boolean(targetDoc || targetId));
|
||||||
},
|
},
|
||||||
@@ -1,11 +1,71 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type {
|
||||||
|
Dispatch,
|
||||||
|
MutableRefObject,
|
||||||
|
SetStateAction,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
type DocumentId = string | number;
|
||||||
|
type FolderId = DocumentId | 'root';
|
||||||
|
|
||||||
|
type DocumentLike = {
|
||||||
|
id?: DocumentId;
|
||||||
|
folder_id?: FolderId | null;
|
||||||
|
filename?: string | null;
|
||||||
|
current_version?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentLink = {
|
||||||
|
url?: string;
|
||||||
|
contentType?: string | null;
|
||||||
|
filename?: string | null;
|
||||||
|
expiresAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ApiClient {
|
||||||
|
get: <T = unknown>(path: string) => Promise<{ data: T }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavigateHandler = (path: string, options?: { replace?: boolean }) => void;
|
||||||
|
|
||||||
|
interface UseDocumentPreviewArgs {
|
||||||
|
routeDocumentId?: DocumentId | null;
|
||||||
|
documentsManager: {
|
||||||
|
getById: (id: DocumentId) => DocumentLike | null;
|
||||||
|
ensure: (id: DocumentId) => Promise<DocumentLike | null>;
|
||||||
|
getMany: (ids: DocumentId[]) => DocumentLike[];
|
||||||
|
subscribe: (listener: () => void) => () => void;
|
||||||
|
ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean };
|
||||||
|
};
|
||||||
|
selectedFolder?: FolderId | null;
|
||||||
|
api: ApiClient;
|
||||||
|
resolveApiPath?: (path: string) => string;
|
||||||
|
notifyApiError: (error: unknown, message: string) => void;
|
||||||
|
navigate: NavigateHandler;
|
||||||
|
locationPathname: string;
|
||||||
|
locationSearch: string;
|
||||||
|
detailPanelControlRef: MutableRefObject<{
|
||||||
|
open?: (args?: { documentIds?: DocumentId[] }) => void;
|
||||||
|
close?: () => void;
|
||||||
|
} | null>;
|
||||||
|
setActivePreviewId: Dispatch<SetStateAction<DocumentId | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDocumentPreviewResult {
|
||||||
|
documentLinks: Map<DocumentId, DocumentLink>;
|
||||||
|
ensureDownloadUrl: (documentId: DocumentId, options?: { force?: boolean }) => Promise<DocumentLink | null>;
|
||||||
|
ensurePreviewData: (documentId: DocumentId) => Promise<DocumentLike | null>;
|
||||||
|
openDocumentPreview: (documentId: DocumentId, options?: { replace?: boolean }) => void;
|
||||||
|
closeDocumentPreview: (folderId?: FolderId) => void;
|
||||||
|
resetPreviewState: () => void;
|
||||||
|
removeDocumentLinks: (ids: DocumentId[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const useDocumentPreview = ({
|
const useDocumentPreview = ({
|
||||||
routeDocumentId,
|
routeDocumentId,
|
||||||
documents,
|
documentsManager,
|
||||||
searchResults,
|
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
assetManager,
|
|
||||||
api,
|
api,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
@@ -14,23 +74,22 @@ const useDocumentPreview = ({
|
|||||||
locationSearch,
|
locationSearch,
|
||||||
detailPanelControlRef,
|
detailPanelControlRef,
|
||||||
setActivePreviewId,
|
setActivePreviewId,
|
||||||
}) => {
|
}: UseDocumentPreviewArgs): UseDocumentPreviewResult => {
|
||||||
const [previewEntries, setPreviewEntries] = useState(() => new Map());
|
const [documentLinks, setDocumentLinks] = useState<Map<DocumentId, DocumentLink>>(() => new Map());
|
||||||
const [previewDocuments, setPreviewDocuments] = useState(() => new Map());
|
const previewInflightRef = useRef<Map<DocumentId, Promise<DocumentLink | null>>>(new Map());
|
||||||
const previewInflightRef = useRef(new Map());
|
const previewReturnPathRef = useRef<string | null>(null);
|
||||||
const previewReturnPathRef = useRef(null);
|
|
||||||
|
|
||||||
const resetPreviewState = useCallback(() => {
|
const resetPreviewState = useCallback(() => {
|
||||||
setPreviewEntries(() => new Map());
|
setDocumentLinks(() => new Map());
|
||||||
previewInflightRef.current = new Map();
|
previewInflightRef.current = new Map();
|
||||||
previewReturnPathRef.current = null;
|
previewReturnPathRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const removePreviewEntries = useCallback((ids) => {
|
const removeDocumentLinks = useCallback((ids: DocumentId[]) => {
|
||||||
if (!Array.isArray(ids) || ids.length === 0) {
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPreviewEntries((prev) => {
|
setDocumentLinks((prev) => {
|
||||||
if (!prev.size) {
|
if (!prev.size) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
@@ -46,66 +105,37 @@ const useDocumentPreview = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cachePreviewDocument = useCallback((doc) => {
|
const ensureDownloadUrl = useCallback(
|
||||||
if (!doc?.id) {
|
async (documentId: DocumentId, { force = false }: { force?: boolean } = {}): Promise<DocumentLink | null> => {
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPreviewDocuments((prev) => {
|
|
||||||
const existing = prev.get(doc.id);
|
|
||||||
if (existing === doc) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(doc.id, doc);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeCachedPreviewDocument = useCallback((documentId) => {
|
|
||||||
if (!documentId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPreviewDocuments((prev) => {
|
|
||||||
if (!prev.has(documentId)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(documentId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const ensurePreviewUrl = useCallback(
|
|
||||||
async (documentId, { force = false } = {}) => {
|
|
||||||
if (!documentId) return null;
|
if (!documentId) return null;
|
||||||
|
|
||||||
const existing = previewEntries.get(documentId) || null;
|
const existing = documentLinks.get(documentId) || null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const expiresAt = typeof existing?.expiresAt === 'number' ? existing.expiresAt : null;
|
const expiresAt = Number.isFinite(existing?.expiresAt) ? Number(existing?.expiresAt) : null;
|
||||||
if (!force && existing && (!expiresAt || expiresAt > now)) {
|
if (!force && existing && (!expiresAt || expiresAt > now)) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!force && previewInflightRef.current.has(documentId)) {
|
if (!force && previewInflightRef.current.has(documentId)) {
|
||||||
return previewInflightRef.current.get(documentId);
|
return previewInflightRef.current.get(documentId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = (async () => {
|
const request: Promise<DocumentLink | null> = (async () => {
|
||||||
try {
|
try {
|
||||||
const docResponse = await api.get(`/documents/${documentId}`);
|
const docResponse = await api.get<{ document?: Record<string, any> }>(`/documents/${documentId}`);
|
||||||
const downloadPath = docResponse.data?.document?.current_version?.download_path;
|
const downloadPath = docResponse.data?.document?.current_version?.download_path;
|
||||||
if (!downloadPath || !resolveApiPath) {
|
if (!downloadPath || !resolveApiPath) {
|
||||||
throw new Error('Document missing download path');
|
throw new Error('Document missing download path');
|
||||||
}
|
}
|
||||||
|
|
||||||
const href = resolveApiPath(downloadPath);
|
const href = resolveApiPath(downloadPath);
|
||||||
const entry = {
|
const entry: DocumentLink = {
|
||||||
url: href,
|
url: href,
|
||||||
contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
|
contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
|
||||||
filename: docResponse.data?.document?.filename,
|
filename: docResponse.data?.document?.filename,
|
||||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||||
};
|
};
|
||||||
setPreviewEntries((prev) => {
|
setDocumentLinks((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(documentId, entry);
|
next.set(documentId, entry);
|
||||||
return next;
|
return next;
|
||||||
@@ -122,38 +152,28 @@ const useDocumentPreview = ({
|
|||||||
previewInflightRef.current.set(documentId, request);
|
previewInflightRef.current.set(documentId, request);
|
||||||
return request;
|
return request;
|
||||||
},
|
},
|
||||||
[previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries],
|
[documentLinks, api, resolveApiPath, notifyApiError],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ensurePreviewData = useCallback(
|
const ensurePreviewData = useCallback(
|
||||||
async (documentId) => {
|
async (documentId: DocumentId): Promise<DocumentLike | null> => {
|
||||||
if (!documentId) return null;
|
if (!documentId) return null;
|
||||||
|
|
||||||
const findInCache = () => {
|
const findInCache = () => documentsManager.getById(documentId);
|
||||||
const pool = searchResults ?? documents;
|
|
||||||
return pool.find((item) => item.id === documentId) || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
let doc = findInCache();
|
let doc = findInCache();
|
||||||
|
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
const { data } = await api.get(`/documents/${documentId}`);
|
doc = await documentsManager.ensure(documentId);
|
||||||
const hydratedDetail = assetManager.hydrateDetail(data);
|
|
||||||
const fetched = hydratedDetail?.document || data.document || data;
|
|
||||||
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
|
|
||||||
if (!doc) {
|
|
||||||
throw new Error('Document metadata unavailable.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existsInDocuments = documents.some((item) => item.id === doc.id);
|
if (!doc) {
|
||||||
const existsInSearch = Array.isArray(searchResults)
|
const { data } = await api.get(`/documents/${documentId}`);
|
||||||
? searchResults.some((item) => item.id === doc.id)
|
const fetched = (data as { document?: DocumentLike })?.document || data;
|
||||||
: false;
|
const { canonical } = documentsManager.ingest([fetched as unknown]);
|
||||||
|
doc = (canonical[0] as DocumentLike | undefined) || null;
|
||||||
if (existsInDocuments || existsInSearch) {
|
if (!doc) {
|
||||||
removeCachedPreviewDocument(doc.id);
|
throw new Error('Document metadata unavailable.');
|
||||||
} else {
|
|
||||||
cachePreviewDocument(doc);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,26 +183,22 @@ const useDocumentPreview = ({
|
|||||||
fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`;
|
fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensurePreviewUrl(documentId, { force: false });
|
await ensureDownloadUrl(documentId, { force: false });
|
||||||
setActivePreviewId(documentId);
|
setActivePreviewId(documentId);
|
||||||
return doc;
|
return doc;
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
searchResults,
|
documentsManager,
|
||||||
documents,
|
ensureDownloadUrl,
|
||||||
assetManager,
|
|
||||||
ensurePreviewUrl,
|
|
||||||
setActivePreviewId,
|
setActivePreviewId,
|
||||||
api,
|
api,
|
||||||
cachePreviewDocument,
|
|
||||||
removeCachedPreviewDocument,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openDocumentPreview = useCallback(
|
const openDocumentPreview = useCallback(
|
||||||
(documentId, { replace = false } = {}) => {
|
(documentId: DocumentId, { replace = false }: { replace?: boolean } = {}) => {
|
||||||
if (!documentId) return;
|
if (!documentId) return;
|
||||||
detailPanelControlRef.current.close();
|
detailPanelControlRef.current?.close?.();
|
||||||
previewReturnPathRef.current = `${locationPathname}${locationSearch}`;
|
previewReturnPathRef.current = `${locationPathname}${locationSearch}`;
|
||||||
navigate(`/documents/${documentId}`, { replace });
|
navigate(`/documents/${documentId}`, { replace });
|
||||||
},
|
},
|
||||||
@@ -190,7 +206,7 @@ const useDocumentPreview = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const closeDocumentPreview = useCallback(
|
const closeDocumentPreview = useCallback(
|
||||||
(folderId = null) => {
|
(folderId?: FolderId) => {
|
||||||
const fallbackPath = previewReturnPathRef.current;
|
const fallbackPath = previewReturnPathRef.current;
|
||||||
previewReturnPathRef.current = null;
|
previewReturnPathRef.current = null;
|
||||||
|
|
||||||
@@ -206,21 +222,6 @@ const useDocumentPreview = ({
|
|||||||
[navigate, selectedFolder],
|
[navigate, selectedFolder],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!routeDocumentId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (event) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
closeDocumentPreview();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [routeDocumentId, closeDocumentPreview]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!routeDocumentId) {
|
if (!routeDocumentId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -241,39 +242,14 @@ const useDocumentPreview = ({
|
|||||||
};
|
};
|
||||||
}, [routeDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]);
|
}, [routeDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPreviewDocuments((prev) => {
|
|
||||||
if (!prev.size) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next = new Map(prev);
|
|
||||||
let changed = false;
|
|
||||||
const prune = (list) => {
|
|
||||||
if (!Array.isArray(list)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
list.forEach((doc) => {
|
|
||||||
if (doc?.id && next.has(doc.id)) {
|
|
||||||
next.delete(doc.id);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
prune(documents);
|
|
||||||
prune(searchResults);
|
|
||||||
return changed ? next : prev;
|
|
||||||
});
|
|
||||||
}, [documents, searchResults]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
previewEntries,
|
documentLinks,
|
||||||
previewDocuments,
|
ensureDownloadUrl,
|
||||||
ensurePreviewUrl,
|
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
resetPreviewState,
|
resetPreviewState,
|
||||||
removePreviewEntries,
|
removeDocumentLinks,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
+52
-22
@@ -1,6 +1,30 @@
|
|||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
const DEFAULT_INITIAL_ENTRIES = [];
|
type RowKey = string;
|
||||||
|
type DocumentId = string | number;
|
||||||
|
|
||||||
|
interface SelectionEventLike {
|
||||||
|
shiftKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
preventDefault?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDocumentSelectionOptions {
|
||||||
|
resolveDocumentRowKey: (id: DocumentId | null) => RowKey | null;
|
||||||
|
resolveFolderRowKey: (id: DocumentId | null) => RowKey | null;
|
||||||
|
isDocumentRowKey: (key?: RowKey | null) => boolean;
|
||||||
|
isFolderRowKey: (key?: RowKey | null) => boolean;
|
||||||
|
getRowId: (key?: RowKey | null) => DocumentId | null;
|
||||||
|
initialEntries?: RowKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApplySelectionOptions {
|
||||||
|
anchor?: RowKey | null;
|
||||||
|
interactedKeys?: RowKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_INITIAL_ENTRIES: RowKey[] = [];
|
||||||
|
|
||||||
export const useDocumentSelection = ({
|
export const useDocumentSelection = ({
|
||||||
resolveDocumentRowKey,
|
resolveDocumentRowKey,
|
||||||
@@ -9,21 +33,24 @@ export const useDocumentSelection = ({
|
|||||||
isFolderRowKey,
|
isFolderRowKey,
|
||||||
getRowId,
|
getRowId,
|
||||||
initialEntries = DEFAULT_INITIAL_ENTRIES,
|
initialEntries = DEFAULT_INITIAL_ENTRIES,
|
||||||
}) => {
|
}: UseDocumentSelectionOptions) => {
|
||||||
const [selectedEntries, setSelectedEntries] = useState(initialEntries);
|
const [selectedEntries, setSelectedEntries] = useState<RowKey[]>(initialEntries);
|
||||||
const [selectionOrder, setSelectionOrder] = useState(initialEntries);
|
const [selectionOrder, setSelectionOrder] = useState<RowKey[]>(initialEntries);
|
||||||
const selectionOrderRef = useRef(initialEntries);
|
const selectionOrderRef = useRef<RowKey[]>(initialEntries);
|
||||||
const selectionAnchorRef = useRef(null);
|
const selectionAnchorRef = useRef<RowKey | null>(null);
|
||||||
const selectionInitializedRef = useRef(false);
|
const selectionInitializedRef = useRef(false);
|
||||||
const [focusedDocumentId, setFocusedDocumentId] = useState(null);
|
const [focusedDocumentId, setFocusedDocumentId] = useState<DocumentId | null>(null);
|
||||||
const [focusedRowKey, setFocusedRowKey] = useState(null);
|
const [focusedRowKey, setFocusedRowKey] = useState<RowKey | null>(null);
|
||||||
|
|
||||||
const visibleRowKeySetRef = useRef(new Set());
|
const visibleRowKeySetRef = useRef<Set<RowKey>>(new Set());
|
||||||
const navigableRowKeysRef = useRef([]);
|
const navigableRowKeysRef = useRef<RowKey[]>([]);
|
||||||
|
|
||||||
const configureSelectionEnvironment = useCallback(({
|
const configureSelectionEnvironment = useCallback(({
|
||||||
visibleRowKeySet,
|
visibleRowKeySet,
|
||||||
navigableRowKeys,
|
navigableRowKeys,
|
||||||
|
}: {
|
||||||
|
visibleRowKeySet?: Set<RowKey>;
|
||||||
|
navigableRowKeys?: RowKey[];
|
||||||
}) => {
|
}) => {
|
||||||
if (visibleRowKeySet) {
|
if (visibleRowKeySet) {
|
||||||
visibleRowKeySetRef.current = visibleRowKeySet;
|
visibleRowKeySetRef.current = visibleRowKeySet;
|
||||||
@@ -33,7 +60,7 @@ export const useDocumentSelection = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => {
|
const updateSelectionOrder = useCallback((nextSelection: RowKey[], interactedKeys: RowKey[] = []) => {
|
||||||
const nextSet = new Set(nextSelection);
|
const nextSet = new Set(nextSelection);
|
||||||
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
|
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
|
||||||
const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index);
|
const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index);
|
||||||
@@ -65,13 +92,16 @@ export const useDocumentSelection = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const applySelection = useCallback(
|
const applySelection = useCallback(
|
||||||
(rowKeys, { anchor, interactedKeys = [] } = {}) => {
|
(
|
||||||
|
rowKeys: Array<RowKey | null>,
|
||||||
|
{ anchor, interactedKeys = [] }: ApplySelectionOptions = {},
|
||||||
|
) => {
|
||||||
const visibleRowKeySet = visibleRowKeySetRef.current;
|
const visibleRowKeySet = visibleRowKeySetRef.current;
|
||||||
const unique = [];
|
const unique: RowKey[] = [];
|
||||||
|
|
||||||
(rowKeys || []).forEach((key) => {
|
(rowKeys || []).forEach((key) => {
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
let canonicalKey = null;
|
let canonicalKey: RowKey | null = null;
|
||||||
if (visibleRowKeySet.has(key)) {
|
if (visibleRowKeySet.has(key)) {
|
||||||
canonicalKey = key;
|
canonicalKey = key;
|
||||||
} else if (isDocumentRowKey(key)) {
|
} else if (isDocumentRowKey(key)) {
|
||||||
@@ -99,7 +129,7 @@ export const useDocumentSelection = ({
|
|||||||
setSelectedEntries(unique);
|
setSelectedEntries(unique);
|
||||||
updateSelectionOrder(unique, interactedKeys);
|
updateSelectionOrder(unique, interactedKeys);
|
||||||
|
|
||||||
const nextFocusedDocumentId = (() => {
|
const nextFocusedDocumentId: DocumentId | null = (() => {
|
||||||
if (focusedDocumentId) {
|
if (focusedDocumentId) {
|
||||||
const focusKey = resolveDocumentRowKey(focusedDocumentId);
|
const focusKey = resolveDocumentRowKey(focusedDocumentId);
|
||||||
if (focusKey && unique.includes(focusKey)) {
|
if (focusKey && unique.includes(focusKey)) {
|
||||||
@@ -108,11 +138,11 @@ export const useDocumentSelection = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) {
|
if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) {
|
||||||
return getRowId(resolvedAnchor) || null;
|
return getRowId(resolvedAnchor) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastDocKey = [...unique].reverse().find(isDocumentRowKey);
|
const lastDocKey = [...unique].reverse().find((key) => isDocumentRowKey(key)) ?? null;
|
||||||
return lastDocKey ? getRowId(lastDocKey) || null : null;
|
return lastDocKey ? getRowId(lastDocKey) ?? null : null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
setFocusedDocumentId(nextFocusedDocumentId);
|
setFocusedDocumentId(nextFocusedDocumentId);
|
||||||
@@ -144,7 +174,7 @@ export const useDocumentSelection = ({
|
|||||||
}, [applySelection]);
|
}, [applySelection]);
|
||||||
|
|
||||||
const handleEntrySelection = useCallback(
|
const handleEntrySelection = useCallback(
|
||||||
(rowKey, event) => {
|
(rowKey: RowKey | null, event?: SelectionEventLike) => {
|
||||||
const visibleRowKeySet = visibleRowKeySetRef.current;
|
const visibleRowKeySet = visibleRowKeySetRef.current;
|
||||||
const navigableRowKeys = navigableRowKeysRef.current;
|
const navigableRowKeys = navigableRowKeysRef.current;
|
||||||
if (!rowKey || !visibleRowKeySet.has(rowKey)) {
|
if (!rowKey || !visibleRowKeySet.has(rowKey)) {
|
||||||
@@ -170,8 +200,8 @@ export const useDocumentSelection = ({
|
|||||||
anchorKey = rowKey;
|
anchorKey = rowKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextKeys = [];
|
let nextKeys: RowKey[] = [];
|
||||||
let interactedKeys = [];
|
let interactedKeys: RowKey[] = [];
|
||||||
|
|
||||||
if (shiftKey && anchorKey) {
|
if (shiftKey && anchorKey) {
|
||||||
const anchorIndex = navigableRowKeys.indexOf(anchorKey);
|
const anchorIndex = navigableRowKeys.indexOf(anchorKey);
|
||||||
@@ -213,7 +243,7 @@ export const useDocumentSelection = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const promoteSelectionOrder = useCallback(
|
const promoteSelectionOrder = useCallback(
|
||||||
(docId) => {
|
(docId?: DocumentId | null) => {
|
||||||
if (!docId) return;
|
if (!docId) return;
|
||||||
const rowKey = resolveDocumentRowKey(docId);
|
const rowKey = resolveDocumentRowKey(docId);
|
||||||
if (!rowKey) return;
|
if (!rowKey) return;
|
||||||
+6
-12
@@ -10,10 +10,7 @@ const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field';
|
|||||||
const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction';
|
const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction';
|
||||||
const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants';
|
const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants';
|
||||||
|
|
||||||
const readSessionStorage = (key) => {
|
const readSessionStorage = (key: string): string | null => {
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return window.sessionStorage.getItem(key);
|
return window.sessionStorage.getItem(key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -22,10 +19,7 @@ const readSessionStorage = (key) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeSessionStorage = (key, value) => {
|
const writeSessionStorage = (key: string, value: string): void => {
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
window.sessionStorage.setItem(key, value);
|
window.sessionStorage.setItem(key, value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -34,7 +28,7 @@ const writeSessionStorage = (key, value) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useDocumentsPreferences = () => {
|
export const useDocumentsPreferences = () => {
|
||||||
const [documentsViewMode, setDocumentsViewModeState] = useState(() => {
|
const [documentsViewMode, setDocumentsViewModeState] = useState<'list' | 'grid' | 'desk'>(() => {
|
||||||
const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY);
|
const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY);
|
||||||
if (stored === 'grid' || stored === 'desk') {
|
if (stored === 'grid' || stored === 'desk') {
|
||||||
return stored;
|
return stored;
|
||||||
@@ -42,9 +36,9 @@ export const useDocumentsPreferences = () => {
|
|||||||
return 'list';
|
return 'list';
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
|
const lastNonDeskViewRef = useRef<'list' | 'grid'>((documentsViewMode === 'desk' ? 'list' : documentsViewMode) as 'list' | 'grid');
|
||||||
|
|
||||||
const setDocumentsViewMode = useCallback((mode) => {
|
const setDocumentsViewMode = useCallback((mode: string) => {
|
||||||
const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
|
const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
|
||||||
setDocumentsViewModeState((previous) => {
|
setDocumentsViewModeState((previous) => {
|
||||||
if (next !== previous) {
|
if (next !== previous) {
|
||||||
@@ -81,7 +75,7 @@ export const useDocumentsPreferences = () => {
|
|||||||
writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection);
|
writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection);
|
||||||
}, [documentsSortDirection]);
|
}, [documentsSortDirection]);
|
||||||
|
|
||||||
const handleDocumentsSortFieldChange = useCallback((field) => {
|
const handleDocumentsSortFieldChange = useCallback((field: string) => {
|
||||||
const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
|
const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
|
||||||
setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
|
setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1,33 +1,91 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
TAG_FILTER_UNTAGGED,
|
import { TAG_FILTER_UNTAGGED } from './appLayoutUtils';
|
||||||
resolveDocumentRowKey,
|
|
||||||
isDocumentRowKey,
|
type Identifier = string | number;
|
||||||
} from './appLayoutUtils';
|
|
||||||
|
type DocumentLike = { id?: Identifier } & Record<string, unknown>;
|
||||||
|
|
||||||
|
type ApiClient = {
|
||||||
|
get: <T = unknown>(url: string, config?: { params?: Record<string, unknown> }) => Promise<{ data: T }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseDocumentsSearchArgs {
|
||||||
|
api: ApiClient;
|
||||||
|
token?: string | null;
|
||||||
|
selectedFolder?: Identifier | 'root' | null;
|
||||||
|
navigate?: (path: string, options?: { replace?: boolean }) => void;
|
||||||
|
locationPathname?: string;
|
||||||
|
isDocumentsRoute?: boolean;
|
||||||
|
searchIncludeDescendants?: boolean;
|
||||||
|
documentsSortField?: string;
|
||||||
|
documentsSortDirection?: string;
|
||||||
|
notifyApiError: (error: unknown, message: string) => void;
|
||||||
|
setLoading: (state: boolean) => void;
|
||||||
|
setSearchIncludeDescendants: (value: boolean) => void;
|
||||||
|
documentsManager: {
|
||||||
|
ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDocumentsSearchResult {
|
||||||
|
searchQuery: string;
|
||||||
|
setSearchQuery: Dispatch<SetStateAction<string>>;
|
||||||
|
searchResultIds: Identifier[] | null;
|
||||||
|
setSearchResultIds: Dispatch<SetStateAction<Identifier[] | null>>;
|
||||||
|
searchLoading: boolean;
|
||||||
|
setSearchLoading: Dispatch<SetStateAction<boolean>>;
|
||||||
|
activeTagFilters: Identifier[];
|
||||||
|
setActiveTagFilters: Dispatch<SetStateAction<Identifier[]>>;
|
||||||
|
activeCorrespondentFilters: Identifier[];
|
||||||
|
setActiveCorrespondentFilters: Dispatch<SetStateAction<Identifier[]>>;
|
||||||
|
toggleTagFilter: (tagId: Identifier) => void;
|
||||||
|
toggleCorrespondentFilter: (correspondentId?: Identifier | null) => void;
|
||||||
|
isFilterActive: boolean;
|
||||||
|
clearFilters: () => void;
|
||||||
|
handleSearchChange: (value: string) => void;
|
||||||
|
handleSearchSubmit: () => void;
|
||||||
|
refetchSearchResults: () => void;
|
||||||
|
documentsFilterValue: {
|
||||||
|
query: string;
|
||||||
|
searchResultIds: Identifier[] | null;
|
||||||
|
searchLoading: boolean;
|
||||||
|
includeDescendants: boolean;
|
||||||
|
activeTagIds: Identifier[];
|
||||||
|
activeCorrespondentIds: Identifier[];
|
||||||
|
isActive: boolean;
|
||||||
|
setQuery: (value: string) => void;
|
||||||
|
submit: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
toggleTag: (tagId: Identifier) => void;
|
||||||
|
toggleCorrespondent: (correspondentId?: Identifier | null) => void;
|
||||||
|
toggleIncludeDescendants: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const useDocumentsSearch = ({
|
const useDocumentsSearch = ({
|
||||||
api,
|
api,
|
||||||
assetManager,
|
|
||||||
token,
|
token,
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
navigate,
|
navigate,
|
||||||
locationPathname,
|
locationPathname,
|
||||||
isDocumentsRoute,
|
isDocumentsRoute,
|
||||||
selectionHelpers,
|
|
||||||
searchIncludeDescendants,
|
searchIncludeDescendants,
|
||||||
documentsSortField,
|
documentsSortField,
|
||||||
documentsSortDirection,
|
documentsSortDirection,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
setLoading,
|
setLoading,
|
||||||
setSearchIncludeDescendants,
|
setSearchIncludeDescendants,
|
||||||
}) => {
|
documentsManager,
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
}: UseDocumentsSearchArgs): UseDocumentsSearchResult => {
|
||||||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
const [activeTagFilters, setActiveTagFilters] = useState<Identifier[]>([]);
|
||||||
const [searchResults, setSearchResults] = useState(null);
|
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState<Identifier[]>([]);
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchResultIds, setSearchResultIds] = useState<Identifier[] | null>(null);
|
||||||
|
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||||
|
const [searchTrigger, setSearchTrigger] = useState<number>(0);
|
||||||
|
|
||||||
const toggleTagFilter = useCallback((tagId) => {
|
const toggleTagFilter = useCallback((tagId: Identifier) => {
|
||||||
if (!tagId) return;
|
if (!tagId) return;
|
||||||
setActiveTagFilters((previous) => {
|
setActiveTagFilters((previous) => {
|
||||||
if (tagId === TAG_FILTER_UNTAGGED) {
|
if (tagId === TAG_FILTER_UNTAGGED) {
|
||||||
@@ -41,7 +99,7 @@ const useDocumentsSearch = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleCorrespondentFilter = useCallback((correspondentId) => {
|
const toggleCorrespondentFilter = useCallback((correspondentId?: Identifier | null) => {
|
||||||
setActiveCorrespondentFilters((previous) => {
|
setActiveCorrespondentFilters((previous) => {
|
||||||
if (!correspondentId) {
|
if (!correspondentId) {
|
||||||
return [];
|
return [];
|
||||||
@@ -64,11 +122,12 @@ const useDocumentsSearch = ({
|
|||||||
setActiveCorrespondentFilters([]);
|
setActiveCorrespondentFilters([]);
|
||||||
setSearchLoading(false);
|
setSearchLoading(false);
|
||||||
setSearchIncludeDescendants(true);
|
setSearchIncludeDescendants(true);
|
||||||
|
setSearchResultIds(null);
|
||||||
}, [
|
}, [
|
||||||
setSearchIncludeDescendants,
|
setSearchIncludeDescendants,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value) => {
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
setSearchQuery(value);
|
setSearchQuery(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -81,11 +140,48 @@ const useDocumentsSearch = ({
|
|||||||
}
|
}
|
||||||
}, [navigate, selectedFolder, isDocumentsRoute, locationPathname]);
|
}, [navigate, selectedFolder, isDocumentsRoute, locationPathname]);
|
||||||
|
|
||||||
|
const documentsFilterValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
query: searchQuery,
|
||||||
|
searchResultIds,
|
||||||
|
searchLoading,
|
||||||
|
includeDescendants: Boolean(searchIncludeDescendants),
|
||||||
|
activeTagIds: activeTagFilters,
|
||||||
|
activeCorrespondentIds: activeCorrespondentFilters,
|
||||||
|
isActive: isFilterActive,
|
||||||
|
setQuery: handleSearchChange,
|
||||||
|
submit: handleSearchSubmit,
|
||||||
|
clear: clearFilters,
|
||||||
|
toggleTag: toggleTagFilter,
|
||||||
|
toggleCorrespondent: toggleCorrespondentFilter,
|
||||||
|
toggleIncludeDescendants: () => setSearchIncludeDescendants(!searchIncludeDescendants),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
searchQuery,
|
||||||
|
searchResultIds,
|
||||||
|
searchLoading,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
activeTagFilters,
|
||||||
|
activeCorrespondentFilters,
|
||||||
|
isFilterActive,
|
||||||
|
handleSearchChange,
|
||||||
|
handleSearchSubmit,
|
||||||
|
clearFilters,
|
||||||
|
toggleTagFilter,
|
||||||
|
toggleCorrespondentFilter,
|
||||||
|
setSearchIncludeDescendants,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchSearchResults = useCallback(() => {
|
||||||
|
setSearchTrigger(Date.now());
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return undefined;
|
if (!token) return undefined;
|
||||||
|
|
||||||
if (!isFilterActive) {
|
if (!isFilterActive) {
|
||||||
setSearchResults(null);
|
setSearchResultIds(null);
|
||||||
setSearchLoading(false);
|
setSearchLoading(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -98,7 +194,7 @@ const useDocumentsSearch = ({
|
|||||||
started = true;
|
started = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = {};
|
const params: Record<string, unknown> = {};
|
||||||
const trimmedQuery = searchQuery.trim();
|
const trimmedQuery = searchQuery.trim();
|
||||||
if (trimmedQuery.length) {
|
if (trimmedQuery.length) {
|
||||||
params.query = trimmedQuery;
|
params.query = trimmedQuery;
|
||||||
@@ -131,57 +227,24 @@ const useDocumentsSearch = ({
|
|||||||
if (documentsSortDirection) {
|
if (documentsSortDirection) {
|
||||||
params.dir = documentsSortDirection;
|
params.dir = documentsSortDirection;
|
||||||
}
|
}
|
||||||
const { data } = await api.get('/documents', { params });
|
const { data } = await api.get<unknown[]>('/documents', { params });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const results = assetManager.hydrateDocuments(data || []);
|
const results = Array.isArray(data) ? data : [];
|
||||||
setSearchResults(results);
|
const { canonical } = documentsManager.ingest(results);
|
||||||
|
const ids = canonical
|
||||||
|
.map((doc) => (doc?.id ?? null) as Identifier | null)
|
||||||
|
.filter((id): id is Identifier => id != null);
|
||||||
|
setSearchResultIds(ids);
|
||||||
|
|
||||||
if (!results.length) {
|
if (!ids.length) {
|
||||||
setSearchLoading(false);
|
setSearchLoading(false);
|
||||||
selectionHelpers.setSelectedEntries([]);
|
|
||||||
selectionHelpers.setFocusedDocumentId(null);
|
|
||||||
selectionHelpers.selectionOrderRef.current = [];
|
|
||||||
selectionHelpers.setSelectionOrder([]);
|
|
||||||
selectionHelpers.selectionAnchorRef.current = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultKeys = results
|
|
||||||
.map((doc) => resolveDocumentRowKey(doc.id))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
let targetKey = null;
|
|
||||||
let nextSelectionKeys = [];
|
|
||||||
|
|
||||||
selectionHelpers.setSelectedEntries((previous) => {
|
|
||||||
const previousDocKeys = previous.filter(isDocumentRowKey);
|
|
||||||
const filtered = previousDocKeys.filter((key) => resultKeys.includes(key));
|
|
||||||
if (filtered.length) {
|
|
||||||
targetKey = filtered[filtered.length - 1];
|
|
||||||
nextSelectionKeys = filtered;
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
targetKey = null;
|
|
||||||
nextSelectionKeys = [];
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
selectionHelpers.selectionOrderRef.current = nextSelectionKeys;
|
|
||||||
selectionHelpers.setSelectionOrder(nextSelectionKeys);
|
|
||||||
|
|
||||||
selectionHelpers.setFocusedDocumentId((previous) => {
|
|
||||||
if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
selectionHelpers.selectionAnchorRef.current = targetKey;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
notifyApiError(error, 'Search failed. Please try again.');
|
notifyApiError(error, 'Search failed. Please try again.');
|
||||||
setSearchResults(null);
|
setSearchResultIds(null);
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled && started) {
|
if (!cancelled && started) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -210,16 +273,16 @@ const useDocumentsSearch = ({
|
|||||||
documentsSortDirection,
|
documentsSortDirection,
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
assetManager,
|
|
||||||
selectionHelpers,
|
|
||||||
setLoading,
|
setLoading,
|
||||||
|
documentsManager,
|
||||||
|
searchTrigger,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
searchResults,
|
searchResultIds,
|
||||||
setSearchResults,
|
setSearchResultIds,
|
||||||
searchLoading,
|
searchLoading,
|
||||||
setSearchLoading,
|
setSearchLoading,
|
||||||
activeTagFilters,
|
activeTagFilters,
|
||||||
@@ -232,6 +295,8 @@ const useDocumentsSearch = ({
|
|||||||
clearFilters,
|
clearFilters,
|
||||||
handleSearchChange,
|
handleSearchChange,
|
||||||
handleSearchSubmit,
|
handleSearchSubmit,
|
||||||
|
refetchSearchResults,
|
||||||
|
documentsFilterValue,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
+84
-17
@@ -1,26 +1,63 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import TagsPanel from '../tags/TagsPanel';
|
import TagsPanel from '../tags/TagsPanel';
|
||||||
import CorrespondentsPanel from '../correspondents/CorrespondentsPanel';
|
import CorrespondentsPanel, { CorrespondentsPanelProps } from '../correspondents/CorrespondentsPanel';
|
||||||
import PanelHeader from '../ui/PanelHeader';
|
import PanelHeader from '../ui/PanelHeader';
|
||||||
|
import { CloseIcon } from '../ui/icons';
|
||||||
|
|
||||||
const TAGS_MODAL = 'tags';
|
const TAGS_MODAL = 'tags';
|
||||||
const CORRESPONDENTS_MODAL = 'correspondents';
|
const CORRESPONDENTS_MODAL = 'correspondents';
|
||||||
|
|
||||||
|
interface TagRecord {
|
||||||
|
id?: string | number;
|
||||||
|
label?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CorrespondentRecord {
|
||||||
|
id?: string | number;
|
||||||
|
name?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseManagementModalsArgs {
|
||||||
|
locationPathname?: string;
|
||||||
|
tags?: TagRecord[];
|
||||||
|
refreshTags?: () => void | Promise<void>;
|
||||||
|
onTagCreate?: (...args: any[]) => void | Promise<void>;
|
||||||
|
onTagUpdate?: (...args: any[]) => void | Promise<void>;
|
||||||
|
onTagDelete?: (...args: any[]) => void | Promise<void>;
|
||||||
|
correspondents?: CorrespondentRecord[];
|
||||||
|
refreshCorrespondents?: () => void | Promise<void>;
|
||||||
|
onCorrespondentCreate?: (...args: any[]) => void | Promise<void>;
|
||||||
|
onCorrespondentUpdate?: (...args: any[]) => void | Promise<void>;
|
||||||
|
onCorrespondentDelete?: (...args: any[]) => void | Promise<void>;
|
||||||
|
setStatusMessage?: (message: string, variant?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseManagementModalsResult {
|
||||||
|
managementModals: ReactNode;
|
||||||
|
openTagsModal: () => void;
|
||||||
|
openCorrespondentsModal: () => void;
|
||||||
|
closeActiveModal: () => void;
|
||||||
|
activeModal: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const useManagementModals = ({
|
export const useManagementModals = ({
|
||||||
locationPathname,
|
locationPathname,
|
||||||
tags,
|
tags = [],
|
||||||
refreshTags,
|
refreshTags,
|
||||||
onTagCreate,
|
onTagCreate,
|
||||||
onTagUpdate,
|
onTagUpdate,
|
||||||
onTagDelete,
|
onTagDelete,
|
||||||
correspondents,
|
correspondents = [],
|
||||||
refreshCorrespondents,
|
refreshCorrespondents,
|
||||||
onCorrespondentCreate,
|
onCorrespondentCreate,
|
||||||
onCorrespondentUpdate,
|
onCorrespondentUpdate,
|
||||||
onCorrespondentDelete,
|
onCorrespondentDelete,
|
||||||
setStatusMessage,
|
setStatusMessage,
|
||||||
}) => {
|
}: UseManagementModalsArgs): UseManagementModalsResult => {
|
||||||
const [activeModal, setActiveModal] = useState(null);
|
const [activeModal, setActiveModal] = useState<string | null>(null);
|
||||||
|
|
||||||
const openTagsModal = useCallback(() => setActiveModal(TAGS_MODAL), []);
|
const openTagsModal = useCallback(() => setActiveModal(TAGS_MODAL), []);
|
||||||
const openCorrespondentsModal = useCallback(
|
const openCorrespondentsModal = useCallback(
|
||||||
@@ -37,7 +74,7 @@ export const useManagementModals = ({
|
|||||||
if (!activeModal) {
|
if (!activeModal) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setActiveModal(null);
|
setActiveModal(null);
|
||||||
@@ -70,8 +107,8 @@ export const useManagementModals = ({
|
|||||||
titleTag="h3"
|
titleTag="h3"
|
||||||
titleProps={{ id: 'tags-modal-title' }}
|
titleProps={{ id: 'tags-modal-title' }}
|
||||||
actions={(
|
actions={(
|
||||||
<button type="button" className="secondary" onClick={closeActiveModal}>
|
<button type="button" className="icon-button" onClick={closeActiveModal} aria-label="Close">
|
||||||
Close
|
<CloseIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -99,6 +136,36 @@ export const useManagementModals = ({
|
|||||||
tags,
|
tags,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleCorrespondentCreateSafe = useCallback<CorrespondentsPanelProps['onCreate']>(
|
||||||
|
async (payload) => {
|
||||||
|
if (!onCorrespondentCreate) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return onCorrespondentCreate(payload) ?? undefined;
|
||||||
|
},
|
||||||
|
[onCorrespondentCreate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCorrespondentUpdateSafe = useCallback<CorrespondentsPanelProps['onUpdate']>(
|
||||||
|
async (id, payload) => {
|
||||||
|
if (!onCorrespondentUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onCorrespondentUpdate(id, payload);
|
||||||
|
},
|
||||||
|
[onCorrespondentUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCorrespondentDeleteSafe = useCallback<CorrespondentsPanelProps['onDelete']>(
|
||||||
|
async (id) => {
|
||||||
|
if (!onCorrespondentDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onCorrespondentDelete(id);
|
||||||
|
},
|
||||||
|
[onCorrespondentDelete],
|
||||||
|
);
|
||||||
|
|
||||||
const correspondentsModal = useMemo(() => {
|
const correspondentsModal = useMemo(() => {
|
||||||
if (activeModal !== CORRESPONDENTS_MODAL) {
|
if (activeModal !== CORRESPONDENTS_MODAL) {
|
||||||
return null;
|
return null;
|
||||||
@@ -122,8 +189,8 @@ export const useManagementModals = ({
|
|||||||
titleTag="h3"
|
titleTag="h3"
|
||||||
titleProps={{ id: 'correspondents-modal-title' }}
|
titleProps={{ id: 'correspondents-modal-title' }}
|
||||||
actions={(
|
actions={(
|
||||||
<button type="button" className="secondary" onClick={closeActiveModal}>
|
<button type="button" className="icon-button" onClick={closeActiveModal} aria-label="Close">
|
||||||
Close
|
<CloseIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -131,9 +198,9 @@ export const useManagementModals = ({
|
|||||||
<CorrespondentsPanel
|
<CorrespondentsPanel
|
||||||
correspondents={correspondents}
|
correspondents={correspondents}
|
||||||
onRefresh={refreshCorrespondents}
|
onRefresh={refreshCorrespondents}
|
||||||
onCreate={onCorrespondentCreate}
|
onCreate={handleCorrespondentCreateSafe}
|
||||||
onUpdate={onCorrespondentUpdate}
|
onUpdate={handleCorrespondentUpdateSafe}
|
||||||
onDelete={onCorrespondentDelete}
|
onDelete={handleCorrespondentDeleteSafe}
|
||||||
onNotify={setStatusMessage}
|
onNotify={setStatusMessage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,9 +211,9 @@ export const useManagementModals = ({
|
|||||||
activeModal,
|
activeModal,
|
||||||
closeActiveModal,
|
closeActiveModal,
|
||||||
correspondents,
|
correspondents,
|
||||||
onCorrespondentCreate,
|
handleCorrespondentCreateSafe,
|
||||||
onCorrespondentDelete,
|
handleCorrespondentDeleteSafe,
|
||||||
onCorrespondentUpdate,
|
handleCorrespondentUpdateSafe,
|
||||||
refreshCorrespondents,
|
refreshCorrespondents,
|
||||||
setStatusMessage,
|
setStatusMessage,
|
||||||
]);
|
]);
|
||||||
+28
-9
@@ -1,17 +1,34 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useDocumentSelection } from './useDocumentSelection';
|
import { useDocumentSelection } from './useDocumentSelection';
|
||||||
|
|
||||||
const identity = (value) => value;
|
type RowKey = string;
|
||||||
|
|
||||||
|
interface SelectionEntry {
|
||||||
|
rowKey?: RowKey;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceSelectionOptions {
|
||||||
|
resolveDocumentRowKey?: (id: string | number) => RowKey | null;
|
||||||
|
resolveFolderRowKey?: (id: string | number) => RowKey | null;
|
||||||
|
isDocumentRowKey?: (key: RowKey | SelectionEntry) => boolean;
|
||||||
|
isFolderRowKey?: (key: RowKey | SelectionEntry) => boolean;
|
||||||
|
getRowId?: (key: RowKey | SelectionEntry) => string | number | null;
|
||||||
|
onInspectDocument?: (id: string | number) => void;
|
||||||
|
onInspectFolder?: (id: string | number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = <T,>(value: T) => value;
|
||||||
|
|
||||||
export const useWorkspaceSelection = ({
|
export const useWorkspaceSelection = ({
|
||||||
resolveDocumentRowKey,
|
resolveDocumentRowKey,
|
||||||
resolveFolderRowKey,
|
resolveFolderRowKey,
|
||||||
isDocumentRowKey,
|
isDocumentRowKey = () => false,
|
||||||
isFolderRowKey,
|
isFolderRowKey = () => false,
|
||||||
getRowId,
|
getRowId = () => null,
|
||||||
onInspectDocument = identity,
|
onInspectDocument = identity,
|
||||||
onInspectFolder = identity,
|
onInspectFolder = identity,
|
||||||
} = {}) => {
|
}: WorkspaceSelectionOptions = {}) => {
|
||||||
const selection = useDocumentSelection({
|
const selection = useDocumentSelection({
|
||||||
resolveDocumentRowKey,
|
resolveDocumentRowKey,
|
||||||
resolveFolderRowKey,
|
resolveFolderRowKey,
|
||||||
@@ -58,8 +75,10 @@ export const useWorkspaceSelection = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectEntry = useCallback(
|
const selectEntry = useCallback(
|
||||||
(entry, event) => {
|
(entry: SelectionEntry | string | null, event?: unknown) => {
|
||||||
const rowKey = typeof entry === 'string' ? entry : entry?.rowKey;
|
const rowKey = entry && Object(entry) === entry
|
||||||
|
? (entry as SelectionEntry).rowKey ?? null
|
||||||
|
: (entry as string | null);
|
||||||
if (!rowKey) return;
|
if (!rowKey) return;
|
||||||
handleEntrySelection(rowKey, event);
|
handleEntrySelection(rowKey, event);
|
||||||
},
|
},
|
||||||
@@ -67,7 +86,7 @@ export const useWorkspaceSelection = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const inspectDocument = useCallback(
|
const inspectDocument = useCallback(
|
||||||
(documentId) => {
|
(documentId?: string | number | null) => {
|
||||||
if (!documentId) return;
|
if (!documentId) return;
|
||||||
onInspectDocument(documentId);
|
onInspectDocument(documentId);
|
||||||
},
|
},
|
||||||
@@ -75,7 +94,7 @@ export const useWorkspaceSelection = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const inspectFolder = useCallback(
|
const inspectFolder = useCallback(
|
||||||
(folderId) => {
|
(folderId?: string | number | null) => {
|
||||||
if (!folderId) return;
|
if (!folderId) return;
|
||||||
onInspectFolder(folderId);
|
onInspectFolder(folderId);
|
||||||
},
|
},
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
|
||||||
import { SidebarExpandIcon } from '../ui/icons';
|
|
||||||
import { createDocumentsSurface } from '../documents/DocumentsPanel';
|
|
||||||
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
|
||||||
import createDesktopSurface from '../desktop/createDesktopSurface';
|
|
||||||
import { usePanelManager } from './PanelManagerContext';
|
|
||||||
|
|
||||||
export const useWorkspaceSurface = ({
|
|
||||||
sidebarHidden,
|
|
||||||
onExpandSidebar,
|
|
||||||
documentsTableProps,
|
|
||||||
detailPanelProps,
|
|
||||||
detailPanelOpen,
|
|
||||||
viewMode,
|
|
||||||
deskWorkspaceProps,
|
|
||||||
previewWorkspaceDocument,
|
|
||||||
previewWorkspaceEntry,
|
|
||||||
previewDocumentId,
|
|
||||||
ensureAssetUrl,
|
|
||||||
ensurePreviewData,
|
|
||||||
getDocumentAsset,
|
|
||||||
resolveApiPath,
|
|
||||||
notifyApiError,
|
|
||||||
closeDocumentPreview,
|
|
||||||
parentBreadcrumb,
|
|
||||||
onNavigateParent,
|
|
||||||
}) => {
|
|
||||||
const { registerDetailCloseHandler, setDetailActive } = usePanelManager();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = detailPanelProps?.onClose || null;
|
|
||||||
registerDetailCloseHandler(handler);
|
|
||||||
return () => registerDetailCloseHandler(null);
|
|
||||||
}, [registerDetailCloseHandler, detailPanelProps?.onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDetailActive(Boolean(detailPanelOpen));
|
|
||||||
return () => setDetailActive(false);
|
|
||||||
}, [detailPanelOpen, setDetailActive]);
|
|
||||||
|
|
||||||
const renderSidebarToggle = useCallback(() => {
|
|
||||||
if (!sidebarHidden) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={onExpandSidebar}
|
|
||||||
aria-label="Expand sidebar"
|
|
||||||
title="Expand sidebar"
|
|
||||||
>
|
|
||||||
<SidebarExpandIcon />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}, [sidebarHidden, onExpandSidebar]);
|
|
||||||
|
|
||||||
const documentsSurface = useMemo(() => {
|
|
||||||
if (!documentsTableProps) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return createDocumentsSurface({
|
|
||||||
tableProps: documentsTableProps,
|
|
||||||
parentBreadcrumb,
|
|
||||||
onNavigateParent: parentBreadcrumb ? onNavigateParent : null,
|
|
||||||
renderSidebarToggle,
|
|
||||||
detailProps: detailPanelProps,
|
|
||||||
detailOpen: detailPanelOpen,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
documentsTableProps,
|
|
||||||
parentBreadcrumb,
|
|
||||||
onNavigateParent,
|
|
||||||
renderSidebarToggle,
|
|
||||||
detailPanelProps,
|
|
||||||
detailPanelOpen,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const showPreviewWorkspace = Boolean(previewDocumentId);
|
|
||||||
|
|
||||||
const previewSurface = useMemo(() => {
|
|
||||||
if (!showPreviewWorkspace) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const detailExtras = detailPanelProps || {};
|
|
||||||
const {
|
|
||||||
tagLookupById,
|
|
||||||
tags: tagOptions,
|
|
||||||
onTagAdd,
|
|
||||||
onTagRemove,
|
|
||||||
correspondents,
|
|
||||||
onCorrespondentAdd,
|
|
||||||
onCorrespondentRemove,
|
|
||||||
onUpdateTitle,
|
|
||||||
onUpdateIssued,
|
|
||||||
resolveFolderPath,
|
|
||||||
onFolderNavigate,
|
|
||||||
} = detailExtras;
|
|
||||||
return createDocumentViewerSurface({
|
|
||||||
document: previewWorkspaceDocument,
|
|
||||||
previewEntry: previewWorkspaceEntry,
|
|
||||||
ensureAssetUrl,
|
|
||||||
ensurePreviewData,
|
|
||||||
getDocumentAsset,
|
|
||||||
resolveApiPath,
|
|
||||||
notifyApiError,
|
|
||||||
onClose: closeDocumentPreview,
|
|
||||||
renderSidebarToggle,
|
|
||||||
tagLookupById,
|
|
||||||
tagOptions,
|
|
||||||
onTagAdd,
|
|
||||||
onTagRemove,
|
|
||||||
correspondents,
|
|
||||||
onCorrespondentAdd,
|
|
||||||
onCorrespondentRemove,
|
|
||||||
onUpdateTitle,
|
|
||||||
onUpdateIssued,
|
|
||||||
resolveFolderPath,
|
|
||||||
onFolderNavigate,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
showPreviewWorkspace,
|
|
||||||
previewWorkspaceDocument,
|
|
||||||
previewWorkspaceEntry,
|
|
||||||
ensureAssetUrl,
|
|
||||||
ensurePreviewData,
|
|
||||||
getDocumentAsset,
|
|
||||||
resolveApiPath,
|
|
||||||
notifyApiError,
|
|
||||||
closeDocumentPreview,
|
|
||||||
renderSidebarToggle,
|
|
||||||
detailPanelProps,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const workspaceSurface = useMemo(() => {
|
|
||||||
if (viewMode !== 'desk') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return createDesktopSurface({
|
|
||||||
workspaceProps: deskWorkspaceProps,
|
|
||||||
renderSidebarToggle,
|
|
||||||
parentBreadcrumb,
|
|
||||||
onNavigateParent: parentBreadcrumb ? onNavigateParent : null,
|
|
||||||
detailProps: detailPanelProps,
|
|
||||||
detailOpen: detailPanelOpen,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
viewMode,
|
|
||||||
deskWorkspaceProps,
|
|
||||||
renderSidebarToggle,
|
|
||||||
parentBreadcrumb,
|
|
||||||
onNavigateParent,
|
|
||||||
detailPanelProps,
|
|
||||||
detailPanelOpen,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const surface = useMemo(() => {
|
|
||||||
if (showPreviewWorkspace) {
|
|
||||||
return previewSurface;
|
|
||||||
}
|
|
||||||
if (viewMode === 'desk') {
|
|
||||||
return workspaceSurface;
|
|
||||||
}
|
|
||||||
return documentsSurface;
|
|
||||||
}, [showPreviewWorkspace, viewMode, previewSurface, workspaceSurface, documentsSurface]);
|
|
||||||
|
|
||||||
return { surface };
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { SidebarExpandIcon } from '../ui/icons';
|
||||||
|
import DocumentsPanel from '../documents/panel/DocumentsPanel';
|
||||||
|
import DocumentViewerPanel from '../preview/DocumentViewerPanel';
|
||||||
|
import { usePanelManager } from './PanelManagerContext';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
|
||||||
|
type EnsureAssetUrl = (docId: Identifier, asset: unknown, options?: Record<string, unknown>) => Promise<unknown> | void;
|
||||||
|
type EnsurePreviewData = (docId: Identifier, options?: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
type GetDocumentAsset = (document: unknown, assetType: string) => unknown;
|
||||||
|
type ResolveApiPath = (path: string) => string;
|
||||||
|
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||||
|
|
||||||
|
type WorkspaceSurface = { content: ReactNode; detail?: ReactNode | null } | null;
|
||||||
|
|
||||||
|
interface UseWorkspaceSurfaceArgs {
|
||||||
|
sidebarHidden?: boolean;
|
||||||
|
onExpandSidebar?: () => void;
|
||||||
|
documentsTableProps?: Record<string, any> | null;
|
||||||
|
detailPanelProps?: (Record<string, any> & { onClose?: () => void }) | null;
|
||||||
|
detailPanelOpen?: boolean;
|
||||||
|
previewWorkspaceDocument?: unknown;
|
||||||
|
documentLink?: unknown;
|
||||||
|
previewDocumentId?: Identifier | null;
|
||||||
|
ensureAssetUrl?: EnsureAssetUrl;
|
||||||
|
ensurePreviewData?: EnsurePreviewData;
|
||||||
|
getDocumentAsset?: GetDocumentAsset;
|
||||||
|
resolveApiPath?: ResolveApiPath;
|
||||||
|
notifyApiError?: NotifyApiError;
|
||||||
|
closeDocumentPreview?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseWorkspaceSurfaceResult {
|
||||||
|
surface: WorkspaceSurface;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWorkspaceSurface = ({
|
||||||
|
sidebarHidden = false,
|
||||||
|
onExpandSidebar,
|
||||||
|
documentsTableProps,
|
||||||
|
detailPanelProps,
|
||||||
|
detailPanelOpen = false,
|
||||||
|
previewWorkspaceDocument,
|
||||||
|
documentLink,
|
||||||
|
previewDocumentId,
|
||||||
|
ensureAssetUrl,
|
||||||
|
ensurePreviewData,
|
||||||
|
getDocumentAsset,
|
||||||
|
resolveApiPath,
|
||||||
|
notifyApiError,
|
||||||
|
closeDocumentPreview,
|
||||||
|
}: UseWorkspaceSurfaceArgs): UseWorkspaceSurfaceResult => {
|
||||||
|
const { registerDetailCloseHandler, setDetailActive } = usePanelManager();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = detailPanelProps?.onClose || null;
|
||||||
|
registerDetailCloseHandler(handler);
|
||||||
|
return () => registerDetailCloseHandler(null);
|
||||||
|
}, [registerDetailCloseHandler, detailPanelProps?.onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDetailActive(Boolean(detailPanelOpen));
|
||||||
|
return () => setDetailActive(false);
|
||||||
|
}, [detailPanelOpen, setDetailActive]);
|
||||||
|
|
||||||
|
const renderSidebarToggle = useCallback<() => ReactNode>(() => {
|
||||||
|
if (!sidebarHidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={onExpandSidebar}
|
||||||
|
aria-label="Expand sidebar"
|
||||||
|
title="Expand sidebar"
|
||||||
|
>
|
||||||
|
<SidebarExpandIcon />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}, [sidebarHidden, onExpandSidebar]);
|
||||||
|
|
||||||
|
const documentsSurface = useMemo<WorkspaceSurface>(() => {
|
||||||
|
if (!documentsTableProps) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
|
|
||||||
|
const detail = detailPanelOpen && detailPanelProps
|
||||||
|
? (() => {
|
||||||
|
const { onClose, onOpenPreview, tags: tagOptions, ...restDetailProps } = detailPanelProps;
|
||||||
|
return (
|
||||||
|
<DocumentViewerPanel
|
||||||
|
variant="sidebar"
|
||||||
|
onCollapsePanel={onClose}
|
||||||
|
onMaximizePanel={onOpenPreview}
|
||||||
|
tagOptions={tagOptions}
|
||||||
|
{...restDetailProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: (
|
||||||
|
<DocumentsPanel
|
||||||
|
{...documentsTableProps}
|
||||||
|
headerLeading={sidebarToggle}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
detail,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
documentsTableProps,
|
||||||
|
renderSidebarToggle,
|
||||||
|
detailPanelOpen,
|
||||||
|
detailPanelProps,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const showPreviewWorkspace = Boolean(previewDocumentId);
|
||||||
|
|
||||||
|
const previewSurface = useMemo<WorkspaceSurface>(() => {
|
||||||
|
if (!showPreviewWorkspace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
|
const detailExtras = detailPanelProps || {};
|
||||||
|
const {
|
||||||
|
tagLookupById,
|
||||||
|
tags: tagOptions,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
correspondents,
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssued,
|
||||||
|
resolveFolderPath,
|
||||||
|
} = detailExtras;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: (
|
||||||
|
<DocumentViewerPanel
|
||||||
|
document={previewWorkspaceDocument || null}
|
||||||
|
documentLink={documentLink}
|
||||||
|
hydrateDocument={ensurePreviewData}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
tagOptions={tagOptions}
|
||||||
|
onTagAdd={onTagAdd}
|
||||||
|
onTagRemove={onTagRemove}
|
||||||
|
correspondents={correspondents}
|
||||||
|
onCorrespondentAdd={onCorrespondentAdd}
|
||||||
|
onCorrespondentRemove={onCorrespondentRemove}
|
||||||
|
onUpdateTitle={onUpdateTitle}
|
||||||
|
onUpdateIssued={onUpdateIssued}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
ensurePreviewData={ensurePreviewData}
|
||||||
|
resolveApiPath={resolveApiPath}
|
||||||
|
notifyApiError={notifyApiError}
|
||||||
|
sidebarToggle={sidebarToggle}
|
||||||
|
onClosePanel={closeDocumentPreview}
|
||||||
|
resolveFolderPath={resolveFolderPath}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
detail: null,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
showPreviewWorkspace,
|
||||||
|
previewWorkspaceDocument,
|
||||||
|
documentLink,
|
||||||
|
ensurePreviewData,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
resolveApiPath,
|
||||||
|
notifyApiError,
|
||||||
|
renderSidebarToggle,
|
||||||
|
closeDocumentPreview,
|
||||||
|
detailPanelProps,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const surface = useMemo<WorkspaceSurface>(() => {
|
||||||
|
if (showPreviewWorkspace) {
|
||||||
|
return previewSurface;
|
||||||
|
}
|
||||||
|
return documentsSurface;
|
||||||
|
}, [showPreviewWorkspace, previewSurface, documentsSurface]);
|
||||||
|
|
||||||
|
return { surface };
|
||||||
|
};
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const AppShellContext = React.createContext(null);
|
export type AppShellContextValue = Record<string, unknown>;
|
||||||
|
|
||||||
export const useAppShell = () => {
|
export const AppShellContext = React.createContext<AppShellContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useAppShell = (): AppShellContextValue => {
|
||||||
const context = React.useContext(AppShellContext);
|
const context = React.useContext(AppShellContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
|
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
export const getAssetFromGroup = (assets, assetType) => {
|
|
||||||
if (!assetType || !assets) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(assets)) {
|
|
||||||
return assets.find((entry) => entry?.asset_type === assetType) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return assets?.[assetType] || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAssetFromVersion = (currentVersion, assetType) => {
|
|
||||||
if (!currentVersion) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return getAssetFromGroup(currentVersion.assets, assetType);
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeAssetObjects = (objects) => {
|
|
||||||
if (!Array.isArray(objects)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return objects
|
|
||||||
.filter((entry) => Number.isInteger(entry?.ordinal))
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.ordinal - b.ordinal);
|
|
||||||
};
|
|
||||||
|
|
||||||
const mergeAssetObjects = (existingObjects, incomingObjects) => {
|
|
||||||
const merged = new Map();
|
|
||||||
|
|
||||||
normalizeAssetObjects(existingObjects).forEach((entry) => {
|
|
||||||
merged.set(entry.ordinal, { ...entry });
|
|
||||||
});
|
|
||||||
|
|
||||||
normalizeAssetObjects(incomingObjects).forEach((entry) => {
|
|
||||||
const current = merged.get(entry.ordinal) || {};
|
|
||||||
merged.set(entry.ordinal, { ...current, ...entry });
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...merged.entries()]
|
|
||||||
.sort((a, b) => a[0] - b[0])
|
|
||||||
.map(([, value]) => value);
|
|
||||||
};
|
|
||||||
|
|
||||||
export class AssetView {
|
|
||||||
constructor(asset) {
|
|
||||||
this.asset = asset || null;
|
|
||||||
this._objectsRef = null;
|
|
||||||
this._sortedObjects = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
getCardinality() {
|
|
||||||
if (!this.asset) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reported = Number(this.asset.cardinality);
|
|
||||||
if (Number.isFinite(reported) && reported > 0) {
|
|
||||||
return reported;
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectsCount = this.getObjects().length;
|
|
||||||
if (objectsCount > 0) {
|
|
||||||
return objectsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.asset.metadata ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
getObjects() {
|
|
||||||
if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._objectsRef === this.asset.objects) {
|
|
||||||
return this._sortedObjects;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._objectsRef = this.asset.objects;
|
|
||||||
this._sortedObjects = normalizeAssetObjects(this.asset.objects);
|
|
||||||
return this._sortedObjects;
|
|
||||||
}
|
|
||||||
|
|
||||||
getObject(ordinal = 1) {
|
|
||||||
const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
|
|
||||||
if (fromObjects) {
|
|
||||||
return fromObjects;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ordinal === 1 && this.asset) {
|
|
||||||
if (this.asset.url || this.asset.metadata) {
|
|
||||||
return {
|
|
||||||
ordinal: 1,
|
|
||||||
url: this.asset.url || null,
|
|
||||||
metadata: this.asset.metadata || null,
|
|
||||||
expires_at: this.asset.expiresAt ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getPrimaryObject() {
|
|
||||||
return this.getObject(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
getPrimaryMetadata() {
|
|
||||||
return this.getPrimaryObject()?.metadata || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getPrimaryUrl() {
|
|
||||||
return this.getPrimaryObject()?.url || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasObject(ordinal) {
|
|
||||||
return Boolean(this.getObject(ordinal));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createAssetView = (asset) => new AssetView(asset);
|
|
||||||
|
|
||||||
export const resolveDocumentAssetUrl = (
|
|
||||||
doc,
|
|
||||||
type,
|
|
||||||
{ ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1 } = {},
|
|
||||||
) => {
|
|
||||||
if (!doc || !type) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const asset = typeof getAsset === 'function' ? getAsset(doc, type) : null;
|
|
||||||
if (!asset) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const view = createAssetView(asset);
|
|
||||||
const object = view.getObject(objectOrdinal);
|
|
||||||
const url = object?.url || (objectOrdinal === 1 ? view.getPrimaryUrl() : null);
|
|
||||||
const expiresAt = typeof object?.expires_at === 'number'
|
|
||||||
? object.expires_at
|
|
||||||
: objectOrdinal === 1 && typeof asset.expiresAt === 'number'
|
|
||||||
? asset.expiresAt
|
|
||||||
: null;
|
|
||||||
const now = Date.now();
|
|
||||||
if (url && (!expiresAt || expiresAt > now)) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
|
|
||||||
const force = Boolean(url && expiresAt && expiresAt <= now);
|
|
||||||
const options = {
|
|
||||||
force,
|
|
||||||
start: objectOrdinal,
|
|
||||||
limit: 1,
|
|
||||||
...(ensureOptions || {}),
|
|
||||||
};
|
|
||||||
if (!options.start) {
|
|
||||||
options.start = objectOrdinal;
|
|
||||||
}
|
|
||||||
if (!options.limit) {
|
|
||||||
options.limit = 1;
|
|
||||||
}
|
|
||||||
ensureAssetUrl(doc.id, asset, options).catch(() => {});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
class AssetManager {
|
|
||||||
constructor({ api, assetPresignTtlMs }) {
|
|
||||||
this.api = api;
|
|
||||||
this.assetPresignTtlMs = assetPresignTtlMs;
|
|
||||||
this.assetCache = new Map();
|
|
||||||
this.assetInflight = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
setApi(api) {
|
|
||||||
this.api = api;
|
|
||||||
}
|
|
||||||
|
|
||||||
rememberAsset(entry) {
|
|
||||||
if (entry?.id) {
|
|
||||||
this.assetCache.set(entry.id, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrateAsset(asset) {
|
|
||||||
if (!asset || !asset.id) {
|
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
const cached = this.assetCache.get(asset.id);
|
|
||||||
if (!cached) {
|
|
||||||
const normalized = mergeAssetObjects(null, asset.objects);
|
|
||||||
if (normalized.length) {
|
|
||||||
return { ...asset, objects: normalized };
|
|
||||||
}
|
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
const merged = { ...cached, ...asset };
|
|
||||||
if (cached.url && !asset.url) {
|
|
||||||
merged.url = cached.url;
|
|
||||||
}
|
|
||||||
if (cached.expiresAt) {
|
|
||||||
const cachedExpires = Number(cached.expiresAt) || null;
|
|
||||||
const assetExpires = Number(asset.expiresAt) || null;
|
|
||||||
if (!assetExpires || (cachedExpires && cachedExpires > assetExpires)) {
|
|
||||||
merged.expiresAt = cachedExpires;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mergedObjects = mergeAssetObjects(cached.objects, asset.objects);
|
|
||||||
if (mergedObjects.length) {
|
|
||||||
merged.objects = mergedObjects;
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrateDocument(document) {
|
|
||||||
if (!document) {
|
|
||||||
return document;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentVersion = document.current_version || null;
|
|
||||||
if (!currentVersion) {
|
|
||||||
return document;
|
|
||||||
}
|
|
||||||
|
|
||||||
let changed = false;
|
|
||||||
let nextAssets = currentVersion.assets;
|
|
||||||
|
|
||||||
if (nextAssets && !Array.isArray(nextAssets)) {
|
|
||||||
const hydrated = {};
|
|
||||||
Object.keys(nextAssets).forEach((key) => {
|
|
||||||
hydrated[key] = this.hydrateAsset(nextAssets[key]);
|
|
||||||
if (hydrated[key] !== nextAssets[key]) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (changed) {
|
|
||||||
nextAssets = { ...nextAssets, ...hydrated };
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(nextAssets)) {
|
|
||||||
const hydratedList = nextAssets.map((item) => this.hydrateAsset(item));
|
|
||||||
if (
|
|
||||||
hydratedList.length !== nextAssets.length ||
|
|
||||||
hydratedList.some((item, index) => item !== nextAssets[index])
|
|
||||||
) {
|
|
||||||
changed = true;
|
|
||||||
nextAssets = hydratedList;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!changed) {
|
|
||||||
return document;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextCurrentVersion = { ...currentVersion, assets: nextAssets };
|
|
||||||
return { ...document, current_version: nextCurrentVersion };
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrateDocuments(documents) {
|
|
||||||
if (!Array.isArray(documents)) {
|
|
||||||
return documents;
|
|
||||||
}
|
|
||||||
return documents.map((doc) => this.hydrateDocument(doc));
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrateDetail(detail) {
|
|
||||||
if (!detail) {
|
|
||||||
return detail;
|
|
||||||
}
|
|
||||||
let changed = false;
|
|
||||||
const next = { ...detail };
|
|
||||||
|
|
||||||
if (detail.document) {
|
|
||||||
const hydratedDocument = this.hydrateDocument(detail.document);
|
|
||||||
if (hydratedDocument !== detail.document) {
|
|
||||||
next.document = hydratedDocument;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(detail.assets)) {
|
|
||||||
const hydratedAssets = detail.assets.map((item) => this.hydrateAsset(item));
|
|
||||||
if (
|
|
||||||
hydratedAssets.length !== detail.assets.length ||
|
|
||||||
hydratedAssets.some((item, index) => item !== detail.assets[index])
|
|
||||||
) {
|
|
||||||
next.assets = hydratedAssets;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed ? next : detail;
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrateFolderContents(contents) {
|
|
||||||
if (!contents) {
|
|
||||||
return contents;
|
|
||||||
}
|
|
||||||
const next = { ...contents };
|
|
||||||
if (Array.isArray(contents.documents)) {
|
|
||||||
next.documents = this.hydrateDocuments(contents.documents);
|
|
||||||
}
|
|
||||||
if (contents.document) {
|
|
||||||
next.document = this.hydrateDocument(contents.document);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) {
|
|
||||||
if (!documentId || !asset?.id) {
|
|
||||||
return Promise.resolve(asset || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedStart = Number.isInteger(start) && start > 0 ? start : 1;
|
|
||||||
const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1;
|
|
||||||
const requestedEnd = requestedStart + requestedLimit - 1;
|
|
||||||
|
|
||||||
const baseAsset = this.assetCache.get(asset.id) || asset;
|
|
||||||
const view = createAssetView(baseAsset);
|
|
||||||
const assetExpiresAt = typeof baseAsset.expiresAt === 'number' ? baseAsset.expiresAt : null;
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
const isOrdinalSatisfied = (ordinal) => {
|
|
||||||
const object = view.getObject(ordinal);
|
|
||||||
if (!object) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!object.url) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (typeof object.expires_at === 'number') {
|
|
||||||
return object.expires_at > now;
|
|
||||||
}
|
|
||||||
if (ordinal === 1 && baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
let needsFetch = force;
|
|
||||||
if (!needsFetch) {
|
|
||||||
for (let ordinal = requestedStart; ordinal <= requestedEnd; ordinal += 1) {
|
|
||||||
if (!isOrdinalSatisfied(ordinal)) {
|
|
||||||
needsFetch = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!needsFetch) {
|
|
||||||
this.rememberAsset(baseAsset);
|
|
||||||
return Promise.resolve(baseAsset);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`;
|
|
||||||
if (!force && this.assetInflight.has(inflightKey)) {
|
|
||||||
return this.assetInflight.get(inflightKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.api) {
|
|
||||||
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = {};
|
|
||||||
if (Number.isInteger(start) && start > 0) {
|
|
||||||
params.start = start;
|
|
||||||
}
|
|
||||||
if (Number.isInteger(limit) && limit > 0) {
|
|
||||||
params.limit = limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestConfig = Object.keys(params).length ? { params } : undefined;
|
|
||||||
|
|
||||||
const request = this.api
|
|
||||||
.get(`/assets/${asset.id}`, requestConfig)
|
|
||||||
.then(({ data }) => {
|
|
||||||
const incomingObjects = Array.isArray(data.objects) ? data.objects : [];
|
|
||||||
const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
|
|
||||||
const mergedObjects = mergeAssetObjects(cachedEntry?.objects, incomingObjects);
|
|
||||||
const combined = { ...cachedEntry, ...asset, ...data, objects: mergedObjects };
|
|
||||||
const view = createAssetView(combined);
|
|
||||||
const primaryObject = view.getPrimaryObject();
|
|
||||||
const expiresAt = typeof primaryObject?.expires_at === 'number'
|
|
||||||
? primaryObject.expires_at
|
|
||||||
: Date.now() + this.assetPresignTtlMs;
|
|
||||||
const cardinality = (() => {
|
|
||||||
const reported = Number(data.cardinality ?? asset.cardinality ?? cachedEntry?.cardinality);
|
|
||||||
const objectsCount = mergedObjects.length;
|
|
||||||
if (Number.isFinite(reported) && reported > 0) {
|
|
||||||
return Math.max(reported, objectsCount) || null;
|
|
||||||
}
|
|
||||||
return objectsCount || null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const entry = {
|
|
||||||
...combined,
|
|
||||||
cardinality,
|
|
||||||
url: view.getPrimaryUrl(),
|
|
||||||
expiresAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.rememberAsset(entry);
|
|
||||||
return entry;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.assetInflight.delete(inflightKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.assetInflight.set(inflightKey, request);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.assetCache.clear();
|
|
||||||
this.assetInflight.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AssetManager;
|
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import type { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
export type Identifier = string | number;
|
||||||
|
|
||||||
|
type Nullable<T> = T | null;
|
||||||
|
|
||||||
|
export interface AssetObject {
|
||||||
|
ordinal?: number;
|
||||||
|
url?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
expires_at?: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetLike {
|
||||||
|
id?: Identifier;
|
||||||
|
asset_type?: string;
|
||||||
|
cardinality?: number | null;
|
||||||
|
url?: string | null;
|
||||||
|
expires_at?: number | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
expiresAt?: number | null;
|
||||||
|
assets?: Record<string, AssetLike> | AssetLike[] | null;
|
||||||
|
objects?: AssetObject[] | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentVersionLike {
|
||||||
|
assets?: Record<string, AssetLike> | AssetLike[] | null;
|
||||||
|
metadata?: Record<string, unknown> & { page_count?: number } | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentLike {
|
||||||
|
id?: Identifier;
|
||||||
|
current_version?: DocumentVersionLike | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveAssetExpiresAt = (
|
||||||
|
asset?: { expiresAt?: number | null; expires_at?: number | null } | null,
|
||||||
|
): number | null => {
|
||||||
|
const camel = Number(asset?.expiresAt);
|
||||||
|
if (Number.isFinite(camel)) {
|
||||||
|
return camel;
|
||||||
|
}
|
||||||
|
const snake = Number(asset?.expires_at);
|
||||||
|
if (Number.isFinite(snake)) {
|
||||||
|
return snake;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EnsureAssetUrl = (
|
||||||
|
documentId: Identifier,
|
||||||
|
asset: AssetLike,
|
||||||
|
options?: { force?: boolean; [key: string]: unknown },
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
export type GetAsset = (document: DocumentLike, assetType: string) => Nullable<AssetLike>;
|
||||||
|
|
||||||
|
export const getAssetFromGroup = (
|
||||||
|
assets?: AssetLike[] | Record<string, AssetLike> | null,
|
||||||
|
assetType: string = '',
|
||||||
|
): Nullable<AssetLike> => {
|
||||||
|
if (!assetType || !assets) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(assets)) {
|
||||||
|
return assets.find((entry) => entry?.asset_type === assetType) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assets?.[assetType] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAssetFromVersion = (currentVersion: Nullable<DocumentVersionLike>, assetType: string) => {
|
||||||
|
if (!currentVersion) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getAssetFromGroup(currentVersion.assets ?? null, assetType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAssetObjects = (objects?: AssetObject[] | null): AssetObject[] => {
|
||||||
|
if (!Array.isArray(objects)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return objects
|
||||||
|
.filter((entry) => Number.isInteger(entry?.ordinal))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.ordinal - b.ordinal);
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AssetView {
|
||||||
|
asset: AssetLike | null;
|
||||||
|
private _objectsRef: AssetObject[] | null;
|
||||||
|
private _sortedObjects: AssetObject[];
|
||||||
|
|
||||||
|
constructor(asset?: AssetLike | null) {
|
||||||
|
this.asset = asset || null;
|
||||||
|
this._objectsRef = null;
|
||||||
|
this._sortedObjects = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardinality(): number {
|
||||||
|
if (!this.asset) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectsCount = this.getObjects().length;
|
||||||
|
if (objectsCount > 0) {
|
||||||
|
return objectsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.asset.url || this.asset.metadata) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getObjects(): AssetObject[] {
|
||||||
|
if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._objectsRef === this.asset.objects) {
|
||||||
|
return this._sortedObjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._objectsRef = this.asset.objects;
|
||||||
|
this._sortedObjects = normalizeAssetObjects(this.asset.objects);
|
||||||
|
return this._sortedObjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
getObject(ordinal = 1): AssetObject | null {
|
||||||
|
const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
|
||||||
|
if (fromObjects) {
|
||||||
|
return fromObjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ordinal === 1 && this.asset) {
|
||||||
|
if (this.asset.url || this.asset.metadata) {
|
||||||
|
return {
|
||||||
|
ordinal: 1,
|
||||||
|
url: this.asset.url || null,
|
||||||
|
metadata: this.asset.metadata || null,
|
||||||
|
expires_at: resolveAssetExpiresAt(this.asset),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPrimaryObject(): AssetObject | null {
|
||||||
|
return this.getObject(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPrimaryMetadata(): Record<string, unknown> | null {
|
||||||
|
return this.getPrimaryObject()?.metadata || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPrimaryUrl(): string | null {
|
||||||
|
return this.getPrimaryObject()?.url || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasObject(ordinal: number): boolean {
|
||||||
|
return Boolean(this.getObject(ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAssetView = (asset?: AssetLike | null): AssetView => new AssetView(asset);
|
||||||
|
|
||||||
|
export const resolveDocumentAssetUrl = (
|
||||||
|
doc: Nullable<DocumentLike>,
|
||||||
|
type: string,
|
||||||
|
{
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset,
|
||||||
|
ensureOptions,
|
||||||
|
}: {
|
||||||
|
ensureAssetUrl?: EnsureAssetUrl;
|
||||||
|
getAsset?: GetAsset;
|
||||||
|
ensureOptions?: { force?: boolean; [key: string]: unknown };
|
||||||
|
} = {},
|
||||||
|
): Nullable<string> => {
|
||||||
|
if (!doc || !type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const asset = getAsset ? getAsset(doc, type) : null;
|
||||||
|
if (!asset) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const view = createAssetView(asset);
|
||||||
|
const object = view.getPrimaryObject();
|
||||||
|
const url = object?.url || view.getPrimaryUrl();
|
||||||
|
const expiresAt = Number.isFinite(object?.expires_at)
|
||||||
|
? Number(object?.expires_at)
|
||||||
|
: resolveAssetExpiresAt(asset);
|
||||||
|
const now = Date.now();
|
||||||
|
if (url && (!expiresAt || expiresAt > now)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
if (doc.id && asset.id && ensureAssetUrl) {
|
||||||
|
const force = Boolean(url && expiresAt && expiresAt <= now);
|
||||||
|
const options: { force: boolean; [key: string]: unknown } = {
|
||||||
|
force,
|
||||||
|
...(ensureOptions || {}),
|
||||||
|
};
|
||||||
|
ensureAssetUrl(doc.id, asset, options).catch(() => {});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AssetManager {
|
||||||
|
api: AxiosInstance | null;
|
||||||
|
assetPresignTtlMs: number;
|
||||||
|
assetCache: Map<Identifier, AssetLike>;
|
||||||
|
assetInflight: Map<string, Promise<AssetLike | null>>;
|
||||||
|
|
||||||
|
constructor({ api, assetPresignTtlMs }: { api: AxiosInstance | null; assetPresignTtlMs: number }) {
|
||||||
|
this.api = api;
|
||||||
|
this.assetPresignTtlMs = assetPresignTtlMs;
|
||||||
|
this.assetCache = new Map();
|
||||||
|
this.assetInflight = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
setApi(api: AxiosInstance | null) {
|
||||||
|
this.api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberAsset(entry?: Nullable<AssetLike>) {
|
||||||
|
if (entry?.id) {
|
||||||
|
this.assetCache.set(entry.id, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureAsset(
|
||||||
|
documentId?: Identifier | null,
|
||||||
|
asset?: Nullable<AssetLike>,
|
||||||
|
{ force = false }: { force?: boolean } = {},
|
||||||
|
): Promise<Nullable<AssetLike>> {
|
||||||
|
if (!documentId || !asset?.id) {
|
||||||
|
return Promise.resolve(asset ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseAsset = this.assetCache.get(asset.id) || asset;
|
||||||
|
const view = createAssetView(baseAsset);
|
||||||
|
const assetExpiresAt = resolveAssetExpiresAt(baseAsset);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const isPrimarySatisfied = () => {
|
||||||
|
const object = view.getObject(1);
|
||||||
|
if (object?.url) {
|
||||||
|
const objectExpiresAt = Number.isFinite(object.expires_at) ? Number(object.expires_at) : null;
|
||||||
|
if (!objectExpiresAt || objectExpiresAt > now) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let needsFetch = force;
|
||||||
|
if (!needsFetch) {
|
||||||
|
needsFetch = !isPrimarySatisfied();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsFetch) {
|
||||||
|
this.rememberAsset(baseAsset);
|
||||||
|
return Promise.resolve(baseAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflightKey = `${documentId}:${asset.id}`;
|
||||||
|
if (!force && this.assetInflight.has(inflightKey)) {
|
||||||
|
return this.assetInflight.get(inflightKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.api) {
|
||||||
|
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: Promise<AssetLike | null> = this.api
|
||||||
|
.get(`/assets/${asset.id}`)
|
||||||
|
.then(({ data }) => {
|
||||||
|
const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
|
||||||
|
const combined = { ...cachedEntry, ...asset, ...data };
|
||||||
|
const expiresAt =
|
||||||
|
resolveAssetExpiresAt(data)
|
||||||
|
?? resolveAssetExpiresAt(combined)
|
||||||
|
?? Date.now() + this.assetPresignTtlMs;
|
||||||
|
const entry = {
|
||||||
|
...combined,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.rememberAsset(entry);
|
||||||
|
return entry;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.assetInflight.delete(inflightKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.assetInflight.set(inflightKey, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.assetCache.clear();
|
||||||
|
this.assetInflight.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AssetManager;
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 462 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,75 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:bevel;">
|
||||||
|
<g transform="matrix(1,0,0,1,-117.44,-234.88)">
|
||||||
|
<g transform="matrix(0.500026,0,0,0.500026,90.3513,215.477)">
|
||||||
|
<g>
|
||||||
|
<path d="M168.083,199.099L72.029,143.641" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.556777,0,0,0.556777,80.018,205.619)">
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||||
|
<path d="M132.168,192.252L104.103,176.048" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||||
|
<path d="M132.168,206.14L104.103,189.937" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||||
|
<path d="M167.57,268.244L68.737,211.183" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||||
|
<path d="M104.103,176.048L103.474,175.723L102.852,175.478L102.245,175.315L101.657,175.236L101.096,175.242L100.568,175.334L100.079,175.509L99.634,175.766L99.238,176.102L98.895,176.514L98.609,176.996L98.383,177.545L98.22,178.152L98.122,178.814L98.089,179.52L98.122,180.265L98.22,181.04L98.383,181.836L98.609,182.645L98.895,183.458L99.238,184.265L99.634,185.059L100.079,185.83L100.568,186.57L101.096,187.271L101.657,187.924L102.245,188.524L102.852,189.063L103.474,189.536L104.103,189.937" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||||
|
<path d="M132.168,206.14L132.797,206.465L133.418,206.71L134.026,206.873L134.614,206.952L135.175,206.946L135.703,206.855L136.192,206.68L136.637,206.423L137.034,206.087L137.376,205.675L137.662,205.193L137.888,204.644L138.051,204.036L138.149,203.375L138.182,202.668L138.149,201.923L138.051,201.149L137.888,200.352L137.662,199.543L137.376,198.731L137.034,197.923L136.637,197.13L136.192,196.359L135.703,195.619L135.175,194.918L134.614,194.264L134.026,193.664L133.418,193.125L132.797,192.653L132.168,192.252" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||||
|
<path d="M294.73,124.536L196.717,67.948" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.556777,0,0,0.556777,80.018,205.619)">
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||||
|
<path d="M196.317,98.806L196.317,71.884" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||||
|
<path d="M295.747,125.124L295.747,193.77" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||||
|
<path d="M167.851,198.965L295.747,125.124" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||||
|
<path d="M167.851,266.435L167.851,198.965" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||||
|
<path d="M295.747,194.567L167.851,268.406" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||||
|
<path d="M68.42,141.558L196.317,67.717" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.9164,0,0,1.9164,-51.1148,-197.163)">
|
||||||
|
<path d="M68.42,211.001L68.42,178.595" style="fill:none;stroke:black;stroke-width:3.75px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,7.78289,-9.23527)">
|
||||||
|
<path d="M255.894,171.556L265.958,177.367" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.898073,0,0,0.898073,7.78289,-9.23527)">
|
||||||
|
<path d="M109.608,187.222L121.91,180.119" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,117.44,234.88)">
|
||||||
|
<path d="M20.204,53.429C19.122,53.646 18.067,52.943 17.851,51.86C17.634,50.778 18.337,49.723 19.419,49.507C32.861,46.817 55.928,36.028 66.499,27.302C67.35,26.599 68.612,26.719 69.314,27.571C70.017,28.422 69.897,29.684 69.045,30.386C58.078,39.44 34.149,50.639 20.204,53.429Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,117.44,234.88)">
|
||||||
|
<path d="M67.109,30.448C66.113,29.972 65.691,28.777 66.167,27.781C66.643,26.785 67.838,26.364 68.834,26.84C81.354,32.825 101.513,41.54 107.158,43.063C108.224,43.351 108.856,44.449 108.569,45.515C108.281,46.581 107.182,47.212 106.117,46.925C100.373,45.376 79.848,36.539 67.109,30.448Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,117.44,234.88)">
|
||||||
|
<path d="M59.739,53.406C60.43,52.839 61.443,52.789 62.195,53.343C63.083,53.999 63.272,55.252 62.616,56.14C62.573,56.199 62.083,56.711 61.028,57.286C58.509,58.66 51.351,61.791 41.049,60.655C31.881,59.645 25.742,56.597 18.948,53.577C17.939,53.128 17.485,51.946 17.933,50.937C18.381,49.928 19.564,49.473 20.573,49.922C27.007,52.782 32.805,55.722 41.487,56.68C51.662,57.801 58.36,54.284 59.678,53.445C59.698,53.433 59.72,53.418 59.739,53.406Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,117.44,234.88)">
|
||||||
|
<path d="M105.62,43.391C106.569,42.827 107.798,43.14 108.361,44.089C108.925,45.038 108.612,46.266 107.663,46.83C99.112,51.908 86.613,56.607 84.3,57.143C83.382,57.356 77.624,58.751 71.659,58.922C67.399,59.044 63.055,58.498 60.067,56.682C59.124,56.109 58.823,54.878 59.396,53.934C59.97,52.991 61.201,52.69 62.144,53.264C64.56,54.732 68.1,55.023 71.545,54.924C77.138,54.763 82.536,53.446 83.397,53.246C85.599,52.736 97.48,48.225 105.62,43.391Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,117.44,234.88)">
|
||||||
|
<path d="M36.623,60.052C35.546,59.811 34.868,58.74 35.109,57.663C35.351,56.586 36.421,55.907 37.498,56.149C55.185,60.116 65.349,60.658 86.099,52.366C87.124,51.956 88.288,52.455 88.698,53.48C89.108,54.505 88.608,55.67 87.583,56.08C65.826,64.775 55.169,64.211 36.623,60.052Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
+32
-10
@@ -1,4 +1,26 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { FormEvent, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
|
interface CorrespondentUsage {
|
||||||
|
total?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CorrespondentEntry {
|
||||||
|
id?: string | number;
|
||||||
|
name?: string;
|
||||||
|
usage?: CorrespondentUsage;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CorrespondentsPanelProps {
|
||||||
|
correspondents?: CorrespondentEntry[];
|
||||||
|
onRefresh?: () => void | Promise<void>;
|
||||||
|
onCreate: (payload: { name: string }) => Promise<CorrespondentEntry | void>;
|
||||||
|
onUpdate: (id: string | number, payload: { name: string }) => Promise<void>;
|
||||||
|
onDelete: (id: string | number) => Promise<void>;
|
||||||
|
onNotify?: (message: string, variant?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
function CorrespondentsPanel({
|
function CorrespondentsPanel({
|
||||||
correspondents = [],
|
correspondents = [],
|
||||||
@@ -7,15 +29,15 @@ function CorrespondentsPanel({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onNotify,
|
onNotify,
|
||||||
}) {
|
}: CorrespondentsPanelProps) {
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState<string | number | null>(null);
|
||||||
const [draftName, setDraftName] = useState('');
|
const [draftName, setDraftName] = useState('');
|
||||||
const [createName, setCreateName] = useState('');
|
const [createName, setCreateName] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState(null);
|
const [deletingId, setDeletingId] = useState<string | number | null>(null);
|
||||||
|
|
||||||
const startEdit = useCallback((correspondent) => {
|
const startEdit = useCallback((correspondent: CorrespondentEntry) => {
|
||||||
setEditingId(correspondent.id);
|
setEditingId(correspondent.id);
|
||||||
setDraftName(correspondent.name);
|
setDraftName(correspondent.name);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -46,7 +68,7 @@ function CorrespondentsPanel({
|
|||||||
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
|
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (correspondent) => {
|
async (correspondent: CorrespondentEntry) => {
|
||||||
if (!correspondent?.id) return;
|
if (!correspondent?.id) return;
|
||||||
setDeletingId(correspondent.id);
|
setDeletingId(correspondent.id);
|
||||||
try {
|
try {
|
||||||
@@ -65,7 +87,7 @@ function CorrespondentsPanel({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = useCallback(
|
const handleCreate = useCallback(
|
||||||
async (event) => {
|
async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const trimmed = createName.trim();
|
const trimmed = createName.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -87,7 +109,7 @@ function CorrespondentsPanel({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event) => {
|
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleSave();
|
handleSave();
|
||||||
@@ -99,11 +121,11 @@ function CorrespondentsPanel({
|
|||||||
[handleSave, cancelEdit],
|
[handleSave, cancelEdit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderUsage = useCallback((usage) => {
|
const renderUsage = useCallback((usage?: CorrespondentUsage) => {
|
||||||
if (!usage) {
|
if (!usage) {
|
||||||
return '0';
|
return '0';
|
||||||
}
|
}
|
||||||
const total = typeof usage.total === 'number' ? usage.total : 0;
|
const total = Number.isFinite(usage.total) ? Number(usage.total) : 0;
|
||||||
return total.toString();
|
return total.toString();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
+67
-11
@@ -4,7 +4,46 @@ import { resolveCorrespondents } from '../documents/correspondents';
|
|||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { preventAll } from './events';
|
import { preventAll } from './events';
|
||||||
|
|
||||||
const DesktopDocumentCard = ({
|
type DocumentLike = {
|
||||||
|
id?: string | number;
|
||||||
|
title?: string;
|
||||||
|
tags?: Array<{ id?: string | number; label?: string; color?: string | null }>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PendingRemovalTag {
|
||||||
|
docId?: string | number;
|
||||||
|
tagId?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesktopDocumentCardProps {
|
||||||
|
doc: DocumentLike;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
shouldLoad?: boolean;
|
||||||
|
dragging?: boolean;
|
||||||
|
matchesFilter?: boolean;
|
||||||
|
tagTargetActive?: boolean;
|
||||||
|
tagTargetPending?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
docTagTokens?: string;
|
||||||
|
ensureAssetUrl?: (...args: any[]) => Promise<unknown>;
|
||||||
|
getDocumentAsset?: (...args: any[]) => unknown;
|
||||||
|
handleNavigatorSnapshot?: (...args: any[]) => void;
|
||||||
|
cardPointerHandlers?: React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
onInspectDocument?: (id: string | number) => void;
|
||||||
|
onTagDragEnter?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
|
||||||
|
onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
|
||||||
|
onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
|
||||||
|
onTagDrop?: (event: React.DragEvent<HTMLDivElement>, doc: DocumentLike) => void;
|
||||||
|
onDocTagPointerDown?: (event: React.PointerEvent<HTMLElement>, doc: DocumentLike, tag: any) => void;
|
||||||
|
onDocTagDragStart?: (event: React.DragEvent<HTMLElement>, doc: DocumentLike, tag: any) => void;
|
||||||
|
onDocTagDrag?: (event: React.DragEvent<HTMLElement>) => void;
|
||||||
|
onDocTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
||||||
|
pendingRemovalTag?: PendingRemovalTag | null;
|
||||||
|
registerNode?: (node: HTMLDivElement | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
||||||
doc,
|
doc,
|
||||||
style,
|
style,
|
||||||
shouldLoad,
|
shouldLoad,
|
||||||
@@ -18,7 +57,7 @@ const DesktopDocumentCard = ({
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
handleNavigatorSnapshot,
|
handleNavigatorSnapshot,
|
||||||
cardPointerHandlers,
|
cardPointerHandlers,
|
||||||
onDocumentOpen,
|
onInspectDocument,
|
||||||
onTagDragEnter,
|
onTagDragEnter,
|
||||||
onTagDragOver,
|
onTagDragOver,
|
||||||
onTagDragLeave,
|
onTagDragLeave,
|
||||||
@@ -52,16 +91,33 @@ const DesktopDocumentCard = ({
|
|||||||
data-doc-id={doc.id}
|
data-doc-id={doc.id}
|
||||||
data-tag-ids={dataTagIds}
|
data-tag-ids={dataTagIds}
|
||||||
aria-hidden={ariaHidden}
|
aria-hidden={ariaHidden}
|
||||||
ref={registerNode}
|
ref={registerNode ?? undefined}
|
||||||
{...cardPointerHandlers}
|
{...cardPointerHandlers}
|
||||||
onDragEnter={(event) => onTagDragEnter(event, doc.id)}
|
onDragEnter={(event) => {
|
||||||
onDragOver={(event) => onTagDragOver(event, doc.id)}
|
if (doc?.id == null) {
|
||||||
onDragLeave={(event) => onTagDragLeave(event, doc.id)}
|
return;
|
||||||
onDrop={(event) => onTagDrop(event, doc)}
|
}
|
||||||
|
onTagDragEnter?.(event, doc.id);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (doc?.id == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onTagDragOver?.(event, doc.id);
|
||||||
|
}}
|
||||||
|
onDragLeave={(event) => {
|
||||||
|
if (doc?.id == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onTagDragLeave?.(event, doc.id);
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
onTagDrop?.(event, doc);
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
preventAll(event);
|
preventAll(event);
|
||||||
onDocumentOpen?.(doc.id);
|
onInspectDocument?.(doc.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -107,11 +163,11 @@ const DesktopDocumentCard = ({
|
|||||||
draggable
|
draggable
|
||||||
data-desk-tag-chip="true"
|
data-desk-tag-chip="true"
|
||||||
onPointerDownCapture={(event) => {
|
onPointerDownCapture={(event) => {
|
||||||
onDocTagPointerDown(event, doc, tag);
|
onDocTagPointerDown?.(event, doc, tag);
|
||||||
}}
|
}}
|
||||||
onDragStart={(event) => onDocTagDragStart(event, doc, tag)}
|
onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)}
|
||||||
onDrag={onDocTagDrag}
|
onDrag={onDocTagDrag}
|
||||||
onDragEnd={(event) => onDocTagDragEnd(event)}
|
onDragEnd={(event) => onDocTagDragEnd?.(event)}
|
||||||
>
|
>
|
||||||
<span className="tag-chip__label">{tag.label}</span>
|
<span className="tag-chip__label">{tag.label}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
|
||||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
|
||||||
import { preventAll } from './events';
|
|
||||||
|
|
||||||
const DesktopPreviewCard = ({
|
|
||||||
doc,
|
|
||||||
title,
|
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
prefetch = 3,
|
|
||||||
onNavigatorSnapshot,
|
|
||||||
shouldLoad = true,
|
|
||||||
}) => {
|
|
||||||
const navigator = useAssetNavigator({
|
|
||||||
document: doc,
|
|
||||||
assetType: 'preview',
|
|
||||||
ensureAssetUrl: shouldLoad ? ensureAssetUrl : null,
|
|
||||||
getAsset: getDocumentAsset,
|
|
||||||
prefetch,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator;
|
|
||||||
const docId = doc?.id ?? null;
|
|
||||||
|
|
||||||
const metadataWidth = Number(currentMetadata?.width);
|
|
||||||
const metadataHeight = Number(currentMetadata?.height);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!onNavigatorSnapshot || !docId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const snapshot = {
|
|
||||||
url: currentUrl || null,
|
|
||||||
alt: title,
|
|
||||||
canGoPrev,
|
|
||||||
canGoNext,
|
|
||||||
goPrev: navigator.goPrev,
|
|
||||||
goNext: navigator.goNext,
|
|
||||||
ordinal,
|
|
||||||
width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null,
|
|
||||||
height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null,
|
|
||||||
};
|
|
||||||
onNavigatorSnapshot(docId, snapshot);
|
|
||||||
return () => onNavigatorSnapshot(docId, null);
|
|
||||||
}, [
|
|
||||||
docId,
|
|
||||||
currentUrl,
|
|
||||||
title,
|
|
||||||
canGoPrev,
|
|
||||||
canGoNext,
|
|
||||||
ordinal,
|
|
||||||
metadataWidth,
|
|
||||||
metadataHeight,
|
|
||||||
navigator.goPrev,
|
|
||||||
navigator.goNext,
|
|
||||||
onNavigatorSnapshot,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hasPreview = Boolean(currentUrl);
|
|
||||||
const cardClasses = ['desk-item__card'];
|
|
||||||
if (!hasPreview) cardClasses.push('desk-item__card--empty');
|
|
||||||
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cardClasses.join(' ')}
|
|
||||||
onDragStart={(event) => {
|
|
||||||
if (event instanceof DragEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasPreview ? (
|
|
||||||
<img
|
|
||||||
src={currentUrl}
|
|
||||||
alt={title}
|
|
||||||
draggable={false}
|
|
||||||
onDragStart={(event) => event.preventDefault()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="desk-item__empty">
|
|
||||||
<div className="desk-item__placeholder">DOC</div>
|
|
||||||
<div className="desk-item__title" title={title}>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showNav ? (
|
|
||||||
<div className="desk-card__nav">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="desk-card__nav-button"
|
|
||||||
onClick={(event) => {
|
|
||||||
preventAll(event);
|
|
||||||
navigator.goPrev();
|
|
||||||
}}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
preventAll(event);
|
|
||||||
}}
|
|
||||||
onPointerUp={(event) => {
|
|
||||||
preventAll(event);
|
|
||||||
}}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
preventAll(event);
|
|
||||||
}}
|
|
||||||
onMouseUp={(event) => {
|
|
||||||
preventAll(event);
|
|
||||||
}}
|
|
||||||
disabled={!canGoPrev}
|
|
||||||
aria-label="Previous preview"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="desk-card__nav-button"
|
|
||||||
onClick={(event) => {
|
|
||||||
preventAll(event);
|
|
||||||
navigator.goNext();
|
|
||||||
}}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
preventAll(event);
|
|
||||||
}}
|
|
||||||
onPointerUp={(event) => {
|
|
||||||
preventAll(event);
|
|
||||||
}}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
preventAll(event);
|
|
||||||
}}
|
|
||||||
onMouseUp={(event) => {
|
|
||||||
preventAll(event);
|
|
||||||
}}
|
|
||||||
disabled={!canGoNext}
|
|
||||||
aria-label="Next preview"
|
|
||||||
>
|
|
||||||
<ArrowRightIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DesktopPreviewCard;
|
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { JSX } from 'react';
|
||||||
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
|
||||||
|
interface DocumentLike {
|
||||||
|
id?: Identifier;
|
||||||
|
title?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetLike {
|
||||||
|
id?: Identifier;
|
||||||
|
url?: string | null;
|
||||||
|
expires_at?: number | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnsureAssetUrl = (
|
||||||
|
documentId: Identifier,
|
||||||
|
asset: AssetLike,
|
||||||
|
options?: { force?: boolean; [key: string]: unknown },
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
type GetDocumentAsset = (document: DocumentLike | null, assetType: string) => AssetLike | null;
|
||||||
|
|
||||||
|
interface NavigatorSnapshot {
|
||||||
|
url: string | null;
|
||||||
|
alt?: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesktopPreviewCardProps {
|
||||||
|
doc: DocumentLike | null;
|
||||||
|
title?: string;
|
||||||
|
ensureAssetUrl?: EnsureAssetUrl | null;
|
||||||
|
getDocumentAsset: GetDocumentAsset;
|
||||||
|
onNavigatorSnapshot?: (docId: Identifier, snapshot: NavigatorSnapshot | null) => void;
|
||||||
|
shouldLoad?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DesktopPreviewCard = ({
|
||||||
|
doc,
|
||||||
|
title,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
onNavigatorSnapshot,
|
||||||
|
shouldLoad = true,
|
||||||
|
}: DesktopPreviewCardProps): JSX.Element => {
|
||||||
|
const navigator = useAssetNavigator({
|
||||||
|
document: doc,
|
||||||
|
assetType: 'thumbnail',
|
||||||
|
ensureAssetUrl: shouldLoad ? ensureAssetUrl : null,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { currentUrl, currentMetadata } = navigator;
|
||||||
|
const docId = doc?.id ?? null;
|
||||||
|
|
||||||
|
const metadataWidth = Number((currentMetadata as { width?: number } | null)?.width);
|
||||||
|
const metadataHeight = Number((currentMetadata as { height?: number } | null)?.height);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onNavigatorSnapshot || !docId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!currentUrl) {
|
||||||
|
onNavigatorSnapshot(docId, null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const snapshot = {
|
||||||
|
url: currentUrl,
|
||||||
|
alt: title,
|
||||||
|
width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null,
|
||||||
|
height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null,
|
||||||
|
};
|
||||||
|
onNavigatorSnapshot(docId, snapshot);
|
||||||
|
return () => onNavigatorSnapshot(docId, null);
|
||||||
|
}, [
|
||||||
|
docId,
|
||||||
|
currentUrl,
|
||||||
|
title,
|
||||||
|
metadataWidth,
|
||||||
|
metadataHeight,
|
||||||
|
onNavigatorSnapshot,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasPreview = Boolean(currentUrl);
|
||||||
|
const cardClasses = ['desk-item__card'];
|
||||||
|
if (!hasPreview) cardClasses.push('desk-item__card--empty');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cardClasses.join(' ')}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (event instanceof DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasPreview ? (
|
||||||
|
<img
|
||||||
|
src={currentUrl}
|
||||||
|
alt={title}
|
||||||
|
draggable={false}
|
||||||
|
onDragStart={(event) => event.preventDefault()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="desk-item__empty">
|
||||||
|
<div className="desk-item__placeholder">DOC</div>
|
||||||
|
<div className="desk-item__title" title={title}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DesktopPreviewCard;
|
||||||
+334
-130
@@ -8,6 +8,7 @@ import React, {
|
|||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||||
|
import type { EnsureAssetUrl, GetAsset } from '../asset_manager';
|
||||||
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';
|
||||||
@@ -28,50 +29,212 @@ import '../styles/workspace/workspace-layout.css';
|
|||||||
import '../styles/workspace/workspace-items.css';
|
import '../styles/workspace/workspace-items.css';
|
||||||
import '../styles/workspace/workspace-cards.css';
|
import '../styles/workspace/workspace-cards.css';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
|
||||||
|
type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null;
|
||||||
|
type DocumentLinkLike = { url?: string | null; contentType?: string | null };
|
||||||
|
type OverlaySource = { url: string; alt?: string | null; contentType?: string | null };
|
||||||
|
|
||||||
|
export interface DeskDocument {
|
||||||
|
id?: Identifier | null;
|
||||||
|
title?: string;
|
||||||
|
tags?: TagLike[] | null;
|
||||||
|
documentLink?: OverlaySource | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigatorSnapshot {
|
||||||
|
url: string | null;
|
||||||
|
alt?: string | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OverlayOriginHint = {
|
||||||
|
rotation?: number;
|
||||||
|
scale?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OverlayOriginTransform {
|
||||||
|
rotation: number;
|
||||||
|
scaleX: number;
|
||||||
|
scaleY: number;
|
||||||
|
baseWidth: number;
|
||||||
|
baseHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverlayDisplay {
|
||||||
|
url: string;
|
||||||
|
alt?: string | null;
|
||||||
|
contentType?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentSizeInfo {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
source?: 'snapshot' | 'metadata' | 'fallback';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewMetadataEntry {
|
||||||
|
docId: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragTransformOverride {
|
||||||
|
centerX?: number;
|
||||||
|
centerY?: number;
|
||||||
|
rotation?: number;
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragSettings {
|
||||||
|
canvasPadding: number;
|
||||||
|
defaultCanvasWidth: number;
|
||||||
|
defaultCanvasHeight: number;
|
||||||
|
debugDrag?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutEntry {
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
rotation: number;
|
||||||
|
z: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceSnapshotState = {
|
||||||
|
layout: Map<string, LayoutEntry>;
|
||||||
|
canvasSize: { width: number; height: number };
|
||||||
|
visibleDocIds: Set<string>;
|
||||||
|
draggingId: string | null;
|
||||||
|
tagDropTargetId: string | null;
|
||||||
|
pendingTagDocId: string | null;
|
||||||
|
pendingRemovalTag: unknown;
|
||||||
|
initialLoadDone: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DesktopWorkspaceProps {
|
||||||
|
documents?: DeskDocument[];
|
||||||
|
onInspectDocument?: (...args: unknown[]) => void;
|
||||||
|
onEntryPointer?: (...args: unknown[]) => void;
|
||||||
|
onDocumentStackSelect?: (docIds: Identifier[]) => void;
|
||||||
|
onPromoteSelection?: (...args: unknown[]) => void;
|
||||||
|
onAssignTagToDocument?: (...args: unknown[]) => void;
|
||||||
|
ensureAssetUrl?: EnsureAssetUrl;
|
||||||
|
getDocumentAsset?: GetAsset;
|
||||||
|
activeTagIds?: Array<Identifier | null>;
|
||||||
|
selectedDocumentIds?: Identifier[];
|
||||||
|
onClearSelection?: () => void;
|
||||||
|
tenantId?: Identifier | null;
|
||||||
|
viewId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesktopWorkspaceViewProps {
|
||||||
|
engine: WorkspaceEngine;
|
||||||
|
items: DeskDocument[];
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
handleCanvasDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
handleCanvasDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
handleCanvasDrop: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
ensureDocumentSize: (doc: DeskDocument | null) => DocumentSizeInfo | null;
|
||||||
|
layoutSnapshot: Map<string, LayoutEntry>;
|
||||||
|
layoutRef: React.MutableRefObject<Map<string, LayoutEntry>>;
|
||||||
|
dragTransformsRef: React.MutableRefObject<Map<string, DragTransformOverride>>;
|
||||||
|
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||||
|
visibleDocIds: Set<string>;
|
||||||
|
draggingId: string | null;
|
||||||
|
tagDropTargetId: string | null;
|
||||||
|
pendingTagDocId: string | null;
|
||||||
|
pendingRemovalTag: unknown;
|
||||||
|
ensureAssetUrl?: DesktopWorkspaceProps['ensureAssetUrl'];
|
||||||
|
getDocumentAsset?: DesktopWorkspaceProps['getDocumentAsset'];
|
||||||
|
handleNavigatorSnapshot: (docId: Identifier | null, snapshot: NavigatorSnapshot | null) => void;
|
||||||
|
activeTagSet: Set<string>;
|
||||||
|
handleTagDragEnterDoc: (...args: unknown[]) => void;
|
||||||
|
handleTagDragOverDoc: (...args: unknown[]) => void;
|
||||||
|
handleTagDragLeaveDoc: (...args: unknown[]) => void;
|
||||||
|
handleTagDropOnDoc: (...args: unknown[]) => void;
|
||||||
|
handleDocTagPointerDown: (...args: unknown[]) => void;
|
||||||
|
handleDocTagDragStart: (...args: unknown[]) => void;
|
||||||
|
handleDocTagDrag: (...args: unknown[]) => void;
|
||||||
|
handleDocTagDragEnd: (...args: unknown[]) => void;
|
||||||
|
overlayDisplay: OverlayDisplay | null;
|
||||||
|
closeOverlay: () => void;
|
||||||
|
overlayOriginRect: DOMRect | null;
|
||||||
|
overlayOriginTransform: OverlayOriginTransform | null;
|
||||||
|
overlayDocument: DeskDocument | null;
|
||||||
|
onEntryPointer?: DesktopWorkspaceProps['onEntryPointer'];
|
||||||
|
onDocumentStackSelect?: DesktopWorkspaceProps['onDocumentStackSelect'];
|
||||||
|
onPromoteSelection?: DesktopWorkspaceProps['onPromoteSelection'];
|
||||||
|
selectedDocumentIds: Identifier[];
|
||||||
|
onClearSelection?: DesktopWorkspaceProps['onClearSelection'];
|
||||||
|
documentLookup: Map<string, DeskDocument>;
|
||||||
|
resolveBaseMetrics: (doc: DeskDocument | null, cardWidth: number, cardHeight: number) => {
|
||||||
|
baseWidth: number;
|
||||||
|
baseHeight: number;
|
||||||
|
baseScale: number;
|
||||||
|
};
|
||||||
|
bringToFront: (docId: Identifier | null) => void;
|
||||||
|
setDraggingId: (value: string | null) => void;
|
||||||
|
canvasSize: { width: number; height: number };
|
||||||
|
openOverlayForDoc: (docId: Identifier | null, originInfo?: OverlayOriginHint | null) => void;
|
||||||
|
recalcVisibleDocIds: () => void;
|
||||||
|
dragSettings: DragSettings;
|
||||||
|
onInspectDocument?: DesktopWorkspaceProps['onInspectDocument'];
|
||||||
|
markLayoutDirty: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
const DEBUG_DRAG = false;
|
const DEBUG_DRAG = false;
|
||||||
const DEBUG_FOCUS = false;
|
const DEBUG_FOCUS = false;
|
||||||
const DesktopWorkspace = ({
|
const defaultGetDocumentAsset: GetAsset = () => null;
|
||||||
|
|
||||||
|
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||||
documents = [],
|
documents = [],
|
||||||
searchResults = null,
|
|
||||||
onDocumentOpen,
|
|
||||||
onInspectDocument = null,
|
onInspectDocument = null,
|
||||||
onEntryPointer = null,
|
onEntryPointer = null,
|
||||||
onDocumentStackSelect = null,
|
onDocumentStackSelect = null,
|
||||||
onPromoteSelection = null,
|
onPromoteSelection = null,
|
||||||
onAssignTagToDocument = null,
|
onAssignTagToDocument = null,
|
||||||
onRemoveTagFromDocument = null,
|
|
||||||
ensureAssetUrl = null,
|
ensureAssetUrl = null,
|
||||||
getDocumentAsset = () => null,
|
getDocumentAsset = defaultGetDocumentAsset,
|
||||||
activeTagIds = [],
|
activeTagIds = [],
|
||||||
selectedDocumentIds = [],
|
selectedDocumentIds = [],
|
||||||
onClearSelection = null,
|
onClearSelection = null,
|
||||||
detailPanelOpen = false,
|
|
||||||
onCloseDetailPanel = null,
|
|
||||||
tenantId = null,
|
tenantId = null,
|
||||||
viewId = 'default',
|
viewId = 'default',
|
||||||
|
documentLinks,
|
||||||
|
ensureDownloadUrl,
|
||||||
}) => {
|
}) => {
|
||||||
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
const items = useMemo<DeskDocument[]>(() => documents, [documents]);
|
||||||
|
|
||||||
const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:'));
|
const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:'));
|
||||||
|
const documentLinkMap = documentLinks instanceof Map ? documentLinks : null;
|
||||||
|
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const itemRefs = useRef(new Map());
|
const itemRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||||
const dragTransformsRef = useRef(new Map());
|
const dragTransformsRef = useRef<Map<string, DragTransformOverride>>(new Map());
|
||||||
const [overlayDocId, setOverlayDocId] = useState(null);
|
const [overlayDocId, setOverlayDocId] = useState<string | null>(null);
|
||||||
const [overlayOriginRect, setOverlayOriginRect] = useState(null);
|
const [overlayOriginRect, setOverlayOriginRect] = useState<DOMRect | null>(null);
|
||||||
const [overlayOriginTransform, setOverlayOriginTransform] = useState(null);
|
const [overlayOriginTransform, setOverlayOriginTransform] = useState<OverlayOriginTransform | null>(
|
||||||
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map());
|
null,
|
||||||
|
);
|
||||||
|
const [overlaySource, setOverlaySource] = useState<OverlaySource | null>(null);
|
||||||
|
const [, setPreviewSnapshots] = useState<Map<string, NavigatorSnapshot>>(() => new Map());
|
||||||
const [docSizeVersion, setDocSizeVersion] = useState(0);
|
const [docSizeVersion, setDocSizeVersion] = useState(0);
|
||||||
const docSizeMapRef = useRef(new Map());
|
const docSizeMapRef = useRef<Map<string, DocumentSizeInfo>>(new Map());
|
||||||
const ensureDocumentSize = useCallback((doc) => {
|
const ensureDocumentSize = useCallback((doc: DeskDocument | null): DocumentSizeInfo | null => {
|
||||||
if (!doc?.id) {
|
if (!doc?.id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return docSizeMapRef.current.get(String(doc.id)) || null;
|
return docSizeMapRef.current.get(String(doc.id)) || null;
|
||||||
}, []);
|
}, []);
|
||||||
const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl);
|
const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl);
|
||||||
const documentLookup = useMemo(() => {
|
const documentLookup = useMemo<Map<string, DeskDocument>>(() => {
|
||||||
const map = new Map();
|
const map = new Map<string, DeskDocument>();
|
||||||
items.forEach((doc) => {
|
items.forEach((doc) => {
|
||||||
const key = doc?.id != null ? String(doc.id) : null;
|
const key = doc?.id != null ? String(doc.id) : null;
|
||||||
if (key) {
|
if (key) {
|
||||||
@@ -81,7 +244,7 @@ const DesktopWorkspace = ({
|
|||||||
return map;
|
return map;
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
const engineRef = useRef(null);
|
const engineRef = useRef<WorkspaceEngine | null>(null);
|
||||||
if (!engineRef.current) {
|
if (!engineRef.current) {
|
||||||
engineRef.current = new WorkspaceEngine({
|
engineRef.current = new WorkspaceEngine({
|
||||||
allowLayoutPersistence,
|
allowLayoutPersistence,
|
||||||
@@ -89,16 +252,15 @@ const DesktopWorkspace = ({
|
|||||||
viewId,
|
viewId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const engine = engineRef.current;
|
const engine = engineRef.current as WorkspaceEngine;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
engine.updateConfig({ allowLayoutPersistence, tenantId, viewId });
|
engine.updateConfig({ allowLayoutPersistence, tenantId, viewId });
|
||||||
}, [engine, allowLayoutPersistence, tenantId, viewId]);
|
}, [engine, allowLayoutPersistence, tenantId, viewId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextItems = searchResults ? searchResults : documents;
|
engine.setItems(items || []);
|
||||||
engine.setItems(nextItems || []);
|
}, [engine, items]);
|
||||||
}, [engine, documents, searchResults]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
@@ -115,7 +277,7 @@ const DesktopWorkspace = ({
|
|||||||
engine.setEnsureDocumentSize(ensureDocumentSize);
|
engine.setEnsureDocumentSize(ensureDocumentSize);
|
||||||
}, [engine, ensureDocumentSize]);
|
}, [engine, ensureDocumentSize]);
|
||||||
|
|
||||||
const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore);
|
const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore) as WorkspaceSnapshotState;
|
||||||
const {
|
const {
|
||||||
layout: layoutSnapshot,
|
layout: layoutSnapshot,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
@@ -141,10 +303,10 @@ const DesktopWorkspace = ({
|
|||||||
engine.setItemRefs(itemRefs);
|
engine.setItemRefs(itemRefs);
|
||||||
}, [engine, itemRefs]);
|
}, [engine, itemRefs]);
|
||||||
|
|
||||||
const layoutRef = useRef(layoutSnapshot);
|
const layoutRef = useRef<Map<string, LayoutEntry>>(layoutSnapshot);
|
||||||
layoutRef.current = engine.layout;
|
layoutRef.current = engine.layout as Map<string, LayoutEntry>;
|
||||||
|
|
||||||
const bringToFront = useCallback((docId) => {
|
const bringToFront = useCallback((docId: Identifier | null) => {
|
||||||
engine.bringToFront(docId);
|
engine.bringToFront(docId);
|
||||||
}, [engine]);
|
}, [engine]);
|
||||||
|
|
||||||
@@ -156,11 +318,11 @@ const DesktopWorkspace = ({
|
|||||||
engine.recalcVisibleDocIds();
|
engine.recalcVisibleDocIds();
|
||||||
}, [engine]);
|
}, [engine]);
|
||||||
|
|
||||||
const setDraggingId = useCallback((value) => {
|
const setDraggingId = useCallback((value: string | number | null) => {
|
||||||
engine.setDraggingId(value);
|
engine.setDraggingId(value);
|
||||||
}, [engine]);
|
}, [engine]);
|
||||||
|
|
||||||
const applySnapshotDimensions = useCallback((docKey, snapshot) => {
|
const applySnapshotDimensions = useCallback((docKey: string, snapshot: NavigatorSnapshot | null) => {
|
||||||
const width = Number(snapshot?.width);
|
const width = Number(snapshot?.width);
|
||||||
const height = Number(snapshot?.height);
|
const height = Number(snapshot?.height);
|
||||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
@@ -174,13 +336,13 @@ const DesktopWorkspace = ({
|
|||||||
if (existing && existing.width === normalized.width && existing.height === normalized.height) {
|
if (existing && existing.width === normalized.width && existing.height === normalized.height) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = new Map(docSizeMapRef.current);
|
const next = new Map<string, DocumentSizeInfo>(docSizeMapRef.current);
|
||||||
next.set(docKey, { ...normalized, source: 'snapshot' });
|
next.set(docKey, { ...normalized, source: 'snapshot' });
|
||||||
docSizeMapRef.current = next;
|
docSizeMapRef.current = next;
|
||||||
setDocSizeVersion((value) => value + 1);
|
setDocSizeVersion((value) => value + 1);
|
||||||
}, []);
|
}, []);
|
||||||
const handleNavigatorSnapshot = useCallback(
|
const handleNavigatorSnapshot = useCallback(
|
||||||
(docId, snapshot) => {
|
(docId: Identifier | null, snapshot: NavigatorSnapshot | null) => {
|
||||||
const docKey = docId != null ? String(docId) : null;
|
const docKey = docId != null ? String(docId) : null;
|
||||||
if (!docKey) {
|
if (!docKey) {
|
||||||
return;
|
return;
|
||||||
@@ -202,11 +364,6 @@ const DesktopWorkspace = ({
|
|||||||
prevSnapshot &&
|
prevSnapshot &&
|
||||||
prevSnapshot.url === snapshot.url &&
|
prevSnapshot.url === snapshot.url &&
|
||||||
prevSnapshot.alt === snapshot.alt &&
|
prevSnapshot.alt === snapshot.alt &&
|
||||||
prevSnapshot.canGoPrev === snapshot.canGoPrev &&
|
|
||||||
prevSnapshot.canGoNext === snapshot.canGoNext &&
|
|
||||||
prevSnapshot.goPrev === snapshot.goPrev &&
|
|
||||||
prevSnapshot.goNext === snapshot.goNext &&
|
|
||||||
prevSnapshot.ordinal === snapshot.ordinal &&
|
|
||||||
prevSnapshot.width === snapshot.width &&
|
prevSnapshot.width === snapshot.width &&
|
||||||
prevSnapshot.height === snapshot.height;
|
prevSnapshot.height === snapshot.height;
|
||||||
if (sameSnapshot) {
|
if (sameSnapshot) {
|
||||||
@@ -222,11 +379,11 @@ const DesktopWorkspace = ({
|
|||||||
},
|
},
|
||||||
[applySnapshotDimensions],
|
[applySnapshotDimensions],
|
||||||
);
|
);
|
||||||
const activeTagSet = useMemo(() => {
|
const activeTagSet = useMemo<Set<string>>(() => {
|
||||||
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
|
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
const set = new Set();
|
const set = new Set<string>();
|
||||||
activeTagIds.forEach((id) => {
|
activeTagIds.forEach((id) => {
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
set.add(String(id));
|
set.add(String(id));
|
||||||
@@ -251,20 +408,13 @@ const DesktopWorkspace = ({
|
|||||||
|
|
||||||
commitSize();
|
commitSize();
|
||||||
|
|
||||||
if (typeof ResizeObserver === 'undefined') {
|
|
||||||
window.addEventListener('resize', commitSize);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', commitSize);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(commitSize);
|
const observer = new ResizeObserver(commitSize);
|
||||||
observer.observe(container);
|
observer.observe(container);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [engine]);
|
}, [engine]);
|
||||||
|
|
||||||
const resolvePreviewDimensions = useCallback(
|
const resolvePreviewDimensions = useCallback(
|
||||||
(doc) => {
|
(doc: DeskDocument | null): PreviewMetadataEntry | null => {
|
||||||
if (!doc?.id) {
|
if (!doc?.id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -283,7 +433,7 @@ const DesktopWorkspace = ({
|
|||||||
if (!doc) {
|
if (!doc) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolveDocumentAssetUrl(doc, 'preview', {
|
resolveDocumentAssetUrl(doc, 'thumbnail', {
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getAsset: getDocumentAsset,
|
getAsset: getDocumentAsset,
|
||||||
});
|
});
|
||||||
@@ -292,40 +442,33 @@ const DesktopWorkspace = ({
|
|||||||
|
|
||||||
const requestCanvasFocus = useCallback(() => {
|
const requestCanvasFocus = useCallback(() => {
|
||||||
const canvas = containerRef.current;
|
const canvas = containerRef.current;
|
||||||
if (!canvas || typeof canvas.focus !== 'function') {
|
if (!canvas?.focus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusTarget = () => {
|
const focusTarget = () => {
|
||||||
try {
|
try {
|
||||||
canvas.focus({ preventScroll: true });
|
canvas.focus({ preventScroll: true });
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
if (DEBUG_FOCUS) {
|
if (DEBUG_FOCUS) {
|
||||||
void error;
|
void error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
const raf = window.requestAnimationFrame;
|
||||||
focusTarget();
|
if (raf) {
|
||||||
|
raf(() => focusTarget());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window.requestAnimationFrame === 'function') {
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
focusTarget();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
focusTarget();
|
focusTarget();
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tagInteractions = useDeskTagInteractions({
|
const tagInteractions = useDeskTagInteractions({
|
||||||
engine,
|
engine,
|
||||||
onAssignTagToDocument,
|
onAssignTagToDocument,
|
||||||
onRemoveTagFromDocument,
|
|
||||||
requestCanvasFocus,
|
requestCanvasFocus,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -345,7 +488,7 @@ const DesktopWorkspace = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const current = docSizeMapRef.current;
|
const current = docSizeMapRef.current;
|
||||||
const next = new Map(current);
|
const next = new Map<string, DocumentSizeInfo>(current);
|
||||||
const itemKeys = new Set(items.filter((doc) => doc?.id != null).map((doc) => String(doc.id)));
|
const itemKeys = new Set(items.filter((doc) => doc?.id != null).map((doc) => String(doc.id)));
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
@@ -392,30 +535,77 @@ const DesktopWorkspace = ({
|
|||||||
}
|
}
|
||||||
}, [items, previewMetadata]);
|
}, [items, previewMetadata]);
|
||||||
|
|
||||||
const overlayDisplay = useMemo(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
if (!overlayDocId) {
|
if (!overlayDocId) {
|
||||||
return null;
|
setOverlaySource(null);
|
||||||
}
|
return () => {
|
||||||
const snapshot = previewSnapshots.get(overlayDocId);
|
cancelled = true;
|
||||||
if (!snapshot || !snapshot.url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const doc = documentLookup.get(overlayDocId);
|
|
||||||
const alt = snapshot.alt || doc?.title;
|
|
||||||
return {
|
|
||||||
url: snapshot.url,
|
|
||||||
alt,
|
|
||||||
canGoPrev: snapshot.canGoPrev,
|
|
||||||
canGoNext: snapshot.canGoNext,
|
|
||||||
goPrev: snapshot.goPrev,
|
|
||||||
goNext: snapshot.goNext,
|
|
||||||
};
|
};
|
||||||
}, [overlayDocId, previewSnapshots, documentLookup]);
|
}
|
||||||
|
|
||||||
|
const doc = documentLookup.get(overlayDocId) || null;
|
||||||
|
const docIdentifier = doc?.id ?? null;
|
||||||
|
if (!docIdentifier || !doc) {
|
||||||
|
setOverlaySource(null);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const docContentType = doc?.content_type ?? null;
|
||||||
|
const versionContentType = (doc?.current_version as { version?: { content_type?: string | null } } | null)?.version?.content_type ?? null;
|
||||||
|
|
||||||
|
const applyEntry = (entry?: DocumentLinkLike | null) => {
|
||||||
|
if (!entry?.url) {
|
||||||
|
setOverlaySource(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOverlaySource({
|
||||||
|
url: entry.url,
|
||||||
|
alt: doc.title as string | undefined,
|
||||||
|
contentType: entry.contentType || docContentType || versionContentType || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cachedEntry = documentLinkMap?.get(docIdentifier) || null;
|
||||||
|
if (cachedEntry?.url) {
|
||||||
|
applyEntry(cachedEntry);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureDownloadUrl) {
|
||||||
|
setOverlaySource(null);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDownloadUrl(docIdentifier)
|
||||||
|
.then((entry) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyEntry(entry);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setOverlaySource(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [overlayDocId, documentLookup, documentLinkMap, ensureDownloadUrl]);
|
||||||
|
|
||||||
const closeOverlay = useCallback(() => {
|
const closeOverlay = useCallback(() => {
|
||||||
setOverlayDocId(null);
|
setOverlayDocId(null);
|
||||||
setOverlayOriginRect(null);
|
setOverlayOriginRect(null);
|
||||||
setOverlayOriginTransform(null);
|
setOverlayOriginTransform(null);
|
||||||
|
setOverlaySource(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -427,7 +617,7 @@ const DesktopWorkspace = ({
|
|||||||
}, [overlayDocId, documentLookup]);
|
}, [overlayDocId, documentLookup]);
|
||||||
|
|
||||||
const resolveBaseMetrics = useCallback(
|
const resolveBaseMetrics = useCallback(
|
||||||
(doc, cardWidth, cardHeight) => {
|
(doc: DeskDocument | null, cardWidth: number, cardHeight: number) => {
|
||||||
const previewDims = doc ? resolvePreviewDimensions(doc) : null;
|
const previewDims = doc ? resolvePreviewDimensions(doc) : null;
|
||||||
if (previewDims?.width && previewDims?.height) {
|
if (previewDims?.width && previewDims?.height) {
|
||||||
const baseWidth = Math.max(previewDims.width, cardWidth);
|
const baseWidth = Math.max(previewDims.width, cardWidth);
|
||||||
@@ -457,22 +647,39 @@ const DesktopWorkspace = ({
|
|||||||
}
|
}
|
||||||
}, [draggingId, items, setDraggingId]);
|
}, [draggingId, items, setDraggingId]);
|
||||||
|
|
||||||
|
const overlayDisplay = useMemo<OverlayDisplay | null>(() => {
|
||||||
|
if (!overlaySource) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return overlaySource;
|
||||||
|
}, [overlaySource]);
|
||||||
|
|
||||||
|
const overlayDocument = useMemo<DeskDocument | null>(() => {
|
||||||
|
if (!overlayDocId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const baseDoc = documentLookup.get(String(overlayDocId)) || null;
|
||||||
|
if (baseDoc && overlayDisplay?.url) {
|
||||||
|
return { ...baseDoc, documentLink: overlayDisplay };
|
||||||
|
}
|
||||||
|
return baseDoc;
|
||||||
|
}, [documentLookup, overlayDisplay, overlayDocId]);
|
||||||
|
|
||||||
const openOverlayForDoc = useCallback(
|
const openOverlayForDoc = useCallback(
|
||||||
(docId, originInfo = null) => {
|
(docId: Identifier | null, originInfo: OverlayOriginHint | null = null) => {
|
||||||
if (!docId) {
|
if (!docId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const docKey = String(docId);
|
const docKey = String(docId);
|
||||||
const snapshot = previewSnapshots.get(docKey);
|
|
||||||
if (!snapshot || !snapshot.url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const container = itemRefs.current.get(docKey);
|
const container = itemRefs.current.get(docKey);
|
||||||
const imageNode = container?.querySelector?.('.desk-item__card img');
|
if (!container) {
|
||||||
if (!container || !imageNode) {
|
return;
|
||||||
|
}
|
||||||
|
const imageNode = container.querySelector<HTMLImageElement>('.desk-item__card img');
|
||||||
|
const rect = (imageNode || container).getBoundingClientRect();
|
||||||
|
if (!rect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rect = imageNode.getBoundingClientRect();
|
|
||||||
let originTransform = null;
|
let originTransform = null;
|
||||||
if (originInfo) {
|
if (originInfo) {
|
||||||
const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo;
|
const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo;
|
||||||
@@ -510,7 +717,6 @@ const DesktopWorkspace = ({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
bringToFront,
|
bringToFront,
|
||||||
previewSnapshots,
|
|
||||||
itemRefs,
|
itemRefs,
|
||||||
setOverlayOriginTransform,
|
setOverlayOriginTransform,
|
||||||
ensureDocumentSize,
|
ensureDocumentSize,
|
||||||
@@ -520,7 +726,7 @@ const DesktopWorkspace = ({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const dragSettings = useMemo(
|
const dragSettings = useMemo<DragSettings>(
|
||||||
() => ({
|
() => ({
|
||||||
canvasPadding: DESK_CANVAS_PADDING,
|
canvasPadding: DESK_CANVAS_PADDING,
|
||||||
defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH,
|
defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH,
|
||||||
@@ -530,7 +736,7 @@ const DesktopWorkspace = ({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const viewProps = useMemo(
|
const viewProps = useMemo<DesktopWorkspaceViewProps>(
|
||||||
() => ({
|
() => ({
|
||||||
engine,
|
engine,
|
||||||
items,
|
items,
|
||||||
@@ -548,7 +754,6 @@ const DesktopWorkspace = ({
|
|||||||
tagDropTargetId,
|
tagDropTargetId,
|
||||||
pendingTagDocId,
|
pendingTagDocId,
|
||||||
pendingRemovalTag,
|
pendingRemovalTag,
|
||||||
onDocumentOpen,
|
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
handleNavigatorSnapshot,
|
handleNavigatorSnapshot,
|
||||||
@@ -565,13 +770,12 @@ const DesktopWorkspace = ({
|
|||||||
closeOverlay,
|
closeOverlay,
|
||||||
overlayOriginRect,
|
overlayOriginRect,
|
||||||
overlayOriginTransform,
|
overlayOriginTransform,
|
||||||
|
overlayDocument,
|
||||||
onEntryPointer,
|
onEntryPointer,
|
||||||
onDocumentStackSelect,
|
onDocumentStackSelect,
|
||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
detailPanelOpen,
|
|
||||||
onCloseDetailPanel,
|
|
||||||
documentLookup,
|
documentLookup,
|
||||||
resolveBaseMetrics,
|
resolveBaseMetrics,
|
||||||
bringToFront,
|
bringToFront,
|
||||||
@@ -613,8 +817,6 @@ const DesktopWorkspace = ({
|
|||||||
layoutRef,
|
layoutRef,
|
||||||
layoutSnapshot,
|
layoutSnapshot,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
onCloseDetailPanel,
|
|
||||||
onDocumentOpen,
|
|
||||||
onDocumentStackSelect,
|
onDocumentStackSelect,
|
||||||
onEntryPointer,
|
onEntryPointer,
|
||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
@@ -622,6 +824,7 @@ const DesktopWorkspace = ({
|
|||||||
overlayDisplay,
|
overlayDisplay,
|
||||||
overlayOriginRect,
|
overlayOriginRect,
|
||||||
overlayOriginTransform,
|
overlayOriginTransform,
|
||||||
|
overlayDocument,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
pendingRemovalTag,
|
pendingRemovalTag,
|
||||||
pendingTagDocId,
|
pendingTagDocId,
|
||||||
@@ -629,7 +832,6 @@ const DesktopWorkspace = ({
|
|||||||
resolveBaseMetrics,
|
resolveBaseMetrics,
|
||||||
setDraggingId,
|
setDraggingId,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
detailPanelOpen,
|
|
||||||
onInspectDocument,
|
onInspectDocument,
|
||||||
markLayoutDirty,
|
markLayoutDirty,
|
||||||
tagDropTargetId,
|
tagDropTargetId,
|
||||||
@@ -639,7 +841,7 @@ const DesktopWorkspace = ({
|
|||||||
return <DesktopWorkspaceView {...viewProps} />;
|
return <DesktopWorkspaceView {...viewProps} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DesktopWorkspaceView = ({
|
function DesktopWorkspaceView({
|
||||||
engine,
|
engine,
|
||||||
items,
|
items,
|
||||||
containerRef,
|
containerRef,
|
||||||
@@ -655,7 +857,6 @@ const DesktopWorkspaceView = ({
|
|||||||
tagDropTargetId,
|
tagDropTargetId,
|
||||||
pendingTagDocId,
|
pendingTagDocId,
|
||||||
pendingRemovalTag,
|
pendingRemovalTag,
|
||||||
onDocumentOpen,
|
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
handleNavigatorSnapshot,
|
handleNavigatorSnapshot,
|
||||||
@@ -672,13 +873,12 @@ const DesktopWorkspaceView = ({
|
|||||||
closeOverlay,
|
closeOverlay,
|
||||||
overlayOriginRect,
|
overlayOriginRect,
|
||||||
overlayOriginTransform,
|
overlayOriginTransform,
|
||||||
|
overlayDocument,
|
||||||
onEntryPointer,
|
onEntryPointer,
|
||||||
onDocumentStackSelect,
|
onDocumentStackSelect,
|
||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
detailPanelOpen,
|
|
||||||
onCloseDetailPanel,
|
|
||||||
documentLookup,
|
documentLookup,
|
||||||
resolveBaseMetrics,
|
resolveBaseMetrics,
|
||||||
bringToFront,
|
bringToFront,
|
||||||
@@ -690,7 +890,7 @@ const DesktopWorkspaceView = ({
|
|||||||
onInspectDocument,
|
onInspectDocument,
|
||||||
markLayoutDirty,
|
markLayoutDirty,
|
||||||
dragTransformsRef,
|
dragTransformsRef,
|
||||||
}) => {
|
}: DesktopWorkspaceViewProps) {
|
||||||
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
|
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
|
||||||
useDocumentDrag({
|
useDocumentDrag({
|
||||||
engine,
|
engine,
|
||||||
@@ -711,7 +911,12 @@ const DesktopWorkspaceView = ({
|
|||||||
onDocumentStackSelect,
|
onDocumentStackSelect,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
markLayoutDirty,
|
markLayoutDirty,
|
||||||
});
|
}) as {
|
||||||
|
handlePointerDown: React.PointerEventHandler<HTMLElement>;
|
||||||
|
handlePointerMove: React.PointerEventHandler<HTMLElement>;
|
||||||
|
handlePointerUp: React.PointerEventHandler<HTMLElement>;
|
||||||
|
handlePointerCancel: React.PointerEventHandler<HTMLElement>;
|
||||||
|
};
|
||||||
|
|
||||||
const { getCardPointerHandlers, handleShellKeyDown, focusShell } = useDeskPointer({
|
const { getCardPointerHandlers, handleShellKeyDown, focusShell } = useDeskPointer({
|
||||||
containerRef,
|
containerRef,
|
||||||
@@ -726,12 +931,14 @@ const DesktopWorkspaceView = ({
|
|||||||
onEntryPointer,
|
onEntryPointer,
|
||||||
onDocumentStackSelect,
|
onDocumentStackSelect,
|
||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
onDocumentOpen,
|
onInspectDocument,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
detailPanelOpen,
|
|
||||||
onCloseDetailPanel,
|
|
||||||
openOverlayForDoc,
|
openOverlayForDoc,
|
||||||
});
|
}) as {
|
||||||
|
getCardPointerHandlers: (doc: DeskDocument) => React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
handleShellKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
|
||||||
|
focusShell: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
focusShell();
|
focusShell();
|
||||||
@@ -743,21 +950,13 @@ const DesktopWorkspaceView = ({
|
|||||||
}
|
}
|
||||||
}, [focusShell, selectedDocumentIds.length]);
|
}, [focusShell, selectedDocumentIds.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
const allSizesReady = items.every((doc) => Boolean(ensureDocumentSize(doc)));
|
||||||
if (!detailPanelOpen) {
|
|
||||||
focusShell();
|
|
||||||
}
|
|
||||||
}, [detailPanelOpen, focusShell]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const allSizesReady = items.every((doc) => ensureDocumentSize(doc));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="desk-shell" onPointerDown={(event) => {
|
<div className="desk-shell" onPointerDown={(event) => {
|
||||||
if (event.target === event.currentTarget && typeof onClearSelection === 'function') {
|
if (event.target === event.currentTarget) {
|
||||||
onClearSelection();
|
onClearSelection?.();
|
||||||
}
|
}
|
||||||
focusShell();
|
focusShell();
|
||||||
}}
|
}}
|
||||||
@@ -771,8 +970,8 @@ const DesktopWorkspaceView = ({
|
|||||||
onDragLeave={handleCanvasDragLeave}
|
onDragLeave={handleCanvasDragLeave}
|
||||||
onDrop={handleCanvasDrop}
|
onDrop={handleCanvasDrop}
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
if (event.target === event.currentTarget && typeof onClearSelection === 'function') {
|
if (event.target === event.currentTarget) {
|
||||||
onClearSelection();
|
onClearSelection?.();
|
||||||
}
|
}
|
||||||
focusShell();
|
focusShell();
|
||||||
}}
|
}}
|
||||||
@@ -786,7 +985,7 @@ const DesktopWorkspaceView = ({
|
|||||||
<p>No documents to show here yet. Drop files to make this space come alive.</p>
|
<p>No documents to show here yet. Drop files to make this space come alive.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
items.map((doc) => {
|
items.map((doc, index) => {
|
||||||
const sizeInfo = ensureDocumentSize(doc);
|
const sizeInfo = ensureDocumentSize(doc);
|
||||||
if (!sizeInfo) {
|
if (!sizeInfo) {
|
||||||
return null;
|
return null;
|
||||||
@@ -803,10 +1002,12 @@ const DesktopWorkspaceView = ({
|
|||||||
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
|
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const resolvedCenterX = centerX as number;
|
||||||
|
const resolvedCenterY = centerY as number;
|
||||||
const rotation = dragOverride?.rotation ?? layout?.rotation ?? 0;
|
const rotation = dragOverride?.rotation ?? layout?.rotation ?? 0;
|
||||||
const scale = dragOverride?.scale ?? 1;
|
const scale = dragOverride?.scale ?? 1;
|
||||||
const originX = centerX - cardWidth / 2;
|
const originX = resolvedCenterX - cardWidth / 2;
|
||||||
const originY = centerY - cardHeight / 2;
|
const originY = resolvedCenterY - cardHeight / 2;
|
||||||
const transform = formatTransform(
|
const transform = formatTransform(
|
||||||
Math.round(originX),
|
Math.round(originX),
|
||||||
Math.round(originY),
|
Math.round(originY),
|
||||||
@@ -822,16 +1023,19 @@ const DesktopWorkspaceView = ({
|
|||||||
const shouldLoad = docKey ? visibleDocIds.has(docKey) : false;
|
const shouldLoad = docKey ? visibleDocIds.has(docKey) : false;
|
||||||
const dragging = docKey ? draggingId === docKey : false;
|
const dragging = docKey ? draggingId === docKey : false;
|
||||||
const docTagKeys = Array.isArray(doc?.tags)
|
const docTagKeys = Array.isArray(doc?.tags)
|
||||||
? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean)
|
? doc.tags
|
||||||
|
.map((tag) => (tag?.id != null ? String(tag.id) : null))
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
: [];
|
: [];
|
||||||
const matchesFilter =
|
const matchesFilter =
|
||||||
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
|
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
|
||||||
const dropActive = docKey ? tagDropTargetId === docKey : false;
|
const dropActive = docKey ? tagDropTargetId === docKey : false;
|
||||||
const dropPending = docKey ? pendingTagDocId === docKey : false;
|
const dropPending = docKey ? pendingTagDocId === docKey : false;
|
||||||
const isSelected = selectedDocumentIds.includes(doc.id);
|
const docId = doc?.id ?? null;
|
||||||
|
const isSelected = docId != null ? selectedDocumentIds.includes(docId) : false;
|
||||||
const docTagTokens = docTagKeys.join(' ');
|
const docTagTokens = docTagKeys.join(' ');
|
||||||
const cardPointerHandlers = getCardPointerHandlers(doc);
|
const cardPointerHandlers = getCardPointerHandlers(doc) as React.HTMLAttributes<HTMLDivElement>;
|
||||||
const registerNode = (node) => {
|
const registerNode = (node: HTMLDivElement | null) => {
|
||||||
if (!docKey) {
|
if (!docKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -844,7 +1048,7 @@ const DesktopWorkspaceView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DesktopDocumentCard
|
<DesktopDocumentCard
|
||||||
key={doc.id}
|
key={docKey ?? `desk-doc-${index}`}
|
||||||
doc={doc}
|
doc={doc}
|
||||||
style={style}
|
style={style}
|
||||||
shouldLoad={shouldLoad}
|
shouldLoad={shouldLoad}
|
||||||
@@ -858,7 +1062,7 @@ const DesktopWorkspaceView = ({
|
|||||||
getDocumentAsset={getDocumentAsset}
|
getDocumentAsset={getDocumentAsset}
|
||||||
handleNavigatorSnapshot={handleNavigatorSnapshot}
|
handleNavigatorSnapshot={handleNavigatorSnapshot}
|
||||||
cardPointerHandlers={cardPointerHandlers}
|
cardPointerHandlers={cardPointerHandlers}
|
||||||
onDocumentOpen={onDocumentOpen}
|
onInspectDocument={onInspectDocument}
|
||||||
onTagDragEnter={handleTagDragEnterDoc}
|
onTagDragEnter={handleTagDragEnterDoc}
|
||||||
onTagDragOver={handleTagDragOverDoc}
|
onTagDragOver={handleTagDragOverDoc}
|
||||||
onTagDragLeave={handleTagDragLeaveDoc}
|
onTagDragLeave={handleTagDragLeaveDoc}
|
||||||
@@ -877,13 +1081,13 @@ const DesktopWorkspaceView = ({
|
|||||||
</div>
|
</div>
|
||||||
<PreviewZoomOverlay
|
<PreviewZoomOverlay
|
||||||
open={Boolean(overlayDisplay?.url)}
|
open={Boolean(overlayDisplay?.url)}
|
||||||
display={overlayDisplay}
|
|
||||||
onClose={closeOverlay}
|
onClose={closeOverlay}
|
||||||
|
document={overlayDocument}
|
||||||
originRect={overlayOriginRect}
|
originRect={overlayOriginRect}
|
||||||
originTransform={overlayOriginTransform}
|
originTransform={overlayOriginTransform}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default DesktopWorkspace;
|
export default DesktopWorkspace;
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import SelectionFloatingActions from '../documents/SelectionFloatingActions';
|
|
||||||
import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel';
|
|
||||||
import createWorkspaceSurfaceConfig from '../documents/workspaceHeader';
|
|
||||||
import DocumentViewerPanel from '../preview/DocumentViewerPanel';
|
|
||||||
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,
|
|
||||||
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
|
|
||||||
? (() => {
|
|
||||||
const { onClose, onOpenPreview, tags: tagOptions, ...restDetailProps } = detailProps;
|
|
||||||
return (
|
|
||||||
<DocumentViewerPanel
|
|
||||||
variant="sidebar"
|
|
||||||
onCollapsePanel={onClose}
|
|
||||||
onMaximizePanel={onOpenPreview}
|
|
||||||
tagOptions={tagOptions}
|
|
||||||
{...restDetailProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
: 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;
|
|
||||||
@@ -2,20 +2,15 @@ const DB_NAME = 'papercrate_desk';
|
|||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
const LAYOUT_STORE = 'layouts';
|
const LAYOUT_STORE = 'layouts';
|
||||||
|
|
||||||
const currentDbPromise = { value: null };
|
const currentDbPromise: { value: Promise<IDBDatabase | null> | null } = { value: null };
|
||||||
|
|
||||||
const openDatabase = () => {
|
const openDatabase = (): Promise<IDBDatabase> => {
|
||||||
if (currentDbPromise.value) {
|
if (currentDbPromise.value) {
|
||||||
return currentDbPromise.value;
|
return currentDbPromise.value as Promise<IDBDatabase>;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDbPromise.value = new Promise((resolve, reject) => {
|
currentDbPromise.value = new Promise((resolve, reject) => {
|
||||||
if (typeof indexedDB === 'undefined') {
|
const request = window.indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
reject(new Error('IndexedDB not available'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
||||||
|
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = () => {
|
||||||
const db = request.result;
|
const db = request.result;
|
||||||
@@ -38,10 +33,11 @@ const openDatabase = () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return currentDbPromise.value;
|
return currentDbPromise.value as Promise<IDBDatabase>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestToPromise = (request, defaultValue) => new Promise((resolve, reject) => {
|
const requestToPromise = <T>(request: IDBRequest<T>, defaultValue: T): Promise<T> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
const { result } = request;
|
const { result } = request;
|
||||||
resolve(result ?? defaultValue);
|
resolve(result ?? defaultValue);
|
||||||
@@ -51,9 +47,10 @@ const requestToPromise = (request, defaultValue) => new Promise((resolve, reject
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const iterateCursor = (request, iteratee) => new Promise((resolve, reject) => {
|
const iterateCursor = (request: IDBRequest<IDBCursorWithValue | null>, iteratee: (cursor: IDBCursorWithValue) => void) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
request.onsuccess = (event) => {
|
request.onsuccess = (event) => {
|
||||||
const cursor = event.target.result;
|
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||||
if (!cursor) {
|
if (!cursor) {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
@@ -70,7 +67,8 @@ const iterateCursor = (request, iteratee) => new Promise((resolve, reject) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const transactionComplete = (transaction) => new Promise((resolve, reject) => {
|
const transactionComplete = (transaction: IDBTransaction) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
transaction.oncomplete = () => {
|
transaction.oncomplete = () => {
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
@@ -82,7 +80,9 @@ const transactionComplete = (transaction) => new Promise((resolve, reject) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const withStore = async (mode, handler) => {
|
type TransactionMode = 'readonly' | 'readwrite' | 'versionchange';
|
||||||
|
|
||||||
|
const withStore = async <T>(mode: TransactionMode, handler: (store: IDBObjectStore, tx: IDBTransaction) => Promise<T> | T): Promise<T> => {
|
||||||
const db = await openDatabase();
|
const db = await openDatabase();
|
||||||
const transaction = db.transaction(LAYOUT_STORE, mode);
|
const transaction = db.transaction(LAYOUT_STORE, mode);
|
||||||
const store = transaction.objectStore(LAYOUT_STORE);
|
const store = transaction.objectStore(LAYOUT_STORE);
|
||||||
@@ -100,13 +100,24 @@ const withStore = async (mode, handler) => {
|
|||||||
try {
|
try {
|
||||||
await done;
|
await done;
|
||||||
} catch {
|
} catch {
|
||||||
// noop – prefer original error
|
// ignore
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchLayoutRecords = async ({ tenantId, viewId }) => {
|
interface LayoutRecord {
|
||||||
|
tenantId: string | number;
|
||||||
|
viewId: string | number;
|
||||||
|
documentId: string | number;
|
||||||
|
centerX?: number;
|
||||||
|
centerY?: number;
|
||||||
|
rotation?: number;
|
||||||
|
zIndex?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchLayoutRecords = async ({ tenantId, viewId }: { tenantId?: string | number; viewId?: string | number }): Promise<LayoutRecord[]> => {
|
||||||
if (!tenantId || !viewId) {
|
if (!tenantId || !viewId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -122,7 +133,7 @@ export const fetchLayoutRecords = async ({ tenantId, viewId }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
|
export const upsertLayoutRecords = async ({ tenantId, viewId, entries }: { tenantId?: string | number; viewId?: string | number; entries?: Array<{ documentId?: string | number; centerX?: number; centerY?: number; rotation?: number; zIndex?: number; updatedAt?: number }> }) => {
|
||||||
if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) {
|
if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,7 +154,7 @@ export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
|
|||||||
rotation: Number(entry.rotation) || 0,
|
rotation: Number(entry.rotation) || 0,
|
||||||
zIndex: Number(entry.zIndex) || 0,
|
zIndex: Number(entry.zIndex) || 0,
|
||||||
updatedAt: entry.updatedAt || timestamp,
|
updatedAt: entry.updatedAt || timestamp,
|
||||||
});
|
} satisfies LayoutRecord);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -151,7 +162,7 @@ export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteTenantLayouts = async (tenantId) => {
|
export const deleteTenantLayouts = async (tenantId?: string | number) => {
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -168,16 +179,18 @@ export const deleteTenantLayouts = async (tenantId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const closeDeskDatabase = () => {
|
export const closeDeskDatabase = (): void => {
|
||||||
if (!currentDbPromise.value) {
|
if (!currentDbPromise.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentDbPromise.value = currentDbPromise.value.then((db) => {
|
currentDbPromise.value = currentDbPromise.value.then((db) => {
|
||||||
|
if (db) {
|
||||||
try {
|
try {
|
||||||
db.close();
|
db.close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[desk] Failed to close IndexedDB', error);
|
console.warn('[desk] Failed to close IndexedDB', error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
export const preventAll = (event) => {
|
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||||
|
|
||||||
|
type PointerLikeEvent = MouseEvent & { pageX?: number; pageY?: number };
|
||||||
|
type PreventableEvent = Event | ReactPointerEvent | { preventDefault?: () => void; stopPropagation?: () => void };
|
||||||
|
|
||||||
|
export const preventAll = (event?: PreventableEvent | null): void => {
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -14,9 +19,18 @@ export const preventAll = (event) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const safeInvoke = (fn, ...args) => (typeof fn === 'function' ? fn(...args) : undefined);
|
type AnyFn = (...args: unknown[]) => unknown;
|
||||||
|
|
||||||
export const getPointerPosition = (event, { fallbackToPage = true } = {}) => {
|
export const safeInvoke = <Fn extends AnyFn>(
|
||||||
|
fn: Fn | null,
|
||||||
|
...args: Parameters<Fn>
|
||||||
|
): ReturnType<Fn> | undefined =>
|
||||||
|
(fn ? (fn(...args) as ReturnType<Fn>) : undefined);
|
||||||
|
|
||||||
|
export const getPointerPosition = (
|
||||||
|
event?: PointerLikeEvent | null,
|
||||||
|
{ fallbackToPage = true }: { fallbackToPage?: boolean } = {},
|
||||||
|
): { x: number; y: number } => {
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return { x: 0, y: 0 };
|
return { x: 0, y: 0 };
|
||||||
}
|
}
|
||||||
+30
-7
@@ -1,8 +1,32 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { createAssetView } from '../../asset_manager';
|
import { createAssetView } from '../../asset_manager';
|
||||||
|
|
||||||
const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
|
interface DocumentLike {
|
||||||
const [metadataMap, setMetadataMap] = useState(() => new Map());
|
id?: string | number;
|
||||||
|
current_version?: unknown;
|
||||||
|
tags?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetLike {
|
||||||
|
id?: string | number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewMetadataEntry {
|
||||||
|
docId: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetDocumentAsset = (doc: DocumentLike, type: string) => AssetLike | null;
|
||||||
|
type EnsureAssetUrl = (docId: string | number, asset: AssetLike, options?: { force?: boolean }) => Promise<AssetLike | null>;
|
||||||
|
|
||||||
|
const usePreviewMetadata = (
|
||||||
|
documents: DocumentLike[] | null,
|
||||||
|
getDocumentAsset?: GetDocumentAsset,
|
||||||
|
ensureAssetUrl?: EnsureAssetUrl,
|
||||||
|
) => {
|
||||||
|
const [metadataMap, setMetadataMap] = useState<Map<string, PreviewMetadataEntry>>(() => new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -14,20 +38,19 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchMetadataForDoc = async (doc) => {
|
const fetchMetadataForDoc = async (doc: DocumentLike) => {
|
||||||
if (!doc?.id) {
|
if (!doc?.id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const docId = String(doc.id);
|
const docId = String(doc.id);
|
||||||
const resolveAsset = (type) =>
|
const resolveAsset = (type: string) => getDocumentAsset?.(doc, type) ?? null;
|
||||||
(typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null);
|
|
||||||
|
|
||||||
let asset = resolveAsset('preview') || resolveAsset('thumbnail');
|
let asset = resolveAsset('preview') || resolveAsset('thumbnail');
|
||||||
let view = createAssetView(asset);
|
let view = createAssetView(asset);
|
||||||
let metadata = view.getPrimaryMetadata();
|
let metadata = view.getPrimaryMetadata();
|
||||||
|
|
||||||
const hasDimensions = (meta) =>
|
const hasDimensions = (meta: { width?: number | string; height?: number | string } | null) =>
|
||||||
Number.isFinite(Number(meta?.width)) &&
|
Number.isFinite(Number(meta?.width)) &&
|
||||||
Number.isFinite(Number(meta?.height)) &&
|
Number.isFinite(Number(meta?.height)) &&
|
||||||
Number(meta.width) > 0 &&
|
Number(meta.width) > 0 &&
|
||||||
@@ -35,7 +58,7 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
|
|||||||
|
|
||||||
if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) {
|
if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) {
|
||||||
try {
|
try {
|
||||||
const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 });
|
const ensured = await ensureAssetUrl(doc.id, asset, { force: true });
|
||||||
if (ensured) {
|
if (ensured) {
|
||||||
asset = ensured;
|
asset = ensured;
|
||||||
view = createAssetView(asset);
|
view = createAssetView(asset);
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { clamp } from '../utils/math';
|
|
||||||
|
|
||||||
export const formatTransform = (x, y, rotation = 0, scale = 1) =>
|
|
||||||
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { clamp } from '../utils/math';
|
||||||
|
|
||||||
|
export const formatTransform = (
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
rotation = 0,
|
||||||
|
scale = 1,
|
||||||
|
): string => `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
||||||
+62
-15
@@ -1,4 +1,4 @@
|
|||||||
import { safeInvoke } from '../events.js';
|
import { safeInvoke } from '../events';
|
||||||
|
|
||||||
export const CLICK_ACTIONS = {
|
export const CLICK_ACTIONS = {
|
||||||
selectSingle: 'selectSingle',
|
selectSingle: 'selectSingle',
|
||||||
@@ -6,20 +6,52 @@ export const CLICK_ACTIONS = {
|
|||||||
addCard: 'addCard',
|
addCard: 'addCard',
|
||||||
addStack: 'addStack',
|
addStack: 'addStack',
|
||||||
none: 'none',
|
none: 'none',
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
export const DRAG_ACTIONS = {
|
export const DRAG_ACTIONS = {
|
||||||
dragSelectSingle: 'dragSelectSingle',
|
dragSelectSingle: 'dragSelectSingle',
|
||||||
dragSelection: 'dragSelection',
|
dragSelection: 'dragSelection',
|
||||||
dragSelectStack: 'dragSelectStack',
|
dragSelectStack: 'dragSelectStack',
|
||||||
none: 'none',
|
none: 'none',
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
export type ClickAction = (typeof CLICK_ACTIONS)[keyof typeof CLICK_ACTIONS];
|
||||||
|
export type DragAction = (typeof DRAG_ACTIONS)[keyof typeof DRAG_ACTIONS];
|
||||||
|
|
||||||
export const STACK_HIT_EPSILON = 4;
|
export const STACK_HIT_EPSILON = 4;
|
||||||
export const POINTER_DRAG_THRESHOLD_SQUARED = 16;
|
export const POINTER_DRAG_THRESHOLD_SQUARED = 16;
|
||||||
export const LONG_PRESS_DURATION_MS = 450;
|
export const LONG_PRESS_DURATION_MS = 450;
|
||||||
|
|
||||||
export const withinThreshold = (dx, dy, thresholdSquared) => (dx * dx + dy * dy) <= thresholdSquared;
|
export const withinThreshold = (dx: number, dy: number, thresholdSquared: number): boolean => (dx * dx + dy * dy) <= thresholdSquared;
|
||||||
|
|
||||||
|
interface PointerIntentArgs {
|
||||||
|
doc: { id: string | number };
|
||||||
|
entryDescriptor: unknown;
|
||||||
|
selectedDocumentIds: Array<string | number>;
|
||||||
|
metaKey: boolean;
|
||||||
|
pointerButton?: number;
|
||||||
|
pointerType?: string;
|
||||||
|
stackHits?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointerIntent {
|
||||||
|
docId: string | number;
|
||||||
|
entryDescriptor: unknown;
|
||||||
|
pointerType?: string;
|
||||||
|
pointerButton?: number;
|
||||||
|
selectedAtDown: boolean;
|
||||||
|
selectionCountAtDown: number;
|
||||||
|
metaKey: boolean;
|
||||||
|
clickAction: ClickAction;
|
||||||
|
dragAction: DragAction;
|
||||||
|
stackDocIdsForDrag: string[] | null;
|
||||||
|
stackDocIdsForClick: string[] | null;
|
||||||
|
stackReplaceOnClick: boolean;
|
||||||
|
stackReplaceOnDrag: boolean;
|
||||||
|
clickSelectionApplied: boolean;
|
||||||
|
stackSelectionApplied: boolean;
|
||||||
|
longPressTriggered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const createPointerIntent = ({
|
export const createPointerIntent = ({
|
||||||
doc,
|
doc,
|
||||||
@@ -29,12 +61,12 @@ export const createPointerIntent = ({
|
|||||||
pointerButton,
|
pointerButton,
|
||||||
pointerType,
|
pointerType,
|
||||||
stackHits,
|
stackHits,
|
||||||
}) => {
|
}: PointerIntentArgs): PointerIntent => {
|
||||||
const alreadySelected = selectedDocumentIds.includes(doc.id);
|
const alreadySelected = selectedDocumentIds.includes(doc.id);
|
||||||
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
|
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
|
||||||
|
|
||||||
let clickAction = CLICK_ACTIONS.none;
|
let clickAction: ClickAction = CLICK_ACTIONS.none;
|
||||||
let dragAction = DRAG_ACTIONS.none;
|
let dragAction: DragAction = DRAG_ACTIONS.none;
|
||||||
|
|
||||||
if (metaKey) {
|
if (metaKey) {
|
||||||
clickAction = CLICK_ACTIONS.addStack;
|
clickAction = CLICK_ACTIONS.addStack;
|
||||||
@@ -47,8 +79,8 @@ export const createPointerIntent = ({
|
|||||||
dragAction = DRAG_ACTIONS.dragSelectSingle;
|
dragAction = DRAG_ACTIONS.dragSelectSingle;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stackList = Array.isArray(stackHits) && stackHits.length > 0
|
const stackList: string[] = Array.isArray(stackHits) && stackHits.length > 0
|
||||||
? stackHits.slice()
|
? stackHits.map((value) => String(value))
|
||||||
: [String(doc.id)];
|
: [String(doc.id)];
|
||||||
|
|
||||||
const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null;
|
const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null;
|
||||||
@@ -74,7 +106,12 @@ export const createPointerIntent = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => {
|
export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }: {
|
||||||
|
intent: PointerIntent;
|
||||||
|
event?: unknown;
|
||||||
|
onEntryPointer?: (descriptor: unknown, event?: unknown) => void;
|
||||||
|
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
|
||||||
|
}) => {
|
||||||
switch (intent.clickAction) {
|
switch (intent.clickAction) {
|
||||||
case CLICK_ACTIONS.selectSingle:
|
case CLICK_ACTIONS.selectSingle:
|
||||||
case CLICK_ACTIONS.addCard:
|
case CLICK_ACTIONS.addCard:
|
||||||
@@ -100,7 +137,12 @@ export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDoc
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => {
|
export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }: {
|
||||||
|
intent: PointerIntent;
|
||||||
|
event?: unknown;
|
||||||
|
onEntryPointer?: (descriptor: unknown, event?: unknown) => void;
|
||||||
|
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
|
||||||
|
}) => {
|
||||||
if (!intent || intent.clickSelectionApplied) {
|
if (!intent || intent.clickSelectionApplied) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -108,14 +150,19 @@ export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocume
|
|||||||
applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect });
|
applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }) => {
|
export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }: {
|
||||||
|
intent: PointerIntent;
|
||||||
|
stackDocIds?: string[] | null;
|
||||||
|
syntheticEvent?: unknown;
|
||||||
|
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
|
||||||
|
}) => {
|
||||||
if (!intent) {
|
if (!intent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stackCopy = Array.isArray(stackDocIds) && stackDocIds.length > 0
|
const stackCopy: string[] = Array.isArray(stackDocIds) && stackDocIds.length > 0
|
||||||
? stackDocIds.slice()
|
? stackDocIds.map((value) => String(value))
|
||||||
: [intent.docId];
|
: [String(intent.docId)];
|
||||||
|
|
||||||
safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true });
|
safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true });
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
finalizeClickSelection,
|
finalizeClickSelection,
|
||||||
withinThreshold,
|
withinThreshold,
|
||||||
} from './pointerUtils';
|
} from './pointerUtils';
|
||||||
import { getPointerPosition, safeInvoke } from '../events.js';
|
import { getPointerPosition, safeInvoke } from '../events';
|
||||||
|
|
||||||
const buildEntryDescriptor = (docId) => ({
|
const buildEntryDescriptor = (docId) => ({
|
||||||
type: 'document',
|
type: 'document',
|
||||||
@@ -35,10 +35,8 @@ export const useDeskPointer = ({
|
|||||||
onEntryPointer,
|
onEntryPointer,
|
||||||
onDocumentStackSelect,
|
onDocumentStackSelect,
|
||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
onDocumentOpen,
|
onInspectDocument,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
detailPanelOpen,
|
|
||||||
onCloseDetailPanel,
|
|
||||||
openOverlayForDoc = null,
|
openOverlayForDoc = null,
|
||||||
}) => {
|
}) => {
|
||||||
const pointerIntentRef = useRef(null);
|
const pointerIntentRef = useRef(null);
|
||||||
@@ -183,9 +181,6 @@ export const useDeskPointer = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
longPressActiveRef.current = true;
|
longPressActiveRef.current = true;
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
longPressTimerRef.current = window.setTimeout(() => {
|
longPressTimerRef.current = window.setTimeout(() => {
|
||||||
if (!longPressActiveRef.current || pointerMovedRef.current) {
|
if (!longPressActiveRef.current || pointerMovedRef.current) {
|
||||||
@@ -229,8 +224,8 @@ export const useDeskPointer = ({
|
|||||||
pointerMovedRef.current = false;
|
pointerMovedRef.current = false;
|
||||||
resetLongPressState();
|
resetLongPressState();
|
||||||
|
|
||||||
const pointerButton = typeof event.button === 'number' ? event.button : 0;
|
const pointerButton = Number.isFinite(event?.button) ? event.button : 0;
|
||||||
const pointerType = typeof event.pointerType === 'string' ? event.pointerType : '';
|
const pointerType = String(event?.pointerType ?? '');
|
||||||
const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
|
const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
|
||||||
const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
|
const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
|
||||||
|
|
||||||
@@ -322,18 +317,15 @@ export const useDeskPointer = ({
|
|||||||
&& !pointerState.longPressTriggered
|
&& !pointerState.longPressTriggered
|
||||||
&& pointerState.docId === doc.id
|
&& pointerState.docId === doc.id
|
||||||
) {
|
) {
|
||||||
const expectedButton = typeof pointerState.pointerButton === 'number'
|
const expectedButton = Number.isFinite(pointerState?.pointerButton)
|
||||||
? pointerState.pointerButton
|
? pointerState.pointerButton
|
||||||
: 0;
|
: 0;
|
||||||
const releasedButton = typeof event.button === 'number'
|
const releasedButton = Number.isFinite(event?.button) ? event.button : expectedButton;
|
||||||
? event.button
|
|
||||||
: expectedButton;
|
|
||||||
const isPrimaryRelease = expectedButton === 0 && releasedButton === 0;
|
const isPrimaryRelease = expectedButton === 0 && releasedButton === 0;
|
||||||
const stillSelected = Array.isArray(selectedDocumentIds)
|
const stillSelected = Array.isArray(selectedDocumentIds)
|
||||||
&& selectedDocumentIds.includes(doc.id);
|
&& selectedDocumentIds.includes(doc.id);
|
||||||
if (isPrimaryRelease && stillSelected) {
|
if (isPrimaryRelease && stillSelected) {
|
||||||
const useSelection = pointerState.selectedAtDown && pointerState.selectionCountAtDown > 0;
|
safeInvoke(onInspectDocument, doc.id);
|
||||||
safeInvoke(onDocumentOpen, doc.id, { useSelection });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,7 +335,7 @@ export const useDeskPointer = ({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
handlePointerUp,
|
handlePointerUp,
|
||||||
onDocumentOpen,
|
onInspectDocument,
|
||||||
onDocumentStackSelect,
|
onDocumentStackSelect,
|
||||||
onEntryPointer,
|
onEntryPointer,
|
||||||
resetLongPressState,
|
resetLongPressState,
|
||||||
@@ -401,11 +393,7 @@ export const useDeskPointer = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (openOverlayForDoc && Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0) {
|
||||||
typeof openOverlayForDoc === 'function'
|
|
||||||
&& Array.isArray(selectedDocumentIds)
|
|
||||||
&& selectedDocumentIds.length > 0
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const targetId = selectedDocumentIds[selectedDocumentIds.length - 1];
|
const targetId = selectedDocumentIds[selectedDocumentIds.length - 1];
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
@@ -414,12 +402,8 @@ export const useDeskPointer = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detailPanelOpen) {
|
|
||||||
event.preventDefault();
|
|
||||||
safeInvoke(onCloseDetailPanel);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[detailPanelOpen, onCloseDetailPanel, openOverlayForDoc, selectedDocumentIds],
|
[openOverlayForDoc, selectedDocumentIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -427,9 +411,7 @@ export const useDeskPointer = ({
|
|||||||
handleShellKeyDown,
|
handleShellKeyDown,
|
||||||
focusShell: () => {
|
focusShell: () => {
|
||||||
const shell = containerRef.current;
|
const shell = containerRef.current;
|
||||||
if (shell && typeof shell.focus === 'function') {
|
shell?.focus?.({ preventScroll: true });
|
||||||
shell.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { getPointerPosition, preventAll, safeInvoke } from '../events.js';
|
import { getPointerPosition, preventAll, safeInvoke } from '../events';
|
||||||
import {
|
import {
|
||||||
isTagTransferEvent,
|
isTagTransferEvent,
|
||||||
parseTagTransferPayload,
|
parseTagTransferPayload,
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from '../../documents/tagTransfer';
|
} from '../../documents/tagTransfer';
|
||||||
|
|
||||||
const TAG_REMOVE_DISTANCE = 160;
|
const TAG_REMOVE_DISTANCE = 160;
|
||||||
const DEBUG_DROP = false;
|
|
||||||
|
|
||||||
const createDragPreview = (node, clientX, clientY) => {
|
const createDragPreview = (node, clientX, clientY) => {
|
||||||
if (!(node instanceof HTMLElement)) {
|
if (!(node instanceof HTMLElement)) {
|
||||||
@@ -42,147 +41,22 @@ const cleanupPreview = (previewNode) => {
|
|||||||
export const useDeskTagInteractions = ({
|
export const useDeskTagInteractions = ({
|
||||||
engine,
|
engine,
|
||||||
onAssignTagToDocument,
|
onAssignTagToDocument,
|
||||||
onRemoveTagFromDocument,
|
|
||||||
requestCanvasFocus,
|
requestCanvasFocus,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const draggingTagRef = useRef(null);
|
const draggingTagRef = useRef(null);
|
||||||
const pendingDocTagDragRef = useRef(null);
|
|
||||||
const removalCursorActiveRef = useRef(false);
|
|
||||||
|
|
||||||
const updateRemovalCursor = useCallback((active) => {
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (removalCursorActiveRef.current === active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = document.body;
|
|
||||||
if (!body) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
removalCursorActiveRef.current = active;
|
|
||||||
if (active) {
|
|
||||||
body.classList.add('desk-cursor-remove');
|
|
||||||
} else {
|
|
||||||
body.classList.remove('desk-cursor-remove');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
updateRemovalCursor(false);
|
|
||||||
},
|
|
||||||
[updateRemovalCursor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []);
|
const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []);
|
||||||
|
|
||||||
const handleTagDragEnd = useCallback(() => {
|
const handleDocTagPointerDown = useCallback(() => {
|
||||||
updateRemovalCursor(false);
|
|
||||||
engine.setTagDropTargetId(null);
|
|
||||||
}, [engine, updateRemovalCursor]);
|
|
||||||
|
|
||||||
const finalizeTagDrag = useCallback(
|
|
||||||
(dropEffect = 'none') => {
|
|
||||||
const state = draggingTagRef.current;
|
|
||||||
if (!state) {
|
|
||||||
updateRemovalCursor(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
draggingTagRef.current = null;
|
|
||||||
|
|
||||||
const node = state.element;
|
|
||||||
const showNode = () => {
|
|
||||||
if (node instanceof HTMLElement) {
|
|
||||||
node.classList.remove('is-drag-hidden');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const scheduleShowNode = () => {
|
|
||||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
||||||
window.requestAnimationFrame(showNode);
|
|
||||||
} else {
|
|
||||||
setTimeout(showNode, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
cleanupPreview(state.previewClone);
|
|
||||||
|
|
||||||
const shouldRemove =
|
|
||||||
!state.dropHandled
|
|
||||||
&& dropEffect === 'none'
|
|
||||||
&& state.sourceDocId
|
|
||||||
&& (state.distance || 0) >= TAG_REMOVE_DISTANCE;
|
|
||||||
|
|
||||||
if (!shouldRemove) {
|
|
||||||
scheduleShowNode();
|
|
||||||
updateRemovalCursor(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateRemovalCursor(false);
|
|
||||||
engine.setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId });
|
|
||||||
const removePromise = safeInvoke(onRemoveTagFromDocument, state.sourceDocId, state.tagId);
|
|
||||||
if (!removePromise || typeof removePromise.then !== 'function') {
|
|
||||||
scheduleShowNode();
|
|
||||||
engine.setPendingRemovalTag(null);
|
engine.setPendingRemovalTag(null);
|
||||||
updateRemovalCursor(false);
|
}, [engine]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await removePromise;
|
|
||||||
void DEBUG_DROP;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to remove tag after drag', error);
|
|
||||||
scheduleShowNode();
|
|
||||||
} finally {
|
|
||||||
engine.setPendingRemovalTag(null);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
[engine, onRemoveTagFromDocument, updateRemovalCursor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDocTagPointerDown = useCallback((event, doc, tag) => {
|
|
||||||
if (!doc || !tag) {
|
|
||||||
pendingDocTagDragRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { x: startX, y: startY } = getPointerPosition(event);
|
|
||||||
pendingDocTagDragRef.current = {
|
|
||||||
docId: doc.id,
|
|
||||||
tagId: tag.id,
|
|
||||||
startX,
|
|
||||||
startY,
|
|
||||||
};
|
|
||||||
updateRemovalCursor(false);
|
|
||||||
}, [updateRemovalCursor]);
|
|
||||||
|
|
||||||
const markActiveTagDropHandled = useCallback((tagId, sourceDocId = null) => {
|
|
||||||
const state = draggingTagRef.current;
|
|
||||||
if (!state) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.tagId !== tagId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (sourceDocId && state.sourceDocId !== sourceDocId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.dropHandled = true;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const runTagHoverTransition = useCallback(
|
const runTagHoverTransition = useCallback(
|
||||||
(event, docId, { applyTarget = false, applyPending = false, updateCursor = true } = {}) => {
|
(event, docId, { applyTarget = false, applyPending = false } = {}) => {
|
||||||
if (!isTagTransfer(event)) {
|
if (!isTagTransfer(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
preventAll(event);
|
preventAll(event);
|
||||||
if (updateCursor) {
|
|
||||||
updateRemovalCursor(false);
|
|
||||||
}
|
|
||||||
const stringId = docId != null ? String(docId) : null;
|
const stringId = docId != null ? String(docId) : null;
|
||||||
if (applyTarget) {
|
if (applyTarget) {
|
||||||
engine.setTagDropTargetId(stringId);
|
engine.setTagDropTargetId(stringId);
|
||||||
@@ -191,7 +65,7 @@ export const useDeskTagInteractions = ({
|
|||||||
engine.setPendingTagDocId(stringId);
|
engine.setPendingTagDocId(stringId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[engine, isTagTransfer, updateRemovalCursor],
|
[engine, isTagTransfer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTagDragEnterDoc = useCallback(
|
const handleTagDragEnterDoc = useCallback(
|
||||||
@@ -240,7 +114,7 @@ export const useDeskTagInteractions = ({
|
|||||||
if (!payload || !payload.id) {
|
if (!payload || !payload.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
markActiveTagDropHandled(payload.id, payload.sourceDocId);
|
engine.setPendingRemovalTag(null);
|
||||||
|
|
||||||
if (payload.sourceDocId === doc.id) {
|
if (payload.sourceDocId === doc.id) {
|
||||||
return;
|
return;
|
||||||
@@ -254,7 +128,7 @@ export const useDeskTagInteractions = ({
|
|||||||
sourceDocId: payload.sourceDocId ?? null,
|
sourceDocId: payload.sourceDocId ?? null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[engine, isTagTransfer, markActiveTagDropHandled, onAssignTagToDocument, requestCanvasFocus],
|
[engine, isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocTagDragStart = useCallback(
|
const handleDocTagDragStart = useCallback(
|
||||||
@@ -267,7 +141,7 @@ export const useDeskTagInteractions = ({
|
|||||||
|
|
||||||
const { x: pointerX, y: pointerY } = getPointerPosition(event, { fallbackToPage: false });
|
const { x: pointerX, y: pointerY } = getPointerPosition(event, { fallbackToPage: false });
|
||||||
const { clone, offsetX, offsetY } = createDragPreview(event.currentTarget, pointerX, pointerY) || {};
|
const { clone, offsetX, offsetY } = createDragPreview(event.currentTarget, pointerX, pointerY) || {};
|
||||||
if (clone && typeof event.dataTransfer.setDragImage === 'function') {
|
if (clone) {
|
||||||
event.dataTransfer.setDragImage(clone, offsetX || 0, offsetY || 0);
|
event.dataTransfer.setDragImage(clone, offsetX || 0, offsetY || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,12 +158,11 @@ export const useDeskTagInteractions = ({
|
|||||||
initialX: pointerX,
|
initialX: pointerX,
|
||||||
initialY: pointerY,
|
initialY: pointerY,
|
||||||
distance: 0,
|
distance: 0,
|
||||||
dropHandled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
updateRemovalCursor(false);
|
engine.setPendingRemovalTag(null);
|
||||||
},
|
},
|
||||||
[updateRemovalCursor],
|
[engine],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocTagDrag = useCallback((event) => {
|
const handleDocTagDrag = useCallback((event) => {
|
||||||
@@ -302,35 +175,36 @@ export const useDeskTagInteractions = ({
|
|||||||
const dy = y - (state.initialY || 0);
|
const dy = y - (state.initialY || 0);
|
||||||
state.distance = Math.sqrt(dx * dx + dy * dy);
|
state.distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
if (state.distance >= TAG_REMOVE_DISTANCE) {
|
if (state.distance >= TAG_REMOVE_DISTANCE) {
|
||||||
updateRemovalCursor(true);
|
engine.setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId });
|
||||||
} else {
|
} else {
|
||||||
updateRemovalCursor(false);
|
engine.setPendingRemovalTag(null);
|
||||||
}
|
}
|
||||||
}, [updateRemovalCursor]);
|
}, [engine]);
|
||||||
|
|
||||||
const handleDocTagDragEnd = useCallback(
|
const handleDocTagDragEnd = useCallback(
|
||||||
(event) => {
|
() => {
|
||||||
finalizeTagDrag(event?.dataTransfer?.dropEffect || 'none');
|
|
||||||
const state = draggingTagRef.current;
|
const state = draggingTagRef.current;
|
||||||
if (!state) {
|
if (state) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
const element = state.element;
|
const element = state.element;
|
||||||
if (element) {
|
if (element) {
|
||||||
element.classList.remove('is-drag-hidden');
|
element.classList.remove('is-drag-hidden');
|
||||||
}
|
}
|
||||||
cleanupPreview(state.previewClone);
|
cleanupPreview(state.previewClone);
|
||||||
|
}
|
||||||
draggingTagRef.current = null;
|
draggingTagRef.current = null;
|
||||||
|
engine.setPendingRemovalTag(null);
|
||||||
|
engine.setTagDropTargetId(null);
|
||||||
|
engine.setPendingTagDocId(null);
|
||||||
},
|
},
|
||||||
[finalizeTagDrag],
|
[engine],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
draggingTagRef.current = null;
|
draggingTagRef.current = null;
|
||||||
pendingDocTagDragRef.current = null;
|
engine.setPendingRemovalTag(null);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [engine]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleTagDragEnterDoc,
|
handleTagDragEnterDoc,
|
||||||
@@ -341,8 +215,6 @@ export const useDeskTagInteractions = ({
|
|||||||
handleDocTagDragStart,
|
handleDocTagDragStart,
|
||||||
handleDocTagDrag,
|
handleDocTagDrag,
|
||||||
handleDocTagDragEnd,
|
handleDocTagDragEnd,
|
||||||
handleTagDragEnd,
|
|
||||||
markActiveTagDropHandled,
|
|
||||||
handleCanvasDragOver,
|
handleCanvasDragOver,
|
||||||
handleCanvasDragLeave,
|
handleCanvasDragLeave,
|
||||||
handleCanvasDrop,
|
handleCanvasDrop,
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
const useDeskWorkspaceProps = ({
|
|
||||||
documents,
|
|
||||||
searchResults,
|
|
||||||
breadcrumbs,
|
|
||||||
currentFolderName,
|
|
||||||
documentsViewMode,
|
|
||||||
handleDocumentsViewModeChange,
|
|
||||||
handleDeskExit,
|
|
||||||
refreshCurrentFolder,
|
|
||||||
inspectDocument,
|
|
||||||
handleEntryPointerCore,
|
|
||||||
promoteSelectionOrder,
|
|
||||||
currentTenantId,
|
|
||||||
selectedDocumentIds,
|
|
||||||
selectedFolderIds,
|
|
||||||
clearDocumentSelection,
|
|
||||||
detailPanelOpen,
|
|
||||||
handleDetailPanelClose,
|
|
||||||
resolveThumbnailUrlForDoc,
|
|
||||||
handleDocumentTagDrop,
|
|
||||||
handleTagRemove,
|
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
activeTagFilters,
|
|
||||||
handleDeleteSelection,
|
|
||||||
tags,
|
|
||||||
correspondents,
|
|
||||||
documentLookup,
|
|
||||||
tagLookupById,
|
|
||||||
handleBulkTagAddFromDetail,
|
|
||||||
handleBulkTagRemoveFromDetail,
|
|
||||||
handleBulkCorrespondentAdd,
|
|
||||||
handleBulkCorrespondentRemove,
|
|
||||||
handleBulkSelectionReanalyze,
|
|
||||||
folderOptions,
|
|
||||||
moveDocumentsToFolder,
|
|
||||||
searchIncludeDescendants,
|
|
||||||
toggleSearchIncludeDescendants,
|
|
||||||
selectedEntries,
|
|
||||||
selectionAnchorRef,
|
|
||||||
applySelection,
|
|
||||||
resolveDocumentRowKey,
|
|
||||||
showingSearchResults,
|
|
||||||
searchQuery,
|
|
||||||
activeCorrespondentFilters,
|
|
||||||
selectedFolder,
|
|
||||||
openDetailPanel,
|
|
||||||
}) => {
|
|
||||||
const handleDeskDocumentStackSelect = useCallback(
|
|
||||||
(docIds) => {
|
|
||||||
if (!Array.isArray(docIds) || docIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowKeys = docIds
|
|
||||||
.map((id) => resolveDocumentRowKey(id))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (!rowKeys.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextKeys = [...selectedEntries];
|
|
||||||
rowKeys.forEach((key) => {
|
|
||||||
if (!nextKeys.includes(key)) {
|
|
||||||
nextKeys.push(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const anchor = rowKeys[0]
|
|
||||||
|| selectionAnchorRef.current
|
|
||||||
|| nextKeys[nextKeys.length - 1];
|
|
||||||
|
|
||||||
applySelection(nextKeys, {
|
|
||||||
anchor,
|
|
||||||
interactedKeys: rowKeys,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[applySelection, resolveDocumentRowKey, selectedEntries, selectionAnchorRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeskDocumentOpen = useCallback(
|
|
||||||
(docId, { useSelection = false } = {}) => {
|
|
||||||
const selectionDocIds = Array.isArray(selectedDocumentIds)
|
|
||||||
? selectedDocumentIds
|
|
||||||
: [];
|
|
||||||
let targetIds = [];
|
|
||||||
|
|
||||||
if ((useSelection || selectionDocIds.includes(docId)) && selectionDocIds.length) {
|
|
||||||
targetIds = selectionDocIds;
|
|
||||||
} else if (selectionDocIds.length) {
|
|
||||||
targetIds = selectionDocIds;
|
|
||||||
} else if (docId) {
|
|
||||||
targetIds = [docId];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
openDetailPanel({ documentIds: targetIds });
|
|
||||||
},
|
|
||||||
[openDetailPanel, selectedDocumentIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const deskViewId = useMemo(() => {
|
|
||||||
if (showingSearchResults) {
|
|
||||||
const trimmedQuery = searchQuery.trim();
|
|
||||||
const tagsKey = [...activeTagFilters].sort().join(',');
|
|
||||||
const correspondentsKey = [...activeCorrespondentFilters].sort().join(',');
|
|
||||||
return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root';
|
|
||||||
return `folder:${folderKey}`;
|
|
||||||
}, [
|
|
||||||
showingSearchResults,
|
|
||||||
searchQuery,
|
|
||||||
activeTagFilters,
|
|
||||||
activeCorrespondentFilters,
|
|
||||||
selectedFolder,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
documents,
|
|
||||||
searchResults,
|
|
||||||
breadcrumbs,
|
|
||||||
currentFolderName,
|
|
||||||
viewMode: documentsViewMode,
|
|
||||||
onViewModeChange: handleDocumentsViewModeChange,
|
|
||||||
onExit: handleDeskExit,
|
|
||||||
onRefresh: refreshCurrentFolder,
|
|
||||||
onDocumentOpen: handleDeskDocumentOpen,
|
|
||||||
onInspectDocument: inspectDocument,
|
|
||||||
onEntryPointer: handleEntryPointerCore,
|
|
||||||
onDocumentStackSelect: handleDeskDocumentStackSelect,
|
|
||||||
onPromoteSelection: promoteSelectionOrder,
|
|
||||||
tenantId: currentTenantId,
|
|
||||||
viewId: deskViewId,
|
|
||||||
selectedDocumentIds,
|
|
||||||
selectedFolderIds,
|
|
||||||
onClearSelection: clearDocumentSelection,
|
|
||||||
detailPanelOpen,
|
|
||||||
onCloseDetailPanel: handleDetailPanelClose,
|
|
||||||
resolveThumbnailUrl: resolveThumbnailUrlForDoc,
|
|
||||||
onAssignTagToDocument: handleDocumentTagDrop,
|
|
||||||
onRemoveTagFromDocument: handleTagRemove,
|
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
activeTagIds: activeTagFilters,
|
|
||||||
onDeleteSelection: handleDeleteSelection,
|
|
||||||
tags,
|
|
||||||
correspondents,
|
|
||||||
documentLookup,
|
|
||||||
tagLookupById,
|
|
||||||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
|
||||||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
|
||||||
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
|
||||||
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
|
||||||
onBulkReanalyze: handleBulkSelectionReanalyze,
|
|
||||||
folderOptions,
|
|
||||||
onMoveDocumentsToFolder: moveDocumentsToFolder,
|
|
||||||
searchIncludeDescendants,
|
|
||||||
onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
documents,
|
|
||||||
searchResults,
|
|
||||||
breadcrumbs,
|
|
||||||
currentFolderName,
|
|
||||||
documentsViewMode,
|
|
||||||
handleDocumentsViewModeChange,
|
|
||||||
handleDeskExit,
|
|
||||||
refreshCurrentFolder,
|
|
||||||
handleDeskDocumentOpen,
|
|
||||||
inspectDocument,
|
|
||||||
handleEntryPointerCore,
|
|
||||||
handleDeskDocumentStackSelect,
|
|
||||||
promoteSelectionOrder,
|
|
||||||
currentTenantId,
|
|
||||||
deskViewId,
|
|
||||||
selectedDocumentIds,
|
|
||||||
selectedFolderIds,
|
|
||||||
clearDocumentSelection,
|
|
||||||
detailPanelOpen,
|
|
||||||
handleDetailPanelClose,
|
|
||||||
resolveThumbnailUrlForDoc,
|
|
||||||
handleDocumentTagDrop,
|
|
||||||
handleTagRemove,
|
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
activeTagFilters,
|
|
||||||
handleDeleteSelection,
|
|
||||||
tags,
|
|
||||||
correspondents,
|
|
||||||
documentLookup,
|
|
||||||
tagLookupById,
|
|
||||||
handleBulkTagAddFromDetail,
|
|
||||||
handleBulkTagRemoveFromDetail,
|
|
||||||
handleBulkCorrespondentAdd,
|
|
||||||
handleBulkCorrespondentRemove,
|
|
||||||
handleBulkSelectionReanalyze,
|
|
||||||
folderOptions,
|
|
||||||
moveDocumentsToFolder,
|
|
||||||
searchIncludeDescendants,
|
|
||||||
toggleSearchIncludeDescendants,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useDeskWorkspaceProps;
|
|
||||||
+369
-89
@@ -1,22 +1,202 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
type MutableRefObject,
|
||||||
|
type RefObject,
|
||||||
|
} from 'react';
|
||||||
|
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import { preventAll, safeInvoke } from './events';
|
import { preventAll, safeInvoke } from './events';
|
||||||
import { clamp } from './math';
|
import { clamp } from './math';
|
||||||
import usePointerTap from '../ui/usePointerTap';
|
import usePointerTap from '../ui/usePointerTap';
|
||||||
import { MIN_TIMESTEP, MAX_TIMESTEP, applyDomTransform } from './workspaceEngine';
|
import {
|
||||||
|
MIN_TIMESTEP,
|
||||||
|
MAX_TIMESTEP,
|
||||||
|
MAX_ANGULAR_VELOCITY,
|
||||||
|
MAX_DYNAMIC_ROTATION,
|
||||||
|
CARD_BASE_WEIGHT_GRAMS,
|
||||||
|
CARD_PAGE_WEIGHT_GRAMS,
|
||||||
|
applyDomTransform,
|
||||||
|
type WorkspaceEngine,
|
||||||
|
} from './workspaceEngine';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
|
||||||
|
interface DocumentLike {
|
||||||
|
id?: Identifier | null;
|
||||||
|
title?: string;
|
||||||
|
current_version?: {
|
||||||
|
metadata?: { page_count?: number | string | null } | null;
|
||||||
|
} | null;
|
||||||
|
metadata?: { page_count?: number | string | null } | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentSizeInfo {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutEntry {
|
||||||
|
centerX?: number;
|
||||||
|
centerY?: number;
|
||||||
|
rotation?: number;
|
||||||
|
z?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragTransform {
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
rotation: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EngineDragState = Parameters<WorkspaceEngine['finalizeGroupDrag']>[0];
|
||||||
|
type EngineGroupItem = NonNullable<EngineDragState['groupItems']>[number];
|
||||||
|
|
||||||
|
interface DragGroupItemInternal extends EngineGroupItem {
|
||||||
|
baseOffsetX?: number;
|
||||||
|
baseOffsetY?: number;
|
||||||
|
offsetX?: number;
|
||||||
|
offsetY?: number;
|
||||||
|
targetRotation?: number;
|
||||||
|
initialRotation?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnsureDocumentSizeFn = (doc: DocumentLike | null) => DocumentSizeInfo | null;
|
||||||
|
|
||||||
|
type ResolveBaseMetricsFn = (
|
||||||
|
doc: DocumentLike | null,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) => { baseWidth: number; baseHeight: number; baseScale: number };
|
||||||
|
|
||||||
|
interface DragSettings {
|
||||||
|
canvasPadding?: number;
|
||||||
|
defaultCanvasWidth?: number;
|
||||||
|
defaultCanvasHeight?: number;
|
||||||
|
debugDrag?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PointerDownOptions {
|
||||||
|
stackDocIds?: Array<Identifier | null>;
|
||||||
|
stackSelectionApplied?: boolean;
|
||||||
|
wasSelected?: boolean;
|
||||||
|
modifierActive?: boolean;
|
||||||
|
stackReplace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDocumentDragOptions {
|
||||||
|
engine?: WorkspaceEngine | null;
|
||||||
|
layoutRef: MutableRefObject<Map<string, LayoutEntry>>;
|
||||||
|
dragTransformsRef: MutableRefObject<Map<string, DragTransform>>;
|
||||||
|
itemRefs: MutableRefObject<Map<string, HTMLElement | null>>;
|
||||||
|
documentLookup: Map<string, DocumentLike>;
|
||||||
|
ensureDocumentSize: EnsureDocumentSizeFn;
|
||||||
|
resolveBaseMetrics: ResolveBaseMetricsFn;
|
||||||
|
bringToFront: (docId: Identifier | null) => void;
|
||||||
|
setDraggingId: (docKey: string | null) => void;
|
||||||
|
canvasSize: { width: number; height: number };
|
||||||
|
openOverlayForDoc?: (
|
||||||
|
docId: Identifier | null,
|
||||||
|
originInfo?: { rotation: number; scale: number; width: number; height: number },
|
||||||
|
) => void;
|
||||||
|
recalcVisibleDocIds: () => void;
|
||||||
|
settings?: DragSettings;
|
||||||
|
containerRef?: RefObject<HTMLElement>;
|
||||||
|
onInspectDocument?: (docId: Identifier | null, event?: PointerEvent | ReactPointerEvent) => void;
|
||||||
|
onDocumentStackSelect?: (
|
||||||
|
docIds: Identifier[],
|
||||||
|
event: PointerEvent | ReactPointerEvent,
|
||||||
|
options?: { replace?: boolean },
|
||||||
|
) => void;
|
||||||
|
selectedDocumentIds?: Array<Identifier | null>;
|
||||||
|
markLayoutDirty?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EngineInertiaState = Parameters<WorkspaceEngine['startInertiaAnimation']>[1];
|
||||||
|
|
||||||
|
interface DragStateInternal extends EngineDragState {
|
||||||
|
docId: string;
|
||||||
|
docKey: string;
|
||||||
|
pointerId: number;
|
||||||
|
originCenterX: number;
|
||||||
|
originCenterY: number;
|
||||||
|
currentCenterX: number;
|
||||||
|
currentCenterY: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
rotation: number;
|
||||||
|
restRotation: number;
|
||||||
|
dynamicRotation: number;
|
||||||
|
angularVelocity: number;
|
||||||
|
moved: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
dragScale: number;
|
||||||
|
baseScale: number;
|
||||||
|
capturedTarget: HTMLElement | null;
|
||||||
|
lastClientX: number;
|
||||||
|
lastClientY: number;
|
||||||
|
lastTimestamp: number;
|
||||||
|
localPointerOffsetX: number;
|
||||||
|
localPointerOffsetY: number;
|
||||||
|
containerRectLeft: number;
|
||||||
|
containerRectTop: number;
|
||||||
|
isGroup: boolean;
|
||||||
|
activeDocIds: string[];
|
||||||
|
groupItems: DragGroupItemInternal[];
|
||||||
|
groupElevated: boolean;
|
||||||
|
stackDocIds: string[] | null;
|
||||||
|
stackSelectionApplied: boolean;
|
||||||
|
stackReplace: boolean;
|
||||||
|
massGrams: number;
|
||||||
|
pointerRadiusScale: number;
|
||||||
|
lastPointerCanvasX: number;
|
||||||
|
lastPointerCanvasY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PointerEventLike = PointerEvent | ReactPointerEvent<HTMLElement>;
|
||||||
|
|
||||||
|
interface DragTapMetadata {
|
||||||
|
docId: Identifier | null;
|
||||||
|
originInfo?: { rotation: number; scale: number; width: number; height: number };
|
||||||
|
docTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
const DRAG_HYSTERESIS_PX = 4;
|
const DRAG_HYSTERESIS_PX = 4;
|
||||||
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||||
const EDGE_COLLISION_THRESHOLD = 0.5;
|
const EDGE_COLLISION_THRESHOLD = 0.5;
|
||||||
|
|
||||||
const getEventTargetElement = (event) => {
|
const getDocumentPageCount = (doc?: DocumentLike | null): number | null => {
|
||||||
if (typeof Element === 'undefined' || !event) {
|
const raw = doc?.current_version?.metadata?.page_count ?? doc?.metadata?.page_count;
|
||||||
|
if (raw == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const candidate = event.target || (event.nativeEvent ? event.nativeEvent.target : null);
|
const value = Number(raw);
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeDocumentMassGrams = (doc?: DocumentLike | null): number => {
|
||||||
|
const pages = Math.max(1, Math.round(getDocumentPageCount(doc) ?? 1));
|
||||||
|
return CARD_BASE_WEIGHT_GRAMS + pages * CARD_PAGE_WEIGHT_GRAMS;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventTargetElement = (event?: PointerEventLike | null): Element | null => {
|
||||||
|
if (!event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nativeEvent = 'nativeEvent' in event ? (event as ReactPointerEvent).nativeEvent : null;
|
||||||
|
const candidate = (event.target as Element | null) || (nativeEvent ? (nativeEvent.target as Element | null) : null);
|
||||||
return candidate instanceof Element ? candidate : null;
|
return candidate instanceof Element ? candidate : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useDocumentDrag = (options = {}) => {
|
const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
||||||
const {
|
const {
|
||||||
engine,
|
engine,
|
||||||
layoutRef,
|
layoutRef,
|
||||||
@@ -31,13 +211,16 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
openOverlayForDoc,
|
openOverlayForDoc,
|
||||||
recalcVisibleDocIds,
|
recalcVisibleDocIds,
|
||||||
settings,
|
settings,
|
||||||
containerRef,
|
containerRef: providedContainerRef,
|
||||||
onInspectDocument,
|
onInspectDocument,
|
||||||
onDocumentStackSelect,
|
onDocumentStackSelect,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds = [],
|
||||||
markLayoutDirty,
|
markLayoutDirty,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
const fallbackContainerRef = useRef<HTMLElement | null>(null);
|
||||||
|
const containerRef = providedContainerRef ?? fallbackContainerRef;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
canvasPadding = 24,
|
canvasPadding = 24,
|
||||||
defaultCanvasWidth = 1024,
|
defaultCanvasWidth = 1024,
|
||||||
@@ -52,25 +235,23 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
[engine],
|
[engine],
|
||||||
);
|
);
|
||||||
|
|
||||||
const tapHandler = usePointerTap({
|
const tapHandler = usePointerTap<DragTapMetadata>({
|
||||||
delay: 220,
|
delay: 220,
|
||||||
onSingle: () => {},
|
onSingle: () => {},
|
||||||
onDouble: ({ data, event }) => {
|
onDouble: ({ data, event }) => {
|
||||||
if (!data || !data.docId) {
|
if (!data?.docId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event?.altKey) {
|
if (event?.altKey) {
|
||||||
openOverlayForDoc(data.docId, data.originInfo);
|
openOverlayForDoc?.(data.docId, data.originInfo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof onInspectDocument === 'function') {
|
onInspectDocument?.(data.docId, event);
|
||||||
onInspectDocument(data.docId, event);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dragStateRef = useRef(null);
|
const dragStateRef = useRef<DragStateInternal | null>(null);
|
||||||
|
|
||||||
const setDragTransform = useCallback((docKey, transform) => {
|
const setDragTransform = useCallback((docKey: Identifier | null, transform: DragTransform | null) => {
|
||||||
if (!docKey) {
|
if (!docKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -78,24 +259,30 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
if (!map) {
|
if (!map) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (transform) {
|
||||||
map.set(String(docKey), transform);
|
map.set(String(docKey), transform);
|
||||||
|
} else {
|
||||||
|
map.delete(String(docKey));
|
||||||
|
}
|
||||||
}, [dragTransformsRef]);
|
}, [dragTransformsRef]);
|
||||||
|
|
||||||
const clearDragTransforms = useCallback(() => {
|
const clearDragTransforms = useCallback(() => {
|
||||||
const map = dragTransformsRef?.current;
|
const map = dragTransformsRef?.current;
|
||||||
if (!map || typeof map.clear !== 'function') {
|
if (!map?.clear) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
map.clear();
|
map.clear();
|
||||||
}, [dragTransformsRef]);
|
}, [dragTransformsRef]);
|
||||||
|
|
||||||
const commitActiveDragTransforms = useCallback((docIds = null) => {
|
const commitActiveDragTransforms = useCallback((docIds: Array<Identifier | null> | null = null) => {
|
||||||
const map = dragTransformsRef?.current;
|
const map = dragTransformsRef?.current;
|
||||||
if (!map || !map.size) {
|
if (!map || !map.size) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const keys = Array.isArray(docIds) && docIds.length
|
const keys = Array.isArray(docIds) && docIds.length
|
||||||
? docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean)
|
? docIds
|
||||||
|
.map((id) => (id != null ? String(id) : null))
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
: Array.from(map.keys());
|
: Array.from(map.keys());
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
const transform = map.get(key);
|
const transform = map.get(key);
|
||||||
@@ -114,11 +301,11 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
}, [dragTransformsRef, layoutRef, markLayoutDirty]);
|
}, [dragTransformsRef, layoutRef, markLayoutDirty]);
|
||||||
|
|
||||||
const finishDrag = useCallback(
|
const finishDrag = useCallback(
|
||||||
(pointerId, { clearTransforms = true } = {}) => {
|
(pointerId: number, { clearTransforms = true }: { clearTransforms?: boolean } = {}) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (state && state.pointerId === pointerId) {
|
if (state && state.pointerId === pointerId) {
|
||||||
const capturedTarget = state.capturedTarget;
|
const capturedTarget = state.capturedTarget;
|
||||||
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
if (capturedTarget?.releasePointerCapture) {
|
||||||
try {
|
try {
|
||||||
capturedTarget.releasePointerCapture(pointerId);
|
capturedTarget.releasePointerCapture(pointerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -139,9 +326,9 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = useCallback(
|
||||||
(event, docIdInput, options = {}) => {
|
(event: PointerEventLike, docIdInput?: Identifier | null, options: PointerDownOptions = {}) => {
|
||||||
const targetElement = getEventTargetElement(event);
|
const targetElement = getEventTargetElement(event);
|
||||||
if (targetElement && typeof targetElement.closest === 'function' && targetElement.closest('[data-desk-tag-chip="true"]')) {
|
if (targetElement?.closest && targetElement.closest('[data-desk-tag-chip="true"]')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
preventAll(event);
|
preventAll(event);
|
||||||
@@ -158,22 +345,24 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
if (!doc) {
|
if (!doc) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const massGrams = computeDocumentMassGrams(doc);
|
||||||
|
|
||||||
const stackDocIdsOptionRaw = options?.stackDocIds;
|
const stackDocIdsOptionRaw = options?.stackDocIds;
|
||||||
const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw)
|
const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw)
|
||||||
? stackDocIdsOptionRaw
|
? stackDocIdsOptionRaw
|
||||||
.map((value) => (value != null ? String(value) : null))
|
.map((value) => (value != null ? String(value) : null))
|
||||||
.filter(Boolean)
|
.filter((value): value is string => Boolean(value))
|
||||||
: null;
|
: null;
|
||||||
const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied);
|
const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied);
|
||||||
const wasSelectedAtPointerDown = Boolean(options?.wasSelected);
|
const wasSelectedAtPointerDown = Boolean(options?.wasSelected);
|
||||||
const pointerModifierActive = typeof options?.modifierActive === 'boolean'
|
const pointerModifierActive =
|
||||||
? options.modifierActive
|
options?.modifierActive ?? Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
|
||||||
: Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
|
|
||||||
const stackReplace = Boolean(options?.stackReplace);
|
const stackReplace = Boolean(options?.stackReplace);
|
||||||
|
|
||||||
let selectionIds = Array.isArray(selectedDocumentIds)
|
let selectionIds: string[] = Array.isArray(selectedDocumentIds)
|
||||||
? selectedDocumentIds.map((id) => String(id))
|
? selectedDocumentIds
|
||||||
|
.map((id) => (id != null ? String(id) : null))
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) {
|
if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) {
|
||||||
@@ -195,9 +384,7 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
selectionIds = [...selectionIds, docKey];
|
selectionIds = [...selectionIds, docKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
selectionIds = selectionIds
|
selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
|
||||||
.map((id) => String(id))
|
|
||||||
.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
|
|
||||||
|
|
||||||
if (!selectionIds.includes(docKey)) {
|
if (!selectionIds.includes(docKey)) {
|
||||||
selectionIds.unshift(docKey);
|
selectionIds.unshift(docKey);
|
||||||
@@ -227,8 +414,8 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
const entry = layoutRef.current.get(docKey) || null;
|
const entry = layoutRef.current.get(docKey) || null;
|
||||||
const defaultCenterX = canvasPadding + docWidth / 2;
|
const defaultCenterX = canvasPadding + docWidth / 2;
|
||||||
const defaultCenterY = canvasPadding + docHeight / 2;
|
const defaultCenterY = canvasPadding + docHeight / 2;
|
||||||
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
|
const centerX = Number.isFinite(entry?.centerX) ? entry.centerX : defaultCenterX;
|
||||||
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
|
const centerY = Number.isFinite(entry?.centerY) ? entry.centerY : defaultCenterY;
|
||||||
|
|
||||||
const modifierPressed = pointerModifierActive;
|
const modifierPressed = pointerModifierActive;
|
||||||
if (!modifierPressed) {
|
if (!modifierPressed) {
|
||||||
@@ -255,7 +442,7 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||||
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
|
if (capturedTarget?.setPointerCapture) {
|
||||||
try {
|
try {
|
||||||
capturedTarget.setPointerCapture(event.pointerId);
|
capturedTarget.setPointerCapture(event.pointerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -265,7 +452,7 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerRect = containerRef?.current?.getBoundingClientRect?.() || null;
|
const containerRect = containerRef.current?.getBoundingClientRect?.() || null;
|
||||||
const containerLeft = containerRect?.left || 0;
|
const containerLeft = containerRect?.left || 0;
|
||||||
const containerTop = containerRect?.top || 0;
|
const containerTop = containerRect?.top || 0;
|
||||||
const pointerCanvasX = event.clientX - containerLeft;
|
const pointerCanvasX = event.clientX - containerLeft;
|
||||||
@@ -279,20 +466,19 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
|
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
|
||||||
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
|
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
|
||||||
|
|
||||||
const groupItems = selectionIds.map((id) => {
|
const groupItems: DragGroupItemInternal[] = selectionIds.map((id) => {
|
||||||
const itemDoc = documentLookup.get(id);
|
const itemDoc = documentLookup.get(id);
|
||||||
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
|
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
|
||||||
const itemWidth = itemSize.width || docWidth;
|
const itemWidth = itemSize.width || docWidth;
|
||||||
const itemHeight = itemSize.height || docHeight;
|
const itemHeight = itemSize.height || docHeight;
|
||||||
const itemEntry = layoutRef.current.get(id) || null;
|
const itemEntry = layoutRef.current.get(id) || null;
|
||||||
const itemCenterX =
|
const itemCenterX =
|
||||||
typeof itemEntry?.centerX === 'number' ? itemEntry.centerX : canvasPadding + itemWidth / 2;
|
Number.isFinite(itemEntry?.centerX) ? itemEntry.centerX : canvasPadding + itemWidth / 2;
|
||||||
const itemCenterY =
|
const itemCenterY =
|
||||||
typeof itemEntry?.centerY === 'number' ? itemEntry.centerY : canvasPadding + itemHeight / 2;
|
Number.isFinite(itemEntry?.centerY) ? itemEntry.centerY : canvasPadding + itemHeight / 2;
|
||||||
const baseOffsetX = itemCenterX - centerX;
|
const baseOffsetX = itemCenterX - centerX;
|
||||||
const baseOffsetY = itemCenterY - centerY;
|
const baseOffsetY = itemCenterY - centerY;
|
||||||
const initialRotation = itemEntry?.rotation ?? 0;
|
const initialRotation = itemEntry?.rotation ?? 0;
|
||||||
const targetRotation = initialRotation;
|
|
||||||
return {
|
return {
|
||||||
docId: id,
|
docId: id,
|
||||||
width: itemWidth,
|
width: itemWidth,
|
||||||
@@ -303,16 +489,16 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
baseOffsetY,
|
baseOffsetY,
|
||||||
offsetX: baseOffsetX,
|
offsetX: baseOffsetX,
|
||||||
offsetY: baseOffsetY,
|
offsetY: baseOffsetY,
|
||||||
initialRotation,
|
targetRotation: initialRotation,
|
||||||
displayRotation: initialRotation,
|
displayRotation: initialRotation,
|
||||||
targetRotation,
|
|
||||||
};
|
} satisfies DragGroupItemInternal;
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventTimestamp =
|
const eventTimestamp =
|
||||||
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
|
(Number.isFinite(event?.timeStamp))
|
||||||
? event.timeStamp
|
? event.timeStamp
|
||||||
: typeof performance !== 'undefined' && typeof performance.now === 'function'
|
: performance?.now
|
||||||
? performance.now()
|
? performance.now()
|
||||||
: Date.now();
|
: Date.now();
|
||||||
|
|
||||||
@@ -353,9 +539,16 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
stackDocIds: hasStackSource ? stackDocIdsOption : null,
|
stackDocIds: hasStackSource ? stackDocIdsOption : null,
|
||||||
stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource,
|
stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource,
|
||||||
stackReplace,
|
stackReplace,
|
||||||
};
|
massGrams,
|
||||||
|
pointerRadiusScale: 1,
|
||||||
|
lastPointerCanvasX: pointerCanvasX,
|
||||||
|
lastPointerCanvasY: pointerCanvasY,
|
||||||
|
} satisfies DragStateInternal;
|
||||||
|
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
clearDragTransforms();
|
clearDragTransforms();
|
||||||
state.groupItems.forEach((item) => {
|
state.groupItems.forEach((item) => {
|
||||||
@@ -415,7 +608,7 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
const handlePointerMove = useCallback(
|
||||||
(event) => {
|
(event: PointerEventLike) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return;
|
return;
|
||||||
@@ -425,8 +618,64 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
}
|
}
|
||||||
preventAll(event);
|
preventAll(event);
|
||||||
|
|
||||||
|
const updatePointerAngularVelocity = (
|
||||||
|
pointerCanvasX: number,
|
||||||
|
pointerCanvasY: number,
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
dtSeconds: number,
|
||||||
|
) => {
|
||||||
|
if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const leverX = pointerCanvasX - centerX;
|
||||||
|
const leverY = pointerCanvasY - centerY;
|
||||||
|
if (!Number.isFinite(leverX) || !Number.isFinite(leverY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prevCanvasX = Number.isFinite(state.lastPointerCanvasX)
|
||||||
|
? state.lastPointerCanvasX
|
||||||
|
: pointerCanvasX;
|
||||||
|
const prevCanvasY = Number.isFinite(state.lastPointerCanvasY)
|
||||||
|
? state.lastPointerCanvasY
|
||||||
|
: pointerCanvasY;
|
||||||
|
const velocityCanvasX = (pointerCanvasX - prevCanvasX) / dtSeconds;
|
||||||
|
const velocityCanvasY = (pointerCanvasY - prevCanvasY) / dtSeconds;
|
||||||
|
state.lastPointerCanvasX = pointerCanvasX;
|
||||||
|
state.lastPointerCanvasY = pointerCanvasY;
|
||||||
|
if (!Number.isFinite(velocityCanvasX) || !Number.isFinite(velocityCanvasY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const torque = leverX * velocityCanvasY - leverY * velocityCanvasX;
|
||||||
|
const influenceRadius = Math.max(state.width, state.height) / 2 || 1;
|
||||||
|
const radiusScale = clamp(Math.hypot(leverX, leverY) / influenceRadius, 0.2, 2.5);
|
||||||
|
state.pointerRadiusScale = radiusScale;
|
||||||
|
const torqueResponse = 0.0025 * radiusScale;
|
||||||
|
const angularVelocityDeg = clamp(
|
||||||
|
torque * torqueResponse,
|
||||||
|
-MAX_ANGULAR_VELOCITY,
|
||||||
|
MAX_ANGULAR_VELOCITY,
|
||||||
|
);
|
||||||
|
const mass = Math.max(state.massGrams || CARD_BASE_WEIGHT_GRAMS, CARD_BASE_WEIGHT_GRAMS);
|
||||||
|
const massScale = Math.max(mass / CARD_BASE_WEIGHT_GRAMS, 1);
|
||||||
|
state.angularVelocity = angularVelocityDeg / massScale;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDynamicRotation = (dtSeconds: number, dampingFactor = 0.94) => {
|
||||||
|
if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const radiusInfluence = clamp(state.pointerRadiusScale || 1, 0.3, 3);
|
||||||
|
const response = 1.1 * radiusInfluence;
|
||||||
|
let nextDynamic = state.dynamicRotation + state.angularVelocity * dtSeconds * response;
|
||||||
|
nextDynamic = clamp(nextDynamic, -MAX_DYNAMIC_ROTATION, MAX_DYNAMIC_ROTATION);
|
||||||
|
const adjustedDamping = Math.pow(dampingFactor, 1 / Math.max(radiusInfluence, 0.8));
|
||||||
|
state.dynamicRotation = nextDynamic * adjustedDamping;
|
||||||
|
state.rotation = state.restRotation + state.dynamicRotation;
|
||||||
|
};
|
||||||
|
|
||||||
if (state.isGroup) {
|
if (state.isGroup) {
|
||||||
const containerRect = containerRef?.current?.getBoundingClientRect?.();
|
const containerRect = containerRef.current?.getBoundingClientRect?.();
|
||||||
if (containerRect) {
|
if (containerRect) {
|
||||||
state.containerRectLeft = containerRect.left;
|
state.containerRectLeft = containerRect.left;
|
||||||
state.containerRectTop = containerRect.top;
|
state.containerRectTop = containerRect.top;
|
||||||
@@ -448,7 +697,9 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
&& Array.isArray(state.stackDocIds)
|
&& Array.isArray(state.stackDocIds)
|
||||||
&& state.stackDocIds.length > 0
|
&& state.stackDocIds.length > 0
|
||||||
) {
|
) {
|
||||||
safeInvoke(onDocumentStackSelect, state.stackDocIds, event, { replace: state.stackReplace });
|
safeInvoke(onDocumentStackSelect, state.stackDocIds as Identifier[], event, {
|
||||||
|
replace: state.stackReplace,
|
||||||
|
});
|
||||||
state.stackSelectionApplied = true;
|
state.stackSelectionApplied = true;
|
||||||
}
|
}
|
||||||
if (!state.groupElevated) {
|
if (!state.groupElevated) {
|
||||||
@@ -537,14 +788,30 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
applyDomTransform(node, payload);
|
applyDomTransform(node, payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
state.lastClientX = event.clientX;
|
const currentTimestampGroup =
|
||||||
state.lastClientY = event.clientY;
|
(Number.isFinite(event?.timeStamp))
|
||||||
state.lastTimestamp =
|
|
||||||
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
|
|
||||||
? event.timeStamp
|
? event.timeStamp
|
||||||
: typeof performance !== 'undefined' && typeof performance.now === 'function'
|
: performance?.now
|
||||||
? performance.now()
|
? performance.now()
|
||||||
: Date.now();
|
: Date.now();
|
||||||
|
const previousTimestampGroup = state.lastTimestamp ?? currentTimestampGroup;
|
||||||
|
let dtGroup = (currentTimestampGroup - previousTimestampGroup) / 1000;
|
||||||
|
if (!Number.isFinite(dtGroup) || dtGroup <= 0) {
|
||||||
|
dtGroup = MIN_TIMESTEP;
|
||||||
|
}
|
||||||
|
dtGroup = clamp(dtGroup, MIN_TIMESTEP, MAX_TIMESTEP);
|
||||||
|
|
||||||
|
state.lastClientX = event.clientX;
|
||||||
|
state.lastClientY = event.clientY;
|
||||||
|
state.lastTimestamp = currentTimestampGroup;
|
||||||
|
|
||||||
|
updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, centerX, centerY, dtGroup);
|
||||||
|
applyDynamicRotation(dtGroup, 0.96);
|
||||||
|
state.groupItems.forEach((item) => {
|
||||||
|
if (item.docId === state.docKey) {
|
||||||
|
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -560,7 +827,7 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
const halfWidth = docWidth / 2;
|
const halfWidth = docWidth / 2;
|
||||||
const halfHeight = docHeight / 2;
|
const halfHeight = docHeight / 2;
|
||||||
|
|
||||||
const containerRect = containerRef?.current?.getBoundingClientRect?.();
|
const containerRect = containerRef.current?.getBoundingClientRect?.();
|
||||||
if (containerRect) {
|
if (containerRect) {
|
||||||
state.containerRectLeft = containerRect.left;
|
state.containerRectLeft = containerRect.left;
|
||||||
state.containerRectTop = containerRect.top;
|
state.containerRectTop = containerRect.top;
|
||||||
@@ -572,6 +839,35 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
const pointerCanvasY = event.clientY - containerTop;
|
const pointerCanvasY = event.clientY - containerTop;
|
||||||
|
|
||||||
const entry = layoutRef.current.get(state.docKey) || {};
|
const entry = layoutRef.current.get(state.docKey) || {};
|
||||||
|
const previousCenterX = Number.isFinite(state.currentCenterX)
|
||||||
|
? state.currentCenterX
|
||||||
|
: state.originCenterX;
|
||||||
|
const previousCenterY = Number.isFinite(state.currentCenterY)
|
||||||
|
? state.currentCenterY
|
||||||
|
: state.originCenterY;
|
||||||
|
|
||||||
|
const currentTimestamp =
|
||||||
|
(Number.isFinite(event?.timeStamp))
|
||||||
|
? event.timeStamp
|
||||||
|
: performance?.now
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
const previousTimestamp = state.lastTimestamp ?? currentTimestamp;
|
||||||
|
let dt = (currentTimestamp - previousTimestamp) / 1000;
|
||||||
|
if (!Number.isFinite(dt) || dt <= 0) {
|
||||||
|
dt = MIN_TIMESTEP;
|
||||||
|
}
|
||||||
|
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
|
||||||
|
|
||||||
|
const torqueCenterX = Number.isFinite(previousCenterX) ? previousCenterX : state.originCenterX;
|
||||||
|
const torqueCenterY = Number.isFinite(previousCenterY) ? previousCenterY : state.originCenterY;
|
||||||
|
updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, torqueCenterX, torqueCenterY, dt);
|
||||||
|
applyDynamicRotation(dt);
|
||||||
|
|
||||||
|
state.lastClientX = event.clientX;
|
||||||
|
state.lastClientY = event.clientY;
|
||||||
|
state.lastTimestamp = currentTimestamp;
|
||||||
|
|
||||||
const rotationDeg = state.rotation ?? entry.rotation ?? 0;
|
const rotationDeg = state.rotation ?? entry.rotation ?? 0;
|
||||||
const rotationRad = (rotationDeg * Math.PI) / 180;
|
const rotationRad = (rotationDeg * Math.PI) / 180;
|
||||||
const cosRot = Math.cos(rotationRad);
|
const cosRot = Math.cos(rotationRad);
|
||||||
@@ -581,13 +877,6 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
const rotatedOffsetY =
|
const rotatedOffsetY =
|
||||||
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
|
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
|
||||||
|
|
||||||
const previousCenterX = Number.isFinite(state.currentCenterX)
|
|
||||||
? state.currentCenterX
|
|
||||||
: state.originCenterX;
|
|
||||||
const previousCenterY = Number.isFinite(state.currentCenterY)
|
|
||||||
? state.currentCenterY
|
|
||||||
: state.originCenterY;
|
|
||||||
|
|
||||||
const absCos = Math.abs(cosRot);
|
const absCos = Math.abs(cosRot);
|
||||||
const absSin = Math.abs(sinRot);
|
const absSin = Math.abs(sinRot);
|
||||||
const rotatedHalfWidth = absCos * halfWidth + absSin * halfHeight;
|
const rotatedHalfWidth = absCos * halfWidth + absSin * halfHeight;
|
||||||
@@ -662,23 +951,6 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
const pointerInsideCard =
|
const pointerInsideCard =
|
||||||
Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight;
|
Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight;
|
||||||
|
|
||||||
const currentTimestamp =
|
|
||||||
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
|
|
||||||
? event.timeStamp
|
|
||||||
: typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
||||||
? performance.now()
|
|
||||||
: Date.now();
|
|
||||||
const previousTimestamp = state.lastTimestamp ?? currentTimestamp;
|
|
||||||
let dt = (currentTimestamp - previousTimestamp) / 1000;
|
|
||||||
if (!Number.isFinite(dt) || dt <= 0) {
|
|
||||||
dt = MIN_TIMESTEP;
|
|
||||||
}
|
|
||||||
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
|
|
||||||
|
|
||||||
state.lastClientX = event.clientX;
|
|
||||||
state.lastClientY = event.clientY;
|
|
||||||
state.lastTimestamp = currentTimestamp;
|
|
||||||
|
|
||||||
const rotationForOffsetDeg = state.rotation || 0;
|
const rotationForOffsetDeg = state.rotation || 0;
|
||||||
const rotationForOffsetRad = (rotationForOffsetDeg * Math.PI) / 180;
|
const rotationForOffsetRad = (rotationForOffsetDeg * Math.PI) / 180;
|
||||||
const cosInverse = Math.cos(-rotationForOffsetRad);
|
const cosInverse = Math.cos(-rotationForOffsetRad);
|
||||||
@@ -711,7 +983,7 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerUp = useCallback(
|
const handlePointerUp = useCallback(
|
||||||
(event) => {
|
(event: PointerEventLike) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (!state || state.pointerId !== event.pointerId) {
|
if (!state || state.pointerId !== event.pointerId) {
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
@@ -728,14 +1000,18 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
|
|
||||||
if (state.moved) {
|
if (state.moved) {
|
||||||
commitActiveDragTransforms([state.docKey]);
|
commitActiveDragTransforms([state.docKey]);
|
||||||
const inertiaState = {
|
const finalRotation = state.rotation ?? state.restRotation;
|
||||||
restRotation: state.restRotation,
|
const inertiaState: EngineInertiaState = {
|
||||||
dynamicRotation: state.dynamicRotation,
|
docId: state.docKey,
|
||||||
|
restRotation: finalRotation,
|
||||||
|
dynamicRotation: 0,
|
||||||
angularVelocity: state.angularVelocity,
|
angularVelocity: state.angularVelocity,
|
||||||
rotation: state.rotation,
|
rotation: finalRotation,
|
||||||
width: state.width,
|
width: state.width,
|
||||||
height: state.height,
|
height: state.height,
|
||||||
dragScale: state.dragScale || 1,
|
dragScale: state.dragScale || 1,
|
||||||
|
lastTimestamp: state.lastTimestamp,
|
||||||
|
massGrams: state.massGrams,
|
||||||
};
|
};
|
||||||
const docId = state.docKey;
|
const docId = state.docKey;
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
@@ -775,7 +1051,7 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerCancel = useCallback(
|
const handlePointerCancel = useCallback(
|
||||||
(event) => {
|
(event: PointerEventLike) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (state && state.pointerId === event.pointerId && state.moved) {
|
if (state && state.pointerId === event.pointerId && state.moved) {
|
||||||
if (state.isGroup) {
|
if (state.isGroup) {
|
||||||
@@ -787,14 +1063,18 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
commitActiveDragTransforms([state.docKey]);
|
commitActiveDragTransforms([state.docKey]);
|
||||||
const inertiaState = {
|
const finalRotation = state.rotation ?? state.restRotation;
|
||||||
restRotation: state.restRotation,
|
const inertiaState: EngineInertiaState = {
|
||||||
dynamicRotation: state.dynamicRotation,
|
docId: state.docKey,
|
||||||
|
restRotation: finalRotation,
|
||||||
|
dynamicRotation: 0,
|
||||||
angularVelocity: state.angularVelocity,
|
angularVelocity: state.angularVelocity,
|
||||||
rotation: state.rotation,
|
rotation: finalRotation,
|
||||||
width: state.width,
|
width: state.width,
|
||||||
height: state.height,
|
height: state.height,
|
||||||
dragScale: state.dragScale || 1,
|
dragScale: state.dragScale || 1,
|
||||||
|
lastTimestamp: state.lastTimestamp,
|
||||||
|
massGrams: state.massGrams,
|
||||||
};
|
};
|
||||||
const docId = state.docKey;
|
const docId = state.docKey;
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
+349
-117
@@ -1,5 +1,132 @@
|
|||||||
import { clamp, formatTransform } from './math.js';
|
import { clamp, formatTransform } from './math';
|
||||||
import { fetchLayoutRecords, upsertLayoutRecords } from './db.js';
|
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
|
||||||
|
|
||||||
|
type DocumentId = string;
|
||||||
|
|
||||||
|
interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Polygon = Point[];
|
||||||
|
|
||||||
|
interface TransformOptions {
|
||||||
|
centerX?: number;
|
||||||
|
centerY?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
rotation?: number;
|
||||||
|
scale?: number;
|
||||||
|
zIndex?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardDimensions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutEntry {
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
rotation: number;
|
||||||
|
z: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutGenerationEntry {
|
||||||
|
id: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
seedKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutGenerationOptions {
|
||||||
|
canvasWidth: number;
|
||||||
|
canvasHeight: number;
|
||||||
|
padding: number;
|
||||||
|
startZ?: number;
|
||||||
|
rotationRange?: number;
|
||||||
|
minSpacing?: number;
|
||||||
|
shelfWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseMetrics {
|
||||||
|
baseWidth: number;
|
||||||
|
baseHeight: number;
|
||||||
|
baseScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragGroupItem {
|
||||||
|
docId?: string | number | null;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
currentCenterX?: number;
|
||||||
|
currentCenterY?: number;
|
||||||
|
displayRotation?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
docKey?: string | null;
|
||||||
|
dragScale?: number;
|
||||||
|
originCenterX?: number;
|
||||||
|
originCenterY?: number;
|
||||||
|
groupItems?: DragGroupItem[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InertiaSimulationState {
|
||||||
|
docId: string;
|
||||||
|
restRotation: number;
|
||||||
|
rotation: number;
|
||||||
|
dynamicRotation: number;
|
||||||
|
angularVelocity: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
dragScale?: number;
|
||||||
|
lastTimestamp: number;
|
||||||
|
frameId?: number;
|
||||||
|
massGrams?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceSnapshot {
|
||||||
|
layout: Map<DocumentId, LayoutEntry>;
|
||||||
|
canvasSize: { width: number; height: number };
|
||||||
|
visibleDocIds: Set<DocumentId>;
|
||||||
|
draggingId: string | null;
|
||||||
|
tagDropTargetId: string | null;
|
||||||
|
pendingTagDocId: string | null;
|
||||||
|
pendingRemovalTag: unknown;
|
||||||
|
initialLoadDone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceSubscriber = () => void;
|
||||||
|
|
||||||
|
type DeskDocument = { id?: string | number | null } & Record<string, unknown>;
|
||||||
|
|
||||||
|
type EnsureDocumentSize = (doc: DeskDocument) => DocumentSize | null;
|
||||||
|
|
||||||
|
type ResolveBaseMetrics = () => BaseMetrics;
|
||||||
|
|
||||||
|
interface ItemRefs {
|
||||||
|
current: Map<string, HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceEngineOptions {
|
||||||
|
allowLayoutPersistence?: boolean;
|
||||||
|
tenantId?: string | null;
|
||||||
|
viewId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseSyncExternalStoreHook = <State>(
|
||||||
|
subscribe: (listener: () => void) => () => void,
|
||||||
|
getSnapshot: () => State,
|
||||||
|
getServerSnapshot: () => State,
|
||||||
|
) => State;
|
||||||
|
|
||||||
export const DESK_CANVAS_PADDING = 24;
|
export const DESK_CANVAS_PADDING = 24;
|
||||||
export const DESK_ROTATION_RANGE = 7;
|
export const DESK_ROTATION_RANGE = 7;
|
||||||
@@ -7,18 +134,28 @@ export const DESK_DEFAULT_CANVAS_WIDTH = 1024;
|
|||||||
export const DESK_DEFAULT_CANVAS_HEIGHT = 680;
|
export const DESK_DEFAULT_CANVAS_HEIGHT = 680;
|
||||||
export const DESK_CARD_MIN = 240;
|
export const DESK_CARD_MIN = 240;
|
||||||
export const DESK_CARD_MAX = 340;
|
export const DESK_CARD_MAX = 340;
|
||||||
|
export const CARD_PAGE_WEIGHT_GRAMS = 5;
|
||||||
|
export const CARD_BASE_WEIGHT_GRAMS = 30;
|
||||||
|
|
||||||
const DEFAULT_Z_START = 10;
|
const DEFAULT_Z_START = 10;
|
||||||
export const MIN_TIMESTEP = 1 / 120;
|
export const MIN_TIMESTEP = 1 / 120;
|
||||||
export const MAX_TIMESTEP = 1 / 20;
|
export const MAX_TIMESTEP = 1 / 20;
|
||||||
export const MAX_DYNAMIC_ROTATION = 4;
|
export const MAX_DYNAMIC_ROTATION = 7;
|
||||||
export const MAX_ANGULAR_VELOCITY = 180;
|
export const MAX_ANGULAR_VELOCITY = 210;
|
||||||
export const ANGULAR_DAMPING = 11;
|
export const ANGULAR_DAMPING = 10;
|
||||||
export const TORQUE_TO_ACCELERATION = 0.006;
|
export const TORQUE_TO_ACCELERATION = 0.007;
|
||||||
export const SETTLE_ANGULAR_VELOCITY = 1.2;
|
export const SETTLE_ANGULAR_VELOCITY = 0.45;
|
||||||
|
|
||||||
|
const getMassScale = (massGrams?: number): number => {
|
||||||
|
if (!Number.isFinite(massGrams) || Number(massGrams) <= 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const normalized = Math.max(Number(massGrams), CARD_BASE_WEIGHT_GRAMS) / CARD_BASE_WEIGHT_GRAMS;
|
||||||
|
return Math.max(normalized, 1);
|
||||||
|
};
|
||||||
|
|
||||||
export const applyDomTransform = (
|
export const applyDomTransform = (
|
||||||
node,
|
node: HTMLElement | null,
|
||||||
{
|
{
|
||||||
centerX,
|
centerX,
|
||||||
centerY,
|
centerY,
|
||||||
@@ -27,8 +164,8 @@ export const applyDomTransform = (
|
|||||||
rotation = 0,
|
rotation = 0,
|
||||||
scale = 1,
|
scale = 1,
|
||||||
zIndex,
|
zIndex,
|
||||||
} = {},
|
}: TransformOptions = {},
|
||||||
) => {
|
): void => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -44,7 +181,7 @@ export const applyDomTransform = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clampCardDimensions = (width, height) => {
|
export const clampCardDimensions = (width: number, height: number): CardDimensions | null => {
|
||||||
const w = Number(width);
|
const w = Number(width);
|
||||||
const h = Number(height);
|
const h = Number(height);
|
||||||
|
|
||||||
@@ -56,7 +193,7 @@ export const clampCardDimensions = (width, height) => {
|
|||||||
const high = Math.min(DESK_CARD_MAX / w, DESK_CARD_MAX / h);
|
const high = Math.min(DESK_CARD_MAX / w, DESK_CARD_MAX / h);
|
||||||
|
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
const addCandidate = (scale) => {
|
const addCandidate = (scale: number) => {
|
||||||
if (Number.isFinite(scale) && scale > 0) {
|
if (Number.isFinite(scale) && scale > 0) {
|
||||||
candidates.push(scale);
|
candidates.push(scale);
|
||||||
}
|
}
|
||||||
@@ -66,7 +203,7 @@ export const clampCardDimensions = (width, height) => {
|
|||||||
addCandidate(low);
|
addCandidate(low);
|
||||||
addCandidate(high);
|
addCandidate(high);
|
||||||
|
|
||||||
const best = candidates.reduce((acc, scale) => {
|
const best = candidates.reduce<{ scale: number; violation: number; deviation: number } | null>((acc, scale) => {
|
||||||
const scaledWidth = w * scale;
|
const scaledWidth = w * scale;
|
||||||
const scaledHeight = h * scale;
|
const scaledHeight = h * scale;
|
||||||
const violation = Math.max(
|
const violation = Math.max(
|
||||||
@@ -89,7 +226,7 @@ export const clampCardDimensions = (width, height) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const computeFallbackCardSize = (docId) => {
|
export const computeFallbackCardSize = (docId: string | number): CardDimensions | null => {
|
||||||
const baseSeed = seededRandom(`${docId}:fallback-size`);
|
const baseSeed = seededRandom(`${docId}:fallback-size`);
|
||||||
const aspectSeed = seededRandom(`${docId}:fallback-aspect`);
|
const aspectSeed = seededRandom(`${docId}:fallback-aspect`);
|
||||||
|
|
||||||
@@ -105,7 +242,7 @@ export const computeFallbackCardSize = (docId) => {
|
|||||||
return clampCardDimensions(width, height);
|
return clampCardDimensions(width, height);
|
||||||
};
|
};
|
||||||
|
|
||||||
function seededRandom(input) {
|
function seededRandom(input: unknown): number {
|
||||||
const text = String(input);
|
const text = String(input);
|
||||||
let hash = 2166136261;
|
let hash = 2166136261;
|
||||||
for (let index = 0; index < text.length; index += 1) {
|
for (let index = 0; index < text.length; index += 1) {
|
||||||
@@ -115,22 +252,25 @@ function seededRandom(input) {
|
|||||||
return (hash >>> 0) / 4294967295;
|
return (hash >>> 0) / 4294967295;
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomRangeFromSeed(seedKey, min, max) {
|
function randomRangeFromSeed(seedKey: string, min: number, max: number): number {
|
||||||
const span = max - min;
|
const span = max - min;
|
||||||
if (span <= 0) return min;
|
if (span <= 0) return min;
|
||||||
const seed = seededRandom(seedKey);
|
const seed = seededRandom(seedKey);
|
||||||
return min + seed * span;
|
return min + seed * span;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildKey(docId, suffix) {
|
function buildKey(docId: string | number, suffix: string): string {
|
||||||
return `${docId}::${suffix}`;
|
return `${docId}::${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedDistanceToEdge = (edgeStart, edgeEnd, point) =>
|
const signedDistanceToEdge = (edgeStart: Point, edgeEnd: Point, point: Point): number =>
|
||||||
(edgeEnd.x - edgeStart.x) * (point.y - edgeStart.y)
|
(edgeEnd.x - edgeStart.x) * (point.y - edgeStart.y)
|
||||||
- (edgeEnd.y - edgeStart.y) * (point.x - edgeStart.x);
|
- (edgeEnd.y - edgeStart.y) * (point.x - edgeStart.x);
|
||||||
|
|
||||||
const iterateEdges = (polygon, callback) => {
|
const iterateEdges = (
|
||||||
|
polygon: Polygon,
|
||||||
|
callback: (current: Point, next: Point, index: number) => boolean | void,
|
||||||
|
): void => {
|
||||||
if (!Array.isArray(polygon) || polygon.length === 0) {
|
if (!Array.isArray(polygon) || polygon.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,7 +283,10 @@ const iterateEdges = (polygon, callback) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const forEachVertex = (polygon, callback) => {
|
const forEachVertex = (
|
||||||
|
polygon: Polygon,
|
||||||
|
callback: (current: Point, previous: Point, index: number) => boolean | void,
|
||||||
|
): void => {
|
||||||
if (!Array.isArray(polygon) || polygon.length === 0) {
|
if (!Array.isArray(polygon) || polygon.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -156,7 +299,7 @@ const forEachVertex = (polygon, callback) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const lineIntersection = (p1, p2, cp1, cp2) => {
|
const lineIntersection = (p1: Point, p2: Point, cp1: Point, cp2: Point): Point => {
|
||||||
const A1 = p2.y - p1.y;
|
const A1 = p2.y - p1.y;
|
||||||
const B1 = p1.x - p2.x;
|
const B1 = p1.x - p2.x;
|
||||||
const C1 = A1 * p1.x + B1 * p1.y;
|
const C1 = A1 * p1.x + B1 * p1.y;
|
||||||
@@ -175,7 +318,7 @@ const lineIntersection = (p1, p2, cp1, cp2) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const clipPolygon = (subject, clipper) => {
|
const clipPolygon = (subject: Polygon, clipper: Polygon): Polygon => {
|
||||||
if (!Array.isArray(subject) || !subject.length) {
|
if (!Array.isArray(subject) || !subject.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -204,7 +347,7 @@ const clipPolygon = (subject, clipper) => {
|
|||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPointInsideConvex = (point, polygon) => {
|
const isPointInsideConvex = (point: Point, polygon: Polygon): boolean => {
|
||||||
if (!polygon?.length) {
|
if (!polygon?.length) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -229,7 +372,7 @@ const isPointInsideConvex = (point, polygon) => {
|
|||||||
return inside;
|
return inside;
|
||||||
};
|
};
|
||||||
|
|
||||||
const polygonCentroid = (polygon) => {
|
const polygonCentroid = (polygon: Polygon): Point => {
|
||||||
if (!polygon?.length) {
|
if (!polygon?.length) {
|
||||||
return { x: 0, y: 0 };
|
return { x: 0, y: 0 };
|
||||||
}
|
}
|
||||||
@@ -261,7 +404,7 @@ const polygonCentroid = (polygon) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
const generateInitialLayout = (
|
const generateInitialLayout = (
|
||||||
entries,
|
entries: LayoutGenerationEntry[],
|
||||||
{
|
{
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
@@ -270,9 +413,9 @@ const generateInitialLayout = (
|
|||||||
rotationRange = DESK_ROTATION_RANGE,
|
rotationRange = DESK_ROTATION_RANGE,
|
||||||
minSpacing = 48,
|
minSpacing = 48,
|
||||||
shelfWidth = 0,
|
shelfWidth = 0,
|
||||||
},
|
}: LayoutGenerationOptions,
|
||||||
) => {
|
): { layout: Map<string, LayoutEntry>; maxZ: number } => {
|
||||||
const layout = new Map();
|
const layout = new Map<string, LayoutEntry>();
|
||||||
let currentZ = startZ;
|
let currentZ = startZ;
|
||||||
let maxZ = startZ;
|
let maxZ = startZ;
|
||||||
|
|
||||||
@@ -282,9 +425,9 @@ const generateInitialLayout = (
|
|||||||
|
|
||||||
const shelfOffset = Math.max(shelfWidth, 0);
|
const shelfOffset = Math.max(shelfWidth, 0);
|
||||||
const spacingBuffer = Math.max(minSpacing, 0);
|
const spacingBuffer = Math.max(minSpacing, 0);
|
||||||
const placed = [];
|
const placed: Array<{ x: number; y: number; radius: number }> = [];
|
||||||
|
|
||||||
const resolveBounds = (width, height) => {
|
const resolveBounds = (width: number, height: number) => {
|
||||||
const halfWidth = width / 2;
|
const halfWidth = width / 2;
|
||||||
const halfHeight = height / 2;
|
const halfHeight = height / 2;
|
||||||
return {
|
return {
|
||||||
@@ -298,7 +441,7 @@ const generateInitialLayout = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const evaluateCandidateSpacing = (x, y, radius) => {
|
const evaluateCandidateSpacing = (x: number, y: number, radius: number) => {
|
||||||
if (!placed.length) {
|
if (!placed.length) {
|
||||||
return Number.POSITIVE_INFINITY;
|
return Number.POSITIVE_INFINITY;
|
||||||
}
|
}
|
||||||
@@ -381,11 +524,71 @@ const generateInitialLayout = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class WorkspaceEngine {
|
export class WorkspaceEngine {
|
||||||
|
allowLayoutPersistence: boolean;
|
||||||
|
|
||||||
|
tenantId: string | null;
|
||||||
|
|
||||||
|
viewId: string | null;
|
||||||
|
|
||||||
|
layout: Map<DocumentId, LayoutEntry>;
|
||||||
|
|
||||||
|
layoutSnapshot: Map<DocumentId, LayoutEntry>;
|
||||||
|
|
||||||
|
persistedLayout: Map<DocumentId, LayoutEntry>;
|
||||||
|
|
||||||
|
layoutDirty: boolean;
|
||||||
|
|
||||||
|
zCounter: number;
|
||||||
|
|
||||||
|
canvasSize: { width: number; height: number };
|
||||||
|
|
||||||
|
visibleDocIds: Set<DocumentId>;
|
||||||
|
|
||||||
|
draggingId: string | null;
|
||||||
|
|
||||||
|
tagDropTargetId: string | null;
|
||||||
|
|
||||||
|
pendingTagDocId: string | null;
|
||||||
|
|
||||||
|
pendingRemovalTag: unknown;
|
||||||
|
|
||||||
|
dragInProgress: boolean;
|
||||||
|
|
||||||
|
activeDragDocIds: Set<DocumentId>;
|
||||||
|
|
||||||
|
pendingSnapshotSync: boolean;
|
||||||
|
|
||||||
|
pendingPersistSync: boolean;
|
||||||
|
|
||||||
|
persistDebounceId: number | null;
|
||||||
|
|
||||||
|
items: DeskDocument[];
|
||||||
|
|
||||||
|
documentLookup: Map<string, DeskDocument>;
|
||||||
|
|
||||||
|
ensureDocumentSize: EnsureDocumentSize;
|
||||||
|
|
||||||
|
resolveBaseMetrics: ResolveBaseMetrics;
|
||||||
|
|
||||||
|
snapshotCache: WorkspaceSnapshot;
|
||||||
|
|
||||||
|
subscribers: Set<WorkspaceSubscriber>;
|
||||||
|
|
||||||
|
loadingPersisted: boolean;
|
||||||
|
|
||||||
|
pendingPersistence: unknown;
|
||||||
|
|
||||||
|
itemRefs: ItemRefs;
|
||||||
|
|
||||||
|
inertiaAnimations: Map<string, InertiaSimulationState>;
|
||||||
|
|
||||||
|
initialLoadDone: boolean;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
allowLayoutPersistence = false,
|
allowLayoutPersistence = false,
|
||||||
tenantId = null,
|
tenantId = null,
|
||||||
viewId = null,
|
viewId = null,
|
||||||
} = {}) {
|
}: WorkspaceEngineOptions = {}) {
|
||||||
this.allowLayoutPersistence = allowLayoutPersistence;
|
this.allowLayoutPersistence = allowLayoutPersistence;
|
||||||
this.tenantId = tenantId;
|
this.tenantId = tenantId;
|
||||||
this.viewId = viewId;
|
this.viewId = viewId;
|
||||||
@@ -424,9 +627,9 @@ export class WorkspaceEngine {
|
|||||||
this.initialLoadDone = false;
|
this.initialLoadDone = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateConfig({ allowLayoutPersistence, tenantId, viewId }) {
|
updateConfig({ allowLayoutPersistence, tenantId, viewId }: WorkspaceEngineOptions): void {
|
||||||
const allowChanged =
|
const allowChanged =
|
||||||
typeof allowLayoutPersistence === 'boolean'
|
allowLayoutPersistence !== undefined
|
||||||
&& allowLayoutPersistence !== this.allowLayoutPersistence;
|
&& allowLayoutPersistence !== this.allowLayoutPersistence;
|
||||||
const tenantChanged = tenantId !== undefined && tenantId !== this.tenantId;
|
const tenantChanged = tenantId !== undefined && tenantId !== this.tenantId;
|
||||||
const viewChanged = viewId !== undefined && viewId !== this.viewId;
|
const viewChanged = viewId !== undefined && viewId !== this.viewId;
|
||||||
@@ -470,7 +673,7 @@ export class WorkspaceEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setItems(items) {
|
setItems(items: DeskDocument[] | null): void {
|
||||||
const normalized = Array.isArray(items) ? items : [];
|
const normalized = Array.isArray(items) ? items : [];
|
||||||
this.items = normalized;
|
this.items = normalized;
|
||||||
const canGenerateLayoutImmediately =
|
const canGenerateLayoutImmediately =
|
||||||
@@ -484,28 +687,24 @@ export class WorkspaceEngine {
|
|||||||
this.recalcVisibleDocIds();
|
this.recalcVisibleDocIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
setDocumentLookup(map) {
|
setDocumentLookup(map: Map<string, DeskDocument>): void {
|
||||||
this.documentLookup = map instanceof Map ? map : new Map();
|
this.documentLookup = map instanceof Map ? map : new Map();
|
||||||
this.recalcVisibleDocIds();
|
this.recalcVisibleDocIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
setEnsureDocumentSize(fn) {
|
setEnsureDocumentSize(fn: EnsureDocumentSize): void {
|
||||||
if (typeof fn === 'function') {
|
|
||||||
this.ensureDocumentSize = fn;
|
this.ensureDocumentSize = fn;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setResolveBaseMetrics(fn) {
|
setResolveBaseMetrics(fn: ResolveBaseMetrics): void {
|
||||||
if (typeof fn === 'function') {
|
|
||||||
this.resolveBaseMetrics = fn;
|
this.resolveBaseMetrics = fn;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setItemRefs(ref) {
|
setItemRefs(ref: ItemRefs | null): void {
|
||||||
this.itemRefs = ref || { current: new Map() };
|
this.itemRefs = ref || { current: new Map() };
|
||||||
}
|
}
|
||||||
|
|
||||||
setCanvasSize(size) {
|
setCanvasSize(size: { width?: number | null; height?: number | null }): void {
|
||||||
const width = Number(size?.width) || 0;
|
const width = Number(size?.width) || 0;
|
||||||
const height = Number(size?.height) || 0;
|
const height = Number(size?.height) || 0;
|
||||||
if (this.canvasSize.width === width && this.canvasSize.height === height) {
|
if (this.canvasSize.width === width && this.canvasSize.height === height) {
|
||||||
@@ -517,7 +716,7 @@ export class WorkspaceEngine {
|
|||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
setDraggingId(docId) {
|
setDraggingId(docId: string | number | null): void {
|
||||||
const normalized = docId != null ? String(docId) : null;
|
const normalized = docId != null ? String(docId) : null;
|
||||||
if (this.draggingId === normalized) {
|
if (this.draggingId === normalized) {
|
||||||
return;
|
return;
|
||||||
@@ -526,7 +725,7 @@ export class WorkspaceEngine {
|
|||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
beginDrag(docIds = []) {
|
beginDrag(docIds: Array<string | number | null> = []): void {
|
||||||
this.dragInProgress = true;
|
this.dragInProgress = true;
|
||||||
if (Array.isArray(docIds)) {
|
if (Array.isArray(docIds)) {
|
||||||
this.activeDragDocIds = new Set(docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean));
|
this.activeDragDocIds = new Set(docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean));
|
||||||
@@ -535,13 +734,13 @@ export class WorkspaceEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
endDrag() {
|
endDrag(): void {
|
||||||
this.dragInProgress = false;
|
this.dragInProgress = false;
|
||||||
this.activeDragDocIds.clear();
|
this.activeDragDocIds.clear();
|
||||||
this.flushPendingLayoutOps();
|
this.flushPendingLayoutOps();
|
||||||
}
|
}
|
||||||
|
|
||||||
flushPendingLayoutOps() {
|
flushPendingLayoutOps(): void {
|
||||||
if (this.pendingSnapshotSync) {
|
if (this.pendingSnapshotSync) {
|
||||||
this.syncLayoutSnapshot();
|
this.syncLayoutSnapshot();
|
||||||
}
|
}
|
||||||
@@ -550,7 +749,7 @@ export class WorkspaceEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTagDropTargetId(docId) {
|
setTagDropTargetId(docId: string | number | null): void {
|
||||||
const normalized = docId != null ? String(docId) : null;
|
const normalized = docId != null ? String(docId) : null;
|
||||||
if (this.tagDropTargetId === normalized) {
|
if (this.tagDropTargetId === normalized) {
|
||||||
return;
|
return;
|
||||||
@@ -559,7 +758,7 @@ export class WorkspaceEngine {
|
|||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
setPendingTagDocId(docId) {
|
setPendingTagDocId(docId: string | number | null): void {
|
||||||
const normalized = docId != null ? String(docId) : null;
|
const normalized = docId != null ? String(docId) : null;
|
||||||
if (this.pendingTagDocId === normalized) {
|
if (this.pendingTagDocId === normalized) {
|
||||||
return;
|
return;
|
||||||
@@ -568,7 +767,7 @@ export class WorkspaceEngine {
|
|||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
setPendingRemovalTag(payload) {
|
setPendingRemovalTag(payload: unknown): void {
|
||||||
if (payload === this.pendingRemovalTag) {
|
if (payload === this.pendingRemovalTag) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -576,11 +775,11 @@ export class WorkspaceEngine {
|
|||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
markLayoutDirty() {
|
markLayoutDirty(): void {
|
||||||
this.layoutDirty = true;
|
this.layoutDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLayout(docId) {
|
getLayout(docId: string | number | null): LayoutEntry | null {
|
||||||
if (docId == null) {
|
if (docId == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -588,13 +787,16 @@ export class WorkspaceEngine {
|
|||||||
return this.layout.get(key) || null;
|
return this.layout.get(key) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLayoutEntry(docId, updater) {
|
updateLayoutEntry(
|
||||||
|
docId: string | number | null,
|
||||||
|
updater: (previous: LayoutEntry | null) => LayoutEntry | null,
|
||||||
|
): void {
|
||||||
if (docId == null) {
|
if (docId == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const key = String(docId);
|
const key = String(docId);
|
||||||
const previous = this.layout.get(key) || null;
|
const previous = this.layout.get(key) || null;
|
||||||
const next = typeof updater === 'function' ? updater(previous || {}) : updater;
|
const next = updater(previous);
|
||||||
if (!next) {
|
if (!next) {
|
||||||
this.layout.delete(key);
|
this.layout.delete(key);
|
||||||
} else {
|
} else {
|
||||||
@@ -605,7 +807,7 @@ export class WorkspaceEngine {
|
|||||||
this.persistLayoutSnapshot();
|
this.persistLayoutSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
bringToFront(docId) {
|
bringToFront(docId: string | number | null): void {
|
||||||
if (docId == null) {
|
if (docId == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -622,7 +824,16 @@ export class WorkspaceEngine {
|
|||||||
this.recalcVisibleDocIds();
|
this.recalcVisibleDocIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
applyTransform(docId, centerX, centerY, width, height, rotation, scale = 1, zIndex = null) {
|
applyTransform(
|
||||||
|
docId: string | number | null,
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
rotation: number,
|
||||||
|
scale = 1,
|
||||||
|
zIndex: number | null = null,
|
||||||
|
): void {
|
||||||
const key = docId != null ? String(docId) : null;
|
const key = docId != null ? String(docId) : null;
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return;
|
return;
|
||||||
@@ -639,7 +850,7 @@ export class WorkspaceEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
finalizeGroupDrag(dragState) {
|
finalizeGroupDrag(dragState: DragState): void {
|
||||||
if (!dragState?.groupItems) {
|
if (!dragState?.groupItems) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -652,16 +863,18 @@ export class WorkspaceEngine {
|
|||||||
if (!key) {
|
if (!key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const entry = this.layout.get(key) || {};
|
const entry = this.layout.get(key);
|
||||||
const centerX = item.currentCenterX ?? entry.centerX ?? dragState.originCenterX;
|
const centerX = item.currentCenterX ?? entry?.centerX ?? dragState.originCenterX ?? 0;
|
||||||
const centerY = item.currentCenterY ?? entry.centerY ?? dragState.originCenterY;
|
const centerY = item.currentCenterY ?? entry?.centerY ?? dragState.originCenterY ?? 0;
|
||||||
const rotation = item.displayRotation ?? entry.rotation ?? 0;
|
const rotation = item.displayRotation ?? entry?.rotation ?? 0;
|
||||||
|
|
||||||
const nextEntry = {
|
const nextEntry: LayoutEntry = {
|
||||||
...entry,
|
|
||||||
centerX,
|
centerX,
|
||||||
centerY,
|
centerY,
|
||||||
rotation,
|
rotation,
|
||||||
|
z: entry?.z ?? this.zCounter,
|
||||||
|
width: entry?.width ?? item.width,
|
||||||
|
height: entry?.height ?? item.height,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.layout.set(key, nextEntry);
|
this.layout.set(key, nextEntry);
|
||||||
@@ -683,34 +896,33 @@ export class WorkspaceEngine {
|
|||||||
this.persistLayoutSnapshot();
|
this.persistLayoutSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelInertiaAnimation(docId) {
|
cancelInertiaAnimation(docId: string | number | null): void {
|
||||||
const key = docId != null ? String(docId) : null;
|
const key = docId != null ? String(docId) : null;
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
this.inertiaAnimations.delete(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const existing = this.inertiaAnimations.get(key);
|
const existing = this.inertiaAnimations.get(key);
|
||||||
if (existing && typeof window.cancelAnimationFrame === 'function') {
|
if (existing?.frameId != null) {
|
||||||
window.cancelAnimationFrame(existing.frameId);
|
window.cancelAnimationFrame(existing.frameId);
|
||||||
}
|
}
|
||||||
this.inertiaAnimations.delete(key);
|
this.inertiaAnimations.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
disposeInertiaAnimations() {
|
disposeInertiaAnimations(): void {
|
||||||
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
|
||||||
this.inertiaAnimations.forEach((animation) => {
|
this.inertiaAnimations.forEach((animation) => {
|
||||||
if (animation?.frameId != null) {
|
if (animation?.frameId != null) {
|
||||||
window.cancelAnimationFrame(animation.frameId);
|
window.cancelAnimationFrame(animation.frameId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
this.inertiaAnimations.clear();
|
this.inertiaAnimations.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
integrateRotation(simulationState, dt, torque = 0, dampingOverride = null) {
|
integrateRotation(
|
||||||
|
simulationState: InertiaSimulationState,
|
||||||
|
dt: number,
|
||||||
|
torque = 0,
|
||||||
|
dampingOverride: number | null = null,
|
||||||
|
): boolean {
|
||||||
const key = simulationState.docId != null ? String(simulationState.docId) : null;
|
const key = simulationState.docId != null ? String(simulationState.docId) : null;
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return true;
|
return true;
|
||||||
@@ -726,26 +938,43 @@ export class WorkspaceEngine {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const massScale = getMassScale(simulationState.massGrams);
|
||||||
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
|
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
|
||||||
let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt;
|
let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt;
|
||||||
angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY);
|
const maxAngularVelocity = MAX_ANGULAR_VELOCITY / massScale;
|
||||||
|
angularVelocity = clamp(angularVelocity, -maxAngularVelocity, maxAngularVelocity);
|
||||||
|
|
||||||
const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING;
|
const dampingConstant = Number.isFinite(dampingOverride)
|
||||||
|
? Number(dampingOverride)
|
||||||
|
: ANGULAR_DAMPING;
|
||||||
const dampingFactor = Math.exp(-dampingConstant * dt);
|
const dampingFactor = Math.exp(-dampingConstant * dt);
|
||||||
angularVelocity *= dampingFactor;
|
angularVelocity *= dampingFactor;
|
||||||
|
|
||||||
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
|
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
|
||||||
if (dynamicRotation > MAX_DYNAMIC_ROTATION) {
|
const dynamicLimit = MAX_DYNAMIC_ROTATION / Math.sqrt(massScale);
|
||||||
dynamicRotation = MAX_DYNAMIC_ROTATION;
|
if (dynamicRotation > dynamicLimit) {
|
||||||
|
dynamicRotation = dynamicLimit;
|
||||||
angularVelocity = Math.min(angularVelocity, 0);
|
angularVelocity = Math.min(angularVelocity, 0);
|
||||||
} else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) {
|
} else if (dynamicRotation < -dynamicLimit) {
|
||||||
dynamicRotation = -MAX_DYNAMIC_ROTATION;
|
dynamicRotation = -dynamicLimit;
|
||||||
angularVelocity = Math.max(angularVelocity, 0);
|
angularVelocity = Math.max(angularVelocity, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSettled = Math.abs(angularVelocity) < (SETTLE_ANGULAR_VELOCITY * 0.6)
|
||||||
|
|| Math.abs(dynamicRotation) < (MAX_DYNAMIC_ROTATION * 0.05);
|
||||||
|
|
||||||
|
if (isSettled) {
|
||||||
|
simulationState.angularVelocity = 0;
|
||||||
|
simulationState.dynamicRotation = 0;
|
||||||
|
simulationState.rotation = simulationState.restRotation;
|
||||||
|
} else {
|
||||||
simulationState.angularVelocity = angularVelocity;
|
simulationState.angularVelocity = angularVelocity;
|
||||||
simulationState.dynamicRotation = dynamicRotation;
|
simulationState.dynamicRotation = dynamicRotation;
|
||||||
simulationState.rotation = simulationState.restRotation + dynamicRotation;
|
simulationState.rotation = simulationState.restRotation + dynamicRotation;
|
||||||
|
if (Math.sign(simulationState.angularVelocity) !== Math.sign(angularVelocity)) {
|
||||||
|
simulationState.angularVelocity = angularVelocity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rotation = simulationState.rotation;
|
const rotation = simulationState.rotation;
|
||||||
const nextEntry = { ...entry, rotation };
|
const nextEntry = { ...entry, rotation };
|
||||||
@@ -763,12 +992,12 @@ export class WorkspaceEngine {
|
|||||||
nextEntry.z,
|
nextEntry.z,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY;
|
|
||||||
return isSettled;
|
return isSettled;
|
||||||
}
|
}
|
||||||
|
|
||||||
startInertiaAnimation(docId, baseState) {
|
startInertiaAnimation(docId: string | number | null, baseState: InertiaSimulationState): void {
|
||||||
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
|
const raf = window.requestAnimationFrame;
|
||||||
|
if (!raf) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const key = docId != null ? String(docId) : null;
|
const key = docId != null ? String(docId) : null;
|
||||||
@@ -778,19 +1007,19 @@ export class WorkspaceEngine {
|
|||||||
|
|
||||||
this.cancelInertiaAnimation(key);
|
this.cancelInertiaAnimation(key);
|
||||||
|
|
||||||
const now =
|
const now = performance?.now ? performance.now() : Date.now();
|
||||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
||||||
? performance.now()
|
|
||||||
: Date.now();
|
|
||||||
|
|
||||||
const simulationState = {
|
const simulationState = {
|
||||||
...baseState,
|
...baseState,
|
||||||
docId: key,
|
docId: key,
|
||||||
dragScale: baseState.dragScale || 1,
|
dragScale: baseState.dragScale || 1,
|
||||||
lastTimestamp: now,
|
lastTimestamp: now,
|
||||||
|
massGrams: Number.isFinite(baseState.massGrams)
|
||||||
|
? Math.max(Number(baseState.massGrams), CARD_BASE_WEIGHT_GRAMS)
|
||||||
|
: CARD_BASE_WEIGHT_GRAMS,
|
||||||
};
|
};
|
||||||
|
|
||||||
const step = (timestamp) => {
|
const step = (timestamp: number) => {
|
||||||
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
|
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
|
||||||
const previous = simulationState.lastTimestamp;
|
const previous = simulationState.lastTimestamp;
|
||||||
let dt = (safeTimestamp - previous) / 1000;
|
let dt = (safeTimestamp - previous) / 1000;
|
||||||
@@ -807,14 +1036,14 @@ export class WorkspaceEngine {
|
|||||||
this.persistLayoutSnapshot();
|
this.persistLayoutSnapshot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
simulationState.frameId = window.requestAnimationFrame(step);
|
simulationState.frameId = raf(step);
|
||||||
};
|
};
|
||||||
|
|
||||||
simulationState.frameId = window.requestAnimationFrame(step);
|
simulationState.frameId = raf(step);
|
||||||
this.inertiaAnimations.set(key, simulationState);
|
this.inertiaAnimations.set(key, simulationState);
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLayoutSnapshot() {
|
syncLayoutSnapshot(): void {
|
||||||
if (this.dragInProgress) {
|
if (this.dragInProgress) {
|
||||||
this.pendingSnapshotSync = true;
|
this.pendingSnapshotSync = true;
|
||||||
return;
|
return;
|
||||||
@@ -824,7 +1053,7 @@ export class WorkspaceEngine {
|
|||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
async persistLayoutSnapshot() {
|
async persistLayoutSnapshot(): Promise<void> {
|
||||||
if (this.dragInProgress) {
|
if (this.dragInProgress) {
|
||||||
this.pendingPersistSync = true;
|
this.pendingPersistSync = true;
|
||||||
return;
|
return;
|
||||||
@@ -885,17 +1114,13 @@ export class WorkspaceEngine {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
|
|
||||||
this.persistDebounceId = window.setTimeout(() => {
|
this.persistDebounceId = window.setTimeout(() => {
|
||||||
this.persistDebounceId = null;
|
this.persistDebounceId = null;
|
||||||
void persistTask();
|
void persistTask();
|
||||||
}, 100);
|
}, 100);
|
||||||
} else {
|
|
||||||
await persistTask();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureLayoutForItems() {
|
ensureLayoutForItems(): void {
|
||||||
const persistenceReady = !this.allowLayoutPersistence || !this.tenantId || !this.viewId || this.initialLoadDone;
|
const persistenceReady = !this.allowLayoutPersistence || !this.tenantId || !this.viewId || this.initialLoadDone;
|
||||||
const canvasReady = Boolean(this.canvasSize.width && this.canvasSize.height);
|
const canvasReady = Boolean(this.canvasSize.width && this.canvasSize.height);
|
||||||
const sizesReady = !this.items.some((doc) => !this.ensureDocumentSize(doc));
|
const sizesReady = !this.items.some((doc) => !this.ensureDocumentSize(doc));
|
||||||
@@ -919,11 +1144,11 @@ export class WorkspaceEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = new Map();
|
const next = new Map<DocumentId, LayoutEntry>();
|
||||||
let maxZ = this.zCounter;
|
let maxZ = this.zCounter;
|
||||||
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
|
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
|
||||||
const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT;
|
const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT;
|
||||||
const docsNeedingLayout = [];
|
const docsNeedingLayout: LayoutGenerationEntry[] = [];
|
||||||
|
|
||||||
const currentEntries = new Map(this.layout);
|
const currentEntries = new Map(this.layout);
|
||||||
|
|
||||||
@@ -951,8 +1176,12 @@ export class WorkspaceEngine {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
const defaultCenterX = (minCenterX + maxCenterX) / 2;
|
const defaultCenterX = (minCenterX + maxCenterX) / 2;
|
||||||
const defaultCenterY = (minCenterY + maxCenterY) / 2;
|
const defaultCenterY = (minCenterY + maxCenterY) / 2;
|
||||||
const prevCenterX = typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX;
|
const prevCenterX = Number.isFinite(existing.centerX)
|
||||||
const prevCenterY = typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY;
|
? Number(existing.centerX)
|
||||||
|
: defaultCenterX;
|
||||||
|
const prevCenterY = Number.isFinite(existing.centerY)
|
||||||
|
? Number(existing.centerY)
|
||||||
|
: defaultCenterY;
|
||||||
const centerX = clamp(prevCenterX, minCenterX, maxCenterX);
|
const centerX = clamp(prevCenterX, minCenterX, maxCenterX);
|
||||||
const centerY = clamp(prevCenterY, minCenterY, maxCenterY);
|
const centerY = clamp(prevCenterY, minCenterY, maxCenterY);
|
||||||
const rotation = existing.rotation ?? 0;
|
const rotation = existing.rotation ?? 0;
|
||||||
@@ -996,11 +1225,8 @@ export class WorkspaceEngine {
|
|||||||
this.recalcVisibleDocIds();
|
this.recalcVisibleDocIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
recalcVisibleDocIds() {
|
recalcVisibleDocIds(): void {
|
||||||
const ensureSize = this.ensureDocumentSize;
|
const ensureSize = this.ensureDocumentSize;
|
||||||
if (typeof ensureSize !== 'function') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutMap = this.layout;
|
const layoutMap = this.layout;
|
||||||
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
|
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
|
||||||
@@ -1021,7 +1247,7 @@ export class WorkspaceEngine {
|
|||||||
{ x: 0, y: canvasHeight },
|
{ x: 0, y: canvasHeight },
|
||||||
];
|
];
|
||||||
|
|
||||||
const entries = [];
|
const entries: Array<{ key: string; z: number; polygon: Polygon }> = [];
|
||||||
layoutMap.forEach((entry, docKey) => {
|
layoutMap.forEach((entry, docKey) => {
|
||||||
if (!docKey) {
|
if (!docKey) {
|
||||||
return;
|
return;
|
||||||
@@ -1074,8 +1300,8 @@ export class WorkspaceEngine {
|
|||||||
|
|
||||||
entries.sort((a, b) => (b.z || 0) - (a.z || 0));
|
entries.sort((a, b) => (b.z || 0) - (a.z || 0));
|
||||||
|
|
||||||
const visiblePolygons = [];
|
const visiblePolygons: Polygon[] = [];
|
||||||
const result = new Set();
|
const result = new Set<DocumentId>();
|
||||||
|
|
||||||
entries.forEach(({ key, polygon }) => {
|
entries.forEach(({ key, polygon }) => {
|
||||||
if (polygon.length < 3) {
|
if (polygon.length < 3) {
|
||||||
@@ -1129,16 +1355,16 @@ export class WorkspaceEngine {
|
|||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(listener) {
|
subscribe(listener: WorkspaceSubscriber): () => void {
|
||||||
this.subscribers.add(listener);
|
this.subscribers.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
this.subscribers.delete(listener);
|
this.subscribers.delete(listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getSnapshot = () => this.snapshotCache;
|
getSnapshot = (): WorkspaceSnapshot => this.snapshotCache;
|
||||||
|
|
||||||
buildSnapshot() {
|
buildSnapshot(): WorkspaceSnapshot {
|
||||||
return {
|
return {
|
||||||
layout: this.layoutSnapshot,
|
layout: this.layoutSnapshot,
|
||||||
canvasSize: this.canvasSize,
|
canvasSize: this.canvasSize,
|
||||||
@@ -1151,7 +1377,7 @@ export class WorkspaceEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
emit() {
|
emit(): void {
|
||||||
this.snapshotCache = this.buildSnapshot();
|
this.snapshotCache = this.buildSnapshot();
|
||||||
this.subscribers.forEach((listener) => {
|
this.subscribers.forEach((listener) => {
|
||||||
try {
|
try {
|
||||||
@@ -1162,7 +1388,7 @@ export class WorkspaceEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadPersistedLayout() {
|
async loadPersistedLayout(): Promise<void> {
|
||||||
if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) {
|
if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1210,9 +1436,12 @@ export class WorkspaceEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => {
|
export const useWorkspaceSnapshot = (
|
||||||
|
engine: WorkspaceEngine,
|
||||||
|
useSyncExternalStoreHook: UseSyncExternalStoreHook,
|
||||||
|
): WorkspaceSnapshot => {
|
||||||
const useSyncExternalStore = useSyncExternalStoreHook;
|
const useSyncExternalStore = useSyncExternalStoreHook;
|
||||||
if (typeof useSyncExternalStore !== 'function') {
|
if (!useSyncExternalStore) {
|
||||||
throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook');
|
throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook');
|
||||||
}
|
}
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
@@ -1223,8 +1452,11 @@ export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* istanbul ignore next */
|
/* istanbul ignore next */
|
||||||
if (typeof module !== 'undefined' && module && module.exports) {
|
const commonJsModule = (globalThis as typeof globalThis & {
|
||||||
module.exports = {
|
module?: { exports?: Record<string, unknown> };
|
||||||
|
}).module;
|
||||||
|
if (commonJsModule?.exports) {
|
||||||
|
commonJsModule.exports = {
|
||||||
WorkspaceEngine,
|
WorkspaceEngine,
|
||||||
DESK_CANVAS_PADDING,
|
DESK_CANVAS_PADDING,
|
||||||
DESK_ROTATION_RANGE,
|
DESK_ROTATION_RANGE,
|
||||||
+131
-122
@@ -1,40 +1,82 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { CSSProperties, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
|
||||||
import { clamp } from '../utils/math';
|
import { clamp } from '../utils/math';
|
||||||
|
import PdfViewer from '../preview/PdfViewer';
|
||||||
|
|
||||||
|
type DocumentLike = {
|
||||||
|
id?: string | number;
|
||||||
|
title?: string;
|
||||||
|
content_type?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PreviewZoomOverlayProps {
|
||||||
|
open?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
document?: DocumentLike | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NaturalSize = { width: number | null; height: number | null };
|
||||||
|
type FocusPoint = { xRatio: number; yRatio: number } | null;
|
||||||
|
type DisplayKind = 'image' | 'pdf';
|
||||||
|
|
||||||
|
type DocumentLink = {
|
||||||
|
url: string;
|
||||||
|
alt?: string;
|
||||||
|
contentType?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentLikeWithPreview = DocumentLike & { documentLink?: DocumentLink };
|
||||||
|
|
||||||
|
const determineDisplayKind = (entry?: DocumentLink | null): DisplayKind => {
|
||||||
|
const type = entry?.contentType?.toLowerCase?.() || '';
|
||||||
|
if (type.includes('pdf')) {
|
||||||
|
return 'pdf';
|
||||||
|
}
|
||||||
|
if (type.startsWith('image/')) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
const url = entry?.url?.toLowerCase?.() || '';
|
||||||
|
if (url.endsWith('.pdf')) {
|
||||||
|
return 'pdf';
|
||||||
|
}
|
||||||
|
return 'image';
|
||||||
|
};
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
const ensureDocumentRoot = () => {
|
const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return document.body;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PreviewZoomOverlay = ({
|
|
||||||
open = false,
|
open = false,
|
||||||
display = null,
|
|
||||||
onClose = noop,
|
onClose = noop,
|
||||||
|
document: overlayDocument = null,
|
||||||
}) => {
|
}) => {
|
||||||
const portalTarget = ensureDocumentRoot();
|
const portalTarget = document.body;
|
||||||
const [isNativeScale, setIsNativeScale] = useState(false);
|
const [isNativeScale, setIsNativeScale] = useState(false);
|
||||||
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
|
const [naturalSize, setNaturalSize] = useState<NaturalSize>({ width: null, height: null });
|
||||||
const [renderBackdrop, setRenderBackdrop] = useState(false);
|
const [renderBackdrop, setRenderBackdrop] = useState(false);
|
||||||
const [isBackdropVisible, setBackdropVisible] = useState(false);
|
const [isBackdropVisible, setBackdropVisible] = useState(false);
|
||||||
const [displaySnapshot, setDisplaySnapshot] = useState(null);
|
const [documentSnapshot, setDocumentSnapshot] = useState<DocumentLikeWithPreview | null>(null);
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const imageRef = useRef(null);
|
const mediaRef = useRef<HTMLImageElement | null>(null);
|
||||||
const focusRef = useRef(null);
|
const focusRef = useRef<FocusPoint>(null);
|
||||||
const previouslyFocusedRef = useRef(null);
|
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
||||||
const visibilityTimerRef = useRef(null);
|
const visibilityTimerRef = useRef<number | null>(null);
|
||||||
const displayTimerRef = useRef(null);
|
const displayTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const currentDocument = overlayDocument as DocumentLikeWithPreview | null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (display?.url) {
|
if (currentDocument?.documentLink?.url) {
|
||||||
setDisplaySnapshot(display);
|
setDocumentSnapshot(currentDocument);
|
||||||
}
|
}
|
||||||
}, [display]);
|
}, [currentDocument]);
|
||||||
|
|
||||||
|
const activeDocument = open && currentDocument?.documentLink?.url ? currentDocument : documentSnapshot;
|
||||||
|
const documentLink = activeDocument?.documentLink || null;
|
||||||
|
const displayKind = useMemo(() => determineDisplayKind(documentLink), [documentLink]);
|
||||||
|
const isPdfDisplay = displayKind === 'pdf';
|
||||||
|
const documentTitle = activeDocument?.title || undefined;
|
||||||
|
const effectiveAlt = documentLink?.alt || documentTitle || 'Document preview';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visibilityTimerRef.current) {
|
if (visibilityTimerRef.current) {
|
||||||
@@ -46,7 +88,7 @@ const PreviewZoomOverlay = ({
|
|||||||
displayTimerRef.current = null;
|
displayTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (open && display?.url) {
|
if (open && documentLink?.url) {
|
||||||
setRenderBackdrop(true);
|
setRenderBackdrop(true);
|
||||||
displayTimerRef.current = requestAnimationFrame(() => {
|
displayTimerRef.current = requestAnimationFrame(() => {
|
||||||
displayTimerRef.current = requestAnimationFrame(() => {
|
displayTimerRef.current = requestAnimationFrame(() => {
|
||||||
@@ -62,7 +104,7 @@ const PreviewZoomOverlay = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBackdropVisible(false);
|
setBackdropVisible(false);
|
||||||
visibilityTimerRef.current = setTimeout(() => {
|
visibilityTimerRef.current = window.setTimeout(() => {
|
||||||
setRenderBackdrop(false);
|
setRenderBackdrop(false);
|
||||||
}, 260);
|
}, 260);
|
||||||
|
|
||||||
@@ -72,7 +114,7 @@ const PreviewZoomOverlay = ({
|
|||||||
visibilityTimerRef.current = null;
|
visibilityTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [open, display?.url]);
|
}, [open, documentLink?.url]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (visibilityTimerRef.current) {
|
if (visibilityTimerRef.current) {
|
||||||
@@ -83,6 +125,9 @@ const PreviewZoomOverlay = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
}, [isPdfDisplay, open, documentLink?.url]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsNativeScale(false);
|
setIsNativeScale(false);
|
||||||
setNaturalSize({ width: null, height: null });
|
setNaturalSize({ width: null, height: null });
|
||||||
@@ -92,7 +137,7 @@ const PreviewZoomOverlay = ({
|
|||||||
scrollEl.scrollLeft = 0;
|
scrollEl.scrollLeft = 0;
|
||||||
scrollEl.scrollTop = 0;
|
scrollEl.scrollTop = 0;
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open, documentLink?.url, displayKind]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -101,13 +146,13 @@ const PreviewZoomOverlay = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
const imageEl = imageRef.current;
|
const mediaEl = mediaRef.current;
|
||||||
if (!scrollEl || !imageEl) {
|
if (!scrollEl || !mediaEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageWidth = imageEl.naturalWidth || imageEl.clientWidth;
|
const imageWidth = naturalSize.width || mediaEl.clientWidth;
|
||||||
const imageHeight = imageEl.naturalHeight || imageEl.clientHeight;
|
const imageHeight = naturalSize.height || mediaEl.clientHeight;
|
||||||
if (!(imageWidth > 0 && imageHeight > 0)) {
|
if (!(imageWidth > 0 && imageHeight > 0)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -125,27 +170,17 @@ const PreviewZoomOverlay = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
|
previouslyFocusedRef.current?.focus?.();
|
||||||
previouslyFocusedRef.current.focus();
|
|
||||||
}
|
|
||||||
previouslyFocusedRef.current = null;
|
previouslyFocusedRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
if (active && typeof active.focus === 'function') {
|
previouslyFocusedRef.current = active instanceof HTMLElement ? active : null;
|
||||||
previouslyFocusedRef.current = active;
|
|
||||||
} else {
|
|
||||||
previouslyFocusedRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const activeDisplay = open && display?.url ? display : displaySnapshot;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeDisplay?.url) {
|
if (!documentLink?.url) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,24 +190,21 @@ const PreviewZoomOverlay = ({
|
|||||||
scrollEl.scrollTop = 0;
|
scrollEl.scrollTop = 0;
|
||||||
}
|
}
|
||||||
focusRef.current = null;
|
focusRef.current = null;
|
||||||
}, [activeDisplay?.url]);
|
}, [documentLink?.url]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!renderBackdrop || !activeDisplay?.url) {
|
if (!renderBackdrop || !documentLink?.url) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
const scrollEl = scrollRef.current;
|
scrollRef.current?.focus?.({ preventScroll: true });
|
||||||
if (scrollEl && typeof scrollEl.focus === 'function') {
|
|
||||||
scrollEl.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [renderBackdrop, activeDisplay?.url]);
|
}, [renderBackdrop, documentLink?.url]);
|
||||||
|
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -205,27 +237,16 @@ const PreviewZoomOverlay = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'ArrowLeft') {
|
|
||||||
if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) {
|
|
||||||
event.preventDefault();
|
|
||||||
activeDisplay.goPrev();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'ArrowRight') {
|
|
||||||
if (activeDisplay?.canGoNext && activeDisplay?.goNext) {
|
|
||||||
event.preventDefault();
|
|
||||||
activeDisplay.goNext();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleZoomAtPoint = (clientX, clientY) => {
|
const toggleZoomAtPoint = (clientX: number, clientY: number) => {
|
||||||
const img = imageRef.current;
|
if (isPdfDisplay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const media = mediaRef.current;
|
||||||
setIsNativeScale((current) => {
|
setIsNativeScale((current) => {
|
||||||
if (!current && img) {
|
if (!current && media) {
|
||||||
const rect = img.getBoundingClientRect();
|
const rect = media.getBoundingClientRect();
|
||||||
const xRatio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5;
|
const xRatio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5;
|
||||||
const yRatio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5;
|
const yRatio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5;
|
||||||
focusRef.current = {
|
focusRef.current = {
|
||||||
@@ -239,26 +260,23 @@ const PreviewZoomOverlay = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageClick = (event) => {
|
const handleContentClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
event.stopPropagation();
|
if (isPdfDisplay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0);
|
toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!renderBackdrop || !activeDisplay?.url || !portalTarget) {
|
const shouldRender = renderBackdrop && Boolean(documentLink?.url);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveDisplay = activeDisplay;
|
const stageClassName = isPdfDisplay
|
||||||
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
|
? 'preview-zoom__stage preview-zoom__stage--pdf'
|
||||||
const stageClassName = [
|
: 'preview-zoom__stage';
|
||||||
'preview-zoom__stage',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
const containerClassName = [
|
const containerClassName = [
|
||||||
'preview-zoom__scroll',
|
'preview-zoom__scroll',
|
||||||
isNativeScale ? 'preview-zoom__scroll--native' : '',
|
isNativeScale ? 'preview-zoom__scroll--native' : null,
|
||||||
|
isPdfDisplay ? 'preview-zoom__scroll--pdf' : null,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
@@ -270,7 +288,7 @@ const PreviewZoomOverlay = ({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
const imageStyle = isNativeScale
|
const contentStyle: CSSProperties = isNativeScale
|
||||||
? {
|
? {
|
||||||
cursor: 'zoom-out',
|
cursor: 'zoom-out',
|
||||||
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
|
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
|
||||||
@@ -286,6 +304,12 @@ const PreviewZoomOverlay = ({
|
|||||||
touchAction: 'manipulation',
|
touchAction: 'manipulation',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!shouldRender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveDisplay = documentLink;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
(
|
(
|
||||||
<div
|
<div
|
||||||
@@ -294,22 +318,38 @@ const PreviewZoomOverlay = ({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Enlarged document preview"
|
aria-label="Enlarged document preview"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={stageClassName}
|
className={stageClassName}
|
||||||
onKeyDown={handleKeyDown}
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={containerClassName}
|
className={containerClassName}
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
|
{isPdfDisplay ? (
|
||||||
|
<div className="preview-zoom__pdf">
|
||||||
|
<PdfViewer
|
||||||
|
src={effectiveDisplay.url}
|
||||||
|
title={effectiveAlt}
|
||||||
|
className="preview-zoom__pdf-viewer"
|
||||||
|
viewportRef={scrollRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<img
|
<img
|
||||||
src={effectiveDisplay.url}
|
src={effectiveDisplay.url}
|
||||||
alt={effectiveDisplay.alt || 'Document preview'}
|
alt={effectiveAlt}
|
||||||
className="preview-zoom__image"
|
className="preview-zoom__image"
|
||||||
ref={imageRef}
|
ref={(node) => {
|
||||||
|
mediaRef.current = node;
|
||||||
|
}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onLoad={(event) => {
|
onLoad={(event) => {
|
||||||
setNaturalSize({
|
setNaturalSize({
|
||||||
@@ -317,42 +357,11 @@ const PreviewZoomOverlay = ({
|
|||||||
height: event.currentTarget.naturalHeight || null,
|
height: event.currentTarget.naturalHeight || null,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onClick={handleImageClick}
|
onClick={handleContentClick}
|
||||||
style={imageStyle}
|
style={contentStyle}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{navVisible ? (
|
|
||||||
<div className="preview-zoom__nav">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="preview-zoom__nav-button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) {
|
|
||||||
effectiveDisplay.goPrev();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label="Previous preview"
|
|
||||||
disabled={!effectiveDisplay?.canGoPrev}
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="preview-zoom__nav-button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (effectiveDisplay?.canGoNext && effectiveDisplay?.goNext) {
|
|
||||||
effectiveDisplay.goNext();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label="Next preview"
|
|
||||||
disabled={!effectiveDisplay?.canGoNext}
|
|
||||||
>
|
|
||||||
<ArrowRightIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
+77
-27
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import type { MutableRefObject } from 'react';
|
||||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||||
import { useDetailPanel } from '../app/useDetailPanel';
|
import { useDetailPanel } from '../app/useDetailPanel';
|
||||||
import {
|
import {
|
||||||
@@ -6,11 +7,73 @@ import {
|
|||||||
getRowId,
|
getRowId,
|
||||||
isDocumentRowKey,
|
isDocumentRowKey,
|
||||||
} from '../app/appLayoutUtils';
|
} from '../app/appLayoutUtils';
|
||||||
|
import type { DocumentInfoPanelProps } from '../documents/DocumentInfoPanel';
|
||||||
|
import type { EnsureAssetUrl, GetDocumentAsset } from '../utils/ocr';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
|
||||||
|
interface DocumentLike {
|
||||||
|
id?: Identifier;
|
||||||
|
folder_id?: Identifier | 'root';
|
||||||
|
title?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderNode {
|
||||||
|
id: Identifier | 'root';
|
||||||
|
name?: string;
|
||||||
|
parentId?: Identifier | 'root';
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentLink = {
|
||||||
|
url?: string;
|
||||||
|
contentType?: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
interface UseDetailWorkspaceArgs {
|
||||||
|
documents: DocumentLike[];
|
||||||
|
selectionOrder: string[];
|
||||||
|
selectedDocumentIds: Identifier[];
|
||||||
|
documentLookup: Map<Identifier, DocumentLike>;
|
||||||
|
folderNodes: Map<Identifier | 'root', FolderNode>;
|
||||||
|
ensureFolderData: (folderId: Identifier | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise<void>;
|
||||||
|
detailPanelControlRef: MutableRefObject<{ open?: (args?: { documentIds?: Identifier[] }) => void; close?: () => void } | null>;
|
||||||
|
detailFolderFetchRef: MutableRefObject<Set<Identifier | 'root'>>;
|
||||||
|
documentLinks: Map<Identifier, DocumentLink>;
|
||||||
|
previewDocumentId?: Identifier | null;
|
||||||
|
activePreviewId?: Identifier | null;
|
||||||
|
openDocumentPreview?: (args: { documentIds: Identifier[] }) => void;
|
||||||
|
handleDocumentTitleUpdate?: (docId: Identifier, title: string) => Promise<boolean> | boolean;
|
||||||
|
handleDocumentIssuedUpdate?: (docId: Identifier, issued: number | null) => Promise<boolean> | boolean;
|
||||||
|
handleDocumentTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
|
||||||
|
handleTagRemove?: (...args: unknown[]) => void;
|
||||||
|
ensureAssetUrl?: EnsureAssetUrl;
|
||||||
|
getDocumentAsset?: GetDocumentAsset;
|
||||||
|
correspondents?: unknown[];
|
||||||
|
handleCorrespondentAdd?: (...args: unknown[]) => void;
|
||||||
|
handleCorrespondentRemove?: (...args: unknown[]) => void;
|
||||||
|
resolveApiPath?: (path: string) => string;
|
||||||
|
selectFolder?: (folderId?: Identifier | 'root') => void;
|
||||||
|
tags?: unknown[];
|
||||||
|
tagLookupById?: Map<Identifier, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDetailWorkspaceResult {
|
||||||
|
detailPanelProps: DocumentInfoPanelProps;
|
||||||
|
detailPanelOpen: boolean;
|
||||||
|
openDetailPanel: ReturnType<typeof useDetailPanel>['openDetailPanel'];
|
||||||
|
closeDetailPanel: ReturnType<typeof useDetailPanel>['closeDetailPanel'];
|
||||||
|
handleDetailPanelClose: () => void;
|
||||||
|
inspectDocument: (docId: Identifier | null) => void;
|
||||||
|
previewActive: boolean;
|
||||||
|
previewWorkspaceDocument: DocumentLike | null;
|
||||||
|
documentLink: DocumentLink;
|
||||||
|
resolveThumbnailUrlForDoc: (doc: DocumentLike | null) => string | null;
|
||||||
|
resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
const useDetailWorkspace = ({
|
const useDetailWorkspace = ({
|
||||||
documents,
|
documents,
|
||||||
searchResults,
|
|
||||||
previewDocuments,
|
|
||||||
selectionOrder,
|
selectionOrder,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
@@ -18,7 +81,7 @@ const useDetailWorkspace = ({
|
|||||||
ensureFolderData,
|
ensureFolderData,
|
||||||
detailPanelControlRef,
|
detailPanelControlRef,
|
||||||
detailFolderFetchRef,
|
detailFolderFetchRef,
|
||||||
previewEntries,
|
documentLinks,
|
||||||
previewDocumentId,
|
previewDocumentId,
|
||||||
activePreviewId,
|
activePreviewId,
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
@@ -28,7 +91,6 @@ const useDetailWorkspace = ({
|
|||||||
handleTagRemove,
|
handleTagRemove,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
ensurePreviewData,
|
|
||||||
correspondents,
|
correspondents,
|
||||||
handleCorrespondentAdd,
|
handleCorrespondentAdd,
|
||||||
handleCorrespondentRemove,
|
handleCorrespondentRemove,
|
||||||
@@ -36,7 +98,7 @@ const useDetailWorkspace = ({
|
|||||||
selectFolder,
|
selectFolder,
|
||||||
tags,
|
tags,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
}) => {
|
}: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => {
|
||||||
const orderedSelectedDocuments = useMemo(() => {
|
const orderedSelectedDocuments = useMemo(() => {
|
||||||
const ordered = [];
|
const ordered = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -177,29 +239,19 @@ const useDetailWorkspace = ({
|
|||||||
[folderNodes],
|
[folderNodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const detailPanelPreviewEntry = useMemo(() => {
|
const documentLink = useMemo(
|
||||||
if (!detailPanelDocument) {
|
() => (detailPanelDocument ? documentLinks.get(detailPanelDocument.id) || null : null),
|
||||||
return null;
|
[detailPanelDocument, documentLinks],
|
||||||
}
|
);
|
||||||
return previewEntries.get(detailPanelDocument.id) || null;
|
|
||||||
}, [detailPanelDocument, previewEntries]);
|
|
||||||
|
|
||||||
const previewWorkspaceEntry = useMemo(() => {
|
|
||||||
if (!previewDocumentId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return previewEntries.get(previewDocumentId) || null;
|
|
||||||
}, [previewDocumentId, previewEntries]);
|
|
||||||
|
|
||||||
const previewWorkspaceDocument = useMemo(() => {
|
const previewWorkspaceDocument = useMemo(() => {
|
||||||
if (!previewDocumentId) {
|
if (!previewDocumentId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const pool = searchResults ?? documents;
|
return documentLookup.get(previewDocumentId)
|
||||||
return pool.find((doc) => doc.id === previewDocumentId)
|
|| documents.find((doc) => doc.id === previewDocumentId)
|
||||||
|| previewDocuments?.get?.(previewDocumentId)
|
|
||||||
|| null;
|
|| null;
|
||||||
}, [previewDocumentId, searchResults, documents, previewDocuments]);
|
}, [previewDocumentId, documentLookup, documents]);
|
||||||
|
|
||||||
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
|
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
|
||||||
|
|
||||||
@@ -233,14 +285,13 @@ const useDetailWorkspace = ({
|
|||||||
tagLookupById,
|
tagLookupById,
|
||||||
onTagAdd: handleDocumentTagAdd,
|
onTagAdd: handleDocumentTagAdd,
|
||||||
onTagRemove: handleTagRemove,
|
onTagRemove: handleTagRemove,
|
||||||
previewEntry: detailPanelPreviewEntry,
|
documentLink,
|
||||||
onOpenPreview: openDocumentPreview,
|
onOpenPreview: openDocumentPreview,
|
||||||
activePreviewId,
|
activePreviewId,
|
||||||
onUpdateTitle: handleDocumentTitleUpdate,
|
onUpdateTitle: handleDocumentTitleUpdate,
|
||||||
onUpdateIssued: handleDocumentIssuedUpdate,
|
onUpdateIssued: handleDocumentIssuedUpdate,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
hydrateDocument: ensurePreviewData,
|
|
||||||
correspondents,
|
correspondents,
|
||||||
onCorrespondentAdd: handleCorrespondentAdd,
|
onCorrespondentAdd: handleCorrespondentAdd,
|
||||||
onCorrespondentRemove: handleCorrespondentRemove,
|
onCorrespondentRemove: handleCorrespondentRemove,
|
||||||
@@ -254,7 +305,6 @@ const useDetailWorkspace = ({
|
|||||||
correspondents,
|
correspondents,
|
||||||
detailPanelDocument,
|
detailPanelDocument,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
ensurePreviewData,
|
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
handleCorrespondentAdd,
|
handleCorrespondentAdd,
|
||||||
handleCorrespondentRemove,
|
handleCorrespondentRemove,
|
||||||
@@ -267,7 +317,7 @@ const useDetailWorkspace = ({
|
|||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
resolveFolderPath,
|
resolveFolderPath,
|
||||||
selectFolder,
|
selectFolder,
|
||||||
detailPanelPreviewEntry,
|
documentLink,
|
||||||
tags,
|
tags,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
],
|
],
|
||||||
@@ -282,7 +332,7 @@ const useDetailWorkspace = ({
|
|||||||
inspectDocument,
|
inspectDocument,
|
||||||
previewActive,
|
previewActive,
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
previewWorkspaceEntry,
|
documentLink,
|
||||||
resolveThumbnailUrlForDoc,
|
resolveThumbnailUrlForDoc,
|
||||||
resolveFolderPath,
|
resolveFolderPath,
|
||||||
};
|
};
|
||||||
+18
-5
@@ -2,7 +2,19 @@ import React from 'react';
|
|||||||
|
|
||||||
const NBSP = String.fromCharCode(160);
|
const NBSP = String.fromCharCode(160);
|
||||||
|
|
||||||
const CorrespondentLinks = ({
|
export interface CorrespondentLinkEntry {
|
||||||
|
id?: string | number | null;
|
||||||
|
name?: string | null;
|
||||||
|
key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CorrespondentLinksProps {
|
||||||
|
correspondents?: CorrespondentLinkEntry[];
|
||||||
|
activeCorrespondentIdSet?: Set<string | number>;
|
||||||
|
onCorrespondentClick?: (id: string | number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CorrespondentLinks: React.FC<CorrespondentLinksProps> = ({
|
||||||
correspondents,
|
correspondents,
|
||||||
activeCorrespondentIdSet,
|
activeCorrespondentIdSet,
|
||||||
onCorrespondentClick,
|
onCorrespondentClick,
|
||||||
@@ -11,8 +23,8 @@ const CorrespondentLinks = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeSet = activeCorrespondentIdSet || new Set();
|
const activeSet = activeCorrespondentIdSet || new Set<string | number>();
|
||||||
const handleClick = (event, correspondent) => {
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>, correspondent: CorrespondentLinkEntry) => {
|
||||||
if (!onCorrespondentClick || correspondent.id == null) {
|
if (!onCorrespondentClick || correspondent.id == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -27,11 +39,12 @@ const CorrespondentLinks = ({
|
|||||||
if (isActive) classNames.push('is-active');
|
if (isActive) classNames.push('is-active');
|
||||||
if (!hasHandler) classNames.push('is-static');
|
if (!hasHandler) classNames.push('is-static');
|
||||||
const isLast = index === correspondents.length - 1;
|
const isLast = index === correspondents.length - 1;
|
||||||
const label = isLast ? `${correspondent.name}:${NBSP}` : correspondent.name;
|
const fallbackLabel = correspondent.name ?? '—';
|
||||||
|
const label = isLast ? `${fallbackLabel}:${NBSP}` : fallbackLabel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment
|
<React.Fragment
|
||||||
key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}
|
key={correspondent.key ?? correspondent.id ?? `${fallbackLabel}-${index}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
+67
-25
@@ -1,8 +1,54 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import DocumentSummarySection from './DocumentSummarySection';
|
import type { ReactNode } from 'react';
|
||||||
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
|
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
|
||||||
|
import { describeDocumentSummary, extractDocumentMetadataPayload, type DocumentSummaryRow } from './documentSummary';
|
||||||
|
|
||||||
const DocumentInfoPanel = ({
|
type PanelTab = { id: string; label: string; render: (context?: Record<string, unknown>) => ReactNode };
|
||||||
|
|
||||||
|
type ContentState =
|
||||||
|
| { status: 'idle'; data: null; error: null }
|
||||||
|
| { status: 'loading'; data: null; error: null }
|
||||||
|
| { status: 'loaded'; data: string; error: null }
|
||||||
|
| { status: 'empty'; data: string; error: null }
|
||||||
|
| { status: 'unavailable'; data: null; error: null }
|
||||||
|
| { status: 'error'; data: null; error: unknown };
|
||||||
|
|
||||||
|
export interface DocumentInfoPanelProps {
|
||||||
|
document: DocumentSummarySectionProps['document'];
|
||||||
|
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'layout'>;
|
||||||
|
metadataItems?: DocumentSummaryRow[];
|
||||||
|
metadataPayload?: Record<string, unknown>;
|
||||||
|
metadataTabLabel?: string;
|
||||||
|
detailsTabLabel?: string;
|
||||||
|
contentConfig?: {
|
||||||
|
id?: string;
|
||||||
|
label?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
forceDisplay?: boolean;
|
||||||
|
loadContent?: (args: { signal: AbortSignal }) => Promise<string>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
loadingMessage?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
unavailableMessage?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
renderContent?: (data: string) => ReactNode;
|
||||||
|
} | null;
|
||||||
|
activeTab?: string;
|
||||||
|
onTabChange?: (tabId: string) => void;
|
||||||
|
defaultTabId?: string;
|
||||||
|
resetKey?: string | number | null;
|
||||||
|
classNamePrefix?: string;
|
||||||
|
hideTabNavWhenSingle?: boolean;
|
||||||
|
summaryPlacement?: 'inline' | 'tabs';
|
||||||
|
summaryTabLabel?: string;
|
||||||
|
summaryTabId?: string;
|
||||||
|
leadingTabs?: PanelTab[];
|
||||||
|
trailingTabs?: PanelTab[];
|
||||||
|
tabsPlacement?: 'top' | 'bottom';
|
||||||
|
summaryLayout?: 'default' | 'compact';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
|
||||||
document,
|
document,
|
||||||
summaryProps = {},
|
summaryProps = {},
|
||||||
metadataItems: metadataItemsProp,
|
metadataItems: metadataItemsProp,
|
||||||
@@ -30,7 +76,7 @@ const DocumentInfoPanel = ({
|
|||||||
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
|
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
|
||||||
return metadataItemsProp;
|
return metadataItemsProp;
|
||||||
}
|
}
|
||||||
return buildDocumentMetadataItems(document);
|
return describeDocumentSummary(document);
|
||||||
}, [metadataItemsProp, document]);
|
}, [metadataItemsProp, document]);
|
||||||
|
|
||||||
const metadataPayload = useMemo(() => {
|
const metadataPayload = useMemo(() => {
|
||||||
@@ -42,13 +88,14 @@ const DocumentInfoPanel = ({
|
|||||||
|
|
||||||
const contentConfig = contentConfigProp || null;
|
const contentConfig = contentConfigProp || null;
|
||||||
const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true));
|
const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true));
|
||||||
const showContentTab = Boolean(contentConfig && ((contentConfig.forceDisplay ?? contentEnabled)));
|
const loadContent = contentConfig?.loadContent ?? null;
|
||||||
|
const showContentTab = Boolean(contentConfig && (contentConfig.forceDisplay ?? contentEnabled));
|
||||||
|
|
||||||
const [contentState, setContentState] = useState(() => {
|
const [contentState, setContentState] = useState<ContentState | null>(() => {
|
||||||
if (!contentConfig) {
|
if (!contentConfig) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!contentEnabled || typeof contentConfig.loadContent !== 'function') {
|
if (!contentEnabled || !loadContent) {
|
||||||
return { status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null };
|
return { status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null };
|
||||||
}
|
}
|
||||||
return { status: 'idle', data: null, error: null };
|
return { status: 'idle', data: null, error: null };
|
||||||
@@ -60,7 +107,7 @@ const DocumentInfoPanel = ({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!contentEnabled || typeof contentConfig.loadContent !== 'function') {
|
if (!contentEnabled || !loadContent) {
|
||||||
setContentState({ status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null });
|
setContentState({ status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null });
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -70,7 +117,7 @@ const DocumentInfoPanel = ({
|
|||||||
|
|
||||||
setContentState({ status: 'loading', data: null, error: null });
|
setContentState({ status: 'loading', data: null, error: null });
|
||||||
|
|
||||||
Promise.resolve(contentConfig.loadContent({ signal: controller.signal }))
|
Promise.resolve(loadContent({ signal: controller.signal }))
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -97,23 +144,22 @@ const DocumentInfoPanel = ({
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
contentConfig.onCancel?.();
|
contentConfig.onCancel?.();
|
||||||
};
|
};
|
||||||
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
|
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey, loadContent]);
|
||||||
|
|
||||||
const renderSummarySection = useCallback(() => (
|
const renderSummarySection = useCallback(() => (
|
||||||
<DocumentSummarySection
|
<DocumentSummarySection
|
||||||
document={document}
|
document={document}
|
||||||
detailItems={metadataItems}
|
|
||||||
layout={summaryLayout}
|
layout={summaryLayout}
|
||||||
{...summaryProps}
|
{...summaryProps}
|
||||||
/>
|
/>
|
||||||
), [document, summaryLayout, summaryProps, metadataItems]);
|
), [document, summaryLayout, summaryProps]);
|
||||||
|
|
||||||
const renderDetailsSection = useCallback(() => (
|
const renderDetailsSection = useCallback(() => (
|
||||||
<section className={`${base}__section`}>
|
<section className={`${base}__section`}>
|
||||||
{metadataItems.length ? (
|
{metadataItems.length ? (
|
||||||
<dl className={`${base}__section-list`}>
|
<dl className={`${base}__section-list`}>
|
||||||
{metadataItems.map(({ label, value }) => (
|
{metadataItems.map(({ key, label, value }) => (
|
||||||
<div className={`${base}__section-item`} key={label}>
|
<div className={`${base}__section-item`} key={key || label}>
|
||||||
<dt>{label}</dt>
|
<dt>{label}</dt>
|
||||||
<dd>{value || '—'}</dd>
|
<dd>{value || '—'}</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,14 +190,14 @@ const DocumentInfoPanel = ({
|
|||||||
|
|
||||||
const normalizedLeadingTabs = useMemo(
|
const normalizedLeadingTabs = useMemo(
|
||||||
() => (Array.isArray(leadingTabs)
|
() => (Array.isArray(leadingTabs)
|
||||||
? leadingTabs.filter((tab) => tab && tab.id && tab.label)
|
? leadingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
|
||||||
: []),
|
: []),
|
||||||
[leadingTabs],
|
[leadingTabs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const normalizedTrailingTabs = useMemo(
|
const normalizedTrailingTabs = useMemo(
|
||||||
() => (Array.isArray(trailingTabs)
|
() => (Array.isArray(trailingTabs)
|
||||||
? trailingTabs.filter((tab) => tab && tab.id && tab.label)
|
? trailingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
|
||||||
: []),
|
: []),
|
||||||
[trailingTabs],
|
[trailingTabs],
|
||||||
);
|
);
|
||||||
@@ -166,7 +212,7 @@ const DocumentInfoPanel = ({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const visibleTabs = useMemo(() => {
|
const visibleTabs = useMemo(() => {
|
||||||
const tabsList = [];
|
const tabsList: PanelTab[] = [];
|
||||||
|
|
||||||
if (normalizedLeadingTabs.length) {
|
if (normalizedLeadingTabs.length) {
|
||||||
tabsList.push(...normalizedLeadingTabs);
|
tabsList.push(...normalizedLeadingTabs);
|
||||||
@@ -295,18 +341,14 @@ const DocumentInfoPanel = ({
|
|||||||
return visibleTabs[0].id;
|
return visibleTabs[0].id;
|
||||||
}, [visibleTabs, defaultTabId]);
|
}, [visibleTabs, defaultTabId]);
|
||||||
|
|
||||||
const renderTabContent = (tab, context = {}) => {
|
const renderTabContent = (tab?: PanelTab | null, context: Record<string, unknown> = {}) => {
|
||||||
if (!tab) {
|
if (!tab) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof tab.render === 'function') {
|
if (!tab.render) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return tab.render(context);
|
return tab.render(context);
|
||||||
}
|
|
||||||
if (tab.component) {
|
|
||||||
const TabComponent = tab.component;
|
|
||||||
return <TabComponent {...context} />;
|
|
||||||
}
|
|
||||||
return React.isValidElement(tab.render) ? tab.render : null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
|
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
|
||||||
+217
-218
@@ -1,13 +1,86 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||||
import { EditIcon, IconX, PlusIcon } from '../ui/icons';
|
import { EditIcon, IconX, CheckIcon, PlusIcon } from '../ui/icons';
|
||||||
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
|
import SelectionAssignmentMenu, {
|
||||||
|
SelectionAssignmentMenuItem,
|
||||||
|
type NormalizedSelectionAssignmentItem,
|
||||||
|
} from './SelectionAssignmentMenu';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
toDateInputValue,
|
toDateInputValue,
|
||||||
toIssuedTimestamp,
|
toIssuedTimestamp,
|
||||||
} from '../utils/date';
|
} from '../utils/date';
|
||||||
import { describeDocumentSummary } from './documentSummary';
|
import { describeDocumentSummary, type DocumentSummaryRow } from './documentSummary';
|
||||||
|
import { isPlainObject } from '../utils/typeGuards';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
|
||||||
|
interface TagEntry {
|
||||||
|
id?: Identifier;
|
||||||
|
label?: string;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CorrespondentEntry {
|
||||||
|
id?: Identifier;
|
||||||
|
name?: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentLike {
|
||||||
|
id?: Identifier;
|
||||||
|
title?: string;
|
||||||
|
issued_at?: string | null;
|
||||||
|
current_version?: { version_number?: number } | null;
|
||||||
|
tags?: TagEntry[];
|
||||||
|
correspondents?: CorrespondentEntry[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagSectionProps {
|
||||||
|
tags?: TagEntry[];
|
||||||
|
onRemove?: (tag: TagEntry) => void;
|
||||||
|
onAdd?: (payload: { value: string; option?: unknown; input?: unknown }) => void;
|
||||||
|
emptyMessage?: string;
|
||||||
|
addPlaceholder?: string;
|
||||||
|
addButtonLabel?: string;
|
||||||
|
datalistOptions?: Array<SelectionAssignmentMenuItem | string>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CorrespondentSectionProps {
|
||||||
|
entries?: CorrespondentEntry[];
|
||||||
|
onRemove?: (entry: CorrespondentEntry) => void;
|
||||||
|
onAdd?: (payload: { name: string; option?: unknown; input?: unknown }) => void;
|
||||||
|
showCount?: boolean;
|
||||||
|
addPlaceholder?: string;
|
||||||
|
addButtonLabel?: string;
|
||||||
|
datalistOptions?: Array<SelectionAssignmentMenuItem | string>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentSummarySectionProps {
|
||||||
|
document?: DocumentLike | null;
|
||||||
|
tagLookupById?: Map<Identifier, TagEntry>;
|
||||||
|
tagOptions?: SelectionAssignmentMenuItem[];
|
||||||
|
onTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
|
||||||
|
onTagRemove?: (docId: Identifier | undefined, tagId: Identifier | undefined) => void;
|
||||||
|
correspondents?: CorrespondentEntry[];
|
||||||
|
correspondentOptions?: SelectionAssignmentMenuItem[];
|
||||||
|
onCorrespondentAdd?: (payload: { document: DocumentLike; name: string; option?: unknown }) => void;
|
||||||
|
onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void;
|
||||||
|
onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise<boolean> | boolean;
|
||||||
|
onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise<boolean> | boolean;
|
||||||
|
layout?: 'default' | 'compact';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetaItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
valueContent?: React.ReactNode | null;
|
||||||
|
fallbackValue?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const sortCorrespondents = (entries = []) =>
|
export const sortCorrespondents = (entries = []) =>
|
||||||
entries
|
entries
|
||||||
@@ -18,7 +91,7 @@ export const sortCorrespondents = (entries = []) =>
|
|||||||
export const buildCorrespondentOptions = (entries = []) => {
|
export const buildCorrespondentOptions = (entries = []) => {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
return entries.reduce((options, entry) => {
|
return entries.reduce((options, entry) => {
|
||||||
const name = typeof entry?.name === 'string' ? entry.name.trim() : '';
|
const name = entry?.name?.trim?.() || '';
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
@@ -32,32 +105,54 @@ export const buildCorrespondentOptions = (entries = []) => {
|
|||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeOptions = (options) => (Array.isArray(options) ? options : []);
|
const normalizeOptions = <T,>(options?: T[] | null): T[] => (Array.isArray(options) ? options : []);
|
||||||
|
|
||||||
const normalizeQuickAddOption = (option) => {
|
interface QuickAddOption {
|
||||||
|
id?: Identifier;
|
||||||
|
label?: string;
|
||||||
|
name?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickAddEntry {
|
||||||
|
id: Identifier | string;
|
||||||
|
label: string;
|
||||||
|
original: QuickAddOption | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveOptionName = (source?: QuickAddOption | string | null): string => {
|
||||||
|
if (!source) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (isPlainObject(source)) {
|
||||||
|
const raw = source.name ?? source.label ?? '';
|
||||||
|
return `${raw}`.trim();
|
||||||
|
}
|
||||||
|
return `${source}`.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeQuickAddOption = (option?: QuickAddOption | string | null): QuickAddEntry | null => {
|
||||||
if (option == null) {
|
if (option == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof option === 'string') {
|
const label = (() => {
|
||||||
const label = option.trim();
|
if (isPlainObject(option)) {
|
||||||
return label ? { id: label, label, original: option } : null;
|
const sourceLabel = option.label ?? option.name ?? '';
|
||||||
|
return `${sourceLabel}`.trim();
|
||||||
}
|
}
|
||||||
const label = typeof option.label === 'string'
|
return `${option}`.trim();
|
||||||
? option.label.trim()
|
})();
|
||||||
: typeof option.name === 'string'
|
|
||||||
? option.name.trim()
|
|
||||||
: '';
|
|
||||||
if (!label) {
|
if (!label) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: option.id ?? label,
|
id: isPlainObject(option) && option.id ? option.id : label,
|
||||||
label,
|
label,
|
||||||
original: option,
|
original: option,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TagSection = ({
|
export const TagSection: React.FC<TagSectionProps> = ({
|
||||||
tags = [],
|
tags = [],
|
||||||
onRemove,
|
onRemove,
|
||||||
onAdd,
|
onAdd,
|
||||||
@@ -68,16 +163,14 @@ export const TagSection = ({
|
|||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const handleCreate = useCallback(
|
const handleCreate = useCallback(
|
||||||
(label) => onAdd?.({ value: label, input: null }),
|
(label: string) => onAdd?.({ value: label, input: null }),
|
||||||
[onAdd],
|
[onAdd],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(option) => {
|
(option: { label?: string; name?: string } | string | null) => {
|
||||||
if (!onAdd) return;
|
if (!onAdd) return;
|
||||||
const label =
|
const label = resolveOptionName(option as QuickAddOption | string | null);
|
||||||
(option && typeof option === 'object' && option.label) ||
|
|
||||||
(typeof option === 'string' ? option : '');
|
|
||||||
if (!label) {
|
if (!label) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -90,14 +183,14 @@ export const TagSection = ({
|
|||||||
() =>
|
() =>
|
||||||
normalizeOptions(datalistOptions)
|
normalizeOptions(datalistOptions)
|
||||||
.map((option) => normalizeQuickAddOption(option))
|
.map((option) => normalizeQuickAddOption(option))
|
||||||
.filter(Boolean),
|
.filter((option): option is QuickAddEntry => Boolean(option)),
|
||||||
[datalistOptions],
|
[datalistOptions],
|
||||||
);
|
);
|
||||||
const containerClass = className ? `tag-list ${className}` : 'tag-list';
|
const containerClass = className ? `tag-list ${className}` : 'tag-list';
|
||||||
const showQuickAdd = Boolean(onAdd);
|
const showQuickAdd = Boolean(onAdd);
|
||||||
|
|
||||||
const assignmentItems = useMemo(() => {
|
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||||
const map = new Map();
|
const map = new Map<string, SelectionAssignmentMenuItem>();
|
||||||
|
|
||||||
normalizedOptions.forEach((option) => {
|
normalizedOptions.forEach((option) => {
|
||||||
const label = option?.label?.trim();
|
const label = option?.label?.trim();
|
||||||
@@ -117,7 +210,7 @@ export const TagSection = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
tags.forEach((tag) => {
|
tags.forEach((tag) => {
|
||||||
const label = typeof tag?.label === 'string' ? tag.label.trim() : '';
|
const label = tag?.label?.trim?.() || '';
|
||||||
if (!label) {
|
if (!label) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -125,8 +218,10 @@ export const TagSection = ({
|
|||||||
const payload = { id: tag.id, label, color: tag.color ?? null };
|
const payload = { id: tag.id, label, color: tag.color ?? null };
|
||||||
if (map.has(key)) {
|
if (map.has(key)) {
|
||||||
const entry = map.get(key);
|
const entry = map.get(key);
|
||||||
|
if (entry) {
|
||||||
entry.state = 'all';
|
entry.state = 'all';
|
||||||
entry.payload = payload;
|
entry.payload = payload;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
map.set(key, {
|
map.set(key, {
|
||||||
@@ -141,14 +236,21 @@ export const TagSection = ({
|
|||||||
}, [normalizedOptions, tags]);
|
}, [normalizedOptions, tags]);
|
||||||
|
|
||||||
const handleAssignmentSelect = useCallback(
|
const handleAssignmentSelect = useCallback(
|
||||||
(item) => {
|
(item: NormalizedSelectionAssignmentItem) => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (item.state === 'all' && onRemove) {
|
||||||
|
const payload = isPlainObject(item.payload)
|
||||||
|
? (item.payload as TagEntry)
|
||||||
|
: tags.find((tag) => (tag.id ?? tag.label) === item.id) ?? { id: item.id, label: item.label };
|
||||||
|
onRemove(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = item.payload ?? { label: item.label };
|
const payload = item.payload ?? { label: item.label };
|
||||||
handleSelect(payload);
|
handleSelect(payload);
|
||||||
},
|
},
|
||||||
[handleSelect],
|
[handleSelect, onRemove, tags],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -185,11 +287,9 @@ export const TagSection = ({
|
|||||||
showCounts={false}
|
showCounts={false}
|
||||||
positionStrategy="fixed"
|
positionStrategy="fixed"
|
||||||
triggerClassName="quick-add__chip quick-add__trigger"
|
triggerClassName="quick-add__chip quick-add__trigger"
|
||||||
triggerContent={(
|
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
|
||||||
<span className="quick-add__chip-label">
|
closeOnSelection={false}
|
||||||
<PlusIcon className="icon-inline" aria-hidden="true" /> Add tag
|
freezeSortOnOpen
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!tags.length && !showQuickAdd ? <span className="tag-list__empty meta">{emptyMessage}</span> : null}
|
{!tags.length && !showQuickAdd ? <span className="tag-list__empty meta">{emptyMessage}</span> : null}
|
||||||
@@ -197,7 +297,7 @@ export const TagSection = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CorrespondentSection = ({
|
export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
|
||||||
entries = [],
|
entries = [],
|
||||||
onRemove,
|
onRemove,
|
||||||
onAdd,
|
onAdd,
|
||||||
@@ -208,7 +308,7 @@ export const CorrespondentSection = ({
|
|||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const handleCreate = useCallback(
|
const handleCreate = useCallback(
|
||||||
(name) => onAdd?.({ name, input: null }),
|
(name: string) => onAdd?.({ name, input: null }),
|
||||||
[onAdd],
|
[onAdd],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -216,15 +316,15 @@ export const CorrespondentSection = ({
|
|||||||
() =>
|
() =>
|
||||||
normalizeOptions(datalistOptions)
|
normalizeOptions(datalistOptions)
|
||||||
.map((option) => normalizeQuickAddOption(option))
|
.map((option) => normalizeQuickAddOption(option))
|
||||||
.filter(Boolean),
|
.filter((option): option is QuickAddEntry => Boolean(option)),
|
||||||
[datalistOptions],
|
[datalistOptions],
|
||||||
);
|
);
|
||||||
const hasEntries = entries && entries.length > 0;
|
const hasEntries = entries && entries.length > 0;
|
||||||
const showQuickAdd = Boolean(onAdd);
|
const showQuickAdd = Boolean(onAdd);
|
||||||
const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
|
const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
|
||||||
|
|
||||||
const assignmentItems = useMemo(() => {
|
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||||
const map = new Map();
|
const map = new Map<string, SelectionAssignmentMenuItem>();
|
||||||
|
|
||||||
normalizedOptions.forEach((option) => {
|
normalizedOptions.forEach((option) => {
|
||||||
const label = option?.label?.trim();
|
const label = option?.label?.trim();
|
||||||
@@ -244,7 +344,7 @@ export const CorrespondentSection = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
const label = typeof entry?.name === 'string' ? entry.name.trim() : '';
|
const label = entry?.name?.trim?.() || '';
|
||||||
if (!label) {
|
if (!label) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -252,8 +352,10 @@ export const CorrespondentSection = ({
|
|||||||
const payload = { id: entry.id, name: label };
|
const payload = { id: entry.id, name: label };
|
||||||
if (map.has(key)) {
|
if (map.has(key)) {
|
||||||
const item = map.get(key);
|
const item = map.get(key);
|
||||||
|
if (item) {
|
||||||
item.state = 'all';
|
item.state = 'all';
|
||||||
item.payload = payload;
|
item.payload = payload;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
map.set(key, {
|
map.set(key, {
|
||||||
@@ -268,24 +370,31 @@ export const CorrespondentSection = ({
|
|||||||
}, [normalizedOptions, entries]);
|
}, [normalizedOptions, entries]);
|
||||||
|
|
||||||
const handleAssignmentSelect = useCallback(
|
const handleAssignmentSelect = useCallback(
|
||||||
(item) => {
|
(item: NormalizedSelectionAssignmentItem) => {
|
||||||
if (!onAdd || !item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const source = item.payload ?? item;
|
if (item.state === 'all' && onRemove) {
|
||||||
const resolvedName =
|
const payload = isPlainObject(item.payload)
|
||||||
(source && typeof source.name === 'string' && source.name.trim())
|
? (item.payload as CorrespondentEntry)
|
||||||
|| (typeof source === 'string' ? source.trim() : '')
|
: entries.find((entry) => (entry.id ?? entry.name) === item.id) ?? { id: item.id, name: item.label };
|
||||||
|| (source && typeof source.label === 'string' ? source.label.trim() : '');
|
onRemove(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!onAdd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const source = (item.payload ?? item) as QuickAddOption | string | null;
|
||||||
|
const resolvedName = resolveOptionName(source);
|
||||||
if (!resolvedName) {
|
if (!resolvedName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = typeof source === 'object'
|
const payload = isPlainObject(source)
|
||||||
? { ...source, name: resolvedName }
|
? { ...source, name: resolvedName }
|
||||||
: { id: null, name: resolvedName };
|
: { id: null, name: resolvedName };
|
||||||
onAdd({ name: resolvedName, option: payload, input: null });
|
onAdd({ name: resolvedName, option: payload, input: null });
|
||||||
},
|
},
|
||||||
[onAdd],
|
[entries, onAdd, onRemove],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -326,18 +435,16 @@ export const CorrespondentSection = ({
|
|||||||
showCounts={false}
|
showCounts={false}
|
||||||
positionStrategy="fixed"
|
positionStrategy="fixed"
|
||||||
triggerClassName="quick-add__chip quick-add__trigger"
|
triggerClassName="quick-add__chip quick-add__trigger"
|
||||||
triggerContent={(
|
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
|
||||||
<span className="quick-add__chip-label">
|
closeOnSelection={false}
|
||||||
<PlusIcon className="icon-inline" aria-hidden="true" /> Add correspondent
|
freezeSortOnOpen
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DocumentSummarySection = ({
|
const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
||||||
document,
|
document,
|
||||||
tagLookupById = new Map(),
|
tagLookupById = new Map(),
|
||||||
tagOptions = [],
|
tagOptions = [],
|
||||||
@@ -350,20 +457,9 @@ const DocumentSummarySection = ({
|
|||||||
onUpdateTitle,
|
onUpdateTitle,
|
||||||
onUpdateIssued,
|
onUpdateIssued,
|
||||||
layout = 'default',
|
layout = 'default',
|
||||||
detailItems = [],
|
|
||||||
}) => {
|
}) => {
|
||||||
const isCompactLayout = layout === 'compact';
|
const isCompactLayout = layout === 'compact';
|
||||||
const summary = useMemo(() => {
|
const summaryRows = useMemo(() => describeDocumentSummary(document), [document]);
|
||||||
if (!document) {
|
|
||||||
return {
|
|
||||||
title: '',
|
|
||||||
originalName: '',
|
|
||||||
sizeLabel: '—',
|
|
||||||
pageCount: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return describeDocumentSummary(document);
|
|
||||||
}, [document]);
|
|
||||||
const issuedDateLabel = useMemo(
|
const issuedDateLabel = useMemo(
|
||||||
() => formatDate(document?.issued_at, { fallback: null }),
|
() => formatDate(document?.issued_at, { fallback: null }),
|
||||||
[document?.issued_at],
|
[document?.issued_at],
|
||||||
@@ -390,8 +486,8 @@ const DocumentSummarySection = ({
|
|||||||
return sortCorrespondents(document?.correspondents || []);
|
return sortCorrespondents(document?.correspondents || []);
|
||||||
}, [correspondents, document?.correspondents]);
|
}, [correspondents, document?.correspondents]);
|
||||||
|
|
||||||
const metaRows = useMemo(() => {
|
const extraSummaryRows = useMemo(() => {
|
||||||
const rows = [];
|
const rows: DocumentSummaryRow[] = [];
|
||||||
const currentVersionNumber = document?.current_version?.version_number;
|
const currentVersionNumber = document?.current_version?.version_number;
|
||||||
if (Number.isFinite(currentVersionNumber)) {
|
if (Number.isFinite(currentVersionNumber)) {
|
||||||
rows.push({
|
rows.push({
|
||||||
@@ -400,17 +496,8 @@ const DocumentSummarySection = ({
|
|||||||
value: `#${currentVersionNumber}`,
|
value: `#${currentVersionNumber}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (summary.sizeLabel && summary.sizeLabel !== '—') {
|
|
||||||
rows.push({ key: 'size', label: 'Size', value: summary.sizeLabel });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Number.isFinite(summary.pageCount)) {
|
|
||||||
rows.push({ key: 'pages', label: 'Pages', value: String(summary.pageCount) });
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
}, [document?.current_version?.version_number, summary]);
|
}, [document?.current_version?.version_number]);
|
||||||
|
|
||||||
const [titleDraft, setTitleDraft] = useState('');
|
const [titleDraft, setTitleDraft] = useState('');
|
||||||
const [titleSaving, setTitleSaving] = useState(false);
|
const [titleSaving, setTitleSaving] = useState(false);
|
||||||
@@ -437,7 +524,7 @@ const DocumentSummarySection = ({
|
|||||||
const startTitleEdit = useCallback(() => {
|
const startTitleEdit = useCallback(() => {
|
||||||
if (!editableTitle || !document) return;
|
if (!editableTitle || !document) return;
|
||||||
setIsTitleEditing(true);
|
setIsTitleEditing(true);
|
||||||
setTitleDraft(document.title);
|
setTitleDraft(document.title || '');
|
||||||
setTitleError(null);
|
setTitleError(null);
|
||||||
}, [document, editableTitle]);
|
}, [document, editableTitle]);
|
||||||
|
|
||||||
@@ -449,9 +536,9 @@ const DocumentSummarySection = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const submitTitleEdit = useCallback(
|
const submitTitleEdit = useCallback(
|
||||||
async (event) => {
|
async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!editableTitle || !document) return;
|
if (!editableTitle || !document || !onUpdateTitle) return;
|
||||||
const trimmed = titleDraft.trim();
|
const trimmed = titleDraft.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setTitleError('Title cannot be empty.');
|
setTitleError('Title cannot be empty.');
|
||||||
@@ -487,9 +574,9 @@ const DocumentSummarySection = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const submitIssuedEdit = useCallback(
|
const submitIssuedEdit = useCallback(
|
||||||
async (event) => {
|
async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!editableIssued || !document) return;
|
if (!editableIssued || !document || !onUpdateIssued) return;
|
||||||
const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null;
|
const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null;
|
||||||
setIssuedSaving(true);
|
setIssuedSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -510,9 +597,8 @@ const DocumentSummarySection = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TitleSection = () => (
|
const renderTitleEditForm = (extraClassName?: string) => (
|
||||||
editableTitle && isTitleEditing ? (
|
<form className={`doc-title-edit${extraClassName ? ` ${extraClassName}` : ''}`} onSubmit={submitTitleEdit}>
|
||||||
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
|
|
||||||
<input
|
<input
|
||||||
value={titleDraft}
|
value={titleDraft}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -531,21 +617,26 @@ const DocumentSummarySection = ({
|
|||||||
autoFocus
|
autoFocus
|
||||||
disabled={titleSaving}
|
disabled={titleSaving}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={titleSaving}>
|
<button type="submit" className="icon-button icon-button--accent" disabled={titleSaving} aria-label="Save title">
|
||||||
Save
|
<CheckIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary"
|
className="icon-button"
|
||||||
onClick={cancelTitleEdit}
|
onClick={cancelTitleEdit}
|
||||||
disabled={titleSaving}
|
disabled={titleSaving}
|
||||||
|
aria-label="Cancel"
|
||||||
>
|
>
|
||||||
Cancel
|
<IconX size={16} />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
);
|
||||||
|
|
||||||
|
const titleMetaDisplay = editableTitle && isTitleEditing
|
||||||
|
? renderTitleEditForm('doc-title-edit--inline')
|
||||||
|
: (
|
||||||
<>
|
<>
|
||||||
<h3 className="doc-title-row__title">{summary.title}</h3>
|
<span className="detail-meta__value">{document?.title}</span>
|
||||||
{editableTitle ? (
|
{editableTitle ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -558,7 +649,6 @@ const DocumentSummarySection = ({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const issuedDisplay = editableIssued && isIssuedEditing ? (
|
const issuedDisplay = editableIssued && isIssuedEditing ? (
|
||||||
@@ -575,16 +665,17 @@ const DocumentSummarySection = ({
|
|||||||
aria-label="Issued on"
|
aria-label="Issued on"
|
||||||
disabled={issuedSaving}
|
disabled={issuedSaving}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={issuedSaving}>
|
<button type="submit" className="icon-button icon-button--accent" disabled={issuedSaving} aria-label="Save issued date">
|
||||||
Save
|
<CheckIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary"
|
className="icon-button"
|
||||||
onClick={cancelIssuedEdit}
|
onClick={cancelIssuedEdit}
|
||||||
disabled={issuedSaving}
|
disabled={issuedSaving}
|
||||||
|
aria-label="Cancel"
|
||||||
>
|
>
|
||||||
Cancel
|
<IconX size={16} />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
@@ -604,32 +695,7 @@ const DocumentSummarySection = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const metaItems = [
|
const tagsValueContent = (
|
||||||
{
|
|
||||||
key: 'issued',
|
|
||||||
label: 'Issued',
|
|
||||||
valueContent: issuedDisplay,
|
|
||||||
error: issuedError,
|
|
||||||
},
|
|
||||||
...metaRows.map((row) => ({
|
|
||||||
key: row.key,
|
|
||||||
label: row.label,
|
|
||||||
fallbackValue: row.value,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
const detailRows = Array.isArray(detailItems)
|
|
||||||
? detailItems.map((item, index) => ({
|
|
||||||
key: `detail-${item?.label || index}`,
|
|
||||||
label: item?.label || '—',
|
|
||||||
fallbackValue: item?.value,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const compactRows = [...metaItems, ...detailRows];
|
|
||||||
|
|
||||||
const renderTags = () => (
|
|
||||||
<section className="document-summary__section document-summary__section--tags">
|
|
||||||
<TagSection
|
<TagSection
|
||||||
tags={resolvedTags}
|
tags={resolvedTags}
|
||||||
onRemove={
|
onRemove={
|
||||||
@@ -645,11 +711,9 @@ const DocumentSummarySection = ({
|
|||||||
datalistOptions={tagOptions}
|
datalistOptions={tagOptions}
|
||||||
className="document-summary__tags"
|
className="document-summary__tags"
|
||||||
/>
|
/>
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderCorrespondents = () => (
|
const correspondentsValueContent = (
|
||||||
<section className="document-summary__section document-summary__section--correspondents">
|
|
||||||
<CorrespondentSection
|
<CorrespondentSection
|
||||||
entries={resolvedCorrespondents}
|
entries={resolvedCorrespondents}
|
||||||
onRemove={
|
onRemove={
|
||||||
@@ -675,22 +739,36 @@ const DocumentSummarySection = ({
|
|||||||
datalistOptions={correspondentOptions}
|
datalistOptions={correspondentOptions}
|
||||||
className="document-summary__correspondents"
|
className="document-summary__correspondents"
|
||||||
/>
|
/>
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isCompactLayout) {
|
const summaryRowOverrides = {
|
||||||
|
title: { valueContent: titleMetaDisplay, error: titleError },
|
||||||
|
issued: { valueContent: issuedDisplay, error: issuedError },
|
||||||
|
tags: { valueContent: tagsValueContent },
|
||||||
|
correspondents: { valueContent: correspondentsValueContent },
|
||||||
|
} as Record<string, { valueContent?: React.ReactNode | null; error?: string | null }>;
|
||||||
|
|
||||||
|
const baseRows: MetaItem[] = [...summaryRows, ...extraSummaryRows].map((row) => {
|
||||||
|
const overrides = summaryRowOverrides[row.key] || {};
|
||||||
|
return {
|
||||||
|
key: row.key,
|
||||||
|
label: row.label,
|
||||||
|
valueContent: overrides.valueContent ?? null,
|
||||||
|
fallbackValue: overrides.valueContent ? row.value : row.value,
|
||||||
|
error: overrides.error ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const allRows = baseRows;
|
||||||
|
const summaryClass = `document-summary${isCompactLayout ? ' document-summary--compact' : ''}`;
|
||||||
|
const sectionClass = `document-summary__section document-summary__meta${isCompactLayout ? ' document-summary__meta--compact' : ''}`;
|
||||||
|
const listClass = `document-summary__details-list${isCompactLayout ? ' document-summary__details-list--meta' : ''}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="document-summary document-summary--compact">
|
<div className={summaryClass}>
|
||||||
<section className="document-summary__section document-summary__title-row">
|
<section className={sectionClass}>
|
||||||
<TitleSection />
|
<dl className={listClass}>
|
||||||
</section>
|
{allRows.map((item) => (
|
||||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
|
||||||
{renderTags()}
|
|
||||||
{renderCorrespondents()}
|
|
||||||
{compactRows.length ? (
|
|
||||||
<section className="document-summary__section document-summary__meta document-summary__meta--compact">
|
|
||||||
<dl className="document-summary__details-list document-summary__details-list--meta">
|
|
||||||
{compactRows.map((item) => (
|
|
||||||
<div key={item.key} className="document-summary__details-row">
|
<div key={item.key} className="document-summary__details-row">
|
||||||
<dt>{item.label}</dt>
|
<dt>{item.label}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -703,85 +781,6 @@ const DocumentSummarySection = ({
|
|||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="document-summary">
|
|
||||||
<div className="doc-title-row">
|
|
||||||
<div className="doc-title-row__primary">
|
|
||||||
{editableTitle && isTitleEditing ? (
|
|
||||||
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
|
|
||||||
<input
|
|
||||||
value={titleDraft}
|
|
||||||
onChange={(event) => {
|
|
||||||
setTitleDraft(event.target.value);
|
|
||||||
if (titleError) {
|
|
||||||
setTitleError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
cancelTitleEdit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label="Document title"
|
|
||||||
autoFocus
|
|
||||||
disabled={titleSaving}
|
|
||||||
/>
|
|
||||||
<button type="submit" disabled={titleSaving}>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={cancelTitleEdit}
|
|
||||||
disabled={titleSaving}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h3 className="doc-title-row__title">{summary.title}</h3>
|
|
||||||
{editableTitle ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={startTitleEdit}
|
|
||||||
aria-label="Edit title"
|
|
||||||
title="Edit title"
|
|
||||||
>
|
|
||||||
<EditIcon className="icon-inline" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
|
||||||
|
|
||||||
<div className="detail-meta">
|
|
||||||
<div className="detail-meta__row">
|
|
||||||
<span className="detail-meta__label">Issued:</span>
|
|
||||||
{issuedDisplay}
|
|
||||||
</div>
|
|
||||||
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
|
|
||||||
|
|
||||||
{metaRows.map((row) => (
|
|
||||||
<div key={row.key} className="detail-meta__row">
|
|
||||||
<span className="detail-meta__label">{row.label}:</span>
|
|
||||||
<span className="detail-meta__value">{row.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderTags()}
|
|
||||||
|
|
||||||
{renderCorrespondents()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+52
-17
@@ -1,11 +1,25 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
import type { CSSProperties, JSX, MutableRefObject } from 'react';
|
||||||
|
import {
|
||||||
|
getAssetFromVersion,
|
||||||
|
resolveDocumentAssetUrl,
|
||||||
|
createAssetView,
|
||||||
|
} from '../asset_manager';
|
||||||
|
import type {
|
||||||
|
DocumentLike as AssetManagerDocumentLike,
|
||||||
|
AssetLike as AssetManagerAssetLike,
|
||||||
|
EnsureAssetUrl as AssetManagerEnsureAssetUrl,
|
||||||
|
GetAsset as AssetManagerGetAsset,
|
||||||
|
} from '../asset_manager';
|
||||||
|
|
||||||
const DEFAULT_THUMBNAIL_SIZE = 48;
|
const DEFAULT_THUMBNAIL_SIZE = 48;
|
||||||
|
|
||||||
// Detect when an element becomes visible within a scroll container so we can delay loading.
|
// Detect when an element becomes visible within a scroll container so we can delay loading.
|
||||||
const useLazyVisibility = (rootRef, resetKey) => {
|
const useLazyVisibility = (
|
||||||
const targetRef = useRef(null);
|
rootRef: MutableRefObject<Element | null> | null,
|
||||||
|
resetKey?: string | number | null,
|
||||||
|
) => {
|
||||||
|
const targetRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -24,12 +38,12 @@ const useLazyVisibility = (rootRef, resetKey) => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
if (!window.IntersectionObserver) {
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new window.IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
@@ -52,10 +66,24 @@ const useLazyVisibility = (rootRef, resetKey) => {
|
|||||||
return { ref: targetRef, isVisible };
|
return { ref: targetRef, isVisible };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPageCount = (doc) =>
|
const getPageCount = (doc?: DocumentLike | null) => {
|
||||||
Number.isFinite(doc?.current_version?.metadata?.page_count)
|
const count = doc?.current_version?.metadata?.page_count;
|
||||||
? doc.current_version.metadata.page_count
|
return Number.isFinite(count) ? Number(count) : null;
|
||||||
: null;
|
};
|
||||||
|
|
||||||
|
type DocumentLike = AssetManagerDocumentLike;
|
||||||
|
type AssetLike = AssetManagerAssetLike;
|
||||||
|
type EnsureAssetUrl = AssetManagerEnsureAssetUrl;
|
||||||
|
type GetDocumentAsset = AssetManagerGetAsset;
|
||||||
|
|
||||||
|
interface DocumentThumbnailImageProps {
|
||||||
|
document?: DocumentLike | null;
|
||||||
|
ensureAssetUrl?: EnsureAssetUrl;
|
||||||
|
getDocumentAsset?: GetDocumentAsset;
|
||||||
|
alt?: string;
|
||||||
|
maxSize?: number;
|
||||||
|
scrollRootRef?: MutableRefObject<Element | null> | null;
|
||||||
|
}
|
||||||
|
|
||||||
const DocumentThumbnailImage = ({
|
const DocumentThumbnailImage = ({
|
||||||
document,
|
document,
|
||||||
@@ -64,12 +92,12 @@ const DocumentThumbnailImage = ({
|
|||||||
alt = '',
|
alt = '',
|
||||||
maxSize = DEFAULT_THUMBNAIL_SIZE,
|
maxSize = DEFAULT_THUMBNAIL_SIZE,
|
||||||
scrollRootRef = null,
|
scrollRootRef = null,
|
||||||
}) => {
|
}: DocumentThumbnailImageProps): JSX.Element => {
|
||||||
const documentId = document?.id;
|
const documentId = document?.id;
|
||||||
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId);
|
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId);
|
||||||
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
||||||
|
|
||||||
const thumbnailAsset = useMemo(
|
const thumbnailAsset = useMemo<AssetLike | null>(
|
||||||
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
||||||
[document?.current_version],
|
[document?.current_version],
|
||||||
);
|
);
|
||||||
@@ -89,7 +117,7 @@ const DocumentThumbnailImage = ({
|
|||||||
};
|
};
|
||||||
}, [assetWidth, assetHeight, resolvedMaxSize]);
|
}, [assetWidth, assetHeight, resolvedMaxSize]);
|
||||||
|
|
||||||
const innerStyle = useMemo(
|
const innerStyle = useMemo<CSSProperties>(
|
||||||
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
||||||
[dimensions.height, dimensions.width],
|
[dimensions.height, dimensions.width],
|
||||||
);
|
);
|
||||||
@@ -98,10 +126,17 @@ const DocumentThumbnailImage = ({
|
|||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return resolveDocumentAssetUrl(document, 'thumbnail', {
|
const options: {
|
||||||
ensureAssetUrl,
|
ensureAssetUrl?: EnsureAssetUrl;
|
||||||
getAsset: getDocumentAsset,
|
getAsset?: GetDocumentAsset;
|
||||||
});
|
} = {};
|
||||||
|
if (ensureAssetUrl) {
|
||||||
|
options.ensureAssetUrl = ensureAssetUrl;
|
||||||
|
}
|
||||||
|
if (getDocumentAsset) {
|
||||||
|
options.getAsset = getDocumentAsset;
|
||||||
|
}
|
||||||
|
return resolveDocumentAssetUrl(document, 'thumbnail', options);
|
||||||
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
|
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
|
||||||
|
|
||||||
const pageCount = getPageCount(document);
|
const pageCount = getPageCount(document);
|
||||||
+112
-22
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import type { DragEvent, MouseEvent, RefObject } from 'react';
|
||||||
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
||||||
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
||||||
import CorrespondentLinks from './CorrespondentLinks';
|
import CorrespondentLinks from './CorrespondentLinks';
|
||||||
@@ -6,11 +7,85 @@ import { getTagColorStyle } from '../utils/colors';
|
|||||||
import { resolveCorrespondents } from './correspondents';
|
import { resolveCorrespondents } from './correspondents';
|
||||||
import { writeTagTransferData } from './tagTransfer';
|
import { writeTagTransferData } from './tagTransfer';
|
||||||
import useInlineRename from './useInlineRename';
|
import useInlineRename from './useInlineRename';
|
||||||
|
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
|
||||||
|
|
||||||
const DocumentsGrid = ({
|
export type Identifier = string | number;
|
||||||
|
|
||||||
|
export interface FolderLike {
|
||||||
|
id?: Identifier | 'root';
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTag {
|
||||||
|
id?: Identifier;
|
||||||
|
label?: string;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentCorrespondent {
|
||||||
|
id?: Identifier;
|
||||||
|
name?: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentLike {
|
||||||
|
id?: Identifier;
|
||||||
|
title?: string;
|
||||||
|
tags?: DocumentTag[] | null;
|
||||||
|
correspondents?: DocumentCorrespondent[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FolderEntry = {
|
||||||
|
type: 'folder';
|
||||||
|
id: Identifier | 'root';
|
||||||
|
key: string;
|
||||||
|
folder: FolderLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentEntry = {
|
||||||
|
type: 'document';
|
||||||
|
id: Identifier;
|
||||||
|
key: string;
|
||||||
|
document: DocumentLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentsGridEntry = FolderEntry | DocumentEntry;
|
||||||
|
|
||||||
|
type FolderEventHandler = (folder: FolderLike, event: MouseEvent<HTMLDivElement>) => void;
|
||||||
|
type DocumentEventHandler = (document: DocumentLike, event: MouseEvent<HTMLDivElement>) => void;
|
||||||
|
|
||||||
|
interface DocumentsGridProps {
|
||||||
|
entries: DocumentsGridEntry[];
|
||||||
|
draggingDocumentIdsSet?: Set<Identifier> | null;
|
||||||
|
draggedFolderId?: Identifier | 'root' | null;
|
||||||
|
onFolderClick?: FolderEventHandler;
|
||||||
|
onFolderSelect?: (folderId: Identifier | 'root') => void;
|
||||||
|
onFolderDragOver?: (event: DragEvent<HTMLDivElement>, folderId: Identifier | 'root') => void;
|
||||||
|
onFolderDragLeave?: (event: DragEvent<HTMLDivElement>) => void;
|
||||||
|
onFolderDrop?: (event: DragEvent<HTMLDivElement>, folderId: Identifier | 'root') => void;
|
||||||
|
onFolderDragStart?: (event: DragEvent<HTMLDivElement>, folderId: Identifier | 'root') => void;
|
||||||
|
onFolderDragEnd?: (event: DragEvent<HTMLDivElement>) => void;
|
||||||
|
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
|
||||||
|
onDocumentClick?: DocumentEventHandler;
|
||||||
|
onDocumentActivate?: DocumentEventHandler;
|
||||||
|
onDocumentDragStart?: (event: DragEvent<HTMLDivElement>, document: DocumentLike) => void;
|
||||||
|
onDocumentDragEnd?: (event: DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDocumentTagDragOver?: (event: DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDocumentTagDragLeave?: (event: DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDocumentTagDrop?: (event: DragEvent<HTMLDivElement>, documentId: Identifier) => void;
|
||||||
|
ensureAssetUrl?: (...args: any[]) => unknown;
|
||||||
|
getDocumentAsset?: (...args: any[]) => unknown;
|
||||||
|
gridIconSize?: number;
|
||||||
|
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||||
|
onTagClick?: (tagId: Identifier) => void;
|
||||||
|
scrollRef?: RefObject<HTMLElement | null>;
|
||||||
|
onCorrespondentClick?: (correspondentId: Identifier) => void;
|
||||||
|
activeCorrespondentIdSet?: Set<Identifier> | null;
|
||||||
|
onDocumentRename?: (docId: Identifier, title: string) => Promise<boolean> | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentsGrid: React.FC<DocumentsGridProps> = ({
|
||||||
entries,
|
entries,
|
||||||
selectedDocumentIdsSet,
|
|
||||||
selectedFolderIdsSet,
|
|
||||||
draggingDocumentIdsSet,
|
draggingDocumentIdsSet,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onFolderClick,
|
onFolderClick,
|
||||||
@@ -35,10 +110,16 @@ const DocumentsGrid = ({
|
|||||||
scrollRef,
|
scrollRef,
|
||||||
onCorrespondentClick,
|
onCorrespondentClick,
|
||||||
activeCorrespondentIdSet,
|
activeCorrespondentIdSet,
|
||||||
onClearSelection,
|
|
||||||
onDocumentRename,
|
onDocumentRename,
|
||||||
onFolderRename,
|
onFolderRename,
|
||||||
}) => {
|
}) => {
|
||||||
|
const {
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
clearSelection,
|
||||||
|
} = useWorkspaceSelectionContext();
|
||||||
|
const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]);
|
||||||
|
const selectedFolderIdsSet = useMemo(() => new Set(selectedFolderIds || []), [selectedFolderIds]);
|
||||||
const {
|
const {
|
||||||
editingId: editingDocumentId,
|
editingId: editingDocumentId,
|
||||||
draftValue: documentDraft,
|
draftValue: documentDraft,
|
||||||
@@ -48,9 +129,9 @@ const DocumentsGrid = ({
|
|||||||
submitEditing: submitDocumentEditing,
|
submitEditing: submitDocumentEditing,
|
||||||
savingId: savingDocumentId,
|
savingId: savingDocumentId,
|
||||||
attachInputRef: attachDocumentInputRef,
|
attachInputRef: attachDocumentInputRef,
|
||||||
} = useInlineRename(onDocumentRename, {
|
} = useInlineRename<DocumentLike>(onDocumentRename, {
|
||||||
getCurrentValue: (doc) => doc?.title ?? '',
|
getCurrentValue: (doc: DocumentLike) => doc?.title ?? '',
|
||||||
getEntityId: (doc) => doc?.id ?? null,
|
getEntityId: (doc: DocumentLike) => doc?.id ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -62,9 +143,9 @@ const DocumentsGrid = ({
|
|||||||
submitEditing: submitFolderEditing,
|
submitEditing: submitFolderEditing,
|
||||||
savingId: savingFolderId,
|
savingId: savingFolderId,
|
||||||
attachInputRef: attachFolderInputRef,
|
attachInputRef: attachFolderInputRef,
|
||||||
} = useInlineRename(onFolderRename, {
|
} = useInlineRename<FolderLike>(onFolderRename, {
|
||||||
getCurrentValue: (folder) => folder?.name ?? '',
|
getCurrentValue: (folder: FolderLike) => folder?.name ?? '',
|
||||||
getEntityId: (folder) => folder?.id ?? null,
|
getEntityId: (folder: FolderLike) => folder?.id ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0;
|
const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0;
|
||||||
@@ -78,7 +159,7 @@ const DocumentsGrid = ({
|
|||||||
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target === event.currentTarget) {
|
if (event.target === event.currentTarget) {
|
||||||
onClearSelection?.();
|
clearSelection();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -94,7 +175,7 @@ const DocumentsGrid = ({
|
|||||||
const classes = ['document-card', 'folder-card'];
|
const classes = ['document-card', 'folder-card'];
|
||||||
if (isDraggingFolder) classes.push('is-dragging');
|
if (isDraggingFolder) classes.push('is-dragging');
|
||||||
if (isSelectedFolder) classes.push('selected');
|
if (isSelectedFolder) classes.push('selected');
|
||||||
const canRenameFolder = folder.id !== 'root' && typeof onFolderRename === 'function';
|
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
|
||||||
const isFolderEditing = editingFolderId === folder.id;
|
const isFolderEditing = editingFolderId === folder.id;
|
||||||
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
|
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
|
||||||
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
|
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
|
||||||
@@ -353,20 +434,26 @@ const DocumentsGrid = ({
|
|||||||
</div>
|
</div>
|
||||||
{visibleTags.length > 0 && (
|
{visibleTags.length > 0 && (
|
||||||
<div className="document-card__tags">
|
<div className="document-card__tags">
|
||||||
{visibleTags.map((tag) => {
|
{visibleTags.map((tag, index) => {
|
||||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||||
const style = getTagColorStyle(colorSource);
|
const style = getTagColorStyle(colorSource);
|
||||||
|
const tagId = tag?.id ?? null;
|
||||||
|
const clickable = tagId != null && typeof onTagClick === 'function';
|
||||||
|
const key = tagId ?? `${doc.id}-tag-${index}`;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={tag.id}
|
key={key}
|
||||||
className="badge tag-chip"
|
className="badge tag-chip"
|
||||||
style={style || undefined}
|
style={style || undefined}
|
||||||
title={tag.label}
|
title={tag.label}
|
||||||
role="button"
|
role={clickable ? 'button' : undefined}
|
||||||
onClick={(event) => {
|
onClick={clickable ? (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onTagClick?.(tag.id);
|
if (tagId == null) {
|
||||||
}}
|
return;
|
||||||
|
}
|
||||||
|
onTagClick?.(tagId);
|
||||||
|
} : undefined}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -382,13 +469,16 @@ const DocumentsGrid = ({
|
|||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={clickable ? (event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onTagClick?.(tag.id);
|
if (tagId == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}}
|
onTagClick?.(tagId);
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
>
|
>
|
||||||
{tag.label}
|
{tag.label}
|
||||||
</span>
|
</span>
|
||||||
+118
-23
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import type { DragEvent, MouseEvent, RefObject } from 'react';
|
||||||
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { formatDate } from '../utils/date';
|
import { formatDate } from '../utils/date';
|
||||||
@@ -7,12 +8,90 @@ import CorrespondentLinks from './CorrespondentLinks';
|
|||||||
import { resolveCorrespondents } from './correspondents';
|
import { resolveCorrespondents } from './correspondents';
|
||||||
import { writeTagTransferData } from './tagTransfer';
|
import { writeTagTransferData } from './tagTransfer';
|
||||||
import useInlineRename from './useInlineRename';
|
import useInlineRename from './useInlineRename';
|
||||||
|
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
|
||||||
|
|
||||||
const DocumentsList = ({
|
export type Identifier = string | number;
|
||||||
|
|
||||||
|
export interface FolderLike {
|
||||||
|
id?: Identifier | 'root';
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTag {
|
||||||
|
id?: Identifier;
|
||||||
|
label?: string;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentCorrespondent {
|
||||||
|
id?: Identifier;
|
||||||
|
name?: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentLike {
|
||||||
|
id?: Identifier;
|
||||||
|
title?: string;
|
||||||
|
issued_at?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
uploaded_at?: string | null;
|
||||||
|
tags?: DocumentTag[] | null;
|
||||||
|
correspondents?: DocumentCorrespondent[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FolderEntry = {
|
||||||
|
type: 'folder';
|
||||||
|
id: Identifier | 'root';
|
||||||
|
key: string;
|
||||||
|
folder: FolderLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentEntry = {
|
||||||
|
type: 'document';
|
||||||
|
id: Identifier;
|
||||||
|
key: string;
|
||||||
|
document: DocumentLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentsListEntry = FolderEntry | DocumentEntry;
|
||||||
|
|
||||||
|
export type FolderEventHandler = (folder: FolderLike, event: MouseEvent<HTMLTableRowElement>) => void;
|
||||||
|
export type DocumentEventHandler = (document: DocumentLike, event: MouseEvent<HTMLTableRowElement>) => void;
|
||||||
|
|
||||||
|
export interface DocumentsListProps {
|
||||||
|
entries: DocumentsListEntry[];
|
||||||
|
focusedRowKey?: string | null;
|
||||||
|
draggingDocumentIdsSet?: Set<Identifier> | null;
|
||||||
|
draggedFolderId?: Identifier | 'root' | null;
|
||||||
|
ensureAssetUrl?: (...args: any[]) => unknown;
|
||||||
|
getDocumentAsset?: (...args: any[]) => unknown;
|
||||||
|
onFolderClick?: FolderEventHandler;
|
||||||
|
onFolderSelect?: (folderId: Identifier | 'root') => void;
|
||||||
|
onFolderDragOver?: (event: DragEvent<HTMLTableRowElement>, folderId: Identifier | 'root') => void;
|
||||||
|
onFolderDragLeave?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||||
|
onFolderDrop?: (event: DragEvent<HTMLTableRowElement>, folderId: Identifier | 'root') => void;
|
||||||
|
onFolderDragStart?: (event: DragEvent<HTMLTableRowElement>, folderId: Identifier | 'root') => void;
|
||||||
|
onFolderDragEnd?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||||
|
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
|
||||||
|
onDocumentClick?: DocumentEventHandler;
|
||||||
|
onDocumentActivate?: DocumentEventHandler;
|
||||||
|
onDocumentDragStart?: (event: DragEvent<HTMLTableRowElement>, document: DocumentLike) => void;
|
||||||
|
onDocumentDragEnd?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||||
|
onDocumentTagDragOver?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||||
|
onDocumentTagDragLeave?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||||
|
onDocumentTagDrop?: (event: DragEvent<HTMLTableRowElement>, documentId: Identifier) => void;
|
||||||
|
onDocumentRename?: (documentId: Identifier, nextTitle: string) => Promise<boolean> | boolean;
|
||||||
|
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||||
|
onTagClick?: (tagId: Identifier) => void;
|
||||||
|
onCorrespondentClick?: (correspondentId: Identifier) => void;
|
||||||
|
activeCorrespondentIdSet?: Set<Identifier> | null;
|
||||||
|
scrollRef?: RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const DocumentsList: React.FC<DocumentsListProps> = ({
|
||||||
entries,
|
entries,
|
||||||
focusedRowKey,
|
focusedRowKey,
|
||||||
selectedDocumentIdsSet,
|
|
||||||
selectedFolderIdsSet,
|
|
||||||
draggingDocumentIdsSet,
|
draggingDocumentIdsSet,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
@@ -38,8 +117,17 @@ const DocumentsList = ({
|
|||||||
onCorrespondentClick,
|
onCorrespondentClick,
|
||||||
activeCorrespondentIdSet,
|
activeCorrespondentIdSet,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
onClearSelection,
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const {
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
clearSelection,
|
||||||
|
} = useWorkspaceSelectionContext();
|
||||||
|
const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]);
|
||||||
|
const selectedFolderIdsSet = useMemo(
|
||||||
|
() => new Set(selectedFolderIds || []),
|
||||||
|
[selectedFolderIds],
|
||||||
|
);
|
||||||
const {
|
const {
|
||||||
editingId: editingDocumentId,
|
editingId: editingDocumentId,
|
||||||
draftValue: documentDraft,
|
draftValue: documentDraft,
|
||||||
@@ -49,9 +137,9 @@ const DocumentsList = ({
|
|||||||
submitEditing: submitDocumentEditing,
|
submitEditing: submitDocumentEditing,
|
||||||
savingId: savingDocumentId,
|
savingId: savingDocumentId,
|
||||||
attachInputRef: attachDocumentInputRef,
|
attachInputRef: attachDocumentInputRef,
|
||||||
} = useInlineRename(onDocumentRename, {
|
} = useInlineRename<DocumentLike>(onDocumentRename, {
|
||||||
getCurrentValue: (doc) => doc?.title ?? '',
|
getCurrentValue: (doc: DocumentLike) => doc?.title ?? '',
|
||||||
getEntityId: (doc) => doc?.id ?? null,
|
getEntityId: (doc: DocumentLike) => doc?.id ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -63,9 +151,9 @@ const DocumentsList = ({
|
|||||||
submitEditing: submitFolderEditing,
|
submitEditing: submitFolderEditing,
|
||||||
savingId: savingFolderId,
|
savingId: savingFolderId,
|
||||||
attachInputRef: attachFolderInputRef,
|
attachInputRef: attachFolderInputRef,
|
||||||
} = useInlineRename(onFolderRename, {
|
} = useInlineRename<FolderLike>(onFolderRename, {
|
||||||
getCurrentValue: (folder) => folder?.name ?? '',
|
getCurrentValue: (folder: FolderLike) => folder?.name ?? '',
|
||||||
getEntityId: (folder) => folder?.id ?? null,
|
getEntityId: (folder: FolderLike) => folder?.id ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -77,11 +165,11 @@ const DocumentsList = ({
|
|||||||
<table aria-multiselectable="true">
|
<table aria-multiselectable="true">
|
||||||
<thead
|
<thead
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClearSelection?.();
|
clearSelection();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="thumb-column"></th>
|
<th> </th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Issued</th>
|
<th>Issued</th>
|
||||||
<th>Added</th>
|
<th>Added</th>
|
||||||
@@ -98,7 +186,7 @@ const DocumentsList = ({
|
|||||||
const isDraggingFolder = draggedFolderId === folder.id;
|
const isDraggingFolder = draggedFolderId === folder.id;
|
||||||
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||||
const rowKey = `folder:${folder.id}`;
|
const rowKey = `folder:${folder.id}`;
|
||||||
const canRenameFolder = folder.id !== 'root' && typeof onFolderRename === 'function';
|
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
|
||||||
const isFolderEditing = editingFolderId === folder.id;
|
const isFolderEditing = editingFolderId === folder.id;
|
||||||
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
|
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
|
||||||
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
|
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
|
||||||
@@ -368,20 +456,24 @@ const DocumentsList = ({
|
|||||||
</div>
|
</div>
|
||||||
{(doc.tags || []).length > 0 && (
|
{(doc.tags || []).length > 0 && (
|
||||||
<div className="doc-name__tags">
|
<div className="doc-name__tags">
|
||||||
{(doc.tags || []).map((tag) => {
|
{(doc.tags || []).map((tag, index) => {
|
||||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||||
const style = getTagColorStyle(colorSource);
|
const style = getTagColorStyle(colorSource);
|
||||||
|
const tagId = tag?.id ?? null;
|
||||||
|
const clickable = tagId != null && typeof onTagClick === 'function';
|
||||||
|
const key = tagId ?? `${doc.id}-tag-${index}`;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={tag.id}
|
key={key}
|
||||||
className="badge tag-chip"
|
className="badge tag-chip"
|
||||||
style={style || undefined}
|
style={style || undefined}
|
||||||
title={tag.label}
|
title={tag.label}
|
||||||
role="button"
|
role={clickable ? 'button' : undefined}
|
||||||
onClick={(event) => {
|
onClick={clickable ? (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onTagClick?.(tag.id);
|
if (tagId == null) return;
|
||||||
}}
|
onTagClick?.(tagId);
|
||||||
|
} : undefined}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -397,13 +489,16 @@ const DocumentsList = ({
|
|||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={clickable ? (event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onTagClick?.(tag.id);
|
if (tagId == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}}
|
onTagClick?.(tagId);
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
>
|
>
|
||||||
{tag.label}
|
{tag.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { shallowEqual } from 'react-redux';
|
||||||
|
|
||||||
|
type DocumentId = string | number;
|
||||||
|
|
||||||
|
export type ManagedDocument = { id?: DocumentId | null } & Record<string, unknown>;
|
||||||
|
|
||||||
|
type FetchDocument = (id: DocumentId) => Promise<unknown>;
|
||||||
|
|
||||||
|
class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
|
||||||
|
private byId: Map<DocumentId, T>;
|
||||||
|
|
||||||
|
private fetcher?: FetchDocument;
|
||||||
|
|
||||||
|
private inflight: Map<DocumentId, Promise<T | null>>;
|
||||||
|
|
||||||
|
private listeners: Set<() => void>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
fetchDocument?: FetchDocument,
|
||||||
|
) {
|
||||||
|
this.byId = new Map();
|
||||||
|
this.fetcher = fetchDocument;
|
||||||
|
this.inflight = new Map();
|
||||||
|
this.listeners = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit() {
|
||||||
|
this.listeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: () => void) {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFetcher(fetchDocument?: FetchDocument) {
|
||||||
|
this.fetcher = fetchDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
ingest(rawDocs: unknown[] = []): { canonical: T[]; changed: boolean } {
|
||||||
|
const docs = rawDocs.map((doc) => doc as T).filter(Boolean);
|
||||||
|
let changed = false;
|
||||||
|
let nextById = this.byId;
|
||||||
|
const canonical: T[] = [];
|
||||||
|
|
||||||
|
docs.forEach((doc) => {
|
||||||
|
const id = doc?.id;
|
||||||
|
if (id == null) {
|
||||||
|
canonical.push(doc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = nextById.get(id as DocumentId);
|
||||||
|
const merged = existing ? ({ ...existing, ...doc } as T) : ({ ...(doc as T) } as T);
|
||||||
|
const useExisting = existing && shallowEqual(existing, merged);
|
||||||
|
const nextDoc = useExisting ? (existing as T) : merged;
|
||||||
|
|
||||||
|
if (!useExisting) {
|
||||||
|
if (!changed) {
|
||||||
|
nextById = new Map(this.byId);
|
||||||
|
}
|
||||||
|
nextById.set(id as DocumentId, nextDoc);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
canonical.push(nextDoc);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
this.byId = nextById;
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canonical, changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensure(id: DocumentId, fetcherOverride?: FetchDocument): Promise<T | null> {
|
||||||
|
if (id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = this.byId.get(id);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = fetcherOverride || this.fetcher;
|
||||||
|
if (!fetcher) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = this.inflight.get(id);
|
||||||
|
if (inflight) {
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
try {
|
||||||
|
const fetched = await fetcher(id);
|
||||||
|
const { canonical } = this.ingest([fetched as unknown]);
|
||||||
|
return canonical[0] ?? null;
|
||||||
|
} finally {
|
||||||
|
this.inflight.delete(id);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
this.inflight.set(id, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
map(mapper: (doc: T) => T | undefined): boolean {
|
||||||
|
if (!this.byId.size) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map<DocumentId, T>();
|
||||||
|
this.byId.forEach((doc, key) => {
|
||||||
|
const updated = mapper(doc);
|
||||||
|
const nextDoc = updated === undefined ? doc : updated;
|
||||||
|
if (nextDoc !== doc) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
next.set(key, nextDoc ?? doc);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
this.byId = next;
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(ids: Array<DocumentId>): boolean {
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
let next = this.byId;
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (next.has(id)) {
|
||||||
|
if (!changed) {
|
||||||
|
next = new Map(this.byId);
|
||||||
|
}
|
||||||
|
next.delete(id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) {
|
||||||
|
this.byId = next;
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: DocumentId): T | null {
|
||||||
|
return this.byId.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMany(ids: Array<DocumentId> = []): T[] {
|
||||||
|
return ids
|
||||||
|
.map((id) => this.byId.get(id) || null)
|
||||||
|
.filter((doc): doc is T => Boolean(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(): Map<DocumentId, T> {
|
||||||
|
return this.byId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DocumentsManager;
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
export { default } from './panel/DocumentsPanel';
|
export { default } from './panel/DocumentsPanel';
|
||||||
export { default as createDocumentsSurface } from './panel/createDocumentsSurface';
|
|
||||||
export { createDocumentsTableHeaderActions } from './panel/DocumentsToolbar';
|
export { createDocumentsTableHeaderActions } from './panel/DocumentsToolbar';
|
||||||
+140
-32
@@ -1,31 +1,91 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import useFloatingMenu from '../ui/useFloatingMenu';
|
import useFloatingMenu from '../ui/useFloatingMenu';
|
||||||
import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons';
|
import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons';
|
||||||
|
|
||||||
const STATE_ORDER = {
|
export type AssignmentState = 'all' | 'partial' | 'none';
|
||||||
|
|
||||||
|
export interface SelectionAssignmentMenuItem {
|
||||||
|
id?: string | number;
|
||||||
|
label?: string;
|
||||||
|
state?: AssignmentState;
|
||||||
|
count?: number | null;
|
||||||
|
total?: number | null;
|
||||||
|
color?: string | null;
|
||||||
|
value?: string | number;
|
||||||
|
payload?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedSelectionAssignmentItem {
|
||||||
|
id: string | number;
|
||||||
|
label: string;
|
||||||
|
state: AssignmentState;
|
||||||
|
count: number | null;
|
||||||
|
total: number | null;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionAssignmentMenuProps {
|
||||||
|
label: React.ReactNode;
|
||||||
|
items?: SelectionAssignmentMenuItem[];
|
||||||
|
placeholder?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
createLabel?: string;
|
||||||
|
onToggle?: (item: NormalizedSelectionAssignmentItem) => Promise<void> | void;
|
||||||
|
onCreate?: (value: string) => Promise<void> | void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
triggerContent?: React.ReactNode;
|
||||||
|
triggerClassName?: string;
|
||||||
|
showStateIndicators?: boolean;
|
||||||
|
showCounts?: boolean;
|
||||||
|
onOpenMenu?: () => void;
|
||||||
|
renderItemLabel?: (item: NormalizedSelectionAssignmentItem) => React.ReactNode;
|
||||||
|
positionStrategy?: 'absolute' | 'fixed';
|
||||||
|
closeOnSelection?: boolean;
|
||||||
|
sortByState?: boolean;
|
||||||
|
freezeSortOnOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_ORDER: Record<AssignmentState, number> = {
|
||||||
all: 0,
|
all: 0,
|
||||||
partial: 1,
|
partial: 1,
|
||||||
none: 2,
|
none: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeItems = (items) =>
|
const normalizeItems = (items?: SelectionAssignmentMenuItem[]): NormalizedSelectionAssignmentItem[] =>
|
||||||
(Array.isArray(items) ? items : [])
|
(Array.isArray(items) ? items : [])
|
||||||
.filter((item) => item && typeof item.label === 'string' && item.label.trim().length > 0)
|
.map<NormalizedSelectionAssignmentItem | null>((item) => {
|
||||||
.map((item) => ({
|
if (!item) {
|
||||||
id: item.id ?? item.label,
|
return null;
|
||||||
label: item.label.trim(),
|
}
|
||||||
state: item.state === 'all' ? 'all' : item.state === 'partial' ? 'partial' : 'none',
|
const trimmedLabel = item.label?.trim?.() || '';
|
||||||
count: typeof item.count === 'number' ? item.count : null,
|
if (!trimmedLabel) {
|
||||||
total: typeof item.total === 'number' ? item.total : null,
|
return null;
|
||||||
|
}
|
||||||
|
const state: AssignmentState = item.state === 'all'
|
||||||
|
? 'all'
|
||||||
|
: item.state === 'partial'
|
||||||
|
? 'partial'
|
||||||
|
: 'none';
|
||||||
|
const numericCount = Number.isFinite(item.count) ? Number(item.count) : null;
|
||||||
|
const numericTotal = Number.isFinite(item.total) ? Number(item.total) : null;
|
||||||
|
return {
|
||||||
|
id: item.id ?? trimmedLabel,
|
||||||
|
label: trimmedLabel,
|
||||||
|
state,
|
||||||
|
count: numericCount,
|
||||||
|
total: numericTotal,
|
||||||
payload: item.payload ?? item,
|
payload: item.payload ?? item,
|
||||||
}));
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is NormalizedSelectionAssignmentItem => Boolean(item));
|
||||||
|
|
||||||
const SelectionAssignmentMenu = ({
|
const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
|
||||||
label,
|
label,
|
||||||
items = [],
|
items = [],
|
||||||
placeholder = 'Search…',
|
placeholder = 'Search…',
|
||||||
emptyMessage = 'No entries',
|
emptyMessage = 'No entries',
|
||||||
createLabel = null,
|
createLabel = 'Add',
|
||||||
onToggle,
|
onToggle,
|
||||||
onCreate,
|
onCreate,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -34,14 +94,18 @@ const SelectionAssignmentMenu = ({
|
|||||||
triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger',
|
triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger',
|
||||||
showStateIndicators = true,
|
showStateIndicators = true,
|
||||||
showCounts = true,
|
showCounts = true,
|
||||||
onOpenMenu = null,
|
onOpenMenu,
|
||||||
renderItemLabel = null,
|
renderItemLabel,
|
||||||
positionStrategy = 'absolute',
|
positionStrategy = 'absolute',
|
||||||
|
closeOnSelection = true,
|
||||||
|
sortByState = true,
|
||||||
|
freezeSortOnOpen = false,
|
||||||
}) => {
|
}) => {
|
||||||
const anchorRef = useRef(null);
|
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
const [sortSnapshot, setSortSnapshot] = useState<Array<string | number> | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -55,7 +119,14 @@ const SelectionAssignmentMenu = ({
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
positionStrategy,
|
positionStrategy,
|
||||||
minWidth: 220,
|
minWidth: 220,
|
||||||
});
|
}) as {
|
||||||
|
isOpen: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
close: () => void;
|
||||||
|
menuRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
|
menuStyle: CSSProperties | null;
|
||||||
|
updatePosition: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disabled && isOpen) {
|
if (disabled && isOpen) {
|
||||||
@@ -81,43 +152,80 @@ const SelectionAssignmentMenu = ({
|
|||||||
|
|
||||||
const normalizedItems = useMemo(() => normalizeItems(items), [items]);
|
const normalizedItems = useMemo(() => normalizeItems(items), [items]);
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const sortedByStateItems = useMemo(() => {
|
||||||
const search = query.trim().toLowerCase();
|
if (!sortByState) {
|
||||||
const sorted = normalizedItems.slice().sort((a, b) => {
|
return normalizedItems;
|
||||||
|
}
|
||||||
|
return normalizedItems.slice().sort((a, b) => {
|
||||||
const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state];
|
const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state];
|
||||||
if (stateDiff !== 0) {
|
if (stateDiff !== 0) {
|
||||||
return stateDiff;
|
return stateDiff;
|
||||||
}
|
}
|
||||||
return a.label.localeCompare(b.label);
|
return a.label.localeCompare(b.label);
|
||||||
});
|
});
|
||||||
if (!search) {
|
}, [normalizedItems, sortByState]);
|
||||||
return sorted;
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !freezeSortOnOpen || !sortByState) {
|
||||||
|
setSortSnapshot(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return sorted.filter((item) => item.label.toLowerCase().includes(search));
|
setSortSnapshot((prev) => prev ?? sortedByStateItems.map((item) => item.id));
|
||||||
}, [normalizedItems, query]);
|
}, [isOpen, freezeSortOnOpen, sortByState, sortedByStateItems]);
|
||||||
|
|
||||||
|
const orderedItems = useMemo(() => {
|
||||||
|
if (freezeSortOnOpen && sortSnapshot && sortByState) {
|
||||||
|
const itemMap = new Map<string | number, NormalizedSelectionAssignmentItem>(
|
||||||
|
sortedByStateItems.map((item) => [item.id, item]),
|
||||||
|
);
|
||||||
|
const seen = new Set<string | number>();
|
||||||
|
const fromSnapshot = sortSnapshot
|
||||||
|
.map((id) => {
|
||||||
|
const entry = itemMap.get(id);
|
||||||
|
if (entry) {
|
||||||
|
seen.add(entry.id);
|
||||||
|
}
|
||||||
|
return entry || null;
|
||||||
|
})
|
||||||
|
.filter((entry): entry is NormalizedSelectionAssignmentItem => Boolean(entry));
|
||||||
|
const remaining = sortedByStateItems.filter((item) => !seen.has(item.id));
|
||||||
|
return [...fromSnapshot, ...remaining];
|
||||||
|
}
|
||||||
|
return sortedByStateItems;
|
||||||
|
}, [freezeSortOnOpen, sortSnapshot, sortByState, sortedByStateItems]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const search = query.trim().toLowerCase();
|
||||||
|
if (!search) {
|
||||||
|
return orderedItems;
|
||||||
|
}
|
||||||
|
return orderedItems.filter((item) => item.label.toLowerCase().includes(search));
|
||||||
|
}, [orderedItems, query]);
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
const handleToggle = useCallback(
|
||||||
async (item) => {
|
async (item: NormalizedSelectionAssignmentItem) => {
|
||||||
if (!item || typeof onToggle !== 'function') {
|
if (!onToggle) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPending(true);
|
setPending(true);
|
||||||
try {
|
try {
|
||||||
await onToggle(item);
|
await onToggle(item);
|
||||||
setPending(false);
|
setPending(false);
|
||||||
|
if (closeOnSelection) {
|
||||||
close();
|
close();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
console.error('[selection-assignment] toggle failed', error);
|
console.error('[selection-assignment] toggle failed', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onToggle, close],
|
[onToggle, close, closeOnSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = useCallback(
|
const handleCreate = useCallback(
|
||||||
async (event) => {
|
async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event?.preventDefault?.();
|
event?.preventDefault?.();
|
||||||
if (typeof onCreate !== 'function') {
|
if (!onCreate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const value = query.trim();
|
const value = query.trim();
|
||||||
@@ -201,8 +309,8 @@ const SelectionAssignmentMenu = ({
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="icon-button selection-assignment__add"
|
className="icon-button selection-assignment__add"
|
||||||
disabled={!canSubmitCreate}
|
disabled={!canSubmitCreate}
|
||||||
aria-label={createLabel || 'Add'}
|
aria-label={createLabel}
|
||||||
title={createLabel || 'Add'}
|
title={createLabel}
|
||||||
>
|
>
|
||||||
<PlusIcon aria-hidden="true" />
|
<PlusIcon aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1,509 +0,0 @@
|
|||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import {
|
|
||||||
TrashIcon,
|
|
||||||
AnalyzeIcon,
|
|
||||||
IconX,
|
|
||||||
FolderOutlineIcon,
|
|
||||||
TagIcon,
|
|
||||||
CorrespondentIcon,
|
|
||||||
LoaderIcon,
|
|
||||||
} from '../ui/icons';
|
|
||||||
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
|
|
||||||
import SelectionSummary from './SelectionSummary';
|
|
||||||
import { api, useAppState } from '../app/appState';
|
|
||||||
|
|
||||||
const normalizeDocumentList = (selectedDocumentIds) =>
|
|
||||||
Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : [];
|
|
||||||
|
|
||||||
const ROOT_FOLDER_LABEL = 'Documents';
|
|
||||||
|
|
||||||
const buildFolderTreeOptions = (tree) => {
|
|
||||||
const entries = [];
|
|
||||||
|
|
||||||
const traverse = (nodes, parentSegments) => {
|
|
||||||
if (!Array.isArray(nodes) || nodes.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
if (!node || !node.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const name = typeof node.name === 'string' && node.name.trim().length
|
|
||||||
? node.name.trim()
|
|
||||||
: 'Folder';
|
|
||||||
const nextSegments = parentSegments.concat([name]);
|
|
||||||
const label = nextSegments.join('/');
|
|
||||||
entries.push({ id: node.id, label });
|
|
||||||
if (Array.isArray(node.children) && node.children.length) {
|
|
||||||
traverse(node.children, nextSegments);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
traverse(Array.isArray(tree) ? tree : [], [ROOT_FOLDER_LABEL]);
|
|
||||||
|
|
||||||
entries.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
|
||||||
|
|
||||||
return [{ id: 'root', label: ROOT_FOLDER_LABEL }, ...entries];
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
|
|
||||||
if (!total) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = new Map();
|
|
||||||
|
|
||||||
const ensureEntry = (id, label, color = null) => {
|
|
||||||
const key = id ?? label;
|
|
||||||
if (!key || !label) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!map.has(key)) {
|
|
||||||
map.set(key, {
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
color,
|
|
||||||
count: 0,
|
|
||||||
total,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return map.get(key);
|
|
||||||
};
|
|
||||||
|
|
||||||
selectedDocuments.forEach((doc) => {
|
|
||||||
(doc?.tags || []).forEach((tag) => {
|
|
||||||
const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null;
|
|
||||||
const entry = ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null);
|
|
||||||
if (entry) {
|
|
||||||
entry.count += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
(tags || []).forEach((tag) => {
|
|
||||||
const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null;
|
|
||||||
ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(map.values()).map((entry) => {
|
|
||||||
const count = entry.count || 0;
|
|
||||||
const state = count === total ? 'all' : count > 0 ? 'partial' : 'none';
|
|
||||||
return {
|
|
||||||
id: entry.id ?? entry.label,
|
|
||||||
label: entry.label,
|
|
||||||
color: entry.color ?? null,
|
|
||||||
count,
|
|
||||||
total,
|
|
||||||
state,
|
|
||||||
payload: entry,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildCorrespondentAssignments = (selectedDocuments, correspondents, total) => {
|
|
||||||
if (!total) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = new Map();
|
|
||||||
|
|
||||||
const ensureEntry = (id, name) => {
|
|
||||||
const key = id ?? name;
|
|
||||||
if (!key || !name) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!map.has(key)) {
|
|
||||||
map.set(key, {
|
|
||||||
id,
|
|
||||||
label: name,
|
|
||||||
count: 0,
|
|
||||||
total,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return map.get(key);
|
|
||||||
};
|
|
||||||
|
|
||||||
selectedDocuments.forEach((doc) => {
|
|
||||||
(doc?.correspondents || []).forEach((entry) => {
|
|
||||||
const target = ensureEntry(entry?.id, entry?.name);
|
|
||||||
if (target) {
|
|
||||||
target.count += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
(correspondents || []).forEach((entry) => {
|
|
||||||
ensureEntry(entry?.id, entry?.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(map.values()).map((entry) => {
|
|
||||||
const count = entry.count || 0;
|
|
||||||
const state = count === total ? 'all' : count > 0 ? 'partial' : 'none';
|
|
||||||
return {
|
|
||||||
id: entry.id ?? entry.label,
|
|
||||||
label: entry.label,
|
|
||||||
count,
|
|
||||||
total,
|
|
||||||
state,
|
|
||||||
payload: entry,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const SelectionFloatingActions = ({
|
|
||||||
selectionCount = 0,
|
|
||||||
selectedDocumentIds,
|
|
||||||
selectedFolderIds = [],
|
|
||||||
documentLookup,
|
|
||||||
tags,
|
|
||||||
tagLookupById,
|
|
||||||
correspondents,
|
|
||||||
folderOptions = [],
|
|
||||||
onBulkTagAdd,
|
|
||||||
onBulkTagRemove,
|
|
||||||
onBulkCorrespondentAdd,
|
|
||||||
onBulkCorrespondentRemove,
|
|
||||||
onBulkReanalyze,
|
|
||||||
onDeleteSelection,
|
|
||||||
onClearSelection = null,
|
|
||||||
onMoveDocumentsToFolder,
|
|
||||||
}) => {
|
|
||||||
const { token, tenant } = useAppState();
|
|
||||||
const tenantId = tenant?.id ?? null;
|
|
||||||
|
|
||||||
const [remoteFolderOptions, setRemoteFolderOptions] = useState(null);
|
|
||||||
const [loadingFolders, setLoadingFolders] = useState(false);
|
|
||||||
const folderTreeFetchRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRemoteFolderOptions(null);
|
|
||||||
folderTreeFetchRef.current = null;
|
|
||||||
setLoadingFolders(false);
|
|
||||||
}, [tenantId, token]);
|
|
||||||
|
|
||||||
const requestFolderTree = useCallback(async () => {
|
|
||||||
if (!token) {
|
|
||||||
setRemoteFolderOptions([]);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(remoteFolderOptions)) {
|
|
||||||
return remoteFolderOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (folderTreeFetchRef.current) {
|
|
||||||
return folderTreeFetchRef.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPromise = (async () => {
|
|
||||||
setLoadingFolders(true);
|
|
||||||
try {
|
|
||||||
const { data } = await api.get('/folders/tree');
|
|
||||||
const options = buildFolderTreeOptions(data);
|
|
||||||
setRemoteFolderOptions(options);
|
|
||||||
return options;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[selection] Failed to load folder tree', error);
|
|
||||||
setRemoteFolderOptions([]);
|
|
||||||
return [];
|
|
||||||
} finally {
|
|
||||||
setLoadingFolders(false);
|
|
||||||
folderTreeFetchRef.current = null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
folderTreeFetchRef.current = fetchPromise;
|
|
||||||
return fetchPromise;
|
|
||||||
}, [remoteFolderOptions, token]);
|
|
||||||
|
|
||||||
const handleMoveMenuOpen = useCallback(() => {
|
|
||||||
requestFolderTree();
|
|
||||||
}, [requestFolderTree]);
|
|
||||||
|
|
||||||
const effectiveFolderOptions = useMemo(() => {
|
|
||||||
if (remoteFolderOptions !== null) {
|
|
||||||
return remoteFolderOptions;
|
|
||||||
}
|
|
||||||
return Array.isArray(folderOptions) ? folderOptions : [];
|
|
||||||
}, [remoteFolderOptions, folderOptions]);
|
|
||||||
|
|
||||||
const documentIdList = useMemo(
|
|
||||||
() => normalizeDocumentList(selectedDocumentIds),
|
|
||||||
[selectedDocumentIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const folderIdList = useMemo(
|
|
||||||
() => normalizeDocumentList(selectedFolderIds),
|
|
||||||
[selectedFolderIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentCount = documentIdList.length;
|
|
||||||
const folderCount = folderIdList.length;
|
|
||||||
const totalCount = typeof selectionCount === 'number'
|
|
||||||
? selectionCount
|
|
||||||
: documentCount + folderCount;
|
|
||||||
|
|
||||||
const selectedDocuments = useMemo(() => {
|
|
||||||
if (!documentIdList.length || !(documentLookup instanceof Map)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return documentIdList
|
|
||||||
.map((id) => documentLookup.get(id))
|
|
||||||
.filter(Boolean);
|
|
||||||
}, [documentIdList, documentLookup]);
|
|
||||||
|
|
||||||
const selectedDocCount = selectedDocuments.length;
|
|
||||||
|
|
||||||
const moveAssignments = useMemo(() => {
|
|
||||||
if (!Array.isArray(effectiveFolderOptions)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return effectiveFolderOptions
|
|
||||||
.map((option) => {
|
|
||||||
const id = option?.id ?? option?.value ?? option;
|
|
||||||
if (!id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const label = option?.label || option?.name || String(id);
|
|
||||||
const segments = label.split('/');
|
|
||||||
const depth = Math.max(segments.length - 1, 0);
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
state: 'none',
|
|
||||||
count: null,
|
|
||||||
total: null,
|
|
||||||
payload: {
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
segments,
|
|
||||||
depth,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}, [effectiveFolderOptions]);
|
|
||||||
|
|
||||||
const tagAssignments = useMemo(
|
|
||||||
() => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount),
|
|
||||||
[selectedDocuments, tagLookupById, tags, selectedDocCount],
|
|
||||||
);
|
|
||||||
|
|
||||||
const correspondentAssignments = useMemo(
|
|
||||||
() => buildCorrespondentAssignments(selectedDocuments, correspondents, selectedDocCount),
|
|
||||||
[selectedDocuments, correspondents, selectedDocCount],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderFolderLabel = useCallback((item) => {
|
|
||||||
const segments = item?.payload?.segments || (item?.label ? item.label.split('/') : []);
|
|
||||||
const depth = item?.payload?.depth ?? Math.max(segments.length - 1, 0);
|
|
||||||
const clampedDepth = Math.min(depth, 6);
|
|
||||||
const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0;
|
|
||||||
const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder';
|
|
||||||
const parentPath = segments.length > 1 ? segments.slice(0, -1).join(' / ') : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{indentWidth ? (
|
|
||||||
<span
|
|
||||||
className="selection-assignment__indent"
|
|
||||||
style={{ width: `${indentWidth}rem` }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<span className="selection-assignment__folder-label">
|
|
||||||
<span className="selection-assignment__folder-name">{name}</span>
|
|
||||||
{parentPath ? (
|
|
||||||
<span className="selection-assignment__folder-path">{parentPath}</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleTagAssignment = useCallback(
|
|
||||||
async (item) => {
|
|
||||||
if (!selectedDocCount || !item) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (item.state === 'all') {
|
|
||||||
await onBulkTagRemove?.({ label: item.label, input: null, documentIds: documentIdList });
|
|
||||||
} else {
|
|
||||||
await onBulkTagAdd?.({ label: item.label, input: null, documentIds: documentIdList });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectedDocCount, onBulkTagAdd, onBulkTagRemove, documentIdList],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreateTagAssignment = useCallback(
|
|
||||||
async (label) => {
|
|
||||||
if (!selectedDocCount || !label) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await onBulkTagAdd?.({ label, input: null, documentIds: documentIdList });
|
|
||||||
},
|
|
||||||
[selectedDocCount, onBulkTagAdd, documentIdList],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggleCorrespondentAssignment = useCallback(
|
|
||||||
async (item) => {
|
|
||||||
if (!selectedDocCount || !item) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (item.state === 'all') {
|
|
||||||
if (!item.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await onBulkCorrespondentRemove?.({
|
|
||||||
assignments: [{ correspondent_id: item.id }],
|
|
||||||
documentIds: documentIdList,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await onBulkCorrespondentAdd?.({ name: item.label, input: null, documentIds: documentIdList });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectedDocCount, onBulkCorrespondentAdd, onBulkCorrespondentRemove, documentIdList],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreateCorrespondentAssignment = useCallback(
|
|
||||||
async (name) => {
|
|
||||||
if (!selectedDocCount || !name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await onBulkCorrespondentAdd?.({ name, input: null, documentIds: documentIdList });
|
|
||||||
},
|
|
||||||
[selectedDocCount, onBulkCorrespondentAdd, documentIdList],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMoveSelectionToFolder = useCallback(
|
|
||||||
async (option) => {
|
|
||||||
if (!documentIdList.length || typeof onMoveDocumentsToFolder !== 'function') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const value = option?.id ?? option?.value ?? option;
|
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await onMoveDocumentsToFolder(documentIdList, value);
|
|
||||||
},
|
|
||||||
[documentIdList, onMoveDocumentsToFolder],
|
|
||||||
);
|
|
||||||
|
|
||||||
const summaryNode = totalCount > 0 ? (
|
|
||||||
<SelectionSummary
|
|
||||||
documentCount={documentCount}
|
|
||||||
folderCount={folderCount}
|
|
||||||
totalCount={totalCount}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{summaryNode ? (
|
|
||||||
<span className="panel-floating__label">{summaryNode}</span>
|
|
||||||
) : null}
|
|
||||||
<div className="panel-floating-actions">
|
|
||||||
{typeof onMoveDocumentsToFolder === 'function' ? (
|
|
||||||
<SelectionAssignmentMenu
|
|
||||||
label="Move"
|
|
||||||
triggerContent={(
|
|
||||||
<span className="quick-add__chip-label">
|
|
||||||
{loadingFolders ? (
|
|
||||||
<LoaderIcon className="icon-inline icon--spin" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<FolderOutlineIcon className="icon-inline" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
{' '}
|
|
||||||
Move
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
items={moveAssignments}
|
|
||||||
placeholder="Search folders…"
|
|
||||||
emptyMessage={loadingFolders ? 'Loading folders…' : 'No folders'}
|
|
||||||
onToggle={(item) => handleMoveSelectionToFolder(item?.payload || item)}
|
|
||||||
disabled={!documentCount || (loadingFolders && !moveAssignments.length)}
|
|
||||||
createLabel={null}
|
|
||||||
showStateIndicators={false}
|
|
||||||
showCounts={false}
|
|
||||||
onOpenMenu={handleMoveMenuOpen}
|
|
||||||
renderItemLabel={renderFolderLabel}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<SelectionAssignmentMenu
|
|
||||||
label="Tags"
|
|
||||||
triggerContent={(
|
|
||||||
<span className="quick-add__chip-label">
|
|
||||||
<TagIcon className="icon-inline" aria-hidden="true" /> Tags
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
items={tagAssignments}
|
|
||||||
placeholder="Search tags…"
|
|
||||||
emptyMessage="No tags"
|
|
||||||
createLabel="Create"
|
|
||||||
onToggle={handleToggleTagAssignment}
|
|
||||||
onCreate={handleCreateTagAssignment}
|
|
||||||
disabled={!documentCount}
|
|
||||||
/>
|
|
||||||
<SelectionAssignmentMenu
|
|
||||||
label="Correspondents"
|
|
||||||
triggerContent={(
|
|
||||||
<span className="quick-add__chip-label">
|
|
||||||
<CorrespondentIcon className="icon-inline" aria-hidden="true" /> Correspondents
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
items={correspondentAssignments}
|
|
||||||
placeholder="Search correspondents…"
|
|
||||||
emptyMessage="No correspondents"
|
|
||||||
createLabel="Create"
|
|
||||||
onToggle={handleToggleCorrespondentAssignment}
|
|
||||||
onCreate={handleCreateCorrespondentAssignment}
|
|
||||||
disabled={!documentCount}
|
|
||||||
/>
|
|
||||||
{typeof onBulkReanalyze === 'function' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button panel-floating-actions__button"
|
|
||||||
onClick={() => onBulkReanalyze(documentIdList)}
|
|
||||||
aria-label="Re-run analysis for selection"
|
|
||||||
title="Re-run analysis for selection"
|
|
||||||
disabled={documentIdList.length === 0}
|
|
||||||
>
|
|
||||||
<AnalyzeIcon className="icon-inline" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{typeof onDeleteSelection === 'function' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button danger panel-floating-actions__button"
|
|
||||||
onClick={onDeleteSelection}
|
|
||||||
aria-label="Delete selected items"
|
|
||||||
disabled={totalCount === 0}
|
|
||||||
>
|
|
||||||
<TrashIcon className="icon-inline" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{typeof onClearSelection === 'function' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button panel-floating-actions__button"
|
|
||||||
onClick={onClearSelection}
|
|
||||||
aria-label="Clear selection"
|
|
||||||
title="Clear selection"
|
|
||||||
disabled={totalCount === 0}
|
|
||||||
>
|
|
||||||
<IconX className="icon-inline" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SelectionFloatingActions;
|
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
TrashIcon,
|
||||||
|
AnalyzeIcon,
|
||||||
|
IconX,
|
||||||
|
FolderOutlineIcon,
|
||||||
|
TagIcon,
|
||||||
|
CorrespondentIcon,
|
||||||
|
LoaderIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
|
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
|
||||||
|
import SelectionSummary from './SelectionSummary';
|
||||||
|
import { api, useAppState } from '../app/appState';
|
||||||
|
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
|
||||||
|
|
||||||
|
const ROOT_FOLDER_LABEL = 'Documents';
|
||||||
|
|
||||||
|
type DocumentId = string | number;
|
||||||
|
type NullableDocumentId = DocumentId | null;
|
||||||
|
|
||||||
|
type SelectedIdList = NullableDocumentId[] | null;
|
||||||
|
|
||||||
|
type FolderTreeNode = {
|
||||||
|
id?: DocumentId;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
value?: DocumentId;
|
||||||
|
children?: FolderTreeNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TagOption {
|
||||||
|
id?: DocumentId;
|
||||||
|
label?: string;
|
||||||
|
name?: string;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CorrespondentOption {
|
||||||
|
id?: DocumentId;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentLike {
|
||||||
|
id?: DocumentId;
|
||||||
|
tags?: TagOption[];
|
||||||
|
correspondents?: CorrespondentOption[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkTagMutationArgs {
|
||||||
|
label: string;
|
||||||
|
input: unknown;
|
||||||
|
documentIds: DocumentId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkCorrespondentAddArgs {
|
||||||
|
name: string;
|
||||||
|
input: unknown;
|
||||||
|
documentIds: DocumentId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkCorrespondentRemoveArgs {
|
||||||
|
assignments: Array<{ correspondent_id: DocumentId }>;
|
||||||
|
documentIds: DocumentId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionFloatingActionsProps {
|
||||||
|
selectionCount?: number;
|
||||||
|
selectedDocumentIds?: SelectedIdList;
|
||||||
|
selectedFolderIds?: SelectedIdList;
|
||||||
|
documentLookup?: Map<DocumentId, DocumentLike> | null;
|
||||||
|
tags?: TagOption[] | null;
|
||||||
|
tagLookupById?: Map<DocumentId, TagOption> | null;
|
||||||
|
correspondents?: CorrespondentOption[] | null;
|
||||||
|
folderOptions?: SelectionAssignmentMenuItem[] | null;
|
||||||
|
onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
||||||
|
onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
||||||
|
onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise<void> | void;
|
||||||
|
onBulkCorrespondentRemove?: (args: BulkCorrespondentRemoveArgs) => Promise<void> | void;
|
||||||
|
onBulkReanalyze?: (documentIds: DocumentId[]) => Promise<void> | void;
|
||||||
|
onDeleteSelection?: () => void;
|
||||||
|
onClearSelection?: () => void;
|
||||||
|
onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] =>
|
||||||
|
Array.isArray(selectedIds)
|
||||||
|
? selectedIds.filter((value): value is DocumentId => value !== null && value !== undefined)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => value != null && Object(value) === value;
|
||||||
|
|
||||||
|
const splitLabelSegments = (input: unknown): string[] => {
|
||||||
|
const text = `${input ?? ''}`.trim();
|
||||||
|
return text ? text.split('/') : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFolderTreeOptions = (tree?: FolderTreeNode[] | null): SelectionAssignmentMenuItem[] => {
|
||||||
|
const entries: SelectionAssignmentMenuItem[] = [];
|
||||||
|
|
||||||
|
const traverse = (nodes: FolderTreeNode[] | undefined | null, parentSegments: string[]) => {
|
||||||
|
if (!Array.isArray(nodes) || nodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (!node || !node.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedName = node.name?.trim?.();
|
||||||
|
const name = trimmedName?.length ? trimmedName : 'Folder';
|
||||||
|
const nextSegments = parentSegments.concat([name]);
|
||||||
|
const label = nextSegments.join('/');
|
||||||
|
entries.push({
|
||||||
|
id: node.id,
|
||||||
|
label,
|
||||||
|
state: 'none',
|
||||||
|
payload: {
|
||||||
|
id: node.id,
|
||||||
|
label,
|
||||||
|
segments: nextSegments,
|
||||||
|
depth: Math.max(nextSegments.length - 1, 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (Array.isArray(node.children) && node.children.length) {
|
||||||
|
traverse(node.children, nextSegments);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
traverse(Array.isArray(tree) ? tree : [], [ROOT_FOLDER_LABEL]);
|
||||||
|
|
||||||
|
entries.sort((a, b) => (a.label || '').localeCompare(b.label || '', undefined, { sensitivity: 'base' }));
|
||||||
|
|
||||||
|
return [{ id: 'root', label: ROOT_FOLDER_LABEL, state: 'none', payload: { id: 'root' } }, ...entries];
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTagAssignments = (
|
||||||
|
selectedDocuments: DocumentLike[],
|
||||||
|
tagLookupById: Map<DocumentId, TagOption> | null,
|
||||||
|
tags: TagOption[] | null,
|
||||||
|
total: number,
|
||||||
|
): SelectionAssignmentMenuItem[] => {
|
||||||
|
if (!total) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new Map<string | number, {
|
||||||
|
id?: DocumentId;
|
||||||
|
label: string;
|
||||||
|
color: string | null;
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const ensureEntry = (id?: DocumentId, label?: string, color: string | null = null) => {
|
||||||
|
const key = id ?? label;
|
||||||
|
if (!key || !label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
count: 0,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map.get(key) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
selectedDocuments.forEach((doc) => {
|
||||||
|
(doc?.tags || []).forEach((tag) => {
|
||||||
|
const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null;
|
||||||
|
const entry = ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null);
|
||||||
|
if (entry) {
|
||||||
|
entry.count += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(tags || []).forEach((tag) => {
|
||||||
|
const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null;
|
||||||
|
ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(map.values()).map((entry) => {
|
||||||
|
const count = entry.count || 0;
|
||||||
|
const state = count === total ? 'all' : count > 0 ? 'partial' : 'none';
|
||||||
|
return {
|
||||||
|
id: entry.id ?? entry.label,
|
||||||
|
label: entry.label,
|
||||||
|
color: entry.color ?? null,
|
||||||
|
count,
|
||||||
|
total,
|
||||||
|
state,
|
||||||
|
payload: entry,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCorrespondentAssignments = (
|
||||||
|
selectedDocuments: DocumentLike[],
|
||||||
|
correspondents: CorrespondentOption[] | null,
|
||||||
|
total: number,
|
||||||
|
): SelectionAssignmentMenuItem[] => {
|
||||||
|
if (!total) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new Map<string | number, {
|
||||||
|
id?: DocumentId;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const ensureEntry = (id?: DocumentId, name?: string) => {
|
||||||
|
const key = id ?? name;
|
||||||
|
if (!key || !name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, {
|
||||||
|
id,
|
||||||
|
label: name,
|
||||||
|
count: 0,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map.get(key) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
selectedDocuments.forEach((doc) => {
|
||||||
|
(doc?.correspondents || []).forEach((entry) => {
|
||||||
|
const target = ensureEntry(entry?.id, entry?.name);
|
||||||
|
if (target) {
|
||||||
|
target.count += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(correspondents || []).forEach((entry) => {
|
||||||
|
ensureEntry(entry?.id, entry?.name || entry?.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(map.values()).map((entry) => {
|
||||||
|
const count = entry.count || 0;
|
||||||
|
const state = count === total ? 'all' : count > 0 ? 'partial' : 'none';
|
||||||
|
return {
|
||||||
|
id: entry.id ?? entry.label,
|
||||||
|
label: entry.label,
|
||||||
|
count,
|
||||||
|
total,
|
||||||
|
state,
|
||||||
|
payload: entry,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||||
|
selectionCount = 0,
|
||||||
|
selectedDocumentIds = [],
|
||||||
|
selectedFolderIds = [],
|
||||||
|
documentLookup,
|
||||||
|
tags = [],
|
||||||
|
tagLookupById,
|
||||||
|
correspondents = [],
|
||||||
|
folderOptions = [],
|
||||||
|
onBulkTagAdd,
|
||||||
|
onBulkTagRemove,
|
||||||
|
onBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze,
|
||||||
|
onDeleteSelection,
|
||||||
|
onClearSelection = null,
|
||||||
|
onMoveDocumentsToFolder,
|
||||||
|
}) => {
|
||||||
|
const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId } | null };
|
||||||
|
const tenantId = tenant?.id ?? null;
|
||||||
|
|
||||||
|
const documentLookupMap = useMemo(() => (
|
||||||
|
documentLookup instanceof Map ? documentLookup : new Map<DocumentId, DocumentLike>()
|
||||||
|
), [documentLookup]);
|
||||||
|
const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null;
|
||||||
|
|
||||||
|
const [remoteFolderOptions, setRemoteFolderOptions] = useState<SelectionAssignmentMenuItem[] | null>(null);
|
||||||
|
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||||
|
const folderTreeFetchRef = useRef<Promise<SelectionAssignmentMenuItem[]> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRemoteFolderOptions(null);
|
||||||
|
folderTreeFetchRef.current = null;
|
||||||
|
setLoadingFolders(false);
|
||||||
|
}, [tenantId, token]);
|
||||||
|
|
||||||
|
const requestFolderTree = useCallback(async (): Promise<SelectionAssignmentMenuItem[]> => {
|
||||||
|
if (!token) {
|
||||||
|
setRemoteFolderOptions([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(remoteFolderOptions)) {
|
||||||
|
return remoteFolderOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderTreeFetchRef.current) {
|
||||||
|
return folderTreeFetchRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPromise = (async () => {
|
||||||
|
setLoadingFolders(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/folders/tree');
|
||||||
|
const options = buildFolderTreeOptions(data);
|
||||||
|
setRemoteFolderOptions(options);
|
||||||
|
return options;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[selection] Failed to load folder tree', error);
|
||||||
|
setRemoteFolderOptions([]);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
setLoadingFolders(false);
|
||||||
|
folderTreeFetchRef.current = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
folderTreeFetchRef.current = fetchPromise;
|
||||||
|
return fetchPromise;
|
||||||
|
}, [remoteFolderOptions, token]);
|
||||||
|
|
||||||
|
const handleMoveMenuOpen = useCallback(() => {
|
||||||
|
requestFolderTree();
|
||||||
|
}, [requestFolderTree]);
|
||||||
|
|
||||||
|
const effectiveFolderOptions = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||||
|
if (remoteFolderOptions !== null) {
|
||||||
|
return remoteFolderOptions;
|
||||||
|
}
|
||||||
|
return Array.isArray(folderOptions) ? folderOptions : [];
|
||||||
|
}, [remoteFolderOptions, folderOptions]);
|
||||||
|
|
||||||
|
const documentIdList = useMemo<DocumentId[]>(
|
||||||
|
() => normalizeDocumentList(selectedDocumentIds),
|
||||||
|
[selectedDocumentIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const folderIdList = useMemo<DocumentId[]>(
|
||||||
|
() => normalizeDocumentList(selectedFolderIds),
|
||||||
|
[selectedFolderIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const documentCount = documentIdList.length;
|
||||||
|
const folderCount = folderIdList.length;
|
||||||
|
const totalCount = Number.isFinite(selectionCount)
|
||||||
|
? Number(selectionCount)
|
||||||
|
: documentCount + folderCount;
|
||||||
|
|
||||||
|
const selectedDocuments = useMemo<DocumentLike[]>(() => {
|
||||||
|
if (!documentIdList.length || !(documentLookupMap instanceof Map)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return documentIdList
|
||||||
|
.map((id) => documentLookupMap.get(id))
|
||||||
|
.filter((doc): doc is DocumentLike => Boolean(doc));
|
||||||
|
}, [documentIdList, documentLookupMap]);
|
||||||
|
|
||||||
|
const selectedDocCount = selectedDocuments.length;
|
||||||
|
|
||||||
|
const moveAssignments = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||||
|
if (!Array.isArray(effectiveFolderOptions)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return effectiveFolderOptions
|
||||||
|
.map<SelectionAssignmentMenuItem | null>((option) => {
|
||||||
|
const id = (option?.id ?? option?.payload?.id ?? option?.value) as DocumentId | undefined;
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = option?.label || option?.payload?.label || option?.name || String(id);
|
||||||
|
const segments = splitLabelSegments(label);
|
||||||
|
const depth = Math.max(segments.length - 1, 0);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
state: 'none',
|
||||||
|
payload: {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
segments,
|
||||||
|
depth,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((entry): entry is SelectionAssignmentMenuItem => Boolean(entry));
|
||||||
|
}, [effectiveFolderOptions]);
|
||||||
|
|
||||||
|
const tagAssignments = useMemo(
|
||||||
|
() => buildTagAssignments(selectedDocuments, tagLookupMap, tags, selectedDocCount),
|
||||||
|
[selectedDocuments, tagLookupMap, tags, selectedDocCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const correspondentAssignments = useMemo(
|
||||||
|
() => buildCorrespondentAssignments(selectedDocuments, correspondents, selectedDocCount),
|
||||||
|
[selectedDocuments, correspondents, selectedDocCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFolderLabel = useCallback((item: SelectionAssignmentMenuItem) => {
|
||||||
|
const payload = (item?.payload as { segments?: string[]; depth?: number }) || {};
|
||||||
|
const segments = payload.segments || splitLabelSegments(item.label);
|
||||||
|
const depth = payload.depth ?? Math.max(segments.length - 1, 0);
|
||||||
|
const clampedDepth = Math.min(depth, 6);
|
||||||
|
const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0;
|
||||||
|
const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder';
|
||||||
|
const parentPath = segments.length > 1 ? segments.slice(0, -1).join(' / ') : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{indentWidth ? (
|
||||||
|
<span
|
||||||
|
className="selection-assignment__indent"
|
||||||
|
style={{ width: `${indentWidth}rem` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="selection-assignment__folder-label">
|
||||||
|
<span className="selection-assignment__folder-name">{name}</span>
|
||||||
|
{parentPath ? (
|
||||||
|
<span className="selection-assignment__folder-path">{parentPath}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleTagAssignment = useCallback(
|
||||||
|
async (item: SelectionAssignmentMenuItem) => {
|
||||||
|
if (!selectedDocCount || !item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.state === 'all') {
|
||||||
|
await onBulkTagRemove?.({ label: item.label || '', input: null, documentIds: documentIdList });
|
||||||
|
} else {
|
||||||
|
await onBulkTagAdd?.({ label: item.label || '', input: null, documentIds: documentIdList });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedDocCount, onBulkTagAdd, onBulkTagRemove, documentIdList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateTagAssignment = useCallback(
|
||||||
|
async (label: string) => {
|
||||||
|
if (!selectedDocCount || !label) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onBulkTagAdd?.({ label, input: null, documentIds: documentIdList });
|
||||||
|
},
|
||||||
|
[selectedDocCount, onBulkTagAdd, documentIdList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleCorrespondentAssignment = useCallback(
|
||||||
|
async (item: SelectionAssignmentMenuItem) => {
|
||||||
|
if (!selectedDocCount || !item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.state === 'all') {
|
||||||
|
if (!item.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onBulkCorrespondentRemove?.({
|
||||||
|
assignments: [{ correspondent_id: item.id }],
|
||||||
|
documentIds: documentIdList,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await onBulkCorrespondentAdd?.({ name: item.label || '', input: null, documentIds: documentIdList });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedDocCount, onBulkCorrespondentAdd, onBulkCorrespondentRemove, documentIdList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateCorrespondentAssignment = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
if (!selectedDocCount || !name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onBulkCorrespondentAdd?.({ name, input: null, documentIds: documentIdList });
|
||||||
|
},
|
||||||
|
[selectedDocCount, onBulkCorrespondentAdd, documentIdList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMoveSelectionToFolder = useCallback(
|
||||||
|
async (option: unknown) => {
|
||||||
|
if (!documentIdList.length || !onMoveDocumentsToFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const candidate = option as { id?: DocumentId; value?: DocumentId } | DocumentId | null;
|
||||||
|
const value = isRecord(candidate)
|
||||||
|
? (candidate?.id ?? candidate?.value ?? null)
|
||||||
|
: candidate;
|
||||||
|
if (!value && value !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onMoveDocumentsToFolder(documentIdList, value as DocumentId);
|
||||||
|
},
|
||||||
|
[documentIdList, onMoveDocumentsToFolder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryNode = totalCount > 0 ? (
|
||||||
|
<SelectionSummary
|
||||||
|
documentCount={documentCount}
|
||||||
|
folderCount={folderCount}
|
||||||
|
totalCount={totalCount}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const showPrimaryButtons = Boolean(onBulkReanalyze || onDeleteSelection || onClearSelection);
|
||||||
|
|
||||||
|
const moveMenu = onMoveDocumentsToFolder ? (
|
||||||
|
<SelectionAssignmentMenu
|
||||||
|
label="Move"
|
||||||
|
triggerContent={(
|
||||||
|
<span className="quick-add__chip-label" title="Move">
|
||||||
|
{loadingFolders ? (
|
||||||
|
<LoaderIcon className="icon-inline icon--spin" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<FolderOutlineIcon className="icon-inline" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span className="quick-add__chip-text" aria-hidden="true">Move</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
items={moveAssignments}
|
||||||
|
placeholder="Search folders…"
|
||||||
|
emptyMessage={loadingFolders ? 'Loading folders…' : 'No folders'}
|
||||||
|
onToggle={(item) => handleMoveSelectionToFolder(item?.payload || item)}
|
||||||
|
disabled={!documentCount || (loadingFolders && !moveAssignments.length)}
|
||||||
|
createLabel={null}
|
||||||
|
showStateIndicators={false}
|
||||||
|
showCounts={false}
|
||||||
|
onOpenMenu={handleMoveMenuOpen}
|
||||||
|
renderItemLabel={renderFolderLabel}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const primaryButtons = showPrimaryButtons ? (
|
||||||
|
<div className="panel-floating__buttons">
|
||||||
|
{onBulkReanalyze ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button panel-floating-actions__button"
|
||||||
|
onClick={() => onBulkReanalyze(documentIdList)}
|
||||||
|
aria-label="Re-run analysis for selection"
|
||||||
|
title="Re-run analysis for selection"
|
||||||
|
disabled={documentIdList.length === 0}
|
||||||
|
>
|
||||||
|
<AnalyzeIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{onDeleteSelection ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button danger panel-floating-actions__button"
|
||||||
|
onClick={onDeleteSelection}
|
||||||
|
aria-label="Delete selected items"
|
||||||
|
disabled={totalCount === 0}
|
||||||
|
>
|
||||||
|
<TrashIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{onClearSelection ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button panel-floating-actions__button"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
aria-label="Clear selection"
|
||||||
|
title="Clear selection"
|
||||||
|
disabled={totalCount === 0}
|
||||||
|
>
|
||||||
|
<IconX className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{summaryNode ? (
|
||||||
|
<span className="panel-floating__label">{summaryNode}</span>
|
||||||
|
) : null}
|
||||||
|
<div className="panel-floating-actions panel-floating-actions--assignments">
|
||||||
|
{moveMenu}
|
||||||
|
<SelectionAssignmentMenu
|
||||||
|
label="Tags"
|
||||||
|
triggerContent={(
|
||||||
|
<span className="quick-add__chip-label" title="Tags">
|
||||||
|
<TagIcon className="icon-inline" aria-hidden="true" />
|
||||||
|
<span className="quick-add__chip-text" aria-hidden="true">Tags</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
items={tagAssignments}
|
||||||
|
placeholder="Search tags…"
|
||||||
|
emptyMessage="No tags"
|
||||||
|
createLabel="Create"
|
||||||
|
onToggle={handleToggleTagAssignment}
|
||||||
|
onCreate={handleCreateTagAssignment}
|
||||||
|
disabled={!documentCount}
|
||||||
|
/>
|
||||||
|
<SelectionAssignmentMenu
|
||||||
|
label="Correspondents"
|
||||||
|
triggerContent={(
|
||||||
|
<span className="quick-add__chip-label" title="Correspondents">
|
||||||
|
<CorrespondentIcon className="icon-inline" aria-hidden="true" />
|
||||||
|
<span className="quick-add__chip-text" aria-hidden="true">Correspondents</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
items={correspondentAssignments}
|
||||||
|
placeholder="Search correspondents…"
|
||||||
|
emptyMessage="No correspondents"
|
||||||
|
createLabel="Create"
|
||||||
|
onToggle={handleToggleCorrespondentAssignment}
|
||||||
|
onCreate={handleCreateCorrespondentAssignment}
|
||||||
|
disabled={!documentCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{primaryButtons}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectionFloatingPanelProps = Omit<SelectionFloatingActionsProps, 'selectedDocumentIds' | 'selectedFolderIds' | 'selectionCount'>;
|
||||||
|
|
||||||
|
export const SelectionFloatingPanel: React.FC<SelectionFloatingPanelProps> = ({ onClearSelection, ...rest }) => {
|
||||||
|
const { selectedDocumentIds, selectedFolderIds, clearSelection } = useWorkspaceSelectionContext();
|
||||||
|
const documentIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
|
||||||
|
const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : [];
|
||||||
|
const selectionCount = documentIds.length + folderIds.length;
|
||||||
|
if (selectionCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const handleClear = onClearSelection || clearSelection;
|
||||||
|
return (
|
||||||
|
<div className="panel-floating-region" aria-live="polite" aria-atomic="true">
|
||||||
|
<div className="panel-floating">
|
||||||
|
<SelectionFloatingActions
|
||||||
|
selectionCount={selectionCount}
|
||||||
|
selectedDocumentIds={documentIds}
|
||||||
|
selectedFolderIds={folderIds}
|
||||||
|
onClearSelection={handleClear}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default SelectionFloatingActions;
|
||||||
+7
-1
@@ -1,7 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FileIcon, FolderOutlineIcon } from '../ui/icons';
|
import { FileIcon, FolderOutlineIcon } from '../ui/icons';
|
||||||
|
|
||||||
const SelectionSummary = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => {
|
interface SelectionSummaryProps {
|
||||||
|
documentCount?: number;
|
||||||
|
folderCount?: number;
|
||||||
|
totalCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectionSummary: React.FC<SelectionSummaryProps> = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => {
|
||||||
const docCount = Number(documentCount) || 0;
|
const docCount = Number(documentCount) || 0;
|
||||||
const folderCountNumber = Number(folderCount) || 0;
|
const folderCountNumber = Number(folderCount) || 0;
|
||||||
const aggregateCount = docCount + folderCountNumber;
|
const aggregateCount = docCount + folderCountNumber;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
|
||||||
|
export interface DocumentsFilterValue {
|
||||||
|
query: string;
|
||||||
|
searchResultIds: Array<string | number> | null;
|
||||||
|
searchLoading: boolean;
|
||||||
|
includeDescendants: boolean;
|
||||||
|
activeTagIds: Identifier[];
|
||||||
|
activeCorrespondentIds: Identifier[];
|
||||||
|
isActive: boolean;
|
||||||
|
setQuery: (value: string) => void;
|
||||||
|
submit: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
toggleTag: (tagId: Identifier) => void;
|
||||||
|
toggleCorrespondent: (correspondentId?: Identifier | null) => void;
|
||||||
|
toggleIncludeDescendants: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentsFilterContext = createContext<DocumentsFilterValue | null>(null);
|
||||||
|
|
||||||
|
interface DocumentsFilterProviderProps {
|
||||||
|
value: DocumentsFilterValue;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocumentsFilterProvider: React.FC<DocumentsFilterProviderProps> = ({ value, children }) => (
|
||||||
|
<DocumentsFilterContext.Provider value={value}>{children}</DocumentsFilterContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useDocumentsFilter = (): DocumentsFilterValue => {
|
||||||
|
const context = useContext(DocumentsFilterContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDocumentsFilter must be used within a DocumentsFilterProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentsFilterContext;
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
export const resolveCorrespondents = (doc) => {
|
|
||||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set();
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
doc.correspondents.forEach((entry = {}, index) => {
|
|
||||||
const { id, name } = entry;
|
|
||||||
if (typeof name !== 'string') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedName = name.trim();
|
|
||||||
if (!trimmedName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id && seen.has(id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
seen.add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
id,
|
|
||||||
name: trimmedName,
|
|
||||||
key: id ?? `${trimmedName}-${index}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default resolveCorrespondents;
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export interface CorrespondentReference {
|
||||||
|
id?: string | number | null;
|
||||||
|
name?: string | null;
|
||||||
|
key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentLike {
|
||||||
|
correspondents?: CorrespondentReference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCorrespondent {
|
||||||
|
id?: string | number | null;
|
||||||
|
name: string;
|
||||||
|
key: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveCorrespondents = (doc?: DocumentLike | null): ResolvedCorrespondent[] => {
|
||||||
|
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string | number>();
|
||||||
|
const results: ResolvedCorrespondent[] = [];
|
||||||
|
|
||||||
|
doc.correspondents.forEach((entry = {}, index) => {
|
||||||
|
const { id, name } = entry;
|
||||||
|
const trimmedName = name?.trim?.();
|
||||||
|
if (!trimmedName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id != null && seen.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id != null) {
|
||||||
|
seen.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
id,
|
||||||
|
name: trimmedName,
|
||||||
|
key: id ?? `${trimmedName}-${index}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default resolveCorrespondents;
|
||||||
+29
-11
@@ -1,25 +1,45 @@
|
|||||||
import { openOcrTextInNewTab } from '../utils/ocr';
|
import { openOcrTextInNewTab } from '../utils/ocr';
|
||||||
|
import type {
|
||||||
|
EnsureAssetUrl,
|
||||||
|
EnsurePreviewData,
|
||||||
|
GetDocumentAsset,
|
||||||
|
DocumentLike as OcrDocumentLike,
|
||||||
|
} from '../utils/ocr';
|
||||||
|
|
||||||
|
type ResolveApiPath = (path: string) => string;
|
||||||
|
|
||||||
|
export type DocumentLike = OcrDocumentLike;
|
||||||
|
|
||||||
const asyncFalse = async () => false;
|
const asyncFalse = async () => false;
|
||||||
|
|
||||||
const resolveDocumentDownloadHref = (document, resolveApiPath) => {
|
const resolveDocumentDownloadHref = (document?: DocumentLike | null, resolveApiPath?: ResolveApiPath | null): string | null => {
|
||||||
if (!document || typeof resolveApiPath !== 'function') {
|
if (!document || !resolveApiPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const downloadPath = document.current_version?.download_path;
|
const downloadPath = (document.current_version as { download_path?: string | null } | null)?.download_path;
|
||||||
if (!downloadPath) {
|
if (!downloadPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return resolveApiPath(downloadPath);
|
return resolveApiPath(downloadPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasDocumentOcrAsset = (document, getDocumentAsset) => {
|
const hasDocumentOcrAsset = (document?: DocumentLike | null, getDocumentAsset?: GetDocumentAsset | null): boolean => {
|
||||||
if (!document || typeof getDocumentAsset !== 'function') {
|
if (!document || !getDocumentAsset) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return Boolean(getDocumentAsset(document, 'ocr-text'));
|
return Boolean(getDocumentAsset(document, 'ocr-text'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface CreateDocumentActionStateArgs {
|
||||||
|
document: DocumentLike | null;
|
||||||
|
resolveApiPath?: ResolveApiPath | null;
|
||||||
|
ensurePreviewData: EnsurePreviewData;
|
||||||
|
ensureAssetUrl: EnsureAssetUrl;
|
||||||
|
getDocumentAsset?: GetDocumentAsset | null;
|
||||||
|
notifyApiError?: (error: unknown, message: string) => void;
|
||||||
|
ocrErrorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const createDocumentActionState = ({
|
export const createDocumentActionState = ({
|
||||||
document,
|
document,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
@@ -28,7 +48,7 @@ export const createDocumentActionState = ({
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
ocrErrorMessage = 'Unable to open OCR text.',
|
ocrErrorMessage = 'Unable to open OCR text.',
|
||||||
}) => {
|
}: CreateDocumentActionStateArgs) => {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return {
|
return {
|
||||||
downloadHref: null,
|
downloadHref: null,
|
||||||
@@ -49,14 +69,12 @@ export const createDocumentActionState = ({
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
});
|
});
|
||||||
if (!success && typeof notifyApiError === 'function') {
|
if (!success) {
|
||||||
notifyApiError(new Error('OCR text URL unavailable.'), ocrErrorMessage);
|
notifyApiError?.(new Error('OCR text URL unavailable.'), ocrErrorMessage);
|
||||||
}
|
}
|
||||||
return success;
|
return success;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof notifyApiError === 'function') {
|
notifyApiError?.(error, ocrErrorMessage);
|
||||||
notifyApiError(error, ocrErrorMessage);
|
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { formatDateTime } from '../utils/date';
|
|
||||||
|
|
||||||
export const buildDocumentMetadataItems = (document) => {
|
|
||||||
if (!document) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = document.current_version || {};
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ label: 'Created at', value: formatDateTime(document.created_at) },
|
|
||||||
{ label: 'Updated at', value: formatDateTime(document.updated_at) },
|
|
||||||
{
|
|
||||||
label: 'Filename',
|
|
||||||
value: document.filename,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Original filename',
|
|
||||||
value: document.original_name || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'SHA-256 checksum',
|
|
||||||
value: metadata.checksum || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Content type',
|
|
||||||
value: document.content_type || '—',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const extractDocumentMetadataPayload = (document) => {
|
|
||||||
if (!document || !document.metadata) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const keys = Object.keys(document.metadata);
|
|
||||||
if (!keys.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return document.metadata;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default buildDocumentMetadataItems;
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { formatFileSize } from '../utils/format';
|
|
||||||
import { formatDateTime as defaultFormatDateTime } from '../utils/date';
|
|
||||||
|
|
||||||
const coercePageCount = (metadata) => {
|
|
||||||
const raw = metadata?.page_count;
|
|
||||||
if (typeof raw === 'number') {
|
|
||||||
return Number.isFinite(raw) && raw >= 0 ? raw : null;
|
|
||||||
}
|
|
||||||
if (raw != null && raw !== '') {
|
|
||||||
const parsed = Number.parseInt(raw, 10);
|
|
||||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeTags = (tags) => {
|
|
||||||
if (!Array.isArray(tags)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return tags.filter(Boolean);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeCorrespondents = (entries) => {
|
|
||||||
if (!Array.isArray(entries)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return entries.filter(Boolean);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const describeDocumentSummary = (document, options = {}) => {
|
|
||||||
if (!document) {
|
|
||||||
return {
|
|
||||||
title: '',
|
|
||||||
originalName: '',
|
|
||||||
mimeTypeLabel: '—',
|
|
||||||
sizeLabel: '—',
|
|
||||||
createdAtLabel: '—',
|
|
||||||
issuedLabel: '—',
|
|
||||||
updatedAtLabel: '—',
|
|
||||||
pageCount: null,
|
|
||||||
pageCountLabel: '—',
|
|
||||||
folderLabel: null,
|
|
||||||
tags: [],
|
|
||||||
correspondents: [],
|
|
||||||
tagsSummary: '—',
|
|
||||||
correspondentsSummary: '—',
|
|
||||||
summaryRows: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
formatDateTime = defaultFormatDateTime,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const originalName = document.original_name;
|
|
||||||
const mimeTypeLabel = document.content_type || 'Unknown';
|
|
||||||
|
|
||||||
const sizeBytes = Number(document.current_version?.size_bytes);
|
|
||||||
const sizeLabel = Number.isFinite(sizeBytes) && sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
|
|
||||||
|
|
||||||
const metadata = document.current_version?.metadata || null;
|
|
||||||
const pageCount = coercePageCount(metadata);
|
|
||||||
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
|
|
||||||
|
|
||||||
const createdAtLabel = formatDateTime(document.created_at);
|
|
||||||
const issuedLabel = formatDateTime(document.issued_at);
|
|
||||||
const updatedAtLabel = formatDateTime(document.updated_at);
|
|
||||||
|
|
||||||
const folderLabel = document.folder_path;
|
|
||||||
|
|
||||||
const tags = sanitizeTags(document.tags);
|
|
||||||
const correspondents = sanitizeCorrespondents(document.correspondents);
|
|
||||||
|
|
||||||
const tagLabels = tags.map((tag) => tag.label).filter(Boolean);
|
|
||||||
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean);
|
|
||||||
|
|
||||||
const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—';
|
|
||||||
const correspondentsSummary = correspondentLabels.length
|
|
||||||
? correspondentLabels.join(', ')
|
|
||||||
: '—';
|
|
||||||
|
|
||||||
const summaryRows = [
|
|
||||||
{ key: 'created', label: 'Created', value: createdAtLabel },
|
|
||||||
{ key: 'size', label: 'Size', value: sizeLabel },
|
|
||||||
{ key: 'type', label: 'Type', value: mimeTypeLabel },
|
|
||||||
{ key: 'issued', label: 'Issued', value: issuedLabel },
|
|
||||||
{ key: 'pages', label: 'Pages', value: pageCountLabel },
|
|
||||||
{ key: 'updated', label: 'Updated', value: updatedAtLabel },
|
|
||||||
{ key: 'folder', label: 'Folder', value: folderLabel },
|
|
||||||
{ key: 'tags', label: 'Tags', value: tagsSummary },
|
|
||||||
{ key: 'correspondents', label: 'Correspondents', value: correspondentsSummary },
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: document.title,
|
|
||||||
originalName,
|
|
||||||
mimeTypeLabel,
|
|
||||||
sizeLabel,
|
|
||||||
createdAtLabel,
|
|
||||||
issuedLabel,
|
|
||||||
updatedAtLabel,
|
|
||||||
pageCount,
|
|
||||||
pageCountLabel,
|
|
||||||
folderLabel,
|
|
||||||
tags,
|
|
||||||
correspondents,
|
|
||||||
tagsSummary,
|
|
||||||
correspondentsSummary,
|
|
||||||
summaryRows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { formatFileSize } from '../utils/format';
|
||||||
|
import { formatDateTime as defaultFormatDateTime } from '../utils/date';
|
||||||
|
|
||||||
|
interface DocumentPageMetadata {
|
||||||
|
page_count?: number | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentVersion {
|
||||||
|
size_bytes?: number | string | null;
|
||||||
|
metadata?: DocumentPageMetadata | null;
|
||||||
|
checksum?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagEntry {
|
||||||
|
label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CorrespondentEntry {
|
||||||
|
name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryDocument {
|
||||||
|
title?: string | null;
|
||||||
|
original_name?: string | null;
|
||||||
|
filename?: string | null;
|
||||||
|
content_type?: string | null;
|
||||||
|
current_version?: DocumentVersion | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
issued_at?: string | null;
|
||||||
|
folder_path?: string | null;
|
||||||
|
tags?: TagEntry[] | null;
|
||||||
|
correspondents?: CorrespondentEntry[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DescribeSummaryOptions {
|
||||||
|
formatDateTime?: typeof defaultFormatDateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents';
|
||||||
|
|
||||||
|
export interface DocumentSummaryRow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
kind?: DocumentSummaryRowType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocumentSummary = DocumentSummaryRow[];
|
||||||
|
|
||||||
|
const coercePageCount = (metadata?: DocumentPageMetadata | null): number | null => {
|
||||||
|
const raw = metadata?.page_count;
|
||||||
|
if (raw == null || raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(String(raw), 10);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeArray = <T>(entries?: Array<T | null> | null): T[] =>
|
||||||
|
Array.isArray(entries) ? entries.filter(Boolean) as T[] : [];
|
||||||
|
|
||||||
|
interface DocumentMetadataPayload {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetadataDocumentLike {
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
filename?: string | null;
|
||||||
|
original_name?: string | null;
|
||||||
|
content_type?: string | null;
|
||||||
|
metadata?: DocumentMetadataPayload | null;
|
||||||
|
current_version?: { checksum?: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const describeDocumentSummary = (document?: SummaryDocument | null, options: DescribeSummaryOptions = {}): DocumentSummary => {
|
||||||
|
const {
|
||||||
|
formatDateTime = defaultFormatDateTime,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const formatDateLabel = (value?: string | null) => formatDateTime(value) || '—';
|
||||||
|
const doc = document ?? {};
|
||||||
|
const sizeBytes = Number(doc.current_version?.size_bytes);
|
||||||
|
const sizeLabel = Number.isFinite(sizeBytes) && sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
|
||||||
|
const metadata = doc.current_version?.metadata || null;
|
||||||
|
const pageCount = coercePageCount(metadata);
|
||||||
|
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
|
||||||
|
const tags = sanitizeArray<TagEntry>(doc.tags);
|
||||||
|
const correspondents = sanitizeArray<CorrespondentEntry>(doc.correspondents);
|
||||||
|
const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[];
|
||||||
|
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean) as string[];
|
||||||
|
const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—';
|
||||||
|
const correspondentsSummary = correspondentLabels.length ? correspondentLabels.join(', ') : '—';
|
||||||
|
return [
|
||||||
|
{ key: 'title', label: 'Title', value: doc.title ?? null, kind: 'editable-title' },
|
||||||
|
{ key: 'tags', label: 'Tags', value: tagsSummary, kind: 'tags' },
|
||||||
|
{ key: 'correspondents', label: 'Correspondents', value: correspondentsSummary, kind: 'correspondents' },
|
||||||
|
{ key: 'issued', label: 'Issued', value: formatDateLabel(doc.issued_at), kind: 'editable-issued' },
|
||||||
|
{ key: 'created', label: 'Created at', value: formatDateLabel(doc.created_at) },
|
||||||
|
{ key: 'updated', label: 'Updated at', value: formatDateLabel(doc.updated_at) },
|
||||||
|
{ key: 'size', label: 'Size', value: sizeLabel },
|
||||||
|
{ key: 'content-type', label: 'Content type', value: doc.content_type || 'Unknown' },
|
||||||
|
{ key: 'pages', label: 'Pages', value: pageCountLabel },
|
||||||
|
{ key: 'filename', label: 'Filename', value: doc.filename },
|
||||||
|
{ key: 'original-filename', label: 'Original filename', value: doc.original_name },
|
||||||
|
{ key: 'checksum', label: 'SHA-256 checksum', value: doc.current_version?.checksum },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractDocumentMetadataPayload = (document?: MetadataDocumentLike | null): DocumentMetadataPayload | null => {
|
||||||
|
if (!document?.metadata) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const keys = Object.keys(document.metadata);
|
||||||
|
if (!keys.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return document.metadata;
|
||||||
|
};
|
||||||
+73
-13
@@ -1,11 +1,41 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
export type Identifier = string | number;
|
||||||
|
|
||||||
|
type ApiClient = {
|
||||||
|
post: <T = { data: unknown }>(url: string, payload: unknown) => Promise<{ data: T } | T>;
|
||||||
|
delete: (url: string) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BulkAssignmentResponse = {
|
||||||
|
assigned?: number;
|
||||||
|
removed?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CorrespondentAssignment = {
|
||||||
|
correspondent_id?: Identifier;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseBulkDocumentActionsArgs {
|
||||||
|
api: ApiClient;
|
||||||
|
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
|
||||||
|
correspondentLookupByName: Map<string, { id?: Identifier }>;
|
||||||
|
handleCorrespondentCreate: (payload: { name: string }) => Promise<{ id?: Identifier } | null>;
|
||||||
|
setStatusMessage: (message: string, variant?: string) => void;
|
||||||
|
selectedDocumentIds?: Identifier[];
|
||||||
|
selectedFolderIds?: Identifier[];
|
||||||
|
handleDocumentsDelete: (ids: Identifier[], options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise<boolean>;
|
||||||
|
handleFolderDelete: (id: Identifier, options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise<boolean>;
|
||||||
|
clearDocumentSelection: () => void;
|
||||||
|
setLoading: (value: boolean) => void;
|
||||||
|
updateDocumentCaches?: (id: Identifier, updater: (doc: any) => any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const useBulkDocumentActions = ({
|
const useBulkDocumentActions = ({
|
||||||
api,
|
api,
|
||||||
resolveTargetDocumentIds,
|
resolveTargetDocumentIds,
|
||||||
correspondentLookupByName,
|
correspondentLookupByName,
|
||||||
handleCorrespondentCreate,
|
handleCorrespondentCreate,
|
||||||
refreshCurrentFolder,
|
|
||||||
setStatusMessage,
|
setStatusMessage,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
selectedFolderIds,
|
selectedFolderIds,
|
||||||
@@ -13,10 +43,11 @@ const useBulkDocumentActions = ({
|
|||||||
handleFolderDelete,
|
handleFolderDelete,
|
||||||
clearDocumentSelection,
|
clearDocumentSelection,
|
||||||
setLoading,
|
setLoading,
|
||||||
}) => {
|
updateDocumentCaches,
|
||||||
|
}: UseBulkDocumentActionsArgs) => {
|
||||||
const handleBulkCorrespondentAdd = useCallback(
|
const handleBulkCorrespondentAdd = useCallback(
|
||||||
async ({ name, input, documentIds }) => {
|
async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||||
const trimmed = typeof name === 'string' ? name.trim() : '';
|
const trimmed = name?.trim?.() || '';
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setStatusMessage('Correspondent name is required.', 'error');
|
setStatusMessage('Correspondent name is required.', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -41,7 +72,7 @@ const useBulkDocumentActions = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.post('/documents/bulk/correspondents', {
|
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
|
||||||
document_ids: targets,
|
document_ids: targets,
|
||||||
assignments: [
|
assignments: [
|
||||||
{
|
{
|
||||||
@@ -51,9 +82,23 @@ const useBulkDocumentActions = ({
|
|||||||
action: 'add',
|
action: 'add',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { assigned = 0, removed = 0 } = response.data || {};
|
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
|
||||||
|
|
||||||
await refreshCurrentFolder();
|
if (updateDocumentCaches && target.id) {
|
||||||
|
targets.forEach((docId) => {
|
||||||
|
updateDocumentCaches(docId, (doc) => {
|
||||||
|
if (!doc) return doc;
|
||||||
|
const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : [];
|
||||||
|
if (current.some((entry: any) => entry?.id === target.id)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(doc as any),
|
||||||
|
correspondents: [...current, { id: target.id, name: (target as any).name }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
const assignedSuffix = assigned === 1 ? '' : 's';
|
const assignedSuffix = assigned === 1 ? '' : 's';
|
||||||
if (removed > 0) {
|
if (removed > 0) {
|
||||||
const removedSuffix = removed === 1 ? '' : 's';
|
const removedSuffix = removed === 1 ? '' : 's';
|
||||||
@@ -76,14 +121,14 @@ const useBulkDocumentActions = ({
|
|||||||
api,
|
api,
|
||||||
correspondentLookupByName,
|
correspondentLookupByName,
|
||||||
handleCorrespondentCreate,
|
handleCorrespondentCreate,
|
||||||
refreshCurrentFolder,
|
|
||||||
resolveTargetDocumentIds,
|
resolveTargetDocumentIds,
|
||||||
setStatusMessage,
|
setStatusMessage,
|
||||||
|
updateDocumentCaches,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBulkCorrespondentRemove = useCallback(
|
const handleBulkCorrespondentRemove = useCallback(
|
||||||
async ({ assignments = [], documentIds }) => {
|
async ({ assignments = [], documentIds }: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => {
|
||||||
if (!assignments.length) {
|
if (!assignments.length) {
|
||||||
setStatusMessage('Select a correspondent to remove.', 'error');
|
setStatusMessage('Select a correspondent to remove.', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -100,14 +145,29 @@ const useBulkDocumentActions = ({
|
|||||||
correspondent_id: entry.correspondent_id,
|
correspondent_id: entry.correspondent_id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const response = await api.post('/documents/bulk/correspondents', {
|
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
|
||||||
document_ids: targets,
|
document_ids: targets,
|
||||||
assignments: normalizedAssignments,
|
assignments: normalizedAssignments,
|
||||||
action: 'remove',
|
action: 'remove',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { assigned = 0, removed = 0 } = response.data || {};
|
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
|
||||||
await refreshCurrentFolder();
|
if (updateDocumentCaches) {
|
||||||
|
targets.forEach((docId) => {
|
||||||
|
updateDocumentCaches(docId, (doc) => {
|
||||||
|
if (!doc || !Array.isArray((doc as any).correspondents)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const filtered = (doc as any).correspondents.filter(
|
||||||
|
(entry: any) =>
|
||||||
|
entry && !normalizedAssignments.some((assignment) => assignment.correspondent_id === entry.id),
|
||||||
|
);
|
||||||
|
return filtered.length === (doc as any).correspondents.length
|
||||||
|
? doc
|
||||||
|
: { ...(doc as any), correspondents: filtered };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (removed > 0) {
|
if (removed > 0) {
|
||||||
const removedSuffix = removed === 1 ? '' : 's';
|
const removedSuffix = removed === 1 ? '' : 's';
|
||||||
@@ -122,7 +182,7 @@ const useBulkDocumentActions = ({
|
|||||||
setStatusMessage('No correspondents changed.', 'info');
|
setStatusMessage('No correspondents changed.', 'info');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage],
|
[api, resolveTargetDocumentIds, setStatusMessage, updateDocumentCaches],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteSelection = useCallback(async () => {
|
const handleDeleteSelection = useCallback(async () => {
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
const useDocumentsPanelProps = ({
|
|
||||||
currentFolderName,
|
|
||||||
breadcrumbs,
|
|
||||||
refreshCurrentFolder,
|
|
||||||
currentSubfolders,
|
|
||||||
documents,
|
|
||||||
searchResults,
|
|
||||||
isFilterActive,
|
|
||||||
folderClickHandlers,
|
|
||||||
selectFolder,
|
|
||||||
handleFolderDragStart,
|
|
||||||
handleFolderDragEnd,
|
|
||||||
draggedFolderId,
|
|
||||||
handleFolderRename,
|
|
||||||
openDocumentPreview,
|
|
||||||
handleDocumentTitleUpdate,
|
|
||||||
selectedDocumentIds,
|
|
||||||
selectedFolderIds,
|
|
||||||
focusedRowKey,
|
|
||||||
draggedDocumentIds,
|
|
||||||
handleDocumentDragStart,
|
|
||||||
handleDocumentDragEnd,
|
|
||||||
searchLoading,
|
|
||||||
tagLookupById,
|
|
||||||
activeCorrespondentFilters,
|
|
||||||
selectedEntries,
|
|
||||||
setFocusedRowKey,
|
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
toggleTagFilter,
|
|
||||||
toggleCorrespondentFilter,
|
|
||||||
handleDocumentTagDrop,
|
|
||||||
documentsViewMode,
|
|
||||||
documentsSortField,
|
|
||||||
documentsSortDirection,
|
|
||||||
handleDocumentsSortFieldChange,
|
|
||||||
handleDocumentsSortDirectionToggle,
|
|
||||||
searchIncludeDescendants,
|
|
||||||
toggleSearchIncludeDescendants,
|
|
||||||
handleDocumentsViewModeChange,
|
|
||||||
clearDocumentSelection,
|
|
||||||
handleDeleteSelection,
|
|
||||||
handleEntryPointerCore,
|
|
||||||
inspectDocument,
|
|
||||||
handleEntrySelection,
|
|
||||||
tags,
|
|
||||||
correspondents,
|
|
||||||
documentLookup,
|
|
||||||
handleBulkTagAddFromDetail,
|
|
||||||
handleBulkTagRemoveFromDetail,
|
|
||||||
handleBulkCorrespondentAdd,
|
|
||||||
handleBulkCorrespondentRemove,
|
|
||||||
handleBulkSelectionReanalyze,
|
|
||||||
folderOptions,
|
|
||||||
moveDocumentsToFolder,
|
|
||||||
}) =>
|
|
||||||
useMemo(
|
|
||||||
() => ({
|
|
||||||
currentFolderName,
|
|
||||||
breadcrumbs,
|
|
||||||
onRefresh: refreshCurrentFolder,
|
|
||||||
subfolders: currentSubfolders,
|
|
||||||
documents,
|
|
||||||
searchResults,
|
|
||||||
isFilterActive,
|
|
||||||
onFolderSelect: selectFolder,
|
|
||||||
onFolderDrop: folderClickHandlers.onDrop,
|
|
||||||
onFolderDragOver: folderClickHandlers.onDragOver,
|
|
||||||
onFolderDragLeave: folderClickHandlers.onDragLeave,
|
|
||||||
onFolderDragStart: handleFolderDragStart,
|
|
||||||
onFolderDragEnd: handleFolderDragEnd,
|
|
||||||
draggedFolderId,
|
|
||||||
onFolderRename: handleFolderRename,
|
|
||||||
onDocumentOpen: openDocumentPreview,
|
|
||||||
onDocumentRename: handleDocumentTitleUpdate,
|
|
||||||
selectedDocumentIds,
|
|
||||||
selectedFolderIds,
|
|
||||||
focusedRowKey,
|
|
||||||
draggingDocumentIds: draggedDocumentIds,
|
|
||||||
onDocumentDragStart: handleDocumentDragStart,
|
|
||||||
onDocumentDragEnd: handleDocumentDragEnd,
|
|
||||||
isSearchLoading: searchLoading,
|
|
||||||
tagLookupById,
|
|
||||||
activeCorrespondentIds: activeCorrespondentFilters,
|
|
||||||
selectedEntries,
|
|
||||||
onFocusedRowChange: setFocusedRowKey,
|
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
onTagClick: toggleTagFilter,
|
|
||||||
onCorrespondentClick: toggleCorrespondentFilter,
|
|
||||||
onDocumentTagDrop: handleDocumentTagDrop,
|
|
||||||
viewMode: documentsViewMode,
|
|
||||||
sortField: documentsSortField,
|
|
||||||
sortDirection: documentsSortDirection,
|
|
||||||
onSortFieldChange: handleDocumentsSortFieldChange,
|
|
||||||
onSortDirectionToggle: handleDocumentsSortDirectionToggle,
|
|
||||||
searchIncludeDescendants,
|
|
||||||
onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
|
|
||||||
onViewModeChange: handleDocumentsViewModeChange,
|
|
||||||
onClearSelection: clearDocumentSelection,
|
|
||||||
onDeleteSelection: handleDeleteSelection,
|
|
||||||
onEntryPointer: handleEntryPointerCore,
|
|
||||||
onInspectDocument: inspectDocument,
|
|
||||||
onEntrySelection: handleEntrySelection,
|
|
||||||
tags,
|
|
||||||
correspondents,
|
|
||||||
documentLookup,
|
|
||||||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
|
||||||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
|
||||||
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
|
||||||
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
|
||||||
onBulkReanalyze: handleBulkSelectionReanalyze,
|
|
||||||
folderOptions,
|
|
||||||
onMoveDocumentsToFolder: moveDocumentsToFolder,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
activeCorrespondentFilters,
|
|
||||||
breadcrumbs,
|
|
||||||
clearDocumentSelection,
|
|
||||||
correspondents,
|
|
||||||
currentFolderName,
|
|
||||||
currentSubfolders,
|
|
||||||
documents,
|
|
||||||
documentsSortDirection,
|
|
||||||
documentsSortField,
|
|
||||||
documentsViewMode,
|
|
||||||
documentLookup,
|
|
||||||
draggedDocumentIds,
|
|
||||||
draggedFolderId,
|
|
||||||
focusedRowKey,
|
|
||||||
folderClickHandlers,
|
|
||||||
handleBulkCorrespondentAdd,
|
|
||||||
handleBulkCorrespondentRemove,
|
|
||||||
handleBulkSelectionReanalyze,
|
|
||||||
handleBulkTagAddFromDetail,
|
|
||||||
handleBulkTagRemoveFromDetail,
|
|
||||||
handleDeleteSelection,
|
|
||||||
handleDocumentDragEnd,
|
|
||||||
handleDocumentDragStart,
|
|
||||||
handleDocumentTagDrop,
|
|
||||||
handleDocumentTitleUpdate,
|
|
||||||
handleDocumentsSortDirectionToggle,
|
|
||||||
handleDocumentsSortFieldChange,
|
|
||||||
handleDocumentsViewModeChange,
|
|
||||||
handleEntryPointerCore,
|
|
||||||
handleEntrySelection,
|
|
||||||
handleFolderDragEnd,
|
|
||||||
handleFolderDragStart,
|
|
||||||
handleFolderRename,
|
|
||||||
inspectDocument,
|
|
||||||
isFilterActive,
|
|
||||||
moveDocumentsToFolder,
|
|
||||||
openDocumentPreview,
|
|
||||||
refreshCurrentFolder,
|
|
||||||
searchIncludeDescendants,
|
|
||||||
searchLoading,
|
|
||||||
searchResults,
|
|
||||||
selectedDocumentIds,
|
|
||||||
selectedEntries,
|
|
||||||
selectedFolderIds,
|
|
||||||
selectFolder,
|
|
||||||
setFocusedRowKey,
|
|
||||||
tagLookupById,
|
|
||||||
tags,
|
|
||||||
toggleCorrespondentFilter,
|
|
||||||
toggleSearchIncludeDescendants,
|
|
||||||
toggleTagFilter,
|
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
folderOptions,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export default useDocumentsPanelProps;
|
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { WorkspaceSelectionValue } from '../../app/WorkspaceSelectionContext';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
|
||||||
|
interface DocumentLinkLike {
|
||||||
|
url?: string | null;
|
||||||
|
contentType?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Breadcrumb {
|
||||||
|
id?: Identifier;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FolderClickHandlers {
|
||||||
|
onDrop?: (...args: unknown[]) => void;
|
||||||
|
onDragOver?: (...args: unknown[]) => void;
|
||||||
|
onDragLeave?: (...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDocumentsPanelPropsArgs {
|
||||||
|
currentFolderName?: string | null;
|
||||||
|
breadcrumbs?: Breadcrumb[];
|
||||||
|
refreshCurrentFolder?: () => void | Promise<void>;
|
||||||
|
currentSubfolders?: unknown[];
|
||||||
|
documents?: unknown[];
|
||||||
|
searchResultIds?: Identifier[] | null;
|
||||||
|
folderClickHandlers: FolderClickHandlers;
|
||||||
|
selectFolder?: (...args: unknown[]) => void;
|
||||||
|
handleFolderDragStart?: (...args: unknown[]) => void;
|
||||||
|
handleFolderDragEnd?: (...args: unknown[]) => void;
|
||||||
|
draggedFolderId?: Identifier | null;
|
||||||
|
handleFolderRename?: (...args: unknown[]) => void;
|
||||||
|
openDocumentPreview?: (...args: unknown[]) => void;
|
||||||
|
handleDocumentTitleUpdate?: (...args: unknown[]) => void;
|
||||||
|
focusedRowKey?: Identifier | string | null;
|
||||||
|
draggedDocumentIds?: Identifier[];
|
||||||
|
handleDocumentDragStart?: (...args: unknown[]) => void;
|
||||||
|
handleDocumentDragEnd?: (...args: unknown[]) => void;
|
||||||
|
searchLoading?: boolean;
|
||||||
|
tagLookupById?: unknown;
|
||||||
|
activeCorrespondentFilters?: Identifier[];
|
||||||
|
ensureAssetUrl?: (...args: unknown[]) => void;
|
||||||
|
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||||
|
handleDocumentTagDrop?: (...args: unknown[]) => void;
|
||||||
|
documentsViewMode?: string;
|
||||||
|
documentsSortField?: string;
|
||||||
|
documentsSortDirection?: string;
|
||||||
|
handleDocumentsSortFieldChange?: (field: string) => void;
|
||||||
|
handleDocumentsSortDirectionToggle?: () => void;
|
||||||
|
handleDocumentsViewModeChange?: (mode: string) => void;
|
||||||
|
clearDocumentSelection?: () => void;
|
||||||
|
handleDeleteSelection?: () => void;
|
||||||
|
handleEntryPointerCore?: (...args: unknown[]) => void;
|
||||||
|
inspectDocument?: (docId: Identifier | null, metadata?: unknown) => void;
|
||||||
|
tags?: unknown[];
|
||||||
|
correspondents?: unknown[];
|
||||||
|
documentLookup?: unknown;
|
||||||
|
handleBulkTagAddFromDetail?: (...args: unknown[]) => void;
|
||||||
|
handleBulkTagRemoveFromDetail?: (...args: unknown[]) => void;
|
||||||
|
handleBulkCorrespondentAdd?: (...args: unknown[]) => void;
|
||||||
|
handleBulkCorrespondentRemove?: (...args: unknown[]) => void;
|
||||||
|
handleBulkSelectionReanalyze?: (...args: unknown[]) => void;
|
||||||
|
folderOptions?: unknown[];
|
||||||
|
moveDocumentsToFolder?: (...args: unknown[]) => void;
|
||||||
|
documentLinks?: Map<Identifier, DocumentLinkLike>;
|
||||||
|
ensureDownloadUrl?: (documentId: Identifier, options?: { force?: boolean }) => Promise<DocumentLinkLike | null>;
|
||||||
|
selectionValue: WorkspaceSelectionValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocumentsPanelProps = ReturnType<typeof useDocumentsPanelProps>;
|
||||||
|
|
||||||
|
const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
||||||
|
const {
|
||||||
|
currentFolderName,
|
||||||
|
breadcrumbs,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
currentSubfolders,
|
||||||
|
documents,
|
||||||
|
searchResultIds,
|
||||||
|
folderClickHandlers,
|
||||||
|
selectFolder,
|
||||||
|
handleFolderDragStart,
|
||||||
|
handleFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
|
handleFolderRename,
|
||||||
|
openDocumentPreview,
|
||||||
|
handleDocumentTitleUpdate,
|
||||||
|
focusedRowKey,
|
||||||
|
draggedDocumentIds,
|
||||||
|
handleDocumentDragStart,
|
||||||
|
handleDocumentDragEnd,
|
||||||
|
searchLoading,
|
||||||
|
tagLookupById,
|
||||||
|
activeCorrespondentFilters,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
handleDocumentTagDrop,
|
||||||
|
documentsViewMode,
|
||||||
|
documentsSortField,
|
||||||
|
documentsSortDirection,
|
||||||
|
handleDocumentsSortFieldChange,
|
||||||
|
handleDocumentsSortDirectionToggle,
|
||||||
|
handleDocumentsViewModeChange,
|
||||||
|
handleDeleteSelection,
|
||||||
|
handleEntryPointerCore,
|
||||||
|
inspectDocument,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
handleBulkTagAddFromDetail,
|
||||||
|
handleBulkTagRemoveFromDetail,
|
||||||
|
handleBulkCorrespondentAdd,
|
||||||
|
handleBulkCorrespondentRemove,
|
||||||
|
handleBulkSelectionReanalyze,
|
||||||
|
folderOptions,
|
||||||
|
moveDocumentsToFolder,
|
||||||
|
documentLinks,
|
||||||
|
ensureDownloadUrl,
|
||||||
|
selectionValue,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
currentFolderName,
|
||||||
|
breadcrumbs,
|
||||||
|
onRefresh: refreshCurrentFolder,
|
||||||
|
subfolders: currentSubfolders,
|
||||||
|
documents,
|
||||||
|
searchResultIds,
|
||||||
|
onFolderSelect: selectFolder,
|
||||||
|
onFolderDrop: folderClickHandlers.onDrop,
|
||||||
|
onFolderDragOver: folderClickHandlers.onDragOver,
|
||||||
|
onFolderDragLeave: folderClickHandlers.onDragLeave,
|
||||||
|
onFolderDragStart: handleFolderDragStart,
|
||||||
|
onFolderDragEnd: handleFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
|
onFolderRename: handleFolderRename,
|
||||||
|
onDocumentOpen: openDocumentPreview,
|
||||||
|
onDocumentRename: handleDocumentTitleUpdate,
|
||||||
|
focusedRowKey,
|
||||||
|
draggingDocumentIds: draggedDocumentIds,
|
||||||
|
onDocumentDragStart: handleDocumentDragStart,
|
||||||
|
onDocumentDragEnd: handleDocumentDragEnd,
|
||||||
|
isSearchLoading: searchLoading,
|
||||||
|
tagLookupById,
|
||||||
|
activeCorrespondentIds: activeCorrespondentFilters,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
onDocumentTagDrop: handleDocumentTagDrop,
|
||||||
|
viewMode: documentsViewMode,
|
||||||
|
sortField: documentsSortField,
|
||||||
|
sortDirection: documentsSortDirection,
|
||||||
|
onSortFieldChange: handleDocumentsSortFieldChange,
|
||||||
|
onSortDirectionToggle: handleDocumentsSortDirectionToggle,
|
||||||
|
onViewModeChange: handleDocumentsViewModeChange,
|
||||||
|
onDeleteSelection: handleDeleteSelection,
|
||||||
|
onEntryPointer: handleEntryPointerCore,
|
||||||
|
onInspectDocument: inspectDocument,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||||
|
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||||
|
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||||||
|
folderOptions,
|
||||||
|
onMoveDocumentsToFolder: moveDocumentsToFolder,
|
||||||
|
documentLinks,
|
||||||
|
ensureDownloadUrl,
|
||||||
|
selectionValue,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
activeCorrespondentFilters,
|
||||||
|
breadcrumbs,
|
||||||
|
correspondents,
|
||||||
|
currentFolderName,
|
||||||
|
currentSubfolders,
|
||||||
|
documents,
|
||||||
|
documentsSortDirection,
|
||||||
|
documentsSortField,
|
||||||
|
documentsViewMode,
|
||||||
|
documentLookup,
|
||||||
|
draggedDocumentIds,
|
||||||
|
draggedFolderId,
|
||||||
|
focusedRowKey,
|
||||||
|
folderClickHandlers,
|
||||||
|
handleBulkCorrespondentAdd,
|
||||||
|
handleBulkCorrespondentRemove,
|
||||||
|
handleBulkSelectionReanalyze,
|
||||||
|
handleBulkTagAddFromDetail,
|
||||||
|
handleBulkTagRemoveFromDetail,
|
||||||
|
handleDeleteSelection,
|
||||||
|
handleDocumentDragEnd,
|
||||||
|
handleDocumentDragStart,
|
||||||
|
handleDocumentTagDrop,
|
||||||
|
handleDocumentTitleUpdate,
|
||||||
|
handleDocumentsSortDirectionToggle,
|
||||||
|
handleDocumentsSortFieldChange,
|
||||||
|
handleDocumentsViewModeChange,
|
||||||
|
handleEntryPointerCore,
|
||||||
|
handleFolderDragEnd,
|
||||||
|
handleFolderDragStart,
|
||||||
|
handleFolderRename,
|
||||||
|
inspectDocument,
|
||||||
|
moveDocumentsToFolder,
|
||||||
|
openDocumentPreview,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
searchLoading,
|
||||||
|
searchResultIds,
|
||||||
|
selectFolder,
|
||||||
|
tagLookupById,
|
||||||
|
tags,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
folderOptions,
|
||||||
|
documentLinks,
|
||||||
|
ensureDownloadUrl,
|
||||||
|
selectionValue,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentsPanelProps;
|
||||||
+41
-5
@@ -1,5 +1,41 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
interface FolderEntry {
|
||||||
|
id: string | number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentEntry {
|
||||||
|
id: string | number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigableRow {
|
||||||
|
key: string;
|
||||||
|
type: 'folder' | 'document';
|
||||||
|
id: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDocumentsSelectionOptions {
|
||||||
|
showingSearchResults: boolean;
|
||||||
|
currentSubfolders: FolderEntry[];
|
||||||
|
visibleDocuments: DocumentEntry[];
|
||||||
|
resolveFolderRowKey: (id: string | number) => string | null;
|
||||||
|
resolveDocumentRowKey: (id: string | number) => string | null;
|
||||||
|
configureSelectionEnvironment: (config: { visibleRowKeySet: Set<string>; navigableRowKeys: string[] }) => void;
|
||||||
|
visibleRowKeySet: Set<string>;
|
||||||
|
selectedEntries: string[];
|
||||||
|
selectionAnchorRef: { current: string | null };
|
||||||
|
promoteSelectionOrderRaw: (id: string | number) => void;
|
||||||
|
setFocusedDocumentId: (id: string | number | null) => void;
|
||||||
|
setActivePreviewId: (id: string | number | null) => void;
|
||||||
|
clearSelection: () => void;
|
||||||
|
focusedDocumentId: string | number | null;
|
||||||
|
setFocusedRowKey: (value: string | null | ((current: string | null) => string | null)) => void;
|
||||||
|
focusedRowKey: string | null;
|
||||||
|
isFolderRowKey: (key: string | null) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const useDocumentsSelection = ({
|
const useDocumentsSelection = ({
|
||||||
showingSearchResults,
|
showingSearchResults,
|
||||||
currentSubfolders,
|
currentSubfolders,
|
||||||
@@ -18,9 +54,9 @@ const useDocumentsSelection = ({
|
|||||||
setFocusedRowKey,
|
setFocusedRowKey,
|
||||||
focusedRowKey,
|
focusedRowKey,
|
||||||
isFolderRowKey,
|
isFolderRowKey,
|
||||||
}) => {
|
}: UseDocumentsSelectionOptions) => {
|
||||||
const navigableRows = useMemo(() => {
|
const navigableRows = useMemo<NavigableRow[]>(() => {
|
||||||
const entries = [];
|
const entries: NavigableRow[] = [];
|
||||||
if (!showingSearchResults) {
|
if (!showingSearchResults) {
|
||||||
currentSubfolders.forEach((folder) => {
|
currentSubfolders.forEach((folder) => {
|
||||||
const key = resolveFolderRowKey(folder.id);
|
const key = resolveFolderRowKey(folder.id);
|
||||||
@@ -51,7 +87,7 @@ const useDocumentsSelection = ({
|
|||||||
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
|
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
|
||||||
|
|
||||||
const promoteSelectionOrder = useCallback(
|
const promoteSelectionOrder = useCallback(
|
||||||
(docId) => {
|
(docId: string | number | null) => {
|
||||||
if (!docId) return;
|
if (!docId) return;
|
||||||
promoteSelectionOrderRaw(docId);
|
promoteSelectionOrderRaw(docId);
|
||||||
const rowKey = resolveDocumentRowKey(docId);
|
const rowKey = resolveDocumentRowKey(docId);
|
||||||
@@ -68,7 +104,7 @@ const useDocumentsSelection = ({
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
}, [clearSelection]);
|
}, [clearSelection]);
|
||||||
|
|
||||||
const prevFocusedDocIdRef = useRef(focusedDocumentId);
|
const prevFocusedDocIdRef = useRef<string | number | null>(focusedDocumentId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previous = prevFocusedDocIdRef.current;
|
const previous = prevFocusedDocIdRef.current;
|
||||||
if (previous === focusedDocumentId) {
|
if (previous === focusedDocumentId) {
|
||||||
@@ -1,748 +0,0 @@
|
|||||||
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 currentFolderId = useMemo(() => {
|
|
||||||
if (showingSearchResults) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const trail = Array.isArray(breadcrumbs) ? breadcrumbs : [];
|
|
||||||
if (trail.length === 0) {
|
|
||||||
return 'root';
|
|
||||||
}
|
|
||||||
return trail[trail.length - 1]?.id || 'root';
|
|
||||||
}, [breadcrumbs, showingSearchResults]);
|
|
||||||
|
|
||||||
const selectionContextRef = useRef(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const nextContext = showingSearchResults
|
|
||||||
? { type: 'search', marker: searchResults }
|
|
||||||
: { type: 'folder', marker: currentFolderId || 'root' };
|
|
||||||
const previous = selectionContextRef.current;
|
|
||||||
selectionContextRef.current = nextContext;
|
|
||||||
if (!previous) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const changed = previous.type !== nextContext.type
|
|
||||||
|| previous.marker !== nextContext.marker;
|
|
||||||
if (changed) {
|
|
||||||
onClearSelection?.();
|
|
||||||
}
|
|
||||||
}, [showingSearchResults, currentFolderId, searchResults, onClearSelection]);
|
|
||||||
|
|
||||||
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 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) {
|
|
||||||
if (!selectedEntries.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (navigableRows.length) {
|
|
||||||
resolvedKey = navigableRows[0].key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolvedKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onFocusedRowChange?.(resolvedKey);
|
|
||||||
}, [
|
|
||||||
focusedRowKey,
|
|
||||||
navigableRowKeys,
|
|
||||||
navigableRows,
|
|
||||||
onFocusedRowChange,
|
|
||||||
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 =
|
|
||||||
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,876 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import DocumentsGrid from '../DocumentsGrid';
|
||||||
|
import DocumentsList from '../DocumentsList';
|
||||||
|
import DesktopWorkspace from '../../desktop/DesktopWorkspace';
|
||||||
|
import { isTagTransferEvent } from '../tagTransfer';
|
||||||
|
import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay';
|
||||||
|
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer';
|
||||||
|
import {
|
||||||
|
WorkspaceSelectionProvider,
|
||||||
|
useWorkspaceSelectionContext,
|
||||||
|
} from '../../app/WorkspaceSelectionContext';
|
||||||
|
import type { WorkspaceSelectionValue } from '../../app/WorkspaceSelectionContext';
|
||||||
|
import DocumentsPanelHeader, {
|
||||||
|
DocumentsPanelHeaderConfig,
|
||||||
|
DocumentsHeaderBreadcrumb,
|
||||||
|
} from './DocumentsPanelHeader';
|
||||||
|
import { SelectionFloatingPanel } from '../SelectionFloatingActions';
|
||||||
|
import { createDocumentsTableHeaderActions } from './DocumentsToolbar';
|
||||||
|
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
|
||||||
|
|
||||||
|
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||||
|
|
||||||
|
const EntryType = {
|
||||||
|
folder: 'folder',
|
||||||
|
document: 'document',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DocumentsPanelInnerProps {
|
||||||
|
headerLeading?: ReactNode;
|
||||||
|
onBreadcrumbNavigate?: (crumb: DocumentsHeaderBreadcrumb) => void;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentsPanelProps extends DocumentsPanelInnerProps {
|
||||||
|
selectionValue: WorkspaceSelectionValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultGetDocumentAsset = (_doc?: unknown, _type?: string) => null;
|
||||||
|
|
||||||
|
export type DocumentLinkLike = { url?: string | null; contentType?: string | null };
|
||||||
|
|
||||||
|
const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
||||||
|
headerLeading = null,
|
||||||
|
onBreadcrumbNavigate,
|
||||||
|
currentFolderName,
|
||||||
|
breadcrumbs,
|
||||||
|
subfolders,
|
||||||
|
documents,
|
||||||
|
searchResultIds,
|
||||||
|
onFolderSelect,
|
||||||
|
onFolderDrop,
|
||||||
|
onFolderDragOver,
|
||||||
|
onFolderDragLeave,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
|
onFolderRename,
|
||||||
|
draggingDocumentIds = [],
|
||||||
|
onDocumentDragStart,
|
||||||
|
onDocumentDragEnd,
|
||||||
|
onDocumentRename,
|
||||||
|
onEntryPointer = null,
|
||||||
|
onInspectDocument = null,
|
||||||
|
tagLookupById,
|
||||||
|
activeCorrespondentIds = [],
|
||||||
|
ensureAssetUrl = null,
|
||||||
|
getDocumentAsset = defaultGetDocumentAsset,
|
||||||
|
isSearchLoading = false,
|
||||||
|
onDocumentTagDrop,
|
||||||
|
viewMode = 'list',
|
||||||
|
onViewModeChange,
|
||||||
|
documentLinks,
|
||||||
|
ensureDownloadUrl,
|
||||||
|
deskWorkspaceProps = null,
|
||||||
|
onRefresh = () => {},
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
onSortFieldChange,
|
||||||
|
onSortDirectionToggle,
|
||||||
|
onDeleteSelection,
|
||||||
|
documentLookup,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
onBulkTagAdd,
|
||||||
|
onBulkTagRemove,
|
||||||
|
onBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze,
|
||||||
|
folderOptions,
|
||||||
|
onMoveDocumentsToFolder,
|
||||||
|
}): ReactNode => {
|
||||||
|
const {
|
||||||
|
selectedEntries,
|
||||||
|
focusedRowKey,
|
||||||
|
setFocusedRowKey,
|
||||||
|
handleEntrySelection,
|
||||||
|
clearSelection,
|
||||||
|
} = useWorkspaceSelectionContext();
|
||||||
|
const {
|
||||||
|
isActive: isFilterActive,
|
||||||
|
includeDescendants,
|
||||||
|
toggleIncludeDescendants,
|
||||||
|
toggleTag: toggleTagFilter,
|
||||||
|
toggleCorrespondent: toggleCorrespondentFilter,
|
||||||
|
} = useDocumentsFilter();
|
||||||
|
const searchDocuments = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.isArray(searchResultIds)
|
||||||
|
? searchResultIds
|
||||||
|
.map((id) => documentLookup?.get?.(id) || null)
|
||||||
|
.filter((doc): doc is Record<string, unknown> => Boolean(doc))
|
||||||
|
: null,
|
||||||
|
[searchResultIds, documentLookup],
|
||||||
|
);
|
||||||
|
const showingSearchResults = Array.isArray(searchResultIds);
|
||||||
|
const rows = showingSearchResults && searchDocuments ? searchDocuments : documents;
|
||||||
|
const documentLinkMap = documentLinks instanceof Map ? documentLinks : null;
|
||||||
|
const searchResultCount = Array.isArray(searchResultIds) ? searchResultIds.length : 0;
|
||||||
|
const headerTitle = showingSearchResults
|
||||||
|
? 'Search results'
|
||||||
|
: currentFolderName || 'Documents';
|
||||||
|
const headerSubtitle = showingSearchResults
|
||||||
|
? `${searchResultCount} matching document${searchResultCount === 1 ? '' : 's'}`
|
||||||
|
: null;
|
||||||
|
const headerActions = useMemo(
|
||||||
|
() => createDocumentsTableHeaderActions({
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onRefresh,
|
||||||
|
sortField,
|
||||||
|
onSortFieldChange,
|
||||||
|
sortDirection,
|
||||||
|
onSortDirectionToggle,
|
||||||
|
isFilterActive,
|
||||||
|
includeDescendants,
|
||||||
|
onToggleIncludeDescendants: toggleIncludeDescendants,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onRefresh,
|
||||||
|
sortField,
|
||||||
|
onSortFieldChange,
|
||||||
|
sortDirection,
|
||||||
|
onSortDirectionToggle,
|
||||||
|
isFilterActive,
|
||||||
|
includeDescendants,
|
||||||
|
toggleIncludeDescendants,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const floatingActions = useMemo(() => (
|
||||||
|
<SelectionFloatingPanel
|
||||||
|
documentLookup={documentLookup}
|
||||||
|
tags={tags}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
correspondents={correspondents}
|
||||||
|
onBulkTagAdd={onBulkTagAdd}
|
||||||
|
onBulkTagRemove={onBulkTagRemove}
|
||||||
|
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
|
||||||
|
onBulkCorrespondentRemove={onBulkCorrespondentRemove}
|
||||||
|
onBulkReanalyze={onBulkReanalyze}
|
||||||
|
onDeleteSelection={onDeleteSelection}
|
||||||
|
folderOptions={folderOptions}
|
||||||
|
onMoveDocumentsToFolder={onMoveDocumentsToFolder}
|
||||||
|
onClearSelection={clearSelection}
|
||||||
|
/>
|
||||||
|
), [
|
||||||
|
documentLookup,
|
||||||
|
tags,
|
||||||
|
tagLookupById,
|
||||||
|
correspondents,
|
||||||
|
onBulkTagAdd,
|
||||||
|
onBulkTagRemove,
|
||||||
|
onBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze,
|
||||||
|
onDeleteSelection,
|
||||||
|
folderOptions,
|
||||||
|
onMoveDocumentsToFolder,
|
||||||
|
clearSelection,
|
||||||
|
]);
|
||||||
|
const headerConfig: DocumentsPanelHeaderConfig = useMemo(() => ({
|
||||||
|
title: headerTitle,
|
||||||
|
subtitle: headerSubtitle,
|
||||||
|
leading: headerLeading,
|
||||||
|
actions: headerActions,
|
||||||
|
breadcrumbs,
|
||||||
|
floatingActions,
|
||||||
|
}), [
|
||||||
|
headerTitle,
|
||||||
|
headerSubtitle,
|
||||||
|
headerLeading,
|
||||||
|
headerActions,
|
||||||
|
breadcrumbs,
|
||||||
|
floatingActions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentFolderId = useMemo(() => {
|
||||||
|
if (showingSearchResults) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trail = Array.isArray(breadcrumbs) ? breadcrumbs : [];
|
||||||
|
if (trail.length === 0) {
|
||||||
|
return 'root';
|
||||||
|
}
|
||||||
|
return trail[trail.length - 1]?.id || 'root';
|
||||||
|
}, [breadcrumbs, showingSearchResults]);
|
||||||
|
|
||||||
|
const selectionContextRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const nextContext = showingSearchResults
|
||||||
|
? { type: 'search', marker: searchResultIds }
|
||||||
|
: { type: 'folder', marker: currentFolderId || 'root' };
|
||||||
|
const previous = selectionContextRef.current;
|
||||||
|
selectionContextRef.current = nextContext;
|
||||||
|
if (!previous) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const changed = previous.type !== nextContext.type
|
||||||
|
|| previous.marker !== nextContext.marker;
|
||||||
|
if (changed) {
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
}, [showingSearchResults, currentFolderId, searchResultIds, clearSelection]);
|
||||||
|
|
||||||
|
const entries = useMemo(() => {
|
||||||
|
const list = [];
|
||||||
|
if (!showingSearchResults) {
|
||||||
|
subfolders.forEach((folder) => {
|
||||||
|
if (!folder || !folder.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.forEach((doc) => {
|
||||||
|
if (!doc || !doc.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc });
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}, [showingSearchResults, subfolders, rows]);
|
||||||
|
|
||||||
|
const draggingSet = useMemo(
|
||||||
|
() => new Set(draggingDocumentIds || []),
|
||||||
|
[draggingDocumentIds],
|
||||||
|
);
|
||||||
|
const activeCorrespondentIdSet = useMemo(
|
||||||
|
() => new Set(activeCorrespondentIds || []),
|
||||||
|
[activeCorrespondentIds],
|
||||||
|
);
|
||||||
|
const scrollRef = useRef<HTMLElement | null>(null);
|
||||||
|
const suppressDocumentClickRef = useRef(false);
|
||||||
|
const isGridView = viewMode === 'grid';
|
||||||
|
const isDeskView = viewMode === 'desk';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
type ZoomSource = { url: string; alt?: string | null; contentType?: string | 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(() => {
|
||||||
|
if (previewDocId && !previewDoc) {
|
||||||
|
setPreviewDocId(null);
|
||||||
|
}
|
||||||
|
}, [previewDocId, previewDoc]);
|
||||||
|
|
||||||
|
const [previewZoomSource, setPreviewZoomSource] = useState<ZoomSource | null>(null);
|
||||||
|
const zoomDisplay = previewZoomSource;
|
||||||
|
const overlayDocument = useMemo(() => (
|
||||||
|
previewDoc && zoomDisplay?.url
|
||||||
|
? { ...previewDoc, documentLink: zoomDisplay }
|
||||||
|
: previewDoc
|
||||||
|
), [previewDoc, zoomDisplay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!previewDocId || !previewDoc) {
|
||||||
|
setPreviewZoomSource(null);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const docContentType = previewDoc.content_type;
|
||||||
|
const versionContentType = previewDoc.current_version?.version?.content_type;
|
||||||
|
const contentFallback = docContentType || versionContentType || null;
|
||||||
|
|
||||||
|
const applyEntry = (entry?: DocumentLinkLike | null) => {
|
||||||
|
if (!entry?.url) {
|
||||||
|
setPreviewZoomSource(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewZoomSource({
|
||||||
|
url: entry.url,
|
||||||
|
alt: previewDoc.title,
|
||||||
|
contentType: entry.contentType || contentFallback || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cachedEntry = documentLinkMap?.get(previewDocId) || null;
|
||||||
|
if (cachedEntry?.url) {
|
||||||
|
applyEntry(cachedEntry);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureDownloadUrl) {
|
||||||
|
setPreviewZoomSource(null);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDownloadUrl(previewDocId)
|
||||||
|
.then((entry) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyEntry(entry);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPreviewZoomSource(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [previewDocId, previewDoc, documentLinkMap, ensureDownloadUrl]);
|
||||||
|
|
||||||
|
const closePreviewOverlay = useCallback(() => {
|
||||||
|
setPreviewDocId(null);
|
||||||
|
setPreviewZoomSource(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDocumentPreviewZoom = useCallback(
|
||||||
|
(doc) => {
|
||||||
|
if (!doc || !doc.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ensureDownloadUrl && !(documentLinkMap?.get(doc.id)?.url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewDocId(doc.id);
|
||||||
|
},
|
||||||
|
[ensureDownloadUrl, documentLinkMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentActivate = useCallback(
|
||||||
|
(doc, event?: React.MouseEvent | KeyboardEvent | null) => {
|
||||||
|
if (!doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
if (event?.altKey) {
|
||||||
|
handleDocumentPreviewZoom(doc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onInspectDocument?.(doc.id);
|
||||||
|
},
|
||||||
|
[handleDocumentPreviewZoom, onInspectDocument],
|
||||||
|
);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (!selectedEntries.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (navigableRows.length) {
|
||||||
|
resolvedKey = navigableRows[0].key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocusedRowKey(resolvedKey);
|
||||||
|
}, [
|
||||||
|
focusedRowKey,
|
||||||
|
navigableRowKeys,
|
||||||
|
navigableRows,
|
||||||
|
setFocusedRowKey,
|
||||||
|
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 =
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocusedRowKey(targetRow.key);
|
||||||
|
handleEntrySelection(targetRow.key, {
|
||||||
|
shiftKey,
|
||||||
|
preventDefault: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
focusedRowKey,
|
||||||
|
getEntryByKey,
|
||||||
|
navigableRowKeys,
|
||||||
|
navigableRows,
|
||||||
|
onFolderSelect,
|
||||||
|
selectedEntries,
|
||||||
|
handleDocumentPreviewZoom,
|
||||||
|
handleEntrySelection,
|
||||||
|
setFocusedRowKey,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
||||||
|
|
||||||
|
const scrollToTop = useCallback(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const searchKey = useMemo(
|
||||||
|
() => (Array.isArray(searchResultIds) ? searchResultIds.join(':') : 'none'),
|
||||||
|
[searchResultIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const breadcrumbKey = useMemo(
|
||||||
|
() => (Array.isArray(breadcrumbs) ? breadcrumbs.map((crumb) => crumb?.id ?? '').join(':') : 'none'),
|
||||||
|
[breadcrumbs],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToTop();
|
||||||
|
}, [
|
||||||
|
scrollToTop,
|
||||||
|
viewMode,
|
||||||
|
showingSearchResults,
|
||||||
|
searchKey,
|
||||||
|
breadcrumbKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
|
||||||
|
const ensureFocusedRowVisible = useCallback(() => {
|
||||||
|
if (!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 || !onEntryPointer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onEntryPointer(
|
||||||
|
{ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc },
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[onEntryPointer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderClick = useCallback(
|
||||||
|
(folder, event) => {
|
||||||
|
if (!folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onEntryPointer) {
|
||||||
|
onEntryPointer(
|
||||||
|
{ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder },
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isPointerModifierEvent(event)
|
||||||
|
&& isPrimaryPointerEvent(event)
|
||||||
|
&& scrollRef.current
|
||||||
|
) {
|
||||||
|
scrollRef.current.focus({ preventScroll: true });
|
||||||
|
setFocusedRowKey(`folder:${folder.id}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onEntryPointer, setFocusedRowKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 renderBody = () => {
|
||||||
|
if (isDeskView) {
|
||||||
|
return deskWorkspaceProps ? (
|
||||||
|
<DesktopWorkspace {...deskWorkspaceProps} />
|
||||||
|
) : (
|
||||||
|
<div className="empty-state empty-state--global">
|
||||||
|
Desk view is unavailable.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDefaultEmptyState) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
Drop files anywhere or onto a folder to upload documents.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showGridSearchEmptyState) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state empty-state--global">
|
||||||
|
No documents match the current filters.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showListSearchEmptyState) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">No documents match the current filters.</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showTableRows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGridView) {
|
||||||
|
return (
|
||||||
|
<DocumentsGrid
|
||||||
|
entries={entries}
|
||||||
|
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={onDocumentTagDrop}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
gridIconSize={gridIconSize}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
onTagClick={toggleTagFilter}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
onCorrespondentClick={toggleCorrespondentFilter}
|
||||||
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
|
onDocumentRename={onDocumentRename}
|
||||||
|
onFolderRename={onFolderRename}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentsList
|
||||||
|
entries={entries}
|
||||||
|
focusedRowKey={focusedRowKey}
|
||||||
|
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={toggleTagFilter}
|
||||||
|
onCorrespondentClick={toggleCorrespondentFilter}
|
||||||
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const panelVariant = isDeskView ? 'desk' : isGridView ? 'grid' : 'list';
|
||||||
|
const shouldHandlePanelInteractions = !isDeskView && showTableRows;
|
||||||
|
|
||||||
|
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>) => {
|
||||||
|
if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearSelection();
|
||||||
|
}, [shouldHandlePanelInteractions, clearSelection]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DocumentsPanelHeader
|
||||||
|
header={headerConfig}
|
||||||
|
onBreadcrumbClick={onBreadcrumbNavigate}
|
||||||
|
/>
|
||||||
|
<section
|
||||||
|
ref={scrollRef}
|
||||||
|
className={`documents-panel documents-panel--view-${panelVariant}`}
|
||||||
|
tabIndex={shouldHandlePanelInteractions ? 0 : undefined}
|
||||||
|
onFocus={handleSectionFocus}
|
||||||
|
onKeyDown={handleSectionKeyDown}
|
||||||
|
onClick={handleSectionClick}
|
||||||
|
aria-activedescendant={shouldHandlePanelInteractions && !isGridView ? activeDescendantId : undefined}
|
||||||
|
>
|
||||||
|
{renderBody()}
|
||||||
|
</section>
|
||||||
|
<PreviewZoomOverlay
|
||||||
|
open={Boolean(zoomDisplay?.url)}
|
||||||
|
onClose={closePreviewOverlay}
|
||||||
|
document={overlayDocument}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentsPanel: React.FC<DocumentsPanelProps> = ({ selectionValue, ...rest }) => (
|
||||||
|
<WorkspaceSelectionProvider value={selectionValue}>
|
||||||
|
<DocumentsPanelInner {...rest} />
|
||||||
|
</WorkspaceSelectionProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DocumentsPanel;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import PanelHeader from '../../ui/PanelHeader';
|
||||||
|
import BreadcrumbTrail from '../../ui/BreadcrumbTrail';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
|
||||||
|
export interface DocumentsHeaderBreadcrumb {
|
||||||
|
id?: Identifier;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentsPanelHeaderConfig {
|
||||||
|
title?: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
leading?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
breadcrumbs?: DocumentsHeaderBreadcrumb[] | null;
|
||||||
|
floatingActions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentsPanelHeaderProps {
|
||||||
|
header?: DocumentsPanelHeaderConfig | null;
|
||||||
|
onBreadcrumbClick?: (crumb: DocumentsHeaderBreadcrumb) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentsPanelHeader: React.FC<DocumentsPanelHeaderProps> = ({
|
||||||
|
header,
|
||||||
|
onBreadcrumbClick,
|
||||||
|
}) => {
|
||||||
|
if (!header) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbEntries = Array.isArray(header.breadcrumbs)
|
||||||
|
? header.breadcrumbs.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const lastIndex = breadcrumbEntries.length - 1;
|
||||||
|
const trailEntries = breadcrumbEntries.length
|
||||||
|
? breadcrumbEntries.map((crumb, index) => ({
|
||||||
|
id: crumb.id ?? index,
|
||||||
|
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
|
||||||
|
onClick: index < lastIndex && onBreadcrumbClick
|
||||||
|
? () => onBreadcrumbClick(crumb)
|
||||||
|
: null,
|
||||||
|
}))
|
||||||
|
: [{ id: 'current-location', label: header.title }];
|
||||||
|
|
||||||
|
const headerTitle = (
|
||||||
|
<h2>
|
||||||
|
<BreadcrumbTrail entries={trailEntries} separator="/" />
|
||||||
|
{header.subtitle ? (
|
||||||
|
<span className="panel-header__subtitle">{header.subtitle}</span>
|
||||||
|
) : null}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PanelHeader
|
||||||
|
leading={header.leading}
|
||||||
|
title={headerTitle}
|
||||||
|
titleTag="h2"
|
||||||
|
actions={header.actions}
|
||||||
|
/>
|
||||||
|
{header.floatingActions}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentsPanelHeader;
|
||||||
+28
-12
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import type { JSX } from 'react';
|
||||||
import {
|
import {
|
||||||
ViewListIcon,
|
ViewListIcon,
|
||||||
ViewGridIcon,
|
ViewGridIcon,
|
||||||
@@ -12,18 +12,34 @@ import {
|
|||||||
} from '../../ui/icons';
|
} from '../../ui/icons';
|
||||||
import SortFieldQuickMenu from './SortFieldQuickMenu';
|
import SortFieldQuickMenu from './SortFieldQuickMenu';
|
||||||
|
|
||||||
|
type ViewMode = 'list' | 'grid' | 'desk' | (string & {});
|
||||||
|
type SortDirection = 'asc' | 'desc' | (string & {});
|
||||||
|
|
||||||
|
interface DocumentsTableHeaderActionOptions {
|
||||||
|
viewMode?: ViewMode;
|
||||||
|
onViewModeChange?: (mode: ViewMode) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
sortField?: string;
|
||||||
|
onSortFieldChange?: (field: string) => void;
|
||||||
|
sortDirection?: SortDirection;
|
||||||
|
onSortDirectionToggle?: () => void;
|
||||||
|
isFilterActive?: boolean;
|
||||||
|
includeDescendants?: boolean;
|
||||||
|
onToggleIncludeDescendants?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export const createDocumentsTableHeaderActions = ({
|
export const createDocumentsTableHeaderActions = ({
|
||||||
viewMode,
|
viewMode = 'list',
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
sortField = 'title',
|
sortField = 'title',
|
||||||
onSortFieldChange = null,
|
onSortFieldChange,
|
||||||
sortDirection = 'asc',
|
sortDirection = 'asc',
|
||||||
onSortDirectionToggle = null,
|
onSortDirectionToggle,
|
||||||
isFilterActive = false,
|
isFilterActive = false,
|
||||||
includeDescendants = true,
|
includeDescendants = true,
|
||||||
onToggleIncludeDescendants = null,
|
onToggleIncludeDescendants,
|
||||||
}) => {
|
}: DocumentsTableHeaderActionOptions): JSX.Element => {
|
||||||
const isListView = viewMode === 'list';
|
const isListView = viewMode === 'list';
|
||||||
const isGridView = viewMode === 'grid';
|
const isGridView = viewMode === 'grid';
|
||||||
const isDeskView = viewMode === 'desk';
|
const isDeskView = viewMode === 'desk';
|
||||||
@@ -33,7 +49,7 @@ export const createDocumentsTableHeaderActions = ({
|
|||||||
? 'Sorting Z → A. Click to switch to ascending.'
|
? 'Sorting Z → A. Click to switch to ascending.'
|
||||||
: 'Sorting A → Z. Click to switch to descending.';
|
: 'Sorting A → Z. Click to switch to descending.';
|
||||||
|
|
||||||
const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function'
|
const includeDescendantsToggle = isFilterActive && onToggleIncludeDescendants
|
||||||
? (
|
? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -50,11 +66,11 @@ export const createDocumentsTableHeaderActions = ({
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const sortControls = typeof onSortFieldChange === 'function'
|
const sortControls = onSortFieldChange
|
||||||
? (
|
? (
|
||||||
<div className="documents-actions__sort-group">
|
<div className="documents-actions__sort-group">
|
||||||
<SortFieldQuickMenu sortField={sortField} onChange={onSortFieldChange} />
|
<SortFieldQuickMenu sortField={sortField} onChange={onSortFieldChange} />
|
||||||
{typeof onSortDirectionToggle === 'function' ? (
|
{onSortDirectionToggle ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button documents-toolbar__toggle documents-sort__direction"
|
className="icon-button documents-toolbar__toggle documents-sort__direction"
|
||||||
@@ -95,7 +111,7 @@ export const createDocumentsTableHeaderActions = ({
|
|||||||
<div className="view-toggle" role="group" aria-label="Change view">
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`view-toggle__button${isListView ? ' active' : ''}`}
|
className={`toggle-button${isListView ? ' active' : ''}`}
|
||||||
onClick={() => onViewModeChange?.('list')}
|
onClick={() => onViewModeChange?.('list')}
|
||||||
aria-pressed={isListView}
|
aria-pressed={isListView}
|
||||||
title="List view"
|
title="List view"
|
||||||
@@ -104,7 +120,7 @@ export const createDocumentsTableHeaderActions = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
className={`toggle-button${isGridView ? ' active' : ''}`}
|
||||||
onClick={() => onViewModeChange?.('grid')}
|
onClick={() => onViewModeChange?.('grid')}
|
||||||
aria-pressed={isGridView}
|
aria-pressed={isGridView}
|
||||||
title="Icons view"
|
title="Icons view"
|
||||||
@@ -113,7 +129,7 @@ export const createDocumentsTableHeaderActions = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
|
className={`toggle-button${isDeskView ? ' active' : ''}`}
|
||||||
onClick={() => onViewModeChange?.('desk')}
|
onClick={() => onViewModeChange?.('desk')}
|
||||||
aria-pressed={isDeskView}
|
aria-pressed={isDeskView}
|
||||||
title="Desk view"
|
title="Desk view"
|
||||||
+9
-4
@@ -14,7 +14,12 @@ const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => {
|
|||||||
return next;
|
return next;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const SortFieldQuickMenu = ({ sortField, onChange }) => {
|
interface SortFieldQuickMenuProps {
|
||||||
|
sortField: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortFieldQuickMenu: React.FC<SortFieldQuickMenuProps> = ({ sortField, onChange }) => {
|
||||||
const currentOption = useMemo(
|
const currentOption = useMemo(
|
||||||
() => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0],
|
() => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0],
|
||||||
[sortField],
|
[sortField],
|
||||||
@@ -26,8 +31,8 @@ const SortFieldQuickMenu = ({ sortField, onChange }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(value, option) => {
|
(value: string, option?: { id?: string; original?: { id?: string } }) => {
|
||||||
if (typeof onChange !== 'function') {
|
if (!onChange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextValue = option?.id || option?.original?.id || value;
|
const nextValue = option?.id || option?.original?.id || value;
|
||||||
@@ -45,7 +50,7 @@ const SortFieldQuickMenu = ({ sortField, onChange }) => {
|
|||||||
className="documents-sort__quickmenu"
|
className="documents-sort__quickmenu"
|
||||||
options={options}
|
options={options}
|
||||||
onSelectOption={handleSelect}
|
onSelectOption={handleSelect}
|
||||||
triggerClassName="view-toggle__button documents-sort__trigger quick-add__trigger"
|
triggerClassName="toggle-button documents-sort__trigger quick-add__trigger"
|
||||||
triggerContent={(
|
triggerContent={(
|
||||||
<span className="documents-sort__trigger-content">
|
<span className="documents-sort__trigger-content">
|
||||||
<span className="documents-sort__label">{label}</span>
|
<span className="documents-sort__label">{label}</span>
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import DocumentViewerPanel from '../../preview/DocumentViewerPanel';
|
|
||||||
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
|
|
||||||
? (() => {
|
|
||||||
const { onClose, onOpenPreview, tags: tagOptions, ...restDetailProps } = detailProps;
|
|
||||||
return (
|
|
||||||
<DocumentViewerPanel
|
|
||||||
variant="sidebar"
|
|
||||||
onCollapsePanel={onClose}
|
|
||||||
onMaximizePanel={onOpenPreview}
|
|
||||||
tagOptions={tagOptions}
|
|
||||||
{...restDetailProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
: 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;
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
|
||||||
const TAG_TEXT_MIME_TYPE = 'text/plain';
|
|
||||||
|
|
||||||
const serializePayload = (payload) => {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(payload);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[tagTransfer] Failed to serialize payload', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createTagTransferPayload = (tag, sourceDocId = null) => {
|
|
||||||
if (!tag || !tag.id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: tag.id,
|
|
||||||
label: tag.label || '',
|
|
||||||
sourceDocId: sourceDocId ?? null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => {
|
|
||||||
if (!dataTransfer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = createTagTransferPayload(tag, sourceDocId);
|
|
||||||
if (!payload) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serialized = serializePayload(payload);
|
|
||||||
if (!serialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
dataTransfer.setData(TAG_MIME_TYPES[0], serialized);
|
|
||||||
dataTransfer.setData(TAG_MIME_TYPES[1], serialized);
|
|
||||||
if (payload.label) {
|
|
||||||
dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[tagTransfer] Failed to write drag data', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const readTagTransferData = (dataTransfer) => {
|
|
||||||
if (!dataTransfer) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < TAG_MIME_TYPES.length; index += 1) {
|
|
||||||
const type = TAG_MIME_TYPES[index];
|
|
||||||
try {
|
|
||||||
const raw = dataTransfer.getData(type);
|
|
||||||
if (raw) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[tagTransfer] Failed to read drag data for type', type, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const parseTagTransferPayload = (input) => {
|
|
||||||
const dataTransfer = input && 'dataTransfer' in input ? input.dataTransfer : input;
|
|
||||||
const raw = readTagTransferData(dataTransfer);
|
|
||||||
if (!raw) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[tagTransfer] Failed to parse drag payload', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isTagTransferEvent = (event) => {
|
|
||||||
const types = event?.dataTransfer?.types;
|
|
||||||
if (!types) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const typeList = Array.isArray(types) ? types : Array.from(types);
|
|
||||||
return TAG_MIME_TYPES.some((type) => typeList.includes(type));
|
|
||||||
};
|
|
||||||
|
|
||||||
export { TAG_MIME_TYPES };
|
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
|
const TAG_TEXT_MIME_TYPE = 'text/plain';
|
||||||
|
|
||||||
|
interface TagPayload {
|
||||||
|
id: string | number;
|
||||||
|
label: string;
|
||||||
|
sourceDocId: string | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagLike {
|
||||||
|
id?: string | number;
|
||||||
|
label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializePayload = (payload: TagPayload): string | null => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[tagTransfer] Failed to serialize payload', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTagTransferPayload = (tag?: TagLike | null, sourceDocId: string | number | null = null): TagPayload | null => {
|
||||||
|
if (!tag || tag.id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tag.id,
|
||||||
|
label: tag.label || '',
|
||||||
|
sourceDocId: sourceDocId ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeTagTransferData = (dataTransfer: DataTransfer | null, tag: TagLike, sourceDocId: string | number | null = null): void => {
|
||||||
|
if (!dataTransfer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = createTagTransferPayload(tag, sourceDocId);
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = serializePayload(payload);
|
||||||
|
if (!serialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
TAG_MIME_TYPES.forEach((type) => dataTransfer.setData(type, serialized));
|
||||||
|
if (payload.label) {
|
||||||
|
dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[tagTransfer] Failed to write drag data', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readTagTransferData = (dataTransfer?: DataTransfer | null): string | null => {
|
||||||
|
if (!dataTransfer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < TAG_MIME_TYPES.length; index += 1) {
|
||||||
|
const type = TAG_MIME_TYPES[index];
|
||||||
|
try {
|
||||||
|
const raw = dataTransfer.getData(type);
|
||||||
|
if (raw) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[tagTransfer] Failed to read drag data for type', type, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DragEventLike = DragEvent | DataTransfer | {
|
||||||
|
dataTransfer?: DataTransfer | null;
|
||||||
|
type?: string;
|
||||||
|
preventDefault?: () => void;
|
||||||
|
stopPropagation?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseTagTransferPayload = (input: DataTransfer | DragEventLike | null): TagPayload | null => {
|
||||||
|
let dataTransfer: DataTransfer | null = null;
|
||||||
|
if (input instanceof DataTransfer) {
|
||||||
|
dataTransfer = input;
|
||||||
|
} else if (input && Object(input) === input && 'dataTransfer' in (input as Record<string, unknown>)) {
|
||||||
|
const candidate = (input as { dataTransfer?: DataTransfer | null }).dataTransfer;
|
||||||
|
if (candidate) {
|
||||||
|
dataTransfer = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const raw = readTagTransferData(dataTransfer || null);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as TagPayload;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[tagTransfer] Failed to parse drag payload', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTagTransferEvent = (event?: DragEventLike | null): boolean => {
|
||||||
|
if (!event) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let types: DOMStringList | ReadonlyArray<string> | undefined;
|
||||||
|
if (event instanceof DataTransfer) {
|
||||||
|
types = event.types;
|
||||||
|
} else if (Object(event) === event && 'dataTransfer' in (event as Record<string, unknown>)) {
|
||||||
|
const payload = (event as { dataTransfer?: DataTransfer | null }).dataTransfer;
|
||||||
|
types = payload?.types;
|
||||||
|
}
|
||||||
|
if (!types) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const typeList = Array.isArray(types) ? [...types] : Array.from(types);
|
||||||
|
return TAG_MIME_TYPES.some((type) => typeList.includes(type));
|
||||||
|
};
|
||||||
|
|
||||||
|
export { TAG_MIME_TYPES };
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
export const isPointerModifierEvent = (event) =>
|
|
||||||
Boolean(event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey));
|
|
||||||
|
|
||||||
export const isPrimaryPointerEvent = (event) => {
|
|
||||||
if (!event) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (typeof event.button === 'number' && event.button !== 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const type = typeof event.type === 'string' ? event.type.toLowerCase() : '';
|
|
||||||
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
|
|
||||||
};
|
|
||||||
|
|
||||||
const EntryType = Object.freeze({
|
|
||||||
document: 'document',
|
|
||||||
folder: 'folder',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useEntryPointer = ({
|
|
||||||
resolveDocumentRowKey,
|
|
||||||
resolveFolderRowKey,
|
|
||||||
onSelectEntry,
|
|
||||||
onInspectDocument,
|
|
||||||
}) =>
|
|
||||||
useCallback(
|
|
||||||
(entry, event) => {
|
|
||||||
if (!entry || !entry.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { type, id } = entry;
|
|
||||||
if (type !== EntryType.document && type !== EntryType.folder) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowKey = entry.key
|
|
||||||
|| (type === EntryType.document ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
|
|
||||||
if (!rowKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modifierClick = isPointerModifierEvent(event);
|
|
||||||
const primaryClick = isPrimaryPointerEvent(event);
|
|
||||||
const metadata = { modifierClick, primaryClick, rowKey, type, id };
|
|
||||||
|
|
||||||
if (typeof onSelectEntry === 'function') {
|
|
||||||
onSelectEntry(entry, event, metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
type === EntryType.document
|
|
||||||
&& !modifierClick
|
|
||||||
&& primaryClick
|
|
||||||
&& typeof onInspectDocument === 'function'
|
|
||||||
) {
|
|
||||||
onInspectDocument(id, metadata);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onInspectDocument],
|
|
||||||
);
|
|
||||||
|
|
||||||
export default useEntryPointer;
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
export type PointerEventLike = MouseEvent | PointerEvent;
|
||||||
|
|
||||||
|
export const isPointerModifierEvent = (event?: PointerEventLike | null): boolean =>
|
||||||
|
Boolean(event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey));
|
||||||
|
|
||||||
|
export const isPrimaryPointerEvent = (event?: PointerEventLike | null): boolean => {
|
||||||
|
if (!event) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const type = event?.type?.toLowerCase?.() ?? '';
|
||||||
|
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntryType = 'document' | 'folder';
|
||||||
|
|
||||||
|
export interface WorkspaceEntry {
|
||||||
|
id: string | number;
|
||||||
|
key?: string;
|
||||||
|
type: EntryType;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseEntryPointerOptions {
|
||||||
|
resolveDocumentRowKey?: (id: string | number) => string | null;
|
||||||
|
resolveFolderRowKey?: (id: string | number) => string | null;
|
||||||
|
onSelectEntry?: (entry: WorkspaceEntry, event?: PointerEventLike | null, metadata?: EntryPointerMetadata) => void;
|
||||||
|
onInspectDocument?: (id: string | number, metadata?: EntryPointerMetadata) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryPointerMetadata {
|
||||||
|
modifierClick: boolean;
|
||||||
|
primaryClick: boolean;
|
||||||
|
rowKey: string;
|
||||||
|
type: EntryType;
|
||||||
|
id: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEntryPointer = ({
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
onSelectEntry,
|
||||||
|
onInspectDocument,
|
||||||
|
}: UseEntryPointerOptions) =>
|
||||||
|
useCallback(
|
||||||
|
(entry?: WorkspaceEntry | null, event?: PointerEventLike | null) => {
|
||||||
|
if (!entry || !entry.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, id } = entry;
|
||||||
|
if (type !== 'document' && type !== 'folder') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowKey = entry.key
|
||||||
|
|| (type === 'document' ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
|
||||||
|
if (!rowKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifierClick = isPointerModifierEvent(event);
|
||||||
|
const primaryClick = isPrimaryPointerEvent(event);
|
||||||
|
const metadata: EntryPointerMetadata = { modifierClick, primaryClick, rowKey, type, id };
|
||||||
|
|
||||||
|
onSelectEntry?.(entry, event, metadata);
|
||||||
|
|
||||||
|
if (type === 'document' && !modifierClick && primaryClick) {
|
||||||
|
onInspectDocument?.(id, metadata);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onInspectDocument],
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useEntryPointer;
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { useCallback, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
const focusInput = (node) => {
|
|
||||||
if (!node) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const applyFocus = () => {
|
|
||||||
node.focus();
|
|
||||||
if (typeof node.select === 'function') {
|
|
||||||
node.select();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
||||||
window.requestAnimationFrame(applyFocus);
|
|
||||||
} else {
|
|
||||||
applyFocus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const identity = (value) => value;
|
|
||||||
|
|
||||||
const useInlineRename = (
|
|
||||||
onRename,
|
|
||||||
{
|
|
||||||
getCurrentValue = identity,
|
|
||||||
getEntityId = (entity) => entity?.id ?? null,
|
|
||||||
} = {},
|
|
||||||
) => {
|
|
||||||
const [editingId, setEditingId] = useState(null);
|
|
||||||
const [draftValue, setDraftValue] = useState('');
|
|
||||||
const [savingId, setSavingId] = useState(null);
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
|
|
||||||
const resetState = useCallback(() => {
|
|
||||||
setEditingId(null);
|
|
||||||
setDraftValue('');
|
|
||||||
setSavingId(null);
|
|
||||||
inputRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const beginEditing = useCallback(
|
|
||||||
(entity, event) => {
|
|
||||||
if (!entity) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
const entityId = getEntityId(entity);
|
|
||||||
if (!entityId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const currentValue = getCurrentValue(entity) ?? '';
|
|
||||||
setEditingId(entityId);
|
|
||||||
setDraftValue(currentValue);
|
|
||||||
setSavingId(null);
|
|
||||||
},
|
|
||||||
[getCurrentValue, getEntityId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancelEditing = useCallback(
|
|
||||||
(event) => {
|
|
||||||
if (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
resetState();
|
|
||||||
},
|
|
||||||
[resetState],
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitEditing = useCallback(
|
|
||||||
async (entity) => {
|
|
||||||
if (!entity) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const entityId = getEntityId(entity);
|
|
||||||
if (!entityId || editingId !== entityId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const trimmed = draftValue.trim();
|
|
||||||
const currentValue = getCurrentValue(entity) ?? '';
|
|
||||||
if (!trimmed || trimmed === currentValue) {
|
|
||||||
resetState();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (typeof onRename !== 'function') {
|
|
||||||
resetState();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
setSavingId(entityId);
|
|
||||||
try {
|
|
||||||
const result = await onRename(entityId, trimmed);
|
|
||||||
if (result === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
resetState();
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setSavingId((current) => (current === entityId ? null : current));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[draftValue, editingId, getCurrentValue, getEntityId, onRename, resetState],
|
|
||||||
);
|
|
||||||
|
|
||||||
const attachInputRef = useCallback(
|
|
||||||
(node) => {
|
|
||||||
if (node) {
|
|
||||||
inputRef.current = node;
|
|
||||||
focusInput(node);
|
|
||||||
} else if (inputRef.current) {
|
|
||||||
inputRef.current = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
editingId,
|
|
||||||
draftValue,
|
|
||||||
setDraftValue,
|
|
||||||
beginEditing,
|
|
||||||
cancelEditing,
|
|
||||||
submitEditing,
|
|
||||||
savingId,
|
|
||||||
attachInputRef,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useInlineRename;
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import {
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
SyntheticEvent,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
type FocusableInput = (HTMLInputElement | HTMLTextAreaElement) & {
|
||||||
|
select?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InlineRenameOptions<TEntity> = {
|
||||||
|
getCurrentValue?: (entity: TEntity) => string | null;
|
||||||
|
getEntityId?: (entity: TEntity) => string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InlineRenameHandler = (
|
||||||
|
id: string | number,
|
||||||
|
value: string,
|
||||||
|
) => boolean | void | Promise<boolean | void>;
|
||||||
|
|
||||||
|
type InlineRenameReturn<TEntity> = {
|
||||||
|
editingId: string | number | null;
|
||||||
|
draftValue: string;
|
||||||
|
setDraftValue: Dispatch<SetStateAction<string>>;
|
||||||
|
beginEditing: (entity?: TEntity | null, event?: SyntheticEvent | Event) => void;
|
||||||
|
cancelEditing: (event?: SyntheticEvent | Event) => void;
|
||||||
|
submitEditing: (entity?: TEntity | null) => Promise<boolean>;
|
||||||
|
savingId: string | number | null;
|
||||||
|
attachInputRef: (node: FocusableInput | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusInput = (node: FocusableInput | null) => {
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const applyFocus = () => {
|
||||||
|
node.focus();
|
||||||
|
node.select?.();
|
||||||
|
};
|
||||||
|
const raf = window.requestAnimationFrame;
|
||||||
|
if (raf) {
|
||||||
|
raf(applyFocus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyFocus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const identity = (value: unknown) => value as string;
|
||||||
|
|
||||||
|
const defaultGetEntityId = <T,>(entity?: T | null) =>
|
||||||
|
(entity as { id?: string | number } | null)?.id ?? null;
|
||||||
|
|
||||||
|
const useInlineRename = <TEntity,>(
|
||||||
|
onRename?: InlineRenameHandler,
|
||||||
|
{
|
||||||
|
getCurrentValue = identity as (entity: TEntity) => string | null,
|
||||||
|
getEntityId = defaultGetEntityId as (entity: TEntity) => string | number | null,
|
||||||
|
}: InlineRenameOptions<TEntity> = {},
|
||||||
|
): InlineRenameReturn<TEntity> => {
|
||||||
|
const [editingId, setEditingId] = useState<string | number | null>(null);
|
||||||
|
const [draftValue, setDraftValue] = useState('');
|
||||||
|
const [savingId, setSavingId] = useState<string | number | null>(null);
|
||||||
|
const inputRef = useRef<FocusableInput | null>(null);
|
||||||
|
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setDraftValue('');
|
||||||
|
setSavingId(null);
|
||||||
|
inputRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const beginEditing = useCallback(
|
||||||
|
(entity?: TEntity | null, event?: SyntheticEvent | Event) => {
|
||||||
|
if (!entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
const entityId = getEntityId(entity);
|
||||||
|
if (!entityId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentValue = getCurrentValue(entity) ?? '';
|
||||||
|
setEditingId(entityId);
|
||||||
|
setDraftValue(currentValue);
|
||||||
|
setSavingId(null);
|
||||||
|
},
|
||||||
|
[getCurrentValue, getEntityId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelEditing = useCallback(
|
||||||
|
(event?: SyntheticEvent | Event) => {
|
||||||
|
if (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
resetState();
|
||||||
|
},
|
||||||
|
[resetState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitEditing = useCallback(
|
||||||
|
async (entity?: TEntity | null) => {
|
||||||
|
if (!entity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const entityId = getEntityId(entity);
|
||||||
|
if (!entityId || editingId !== entityId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const trimmed = draftValue.trim();
|
||||||
|
const currentValue = getCurrentValue(entity) ?? '';
|
||||||
|
if (!trimmed || trimmed === currentValue) {
|
||||||
|
resetState();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!onRename) {
|
||||||
|
resetState();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setSavingId(entityId);
|
||||||
|
try {
|
||||||
|
const result = await onRename(entityId, trimmed);
|
||||||
|
if (result === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
resetState();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSavingId((current) => (current === entityId ? null : current));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[draftValue, editingId, getCurrentValue, getEntityId, onRename, resetState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const attachInputRef = useCallback((node: FocusableInput | null) => {
|
||||||
|
if (node) {
|
||||||
|
inputRef.current = node;
|
||||||
|
focusInput(node);
|
||||||
|
} else if (inputRef.current) {
|
||||||
|
inputRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
editingId,
|
||||||
|
draftValue,
|
||||||
|
setDraftValue,
|
||||||
|
beginEditing,
|
||||||
|
cancelEditing,
|
||||||
|
submitEditing,
|
||||||
|
savingId,
|
||||||
|
attachInputRef,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useInlineRename;
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const createWorkspaceSurfaceConfig = ({
|
|
||||||
title,
|
|
||||||
subtitle = null,
|
|
||||||
sidebarToggle = null,
|
|
||||||
actions = null,
|
|
||||||
breadcrumbs = null,
|
|
||||||
selectionLabel = null,
|
|
||||||
floatingActions = null,
|
|
||||||
content = null,
|
|
||||||
detail = null,
|
|
||||||
variant = 'documents',
|
|
||||||
key = 'documents',
|
|
||||||
}) => {
|
|
||||||
const leading = sidebarToggle
|
|
||||||
? (
|
|
||||||
<>
|
|
||||||
{sidebarToggle}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
variant,
|
|
||||||
header: {
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
leading,
|
|
||||||
actions,
|
|
||||||
breadcrumbs,
|
|
||||||
selectionLabel,
|
|
||||||
floatingActions,
|
|
||||||
},
|
|
||||||
content,
|
|
||||||
detail,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default createWorkspaceSurfaceConfig;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { useCallback, useState } from 'react';
|
|
||||||
|
|
||||||
export const useDocumentsStore = () => {
|
|
||||||
const [status, setStatus] = useState(null);
|
|
||||||
|
|
||||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
|
||||||
setStatus(message ? { message, variant } : null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { status, setStatusMessage };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useDocumentsStore;
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export type StatusVariant = 'info' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface StatusMessage {
|
||||||
|
message: string;
|
||||||
|
variant: StatusVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDocumentsStore = () => {
|
||||||
|
const [status, setStatus] = useState<StatusMessage | null>(null);
|
||||||
|
|
||||||
|
const setStatusMessage = useCallback((message?: string | null, variant: StatusVariant = 'info') => {
|
||||||
|
setStatus(message ? { message, variant } : null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { status, setStatusMessage };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentsStore;
|
||||||
+63
-14
@@ -1,4 +1,52 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import type { MutableRefObject } from 'react';
|
||||||
|
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import { AxiosHeaders } from 'axios';
|
||||||
|
|
||||||
|
type AppStatus = string;
|
||||||
|
|
||||||
|
type AppDispatch = (action: { type: string; [key: string]: unknown }) => void;
|
||||||
|
|
||||||
|
type NotifyApiError = (error: unknown, fallbackMessage: string, variant?: string) => void;
|
||||||
|
|
||||||
|
type SetStatusMessage = (message: string, variant?: string) => void;
|
||||||
|
|
||||||
|
type SetLoading = (state: boolean) => void;
|
||||||
|
|
||||||
|
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
|
||||||
|
_retry?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAuthManagerArgs {
|
||||||
|
apiClient: AxiosInstance;
|
||||||
|
token?: string | null;
|
||||||
|
appStatus: AppStatus;
|
||||||
|
appDispatch: AppDispatch;
|
||||||
|
notifyApiError: NotifyApiError;
|
||||||
|
setStatusMessage: SetStatusMessage;
|
||||||
|
setLoading: SetLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAuthManagerResult {
|
||||||
|
tokenRef: MutableRefObject<string | null>;
|
||||||
|
refreshAccessToken: () => Promise<string>;
|
||||||
|
handleLogout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureAxiosHeaders = (
|
||||||
|
headers?: InternalAxiosRequestConfig['headers'],
|
||||||
|
): AxiosHeaders => {
|
||||||
|
if (headers instanceof AxiosHeaders) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
return AxiosHeaders.from(headers || {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setHeaderAuthorization = (config: InternalAxiosRequestConfig, token: string): void => {
|
||||||
|
const headers = ensureAxiosHeaders(config.headers);
|
||||||
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
config.headers = headers;
|
||||||
|
};
|
||||||
|
|
||||||
const useAuthManager = ({
|
const useAuthManager = ({
|
||||||
apiClient,
|
apiClient,
|
||||||
@@ -8,16 +56,16 @@ const useAuthManager = ({
|
|||||||
notifyApiError,
|
notifyApiError,
|
||||||
setStatusMessage,
|
setStatusMessage,
|
||||||
setLoading,
|
setLoading,
|
||||||
}) => {
|
}: UseAuthManagerArgs): UseAuthManagerResult => {
|
||||||
const tokenRef = useRef(token);
|
const tokenRef = useRef<string | null>(token);
|
||||||
const refreshPromiseRef = useRef(null);
|
const refreshPromiseRef = useRef<Promise<string> | null>(null);
|
||||||
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
||||||
|
|
||||||
const refreshAccessToken = useCallback(async () => {
|
const refreshAccessToken = useCallback(async (): Promise<string> => {
|
||||||
console.log('[Auth] Attempting to refresh access token…');
|
console.log('[Auth] Attempting to refresh access token…');
|
||||||
appDispatch({ type: 'TOKEN_REFRESH_START' });
|
appDispatch({ type: 'TOKEN_REFRESH_START' });
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.post('/auth/refresh');
|
const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh');
|
||||||
if (data?.access_token) {
|
if (data?.access_token) {
|
||||||
appDispatch({
|
appDispatch({
|
||||||
type: 'TOKEN_REFRESH_SUCCESS',
|
type: 'TOKEN_REFRESH_SUCCESS',
|
||||||
@@ -30,7 +78,7 @@ const useAuthManager = ({
|
|||||||
throw new Error('Missing access token in refresh response');
|
throw new Error('Missing access token in refresh response');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Auth] Failed to refresh access token', error);
|
console.warn('[Auth] Failed to refresh access token', error);
|
||||||
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null });
|
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [apiClient, appDispatch]);
|
}, [apiClient, appDispatch]);
|
||||||
@@ -51,10 +99,11 @@ const useAuthManager = ({
|
|||||||
const requestInterceptor = apiClient.interceptors.request.use((config) => {
|
const requestInterceptor = apiClient.interceptors.request.use((config) => {
|
||||||
const currentToken = tokenRef.current;
|
const currentToken = tokenRef.current;
|
||||||
if (currentToken) {
|
if (currentToken) {
|
||||||
config.headers = config.headers || {};
|
const headers = ensureAxiosHeaders(config.headers);
|
||||||
if (!config.headers.Authorization) {
|
if (!headers.has('Authorization')) {
|
||||||
config.headers.Authorization = `Bearer ${currentToken}`;
|
headers.set('Authorization', `Bearer ${currentToken}`);
|
||||||
}
|
}
|
||||||
|
config.headers = headers;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
@@ -62,13 +111,14 @@ const useAuthManager = ({
|
|||||||
const responseInterceptor = apiClient.interceptors.response.use(
|
const responseInterceptor = apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const { response, config } = error;
|
const axiosError = error as AxiosError & { config?: RetryableAxiosRequestConfig };
|
||||||
|
const { response, config } = axiosError;
|
||||||
if (!response || !config) {
|
if (!response || !config) {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = response.status;
|
const status = response.status;
|
||||||
const url = typeof config.url === 'string' ? config.url : '';
|
const url = String(config?.url ?? '');
|
||||||
const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh');
|
const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh');
|
||||||
|
|
||||||
if (status === 401 && !config._retry && !isAuthRoute) {
|
if (status === 401 && !config._retry && !isAuthRoute) {
|
||||||
@@ -90,13 +140,12 @@ const useAuthManager = ({
|
|||||||
throw new Error('No token returned from refresh');
|
throw new Error('No token returned from refresh');
|
||||||
}
|
}
|
||||||
config._retry = true;
|
config._retry = true;
|
||||||
config.headers = config.headers || {};
|
setHeaderAuthorization(config, newToken);
|
||||||
config.headers.Authorization = `Bearer ${newToken}`;
|
|
||||||
console.log('[Auth] Retrying original request', url);
|
console.log('[Auth] Retrying original request', url);
|
||||||
try {
|
try {
|
||||||
return await apiClient(config);
|
return await apiClient(config);
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
if (retryError?.response?.status === 401) {
|
if ((retryError as AxiosError)?.response?.status === 401) {
|
||||||
notifyApiError(retryError, 'Session expired. Please log in again.');
|
notifyApiError(retryError, 'Session expired. Please log in again.');
|
||||||
}
|
}
|
||||||
throw retryError;
|
throw retryError;
|
||||||
+34
-15
@@ -1,4 +1,25 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { MutableRefObject, useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
type ApiClient = {
|
||||||
|
get: (path: string) => Promise<{ data: unknown }>;
|
||||||
|
post: (path: string, body: unknown) => Promise<{ data: unknown }>;
|
||||||
|
patch: (path: string, body: unknown) => Promise<{ data: unknown }>;
|
||||||
|
delete: (path: string) => Promise<{ data: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CorrespondentEntry {
|
||||||
|
id?: string | number;
|
||||||
|
name?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCorrespondentsOptions {
|
||||||
|
apiClient: ApiClient;
|
||||||
|
notifyApiError: (error: unknown, fallback: string) => void;
|
||||||
|
setStatusMessage: (message: string, variant?: string) => void;
|
||||||
|
tenantIdRef: MutableRefObject<string | number | null>;
|
||||||
|
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const useCorrespondents = ({
|
const useCorrespondents = ({
|
||||||
apiClient,
|
apiClient,
|
||||||
@@ -6,8 +27,8 @@ const useCorrespondents = ({
|
|||||||
setStatusMessage,
|
setStatusMessage,
|
||||||
tenantIdRef,
|
tenantIdRef,
|
||||||
mapDocumentCaches,
|
mapDocumentCaches,
|
||||||
}) => {
|
}: UseCorrespondentsOptions) => {
|
||||||
const [correspondents, setCorrespondents] = useState([]);
|
const [correspondents, setCorrespondents] = useState<CorrespondentEntry[]>([]);
|
||||||
|
|
||||||
const refreshCorrespondents = useCallback(async () => {
|
const refreshCorrespondents = useCallback(async () => {
|
||||||
const requestTenantId = tenantIdRef.current;
|
const requestTenantId = tenantIdRef.current;
|
||||||
@@ -26,13 +47,13 @@ const useCorrespondents = ({
|
|||||||
}, [apiClient, notifyApiError, tenantIdRef]);
|
}, [apiClient, notifyApiError, tenantIdRef]);
|
||||||
|
|
||||||
const handleCorrespondentUpdate = useCallback(
|
const handleCorrespondentUpdate = useCallback(
|
||||||
async (correspondentId, changes) => {
|
async (correspondentId: string | number, changes: { name?: string }) => {
|
||||||
if (!correspondentId) {
|
if (correspondentId == null) {
|
||||||
throw new Error('Missing correspondent identifier.');
|
throw new Error('Missing correspondent identifier.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (typeof changes.name === 'string') {
|
if (changes?.name != null) {
|
||||||
const trimmed = changes.name.trim();
|
const trimmed = changes.name.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
throw new Error('Correspondent name cannot be empty.');
|
throw new Error('Correspondent name cannot be empty.');
|
||||||
@@ -59,8 +80,8 @@ const useCorrespondents = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCorrespondentCreate = useCallback(
|
const handleCorrespondentCreate = useCallback(
|
||||||
async ({ name }) => {
|
async ({ name }: { name?: string }) => {
|
||||||
const trimmed = typeof name === 'string' ? name.trim() : '';
|
const trimmed = name?.trim?.() || '';
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
throw new Error('Correspondent name is required.');
|
throw new Error('Correspondent name is required.');
|
||||||
}
|
}
|
||||||
@@ -79,12 +100,12 @@ const useCorrespondents = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCorrespondentDelete = useCallback(
|
const handleCorrespondentDelete = useCallback(
|
||||||
async (correspondentId) => {
|
async (correspondentId: string | number) => {
|
||||||
if (!correspondentId) {
|
if (correspondentId == null) {
|
||||||
throw new Error('Missing correspondent identifier.');
|
throw new Error('Missing correspondent identifier.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripFromDoc = (doc) => {
|
const stripFromDoc = (doc: any) => {
|
||||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
@@ -99,9 +120,7 @@ const useCorrespondents = ({
|
|||||||
await apiClient.delete(`/correspondents/${correspondentId}`);
|
await apiClient.delete(`/correspondents/${correspondentId}`);
|
||||||
await refreshCorrespondents();
|
await refreshCorrespondents();
|
||||||
|
|
||||||
if (typeof mapDocumentCaches === 'function') {
|
mapDocumentCaches?.(stripFromDoc);
|
||||||
mapDocumentCaches(stripFromDoc);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatusMessage('Correspondent deleted.', 'success');
|
setStatusMessage('Correspondent deleted.', 'success');
|
||||||
return true;
|
return true;
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
const useDocumentCorrespondentActions = ({
|
|
||||||
apiClient,
|
|
||||||
correspondents,
|
|
||||||
handleCorrespondentCreate,
|
|
||||||
refreshCurrentFolder,
|
|
||||||
notifyApiError,
|
|
||||||
setStatusMessage,
|
|
||||||
}) => {
|
|
||||||
const correspondentLookupByName = useMemo(() => {
|
|
||||||
const map = new Map();
|
|
||||||
correspondents.forEach((correspondent) => {
|
|
||||||
if (correspondent?.name) {
|
|
||||||
map.set(correspondent.name.toLowerCase(), correspondent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [correspondents]);
|
|
||||||
|
|
||||||
const handleDocumentCorrespondentAttach = useCallback(
|
|
||||||
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
|
||||||
if (!documentId || !correspondentId) {
|
|
||||||
throw new Error('Missing document or correspondent.');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.post(`/documents/${documentId}/correspondents`, {
|
|
||||||
assignments: [{ correspondent_id: correspondentId }],
|
|
||||||
replace: false,
|
|
||||||
});
|
|
||||||
if (refresh) {
|
|
||||||
await refreshCurrentFolder();
|
|
||||||
}
|
|
||||||
if (notify) {
|
|
||||||
setStatusMessage('Correspondent assigned.', 'success');
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error.response?.data?.error || 'Failed to assign correspondent.';
|
|
||||||
notifyApiError(error, message);
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCorrespondentRemove = useCallback(
|
|
||||||
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
|
||||||
if (!documentId || !correspondentId) {
|
|
||||||
throw new Error('Missing document or correspondent.');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.delete(`/documents/${documentId}/correspondents/${correspondentId}`);
|
|
||||||
if (refresh) {
|
|
||||||
await refreshCurrentFolder();
|
|
||||||
}
|
|
||||||
if (notify) {
|
|
||||||
setStatusMessage('Correspondent removed.', 'success');
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error.response?.data?.error || 'Failed to remove correspondent.';
|
|
||||||
notifyApiError(error, message);
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCorrespondentAdd = useCallback(
|
|
||||||
async ({ document, name, input = null, option = null }) => {
|
|
||||||
if (!document?.id) {
|
|
||||||
throw new Error('Missing document for correspondent assignment.');
|
|
||||||
}
|
|
||||||
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
||||||
if (!trimmed) {
|
|
||||||
setStatusMessage('Correspondent name is required.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let target = null;
|
|
||||||
if (option && option.id) {
|
|
||||||
target = correspondentLookupByName.get(trimmed.toLowerCase()) || option;
|
|
||||||
} else {
|
|
||||||
target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
|
||||||
}
|
|
||||||
if (!target) {
|
|
||||||
try {
|
|
||||||
target = await handleCorrespondentCreate({ name: trimmed });
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!target?.id) {
|
|
||||||
setStatusMessage('Unable to resolve correspondent.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handleDocumentCorrespondentAttach({
|
|
||||||
documentId: document.id,
|
|
||||||
correspondentId: target.id,
|
|
||||||
});
|
|
||||||
if (input) {
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setStatusMessage('Failed to assign correspondent.', 'error');
|
|
||||||
console.error('[documents] assign correspondent failed', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
correspondentLookupByName,
|
|
||||||
handleCorrespondentCreate,
|
|
||||||
handleDocumentCorrespondentAttach,
|
|
||||||
setStatusMessage,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
correspondentLookupByName,
|
|
||||||
handleDocumentCorrespondentAttach,
|
|
||||||
handleCorrespondentRemove,
|
|
||||||
handleCorrespondentAdd,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useDocumentCorrespondentActions;
|
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { isPlainObject, isStringValue } from '../../utils/typeGuards';
|
||||||
|
|
||||||
|
type ApiClient = {
|
||||||
|
post: (path: string, body?: unknown) => Promise<{ data: unknown }>;
|
||||||
|
delete: (path: string) => Promise<{ data: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CorrespondentOption {
|
||||||
|
id?: string | number;
|
||||||
|
name?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
|
||||||
|
interface UseDocumentCorrespondentActionsArgs {
|
||||||
|
apiClient: ApiClient;
|
||||||
|
correspondents: CorrespondentOption[];
|
||||||
|
handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null>;
|
||||||
|
notifyApiError: (error: unknown, fallback: string) => void;
|
||||||
|
setStatusMessage: (message: string, variant?: string) => void;
|
||||||
|
updateDocumentCaches?: (
|
||||||
|
id: Identifier,
|
||||||
|
updater: (doc: { correspondents?: CorrespondentOption[] } | null) => { correspondents?: CorrespondentOption[] } | null,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useDocumentCorrespondentActions = ({
|
||||||
|
apiClient,
|
||||||
|
correspondents,
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
updateDocumentCaches,
|
||||||
|
}: UseDocumentCorrespondentActionsArgs) => {
|
||||||
|
const correspondentLookupByName = useMemo(() => {
|
||||||
|
const map = new Map<string, CorrespondentOption>();
|
||||||
|
correspondents.forEach((correspondent) => {
|
||||||
|
if (correspondent?.name) {
|
||||||
|
map.set(correspondent.name.toLowerCase(), correspondent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [correspondents]);
|
||||||
|
|
||||||
|
const handleDocumentCorrespondentAttach = useCallback(
|
||||||
|
async (
|
||||||
|
{ documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier },
|
||||||
|
{ notify = true }: { notify?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
if (documentId == null || correspondentId == null) {
|
||||||
|
throw new Error('Missing document or correspondent.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/documents/${documentId}/correspondents`, {
|
||||||
|
assignments: [{ correspondent_id: correspondentId }],
|
||||||
|
replace: false,
|
||||||
|
});
|
||||||
|
if (updateDocumentCaches) {
|
||||||
|
const correspondent = correspondents.find((entry) => entry?.id === correspondentId) || null;
|
||||||
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
|
if (!doc) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const current = Array.isArray(doc.correspondents) ? doc.correspondents : [];
|
||||||
|
if (current.some((entry) => entry?.id === correspondentId)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const nextEntry = correspondent
|
||||||
|
? { id: correspondent.id, name: correspondent.name }
|
||||||
|
: { id: correspondentId };
|
||||||
|
return { ...doc, correspondents: [...current, nextEntry] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (notify) {
|
||||||
|
setStatusMessage('Correspondent assigned.', 'success');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to assign correspondent.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiClient, correspondents, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCorrespondentRemove = useCallback(
|
||||||
|
async (
|
||||||
|
{ documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier },
|
||||||
|
{ notify = true }: { notify?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
if (documentId == null || correspondentId == null) {
|
||||||
|
throw new Error('Missing document or correspondent.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/documents/${documentId}/correspondents/${correspondentId}`);
|
||||||
|
if (updateDocumentCaches) {
|
||||||
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
|
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const filtered = doc.correspondents.filter((entry) => entry?.id !== correspondentId);
|
||||||
|
return filtered.length === doc.correspondents.length ? doc : { ...doc, correspondents: filtered };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (notify) {
|
||||||
|
setStatusMessage('Correspondent removed.', 'success');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to remove correspondent.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiClient, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizeOption = (
|
||||||
|
option: CorrespondentOption | string | null,
|
||||||
|
): CorrespondentOption | null => {
|
||||||
|
if (!option) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (isPlainObject(option) && 'id' in option) {
|
||||||
|
return option as CorrespondentOption;
|
||||||
|
}
|
||||||
|
if (isStringValue(option)) {
|
||||||
|
const trimmed = option.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
return { id: null, name: trimmed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCorrespondentAdd = useCallback(
|
||||||
|
async ({ document, name, input = null, option = null }: { document?: { id?: string | number }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => {
|
||||||
|
if (!document?.id) {
|
||||||
|
throw new Error('Missing document for correspondent assignment.');
|
||||||
|
}
|
||||||
|
const trimmed = name?.trim?.() || '';
|
||||||
|
if (!trimmed) {
|
||||||
|
setStatusMessage('Correspondent name is required.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || normalizeOption(option);
|
||||||
|
if (!target) {
|
||||||
|
try {
|
||||||
|
target = await handleCorrespondentCreate({ name: trimmed });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target?.id) {
|
||||||
|
setStatusMessage('Unable to resolve correspondent.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleDocumentCorrespondentAttach({
|
||||||
|
documentId: document.id,
|
||||||
|
correspondentId: target.id,
|
||||||
|
});
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatusMessage('Failed to assign correspondent.', 'error');
|
||||||
|
console.error('[documents] assign correspondent failed', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
correspondentLookupByName,
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
handleDocumentCorrespondentAttach,
|
||||||
|
setStatusMessage,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
correspondentLookupByName,
|
||||||
|
handleDocumentCorrespondentAttach,
|
||||||
|
handleCorrespondentRemove,
|
||||||
|
handleCorrespondentAdd,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentCorrespondentActions;
|
||||||
+86
-30
@@ -1,4 +1,39 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import type { DragEvent } from 'react';
|
||||||
|
import { isPlainObject, isFunctionValue } from '../../utils/typeGuards';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
type FolderIdentifier = Identifier | 'root';
|
||||||
|
|
||||||
|
interface DocumentLike {
|
||||||
|
id?: Identifier | null;
|
||||||
|
title?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApplySelectionFn = (
|
||||||
|
keys: string[],
|
||||||
|
options?: { anchor?: string | null; interactedKeys?: string[] },
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type HandleEntrySelectionFn = (
|
||||||
|
key: string,
|
||||||
|
event: { preventDefault?: () => void },
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
interface UseDocumentDragHandlersOptions {
|
||||||
|
selectedEntries: string[];
|
||||||
|
selectedDocumentIds: Identifier[];
|
||||||
|
selectedFolderIds: FolderIdentifier[];
|
||||||
|
applySelection: ApplySelectionFn;
|
||||||
|
handleEntrySelection: HandleEntrySelectionFn;
|
||||||
|
documentLookup: Map<Identifier, DocumentLike>;
|
||||||
|
setDraggedDocumentIds: (ids: Identifier[] | []) => void;
|
||||||
|
setDraggedFolderId: (id: FolderIdentifier | null) => void;
|
||||||
|
resolveDocumentRowKey: (id: Identifier) => string | null;
|
||||||
|
resolveFolderRowKey: (id: FolderIdentifier) => string | null;
|
||||||
|
documentsViewMode: string;
|
||||||
|
}
|
||||||
|
|
||||||
const useDocumentDragHandlers = ({
|
const useDocumentDragHandlers = ({
|
||||||
selectedEntries,
|
selectedEntries,
|
||||||
@@ -12,8 +47,8 @@ const useDocumentDragHandlers = ({
|
|||||||
resolveDocumentRowKey,
|
resolveDocumentRowKey,
|
||||||
resolveFolderRowKey,
|
resolveFolderRowKey,
|
||||||
documentsViewMode,
|
documentsViewMode,
|
||||||
}) => {
|
}: UseDocumentDragHandlersOptions) => {
|
||||||
const dragPreviewRef = useRef(null);
|
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const destroyDragPreview = useCallback(() => {
|
const destroyDragPreview = useCallback(() => {
|
||||||
const node = dragPreviewRef.current;
|
const node = dragPreviewRef.current;
|
||||||
@@ -26,13 +61,9 @@ const useDocumentDragHandlers = ({
|
|||||||
useEffect(() => destroyDragPreview, [destroyDragPreview]);
|
useEffect(() => destroyDragPreview, [destroyDragPreview]);
|
||||||
|
|
||||||
const createDragPreview = useCallback(
|
const createDragPreview = useCallback(
|
||||||
({ documents = [], folders = [] } = {}) => {
|
({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: Array<FolderIdentifier | Identifier> } = {}) => {
|
||||||
destroyDragPreview();
|
destroyDragPreview();
|
||||||
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const docEntries = (documents || []).filter(Boolean);
|
const docEntries = (documents || []).filter(Boolean);
|
||||||
const folderEntries = (folders || []).filter(Boolean);
|
const folderEntries = (folders || []).filter(Boolean);
|
||||||
const totalCount = docEntries.length + folderEntries.length;
|
const totalCount = docEntries.length + folderEntries.length;
|
||||||
@@ -72,12 +103,18 @@ const useDocumentDragHandlers = ({
|
|||||||
if (item.type === 'document') {
|
if (item.type === 'document') {
|
||||||
const doc = item.payload;
|
const doc = item.payload;
|
||||||
const rowEl = doc?.id
|
const rowEl = doc?.id
|
||||||
? document.getElementById(`document-row-${doc.id}`)
|
? (document.getElementById(`document-row-${doc.id}`)
|
||||||
|| document.getElementById(`document-card-${doc.id}`)
|
|| document.getElementById(`document-card-${doc.id}`))
|
||||||
|
: null;
|
||||||
|
const wrapperEl = rowEl instanceof HTMLElement
|
||||||
|
? rowEl.querySelector<HTMLElement>('.document-thumbnail-wrapper')
|
||||||
|
: null;
|
||||||
|
const thumbnailEl = rowEl instanceof HTMLElement
|
||||||
|
? rowEl.querySelector<HTMLImageElement>('.document-thumbnail')
|
||||||
|
: null;
|
||||||
|
const placeholderEl = rowEl instanceof HTMLElement
|
||||||
|
? rowEl.querySelector<HTMLElement>('.thumb-placeholder')
|
||||||
: null;
|
: null;
|
||||||
const wrapperEl = rowEl?.querySelector('.document-thumbnail-wrapper');
|
|
||||||
const thumbnailEl = rowEl?.querySelector('.document-thumbnail');
|
|
||||||
const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
|
|
||||||
const aspectAttr = wrapperEl?.dataset?.thumbnailAspect;
|
const aspectAttr = wrapperEl?.dataset?.thumbnailAspect;
|
||||||
const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null;
|
const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null;
|
||||||
|
|
||||||
@@ -100,7 +137,7 @@ const useDocumentDragHandlers = ({
|
|||||||
layer.classList.add('document-drag-preview__item--image');
|
layer.classList.add('document-drag-preview__item--image');
|
||||||
layer.style.backgroundImage = `url("${thumbSrc}")`;
|
layer.style.backgroundImage = `url("${thumbSrc}")`;
|
||||||
} else if (placeholderEl instanceof HTMLElement) {
|
} else if (placeholderEl instanceof HTMLElement) {
|
||||||
const clone = placeholderEl.cloneNode(true);
|
const clone = placeholderEl.cloneNode(true) as HTMLElement;
|
||||||
clone.style.pointerEvents = 'none';
|
clone.style.pointerEvents = 'none';
|
||||||
layer.appendChild(clone);
|
layer.appendChild(clone);
|
||||||
} else {
|
} else {
|
||||||
@@ -108,22 +145,36 @@ const useDocumentDragHandlers = ({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const payload = item.payload;
|
const payload = item.payload;
|
||||||
const folderId = typeof payload === 'string' ? payload : payload?.id;
|
const folderId = (() => {
|
||||||
|
if (isPlainObject(payload) && 'id' in payload) {
|
||||||
|
return (payload as { id?: FolderIdentifier }).id ?? null;
|
||||||
|
}
|
||||||
|
const maybeTrim = (payload as { trim?: () => string })?.trim;
|
||||||
|
if (isFunctionValue(maybeTrim)) {
|
||||||
|
const nextValue = maybeTrim.call(payload);
|
||||||
|
return nextValue || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
const rowEl = folderId
|
const rowEl = folderId
|
||||||
? document.getElementById(`folder-row-${folderId}`)
|
? (document.getElementById(`folder-row-${folderId}`)
|
||||||
|| document.getElementById(`folder-card-${folderId}`)
|
|| document.getElementById(`folder-card-${folderId}`))
|
||||||
|
: null;
|
||||||
|
const iconEl = rowEl instanceof HTMLElement
|
||||||
|
? rowEl.querySelector('.thumb-icon, .folder-card__icon')
|
||||||
: null;
|
: null;
|
||||||
const iconEl = rowEl?.querySelector('.thumb-icon, .folder-card__icon');
|
|
||||||
layer.style.width = `${size}px`;
|
layer.style.width = `${size}px`;
|
||||||
layer.style.height = `${size}px`;
|
layer.style.height = `${size}px`;
|
||||||
layer.classList.add('document-drag-preview__item--folder');
|
layer.classList.add('document-drag-preview__item--folder');
|
||||||
|
|
||||||
let content = null;
|
let content: HTMLElement | null = null;
|
||||||
if (iconEl instanceof HTMLElement) {
|
if (iconEl instanceof HTMLElement) {
|
||||||
const cloneSource = iconEl.classList.contains('folder-card__icon')
|
const cloneSource = iconEl.classList.contains('folder-card__icon')
|
||||||
? iconEl.querySelector('svg') || iconEl
|
? iconEl.querySelector('svg') || iconEl
|
||||||
: iconEl;
|
: iconEl;
|
||||||
content = cloneSource.cloneNode(true);
|
const clone = cloneSource.cloneNode(true);
|
||||||
|
if (clone instanceof HTMLElement) {
|
||||||
|
content = clone;
|
||||||
content.classList.add('document-drag-preview__folder-thumb');
|
content.classList.add('document-drag-preview__folder-thumb');
|
||||||
const svg = content.querySelector('svg');
|
const svg = content.querySelector('svg');
|
||||||
if (svg) {
|
if (svg) {
|
||||||
@@ -131,6 +182,7 @@ const useDocumentDragHandlers = ({
|
|||||||
svg.setAttribute('height', '48');
|
svg.setAttribute('height', '48');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
content = document.createElement('div');
|
content = document.createElement('div');
|
||||||
@@ -159,8 +211,10 @@ const useDocumentDragHandlers = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentDragStart = useCallback(
|
const handleDocumentDragStart = useCallback(
|
||||||
(event, documentOrId) => {
|
(event: DragEvent<HTMLElement>, documentOrId: DocumentLike | Identifier | null) => {
|
||||||
const documentId = typeof documentOrId === 'string' ? documentOrId : documentOrId?.id;
|
const documentId: Identifier | null = Object(documentOrId) === documentOrId
|
||||||
|
? (documentOrId as DocumentLike)?.id ?? null
|
||||||
|
: (documentOrId as Identifier | null);
|
||||||
if (!documentId) {
|
if (!documentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,12 +226,12 @@ const useDocumentDragHandlers = ({
|
|||||||
|
|
||||||
const isGridView = documentsViewMode === 'grid';
|
const isGridView = documentsViewMode === 'grid';
|
||||||
const isAlreadySelected = selectedDocumentIds.includes(documentId);
|
const isAlreadySelected = selectedDocumentIds.includes(documentId);
|
||||||
const selection = isAlreadySelected
|
const selection: Identifier[] = isAlreadySelected
|
||||||
? [...selectedDocumentIds]
|
? [...selectedDocumentIds]
|
||||||
: isGridView
|
: isGridView
|
||||||
? [...selectedDocumentIds, documentId]
|
? [...selectedDocumentIds, documentId]
|
||||||
: [documentId];
|
: [documentId];
|
||||||
const folderSelection = [];
|
const folderSelection: FolderIdentifier[] = [];
|
||||||
|
|
||||||
if (!isAlreadySelected && !isGridView) {
|
if (!isAlreadySelected && !isGridView) {
|
||||||
applySelection([documentKey], {
|
applySelection([documentKey], {
|
||||||
@@ -186,7 +240,9 @@ const useDocumentDragHandlers = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewDocs = selection.map((id) => documentLookup.get(id) || null).filter(Boolean);
|
const previewDocs = selection
|
||||||
|
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
|
||||||
|
.filter(Boolean);
|
||||||
const previewNode = createDragPreview({
|
const previewNode = createDragPreview({
|
||||||
documents: previewDocs,
|
documents: previewDocs,
|
||||||
folders: folderSelection,
|
folders: folderSelection,
|
||||||
@@ -234,7 +290,7 @@ const useDocumentDragHandlers = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentDragEnd = useCallback(
|
const handleDocumentDragEnd = useCallback(
|
||||||
(event) => {
|
(event: DragEvent<HTMLElement>) => {
|
||||||
setDraggedDocumentIds([]);
|
setDraggedDocumentIds([]);
|
||||||
event.currentTarget.classList.remove('dragging');
|
event.currentTarget.classList.remove('dragging');
|
||||||
destroyDragPreview();
|
destroyDragPreview();
|
||||||
@@ -244,7 +300,7 @@ const useDocumentDragHandlers = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleFolderDragStart = useCallback(
|
const handleFolderDragStart = useCallback(
|
||||||
(event, folderId) => {
|
(event: DragEvent<HTMLElement>, folderId: FolderIdentifier) => {
|
||||||
if (folderId === 'root') {
|
if (folderId === 'root') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -252,8 +308,8 @@ const useDocumentDragHandlers = ({
|
|||||||
const folderKey = resolveFolderRowKey(folderId);
|
const folderKey = resolveFolderRowKey(folderId);
|
||||||
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
|
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
|
||||||
|
|
||||||
let effectiveFolderSelection = selectedFolderIds;
|
let effectiveFolderSelection: FolderIdentifier[] = selectedFolderIds;
|
||||||
let effectiveDocumentSelection = selectedDocumentIds;
|
let effectiveDocumentSelection: Identifier[] = selectedDocumentIds;
|
||||||
|
|
||||||
if (!isAlreadySelected && folderKey) {
|
if (!isAlreadySelected && folderKey) {
|
||||||
effectiveFolderSelection = [folderId];
|
effectiveFolderSelection = [folderId];
|
||||||
@@ -291,7 +347,7 @@ const useDocumentDragHandlers = ({
|
|||||||
|
|
||||||
const previewNode = createDragPreview({
|
const previewNode = createDragPreview({
|
||||||
documents: effectiveDocumentSelection
|
documents: effectiveDocumentSelection
|
||||||
.map((id) => documentLookup.get(id) || null)
|
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
folders: uniqueFolders,
|
folders: uniqueFolders,
|
||||||
});
|
});
|
||||||
@@ -317,7 +373,7 @@ const useDocumentDragHandlers = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleFolderDragEnd = useCallback(
|
const handleFolderDragEnd = useCallback(
|
||||||
(event) => {
|
(event?: DragEvent<HTMLElement>) => {
|
||||||
if (event?.currentTarget) {
|
if (event?.currentTarget) {
|
||||||
event.currentTarget.classList.remove('dragging');
|
event.currentTarget.classList.remove('dragging');
|
||||||
}
|
}
|
||||||
+344
-161
@@ -1,12 +1,183 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { isPlainObject } from '../../utils/typeGuards';
|
||||||
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||||
import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils';
|
import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils';
|
||||||
|
|
||||||
const normalizeDocumentId = (value) => {
|
type DocumentId = string | number;
|
||||||
if (!value) return null;
|
type FolderId = DocumentId | 'root';
|
||||||
if (typeof value === 'object' && value.id) {
|
type NullableFolderId = FolderId | null;
|
||||||
return value.id;
|
|
||||||
|
type StatusLevel = 'success' | 'error' | 'info' | string;
|
||||||
|
|
||||||
|
type DocumentCacheMapper = (
|
||||||
|
doc: DocumentLike | null,
|
||||||
|
) => DocumentLike | null;
|
||||||
|
|
||||||
|
type MapDocumentCaches = (mapper: DocumentCacheMapper) => void;
|
||||||
|
|
||||||
|
type UpdateDocumentCaches = (
|
||||||
|
documentId: DocumentId,
|
||||||
|
updater: DocumentCacheMapper,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type EnsureFolderData = (
|
||||||
|
folderId: FolderId,
|
||||||
|
options?: { force?: boolean; includeDocuments?: boolean; prefetchDepth?: number },
|
||||||
|
) => Promise<FolderContents>;
|
||||||
|
|
||||||
|
type ApplySelectedFolder = (folderId: FolderId, contents?: FolderContents | null) => void;
|
||||||
|
|
||||||
|
type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void;
|
||||||
|
|
||||||
|
type CloseDocumentPreview = () => void;
|
||||||
|
|
||||||
|
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||||
|
|
||||||
|
type SetStatusMessage = (message: string, level?: StatusLevel) => void;
|
||||||
|
|
||||||
|
interface ApiClient {
|
||||||
|
post<T = unknown>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<{ data: T }>;
|
||||||
|
patch<T = unknown>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<{ data: T }>;
|
||||||
|
delete<T = unknown>(url: string, config?: Record<string, unknown>): Promise<{ data: T }>;
|
||||||
}
|
}
|
||||||
return value;
|
|
||||||
|
interface Tag {
|
||||||
|
id: DocumentId;
|
||||||
|
label: string;
|
||||||
|
color?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentLike {
|
||||||
|
id?: DocumentId;
|
||||||
|
folder_id?: NullableFolderId;
|
||||||
|
folder_path?: string | null;
|
||||||
|
folder_name?: string | null;
|
||||||
|
issued_at?: number | null;
|
||||||
|
title?: string;
|
||||||
|
tags?: Tag[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderContents {
|
||||||
|
documents?: DocumentLike[];
|
||||||
|
subfolders?: Array<{ id?: FolderId; [key: string]: unknown }>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderNode {
|
||||||
|
id: FolderId;
|
||||||
|
parentId?: FolderId;
|
||||||
|
children: FolderId[];
|
||||||
|
hasChildren?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagManager {
|
||||||
|
normalizeLabel: (label: string) => string;
|
||||||
|
buildPayload: (args: { label: string }) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentTagExtras {
|
||||||
|
option?: Tag | null;
|
||||||
|
input?: { value?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteOptions {
|
||||||
|
showMessage?: boolean;
|
||||||
|
manageLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagAttachArgs {
|
||||||
|
documentId?: DocumentId;
|
||||||
|
tagId?: DocumentId;
|
||||||
|
tag?: Tag | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagRemoveOptions {
|
||||||
|
refreshTagList?: boolean;
|
||||||
|
showMessage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderDeleteOptions {
|
||||||
|
showMessage?: boolean;
|
||||||
|
manageLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDocumentMutationsArgs {
|
||||||
|
api: ApiClient;
|
||||||
|
token?: string | null;
|
||||||
|
documentLookup: Map<DocumentId, DocumentLike>;
|
||||||
|
folderLabelMap: Map<FolderId, string>;
|
||||||
|
ensureFolderData: EnsureFolderData;
|
||||||
|
selectedFolder: FolderId;
|
||||||
|
setSelectedFolder: Dispatch<SetStateAction<FolderId>>;
|
||||||
|
setDocuments: Dispatch<SetStateAction<DocumentLike[]>>;
|
||||||
|
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContents>>>;
|
||||||
|
setSearchResultIds: Dispatch<SetStateAction<DocumentId[] | null>>;
|
||||||
|
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
|
||||||
|
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
|
||||||
|
selectionOrderRef: MutableRefObject<string[] | null>;
|
||||||
|
selectionAnchorRef: MutableRefObject<string | null>;
|
||||||
|
setFocusedDocumentId: Dispatch<SetStateAction<DocumentId | null>>;
|
||||||
|
focusedDocumentId: DocumentId | null;
|
||||||
|
setFocusedRowKey: Dispatch<SetStateAction<string | null>>;
|
||||||
|
focusedRowKey: string | null;
|
||||||
|
notifyApiError: NotifyApiError;
|
||||||
|
setStatusMessage: SetStatusMessage;
|
||||||
|
setLoading: (next: boolean) => void;
|
||||||
|
mapDocumentCaches: MapDocumentCaches;
|
||||||
|
applySelectedFolder: ApplySelectedFolder;
|
||||||
|
folderNodes: Map<FolderId, FolderNode>;
|
||||||
|
setFolderNodes: Dispatch<SetStateAction<Map<FolderId, FolderNode>>>;
|
||||||
|
removeDocumentsFromCaches: RemoveDocumentsFromCaches;
|
||||||
|
closeDocumentPreview: CloseDocumentPreview;
|
||||||
|
previewDocumentId?: DocumentId | null;
|
||||||
|
refreshCurrentFolder: () => Promise<void>;
|
||||||
|
updateDocumentCaches: UpdateDocumentCaches;
|
||||||
|
tagLookupById: Map<DocumentId, Tag>;
|
||||||
|
tags: Tag[];
|
||||||
|
refreshTags: () => Promise<void>;
|
||||||
|
tagManager: TagManager;
|
||||||
|
extractDocumentFromResponse?: (payload: unknown) => DocumentLike | null;
|
||||||
|
ingestDocuments?: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDocumentMutationsResult {
|
||||||
|
moveDocumentsToFolder: (
|
||||||
|
documentIds: Array<DocumentId | DocumentLike>,
|
||||||
|
targetFolderId?: NullableFolderId,
|
||||||
|
) => Promise<void>;
|
||||||
|
handleThumbnailRegeneration: (documentId: DocumentId) => Promise<void>;
|
||||||
|
handleDocumentsDelete: (
|
||||||
|
documentIds: DocumentId[],
|
||||||
|
options?: DeleteOptions,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
handleDocumentTagAdd: (
|
||||||
|
document: DocumentLike,
|
||||||
|
label: string,
|
||||||
|
extras?: DocumentTagExtras | null,
|
||||||
|
) => Promise<void>;
|
||||||
|
handleDocumentTagAttach: (args: TagAttachArgs) => Promise<boolean>;
|
||||||
|
handleDocumentTitleUpdate: (documentId: DocumentId, nextTitle: string) => Promise<boolean>;
|
||||||
|
handleDocumentIssuedUpdate: (
|
||||||
|
documentId: DocumentId,
|
||||||
|
nextIssuedDate: number | null,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
handleTagRemove: (
|
||||||
|
documentId?: DocumentId,
|
||||||
|
tagId?: DocumentId,
|
||||||
|
options?: TagRemoveOptions,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
handleFolderDelete: (folderId?: FolderId, options?: FolderDeleteOptions) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeDocumentId = (value: unknown): DocumentId | null => {
|
||||||
|
if (!value) return null;
|
||||||
|
if (isPlainObject(value) && 'id' in value && value.id != null) {
|
||||||
|
return value.id as DocumentId;
|
||||||
|
}
|
||||||
|
return value as DocumentId;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useDocumentMutations = ({
|
const useDocumentMutations = ({
|
||||||
@@ -19,7 +190,7 @@ const useDocumentMutations = ({
|
|||||||
setSelectedFolder,
|
setSelectedFolder,
|
||||||
setDocuments,
|
setDocuments,
|
||||||
setFolderContents,
|
setFolderContents,
|
||||||
setSearchResults,
|
setSearchResultIds,
|
||||||
setSelectedEntries,
|
setSelectedEntries,
|
||||||
setSelectionOrder,
|
setSelectionOrder,
|
||||||
selectionOrderRef,
|
selectionOrderRef,
|
||||||
@@ -39,43 +210,41 @@ const useDocumentMutations = ({
|
|||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
previewDocumentId,
|
previewDocumentId,
|
||||||
refreshCurrentFolder,
|
refreshCurrentFolder,
|
||||||
documentsViewMode,
|
|
||||||
updateDocumentCaches,
|
updateDocumentCaches,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
tags,
|
tags,
|
||||||
refreshTags,
|
refreshTags,
|
||||||
tagManager,
|
tagManager,
|
||||||
extractDocumentFromResponse,
|
extractDocumentFromResponse,
|
||||||
}) => {
|
ingestDocuments,
|
||||||
|
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
|
||||||
const moveDocumentsToFolder = useCallback(
|
const moveDocumentsToFolder = useCallback(
|
||||||
async (documentIds, targetFolderId) => {
|
async (documentIds: Array<DocumentId | DocumentLike>, targetFolderId?: NullableFolderId) => {
|
||||||
const uniqueIds = Array.from(
|
const uniqueIds = Array.from(
|
||||||
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean)),
|
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]),
|
||||||
);
|
);
|
||||||
if (!uniqueIds.length) return;
|
if (!uniqueIds.length) return;
|
||||||
|
|
||||||
const uniqueIdSet = new Set(uniqueIds);
|
const uniqueIdSet = new Set(uniqueIds);
|
||||||
const target = targetFolderId === 'root' ? null : targetFolderId;
|
const target = targetFolderId === 'root' ? null : targetFolderId ?? null;
|
||||||
const targetLabel =
|
const targetLabel =
|
||||||
target === null
|
target === null ? DEFAULT_FOLDER_NAME : folderLabelMap.get(targetFolderId as FolderId) || 'target folder';
|
||||||
? DEFAULT_FOLDER_NAME
|
|
||||||
: folderLabelMap.get(targetFolderId) || 'target folder';
|
|
||||||
|
|
||||||
const movedDocs = uniqueIds
|
const movedDocs = uniqueIds
|
||||||
.map((id) => {
|
.map((id) => {
|
||||||
const doc = documentLookup.get(id);
|
const doc = documentLookup.get(id) || null;
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sourceFolderId: doc.folder_id ?? null,
|
sourceFolderId: (doc.folder_id ?? null) as NullableFolderId,
|
||||||
document: doc,
|
document: doc,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: DocumentLike }>;
|
||||||
|
|
||||||
const updatedDocsMap = new Map();
|
const updatedDocsMap = new Map<DocumentId, DocumentLike>();
|
||||||
const resolveTargetName = () => {
|
const resolveTargetName = () => {
|
||||||
if (!targetLabel) {
|
if (!targetLabel) {
|
||||||
return null;
|
return null;
|
||||||
@@ -89,7 +258,7 @@ const useDocumentMutations = ({
|
|||||||
if (!document) {
|
if (!document) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updated = {
|
const updated: DocumentLike = {
|
||||||
...document,
|
...document,
|
||||||
folder_id: target,
|
folder_id: target,
|
||||||
};
|
};
|
||||||
@@ -105,13 +274,13 @@ const useDocumentMutations = ({
|
|||||||
updatedDocsMap.set(id, updated);
|
updatedDocsMap.set(id, updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
const pruneRow = (collection) =>
|
const pruneRow = (collection: string[]): string[] =>
|
||||||
collection.filter((key) => {
|
collection.filter((key) => {
|
||||||
if (!isDocumentRowKey(key)) {
|
if (!isDocumentRowKey(key)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const id = getRowId(key);
|
const id = getRowId(key);
|
||||||
return id ? !uniqueIdSet.has(id) : true;
|
return id ? !uniqueIdSet.has(id as DocumentId) : true;
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -131,10 +300,10 @@ const useDocumentMutations = ({
|
|||||||
|
|
||||||
if (updatedDocsMap.size) {
|
if (updatedDocsMap.size) {
|
||||||
mapDocumentCaches((doc) => {
|
mapDocumentCaches((doc) => {
|
||||||
if (!doc || !uniqueIdSet.has(doc.id)) {
|
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
const updated = updatedDocsMap.get(doc.id);
|
const updated = updatedDocsMap.get(doc.id as DocumentId);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -142,7 +311,7 @@ const useDocumentMutations = ({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
mapDocumentCaches((doc) => {
|
mapDocumentCaches((doc) => {
|
||||||
if (!doc || !uniqueIdSet.has(doc.id)) {
|
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
return { ...doc, folder_id: target };
|
return { ...doc, folder_id: target };
|
||||||
@@ -150,22 +319,22 @@ const useDocumentMutations = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (uniqueIdSet.size) {
|
if (uniqueIdSet.size) {
|
||||||
setSearchResults((prev) => {
|
setSearchResultIds((prev) => {
|
||||||
if (!Array.isArray(prev) || !prev.length) {
|
if (!Array.isArray(prev) || !prev.length) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id));
|
const filtered = prev.filter((id) => !uniqueIdSet.has(id as DocumentId));
|
||||||
return filtered.length === prev.length ? prev : filtered;
|
return filtered.length === prev.length ? prev : filtered;
|
||||||
});
|
});
|
||||||
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id)));
|
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
|
||||||
setFolderContents((prev) => {
|
setFolderContents((prev: Map<FolderId, FolderContents>) => {
|
||||||
if (!prev.size) {
|
if (!prev.size) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next = new Map(prev);
|
const next = new Map<FolderId, FolderContents>(prev);
|
||||||
movedDocs.forEach(({ id, sourceFolderId }) => {
|
movedDocs.forEach(({ id, sourceFolderId }) => {
|
||||||
const sourceKey = sourceFolderId || 'root';
|
const sourceKey = (sourceFolderId || 'root') as FolderId;
|
||||||
const entry = next.get(sourceKey);
|
const entry = next.get(sourceKey);
|
||||||
if (!entry?.documents?.length) {
|
if (!entry?.documents?.length) {
|
||||||
return;
|
return;
|
||||||
@@ -179,13 +348,14 @@ const useDocumentMutations = ({
|
|||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
});
|
});
|
||||||
|
|
||||||
setSelectedEntries((prev) => pruneRow(prev, uniqueIdSet));
|
setSelectedEntries((prev) => pruneRow(prev));
|
||||||
setSelectionOrder((prev) => pruneRow(prev, uniqueIdSet));
|
setSelectionOrder((prev) => pruneRow(prev));
|
||||||
selectionOrderRef.current = pruneRow(selectionOrderRef.current || [], uniqueIdSet);
|
const nextSelectionOrder = pruneRow(selectionOrderRef.current || []);
|
||||||
|
selectionOrderRef.current = nextSelectionOrder;
|
||||||
if (
|
if (
|
||||||
selectionAnchorRef.current &&
|
selectionAnchorRef.current &&
|
||||||
isDocumentRowKey(selectionAnchorRef.current) &&
|
isDocumentRowKey(selectionAnchorRef.current) &&
|
||||||
uniqueIdSet.has(getRowId(selectionAnchorRef.current))
|
uniqueIdSet.has(getRowId(selectionAnchorRef.current) as DocumentId)
|
||||||
) {
|
) {
|
||||||
selectionAnchorRef.current = null;
|
selectionAnchorRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -195,17 +365,17 @@ const useDocumentMutations = ({
|
|||||||
if (
|
if (
|
||||||
focusedRowKey &&
|
focusedRowKey &&
|
||||||
isDocumentRowKey(focusedRowKey) &&
|
isDocumentRowKey(focusedRowKey) &&
|
||||||
uniqueIdSet.has(getRowId(focusedRowKey))
|
uniqueIdSet.has(getRowId(focusedRowKey) as DocumentId)
|
||||||
) {
|
) {
|
||||||
setFocusedRowKey(null);
|
setFocusedRowKey(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetFolderId && targetFolderId !== selectedFolder) {
|
if (targetFolderId && targetFolderId !== selectedFolder) {
|
||||||
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
await ensureFolderData(targetFolderId as FolderId, { force: true, prefetchDepth: 1 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.error || 'Failed to move documents.';
|
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
|
||||||
notifyApiError(error, message);
|
notifyApiError(error, message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -217,7 +387,7 @@ const useDocumentMutations = ({
|
|||||||
folderLabelMap,
|
folderLabelMap,
|
||||||
ensureFolderData,
|
ensureFolderData,
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
setSearchResults,
|
setSearchResultIds,
|
||||||
setDocuments,
|
setDocuments,
|
||||||
setFolderContents,
|
setFolderContents,
|
||||||
setSelectedEntries,
|
setSelectedEntries,
|
||||||
@@ -236,7 +406,7 @@ const useDocumentMutations = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleThumbnailRegeneration = useCallback(
|
const handleThumbnailRegeneration = useCallback(
|
||||||
async (documentId) => {
|
async (documentId: DocumentId) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setStatusMessage('Log in to manage assets.', 'error');
|
setStatusMessage('Log in to manage assets.', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -249,7 +419,7 @@ const useDocumentMutations = ({
|
|||||||
setStatusMessage('Document re-analysis queued.', 'info');
|
setStatusMessage('Document re-analysis queued.', 'info');
|
||||||
await refreshCurrentFolder();
|
await refreshCurrentFolder();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.error || 'Failed to request thumbnail generation.';
|
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to request thumbnail generation.';
|
||||||
notifyApiError(error, message);
|
notifyApiError(error, message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -259,7 +429,7 @@ const useDocumentMutations = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentsDelete = useCallback(
|
const handleDocumentsDelete = useCallback(
|
||||||
async (documentIds, { showMessage = true, manageLoading = true } = {}) => {
|
async (documentIds: DocumentId[], { showMessage = true, manageLoading = true }: DeleteOptions = {}) => {
|
||||||
if (!documentIds || documentIds.length === 0) {
|
if (!documentIds || documentIds.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -274,43 +444,11 @@ const useDocumentMutations = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const softDeleteTargets = [];
|
await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)));
|
||||||
const hardDeleteTargets = [];
|
|
||||||
|
|
||||||
documentIds.forEach((documentId) => {
|
|
||||||
const lookupDoc =
|
|
||||||
documentLookup && typeof documentLookup.get === 'function'
|
|
||||||
? documentLookup.get(documentId)
|
|
||||||
: documentLookup?.[documentId];
|
|
||||||
|
|
||||||
if (lookupDoc && lookupDoc.deleted_at) {
|
|
||||||
hardDeleteTargets.push(documentId);
|
|
||||||
} else {
|
|
||||||
softDeleteTargets.push(documentId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const operations = [];
|
|
||||||
if (softDeleteTargets.length) {
|
|
||||||
operations.push(
|
|
||||||
Promise.all(
|
|
||||||
softDeleteTargets.map((documentId) => api.post(`/documents/${documentId}/trash`)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (hardDeleteTargets.length) {
|
|
||||||
operations.push(
|
|
||||||
Promise.all(
|
|
||||||
hardDeleteTargets.map((documentId) => api.delete(`/documents/${documentId}`)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(operations);
|
|
||||||
|
|
||||||
removeDocumentsFromCaches(documentIds);
|
removeDocumentsFromCaches(documentIds);
|
||||||
|
|
||||||
if (documentIds.includes(previewDocumentId)) {
|
if (previewDocumentId && documentIds.includes(previewDocumentId)) {
|
||||||
closeDocumentPreview();
|
closeDocumentPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +458,7 @@ const useDocumentMutations = ({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.error || 'Failed to delete documents.';
|
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete documents.';
|
||||||
notifyApiError(error, message);
|
notifyApiError(error, message);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -343,8 +481,8 @@ const useDocumentMutations = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentTitleUpdate = useCallback(
|
const handleDocumentTitleUpdate = useCallback(
|
||||||
async (documentId, nextTitle) => {
|
async (documentId: DocumentId, nextTitle: string) => {
|
||||||
const trimmed = typeof nextTitle === 'string' ? nextTitle.trim() : '';
|
const trimmed = nextTitle?.trim?.() || '';
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setStatusMessage('Document title cannot be empty.', 'error');
|
setStatusMessage('Document title cannot be empty.', 'error');
|
||||||
return false;
|
return false;
|
||||||
@@ -355,159 +493,200 @@ const useDocumentMutations = ({
|
|||||||
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
|
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
|
||||||
const updatedDocument = extractDocumentFromResponse?.(data);
|
const updatedDocument = extractDocumentFromResponse?.(data);
|
||||||
|
|
||||||
|
if (updatedDocument && ingestDocuments) {
|
||||||
|
ingestDocuments([updatedDocument]);
|
||||||
|
} else {
|
||||||
updateDocumentCaches(documentId, (doc) => {
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
if (updatedDocument) {
|
if (updatedDocument) {
|
||||||
return { ...doc, ...updatedDocument };
|
return { ...doc, ...updatedDocument };
|
||||||
}
|
}
|
||||||
return { ...doc, title: trimmed };
|
return { ...doc, title: trimmed };
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setStatusMessage('Document title updated.', 'success');
|
setStatusMessage('Document title updated.', 'success');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.error || 'Failed to update document title.';
|
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update document title.';
|
||||||
notifyApiError(error, message);
|
notifyApiError(error, message);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, extractDocumentFromResponse, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches],
|
[
|
||||||
|
api,
|
||||||
|
extractDocumentFromResponse,
|
||||||
|
ingestDocuments,
|
||||||
|
notifyApiError,
|
||||||
|
setLoading,
|
||||||
|
setStatusMessage,
|
||||||
|
updateDocumentCaches,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentIssuedUpdate = useCallback(
|
const handleDocumentIssuedUpdate = useCallback(
|
||||||
async (documentId, nextIssuedDate) => {
|
async (documentId: DocumentId, nextIssuedDate: number | null) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const payload = { issued_at: nextIssuedDate || null };
|
const payload = { issued_at: nextIssuedDate || null };
|
||||||
try {
|
try {
|
||||||
const { data } = await api.patch(`/documents/${documentId}`, payload);
|
const { data } = await api.patch(`/documents/${documentId}`, payload);
|
||||||
const updatedDocument = extractDocumentFromResponse?.(data);
|
const updatedDocument = extractDocumentFromResponse?.(data);
|
||||||
|
|
||||||
|
if (updatedDocument && ingestDocuments) {
|
||||||
|
ingestDocuments([updatedDocument]);
|
||||||
|
} else {
|
||||||
updateDocumentCaches(documentId, (doc) => {
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
if (updatedDocument) {
|
if (updatedDocument) {
|
||||||
return { ...doc, ...updatedDocument };
|
return { ...doc, ...updatedDocument };
|
||||||
}
|
}
|
||||||
return { ...doc, issued_at: payload.issued_at };
|
return { ...doc, issued_at: payload.issued_at };
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.';
|
const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.';
|
||||||
setStatusMessage(message, 'success');
|
setStatusMessage(message, 'success');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.error || 'Failed to update issued date.';
|
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update issued date.';
|
||||||
notifyApiError(error, message);
|
notifyApiError(error, message);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, extractDocumentFromResponse, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches],
|
[
|
||||||
|
api,
|
||||||
|
extractDocumentFromResponse,
|
||||||
|
ingestDocuments,
|
||||||
|
notifyApiError,
|
||||||
|
setLoading,
|
||||||
|
setStatusMessage,
|
||||||
|
updateDocumentCaches,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentTagAdd = useCallback(
|
const attachTagToDocument = useCallback(
|
||||||
async (document, label, extras = null) => {
|
async ({
|
||||||
const normalizedLabel = tagManager.normalizeLabel(label);
|
documentId,
|
||||||
const optionCandidate =
|
tag,
|
||||||
extras && typeof extras === 'object' && 'option' in extras ? extras.option : null;
|
}: {
|
||||||
const input =
|
documentId?: DocumentId;
|
||||||
extras && typeof extras === 'object' && 'input' in extras ? extras.input : null;
|
tag?: Tag | null;
|
||||||
|
}) => {
|
||||||
let tag = null;
|
if (!documentId || !tag?.id) {
|
||||||
if (optionCandidate && optionCandidate.id) {
|
|
||||||
tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate;
|
|
||||||
}
|
|
||||||
if (!tag) {
|
|
||||||
tag =
|
|
||||||
tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (!tag) {
|
|
||||||
const payload = tagManager.buildPayload({ label: normalizedLabel });
|
|
||||||
const { data } = await api.post('/tags', payload);
|
|
||||||
tag = data;
|
|
||||||
await refreshTags();
|
|
||||||
}
|
|
||||||
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
|
|
||||||
setStatusMessage('Tag assigned.', 'success');
|
|
||||||
if (input && typeof input === 'object') {
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
await refreshCurrentFolder();
|
|
||||||
} catch (error) {
|
|
||||||
notifyApiError(error, 'Failed to assign tag.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[api, tags, refreshTags, refreshCurrentFolder, notifyApiError, setStatusMessage, tagManager],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDocumentTagAttach = useCallback(
|
|
||||||
async ({ documentId, tagId, tag: tagData = null }) => {
|
|
||||||
if (!documentId || !tagId) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveTagForCache = () => {
|
const cachedTag: Tag = {
|
||||||
const lookupTag = tagLookupById.get(tagId);
|
id: tag.id,
|
||||||
const source = lookupTag ?? tagData;
|
label: tag.label,
|
||||||
if (!source || source.id == null || typeof source.label !== 'string') {
|
color: Object.prototype.hasOwnProperty.call(tag, 'color') ? tag.color ?? null : null,
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: source.id,
|
|
||||||
label: source.label,
|
|
||||||
color: Object.prototype.hasOwnProperty.call(source, 'color') ? source.color : null,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] });
|
await api.post(`/documents/${documentId}/tags`, { tag_ids: [cachedTag.id] });
|
||||||
updateDocumentCaches(documentId, (doc) => {
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
|
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
|
||||||
if (currentTags.some((existing) => existing?.id === tagId)) {
|
if (currentTags.some((entry) => entry?.id === cachedTag.id)) {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
const resolvedTag = resolveTagForCache();
|
return { ...doc, tags: [...currentTags, cachedTag] };
|
||||||
if (!resolvedTag) {
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
return { ...doc, tags: [...currentTags, resolvedTag] };
|
|
||||||
});
|
});
|
||||||
setStatusMessage('Tag assigned.', 'success');
|
setStatusMessage('Tag assigned.', 'success');
|
||||||
if (documentsViewMode !== 'desk') {
|
|
||||||
await refreshCurrentFolder();
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.error || 'Failed to assign tag.';
|
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
|
||||||
notifyApiError(error, message);
|
notifyApiError(error, message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
[api, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentTagAdd = useCallback(
|
||||||
|
async (document: DocumentLike, label: string, extras: DocumentTagExtras | null = null) => {
|
||||||
|
const normalizedLabel = tagManager.normalizeLabel(label);
|
||||||
|
const optionCandidate = extras?.option ?? null;
|
||||||
|
const input = extras?.input ?? null;
|
||||||
|
|
||||||
|
let tag: Tag | null = null;
|
||||||
|
if (optionCandidate && optionCandidate.id) {
|
||||||
|
tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate;
|
||||||
|
}
|
||||||
|
if (!tag) {
|
||||||
|
tag = tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!tag) {
|
||||||
|
const payload = tagManager.buildPayload({ label: normalizedLabel });
|
||||||
|
const { data } = await api.post('/tags', payload);
|
||||||
|
tag = data as Tag;
|
||||||
|
await refreshTags();
|
||||||
|
}
|
||||||
|
await attachTagToDocument({
|
||||||
|
documentId: document.id as DocumentId,
|
||||||
|
tag,
|
||||||
|
});
|
||||||
|
if (input && Object(input) === input && 'value' in (input as Record<string, unknown>)) {
|
||||||
|
(input as { value?: string }).value = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Failed to assign tag.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, tags, refreshTags, attachTagToDocument, notifyApiError, tagManager],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentTagAttach = useCallback(
|
||||||
|
async ({ documentId, tagId, tag: tagData = null }: TagAttachArgs) => {
|
||||||
|
if (!documentId || !tagId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveTagForCache = (): Tag | null => {
|
||||||
|
const lookupTag = tagLookupById.get(tagId);
|
||||||
|
const source = lookupTag ?? tagData;
|
||||||
|
if (!source || source.id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const labelText = `${source.label ?? ''}`.trim();
|
||||||
|
if (!labelText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: source.id,
|
||||||
|
label: labelText,
|
||||||
|
color: Object.prototype.hasOwnProperty.call(source, 'color') ? (source as Tag).color ?? null : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedTag = resolveTagForCache();
|
||||||
|
return attachTagToDocument({
|
||||||
|
documentId,
|
||||||
|
tag: resolvedTag,
|
||||||
|
});
|
||||||
|
},
|
||||||
[
|
[
|
||||||
api,
|
attachTagToDocument,
|
||||||
refreshCurrentFolder,
|
|
||||||
documentsViewMode,
|
|
||||||
notifyApiError,
|
|
||||||
setStatusMessage,
|
|
||||||
updateDocumentCaches,
|
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyTagRemovalToCaches = useCallback(
|
const applyTagRemovalToCaches = useCallback(
|
||||||
(documentId, tagId) => {
|
(documentId?: DocumentId, tagId?: DocumentId) => {
|
||||||
if (!documentId || !tagId) {
|
if (!documentId || !tagId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDocumentCaches(documentId, (doc) => {
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
if (!Array.isArray(doc.tags)) {
|
if (!doc || !Array.isArray(doc.tags)) {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
|
const nextTags = doc.tags.filter((tagEntry) => tagEntry.id !== tagId);
|
||||||
if (nextTags.length === doc.tags.length) {
|
if (nextTags.length === doc.tags.length) {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
@@ -518,7 +697,11 @@ const useDocumentMutations = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleTagRemove = useCallback(
|
const handleTagRemove = useCallback(
|
||||||
async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => {
|
async (
|
||||||
|
documentId?: DocumentId,
|
||||||
|
tagId?: DocumentId,
|
||||||
|
{ refreshTagList = true, showMessage = true }: TagRemoveOptions = {},
|
||||||
|
) => {
|
||||||
if (!documentId || !tagId) {
|
if (!documentId || !tagId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -534,7 +717,7 @@ const useDocumentMutations = ({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.error || 'Failed to remove tag.';
|
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
|
||||||
notifyApiError(error, message);
|
notifyApiError(error, message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -543,7 +726,7 @@ const useDocumentMutations = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleFolderDelete = useCallback(
|
const handleFolderDelete = useCallback(
|
||||||
async (folderId, { showMessage = true, manageLoading = true } = {}) => {
|
async (folderId?: FolderId, { showMessage = true, manageLoading = true }: FolderDeleteOptions = {}) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
if (showMessage) {
|
if (showMessage) {
|
||||||
setStatusMessage('Log in to manage folders.', 'error');
|
setStatusMessage('Log in to manage folders.', 'error');
|
||||||
@@ -577,8 +760,8 @@ const useDocumentMutations = ({
|
|||||||
|
|
||||||
await api.delete(`/folders/${folderId}`);
|
await api.delete(`/folders/${folderId}`);
|
||||||
|
|
||||||
setFolderNodes((prev) => {
|
setFolderNodes((prev: Map<FolderId, FolderNode>) => {
|
||||||
const next = new Map(prev);
|
const next = new Map<FolderId, FolderNode>(prev);
|
||||||
const node = next.get(folderId);
|
const node = next.get(folderId);
|
||||||
next.delete(folderId);
|
next.delete(folderId);
|
||||||
if (node) {
|
if (node) {
|
||||||
@@ -596,8 +779,8 @@ const useDocumentMutations = ({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
setFolderContents((prev) => {
|
setFolderContents((prev: Map<FolderId, FolderContents>) => {
|
||||||
const next = new Map(prev);
|
const next = new Map<FolderId, FolderContents>(prev);
|
||||||
next.delete(folderId);
|
next.delete(folderId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -620,7 +803,7 @@ const useDocumentMutations = ({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.error || 'Failed to delete folder.';
|
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete folder.';
|
||||||
notifyApiError(error, message);
|
notifyApiError(error, message);
|
||||||
if (showMessage) {
|
if (showMessage) {
|
||||||
setStatusMessage(message, 'error');
|
setStatusMessage(message, 'error');
|
||||||
+118
-18
@@ -1,18 +1,60 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
type Identifier = string | number;
|
||||||
|
|
||||||
|
interface TagRecord {
|
||||||
|
id?: Identifier;
|
||||||
|
label: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiClient {
|
||||||
|
post: <T = { data: unknown }>(path: string, payload: unknown) => Promise<{ data: T } | T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagManager {
|
||||||
|
buildPayload: (input: { label: string }) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDocumentTaggingArgs {
|
||||||
|
apiClient: ApiClient;
|
||||||
|
tags: TagRecord[];
|
||||||
|
tagManager: TagManager;
|
||||||
|
refreshTags: () => Promise<void> | void;
|
||||||
|
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
|
||||||
|
notifyApiError: (error: unknown, message: string) => void;
|
||||||
|
setStatusMessage: (message: string, variant?: string) => void;
|
||||||
|
setLoading: (state: boolean) => void;
|
||||||
|
updateDocumentCaches?: (id: Identifier, updater: (doc: TagRecord | null) => TagRecord | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkTagOperationArgs {
|
||||||
|
labels: string[];
|
||||||
|
action: 'add' | 'remove';
|
||||||
|
documentIds?: Identifier[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkTagOperationResult {
|
||||||
|
ok: boolean;
|
||||||
|
reason?: 'no-labels' | 'no-selection' | 'tag-missing' | 'no-tags' | 'request-failed';
|
||||||
|
label?: string;
|
||||||
|
tagCount?: number;
|
||||||
|
docsCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const useDocumentTagging = ({
|
const useDocumentTagging = ({
|
||||||
apiClient,
|
apiClient,
|
||||||
tags,
|
tags,
|
||||||
tagManager,
|
tagManager,
|
||||||
refreshTags,
|
refreshTags,
|
||||||
refreshCurrentFolder,
|
|
||||||
resolveTargetDocumentIds,
|
resolveTargetDocumentIds,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
setStatusMessage,
|
setStatusMessage,
|
||||||
setLoading,
|
setLoading,
|
||||||
}) => {
|
updateDocumentCaches,
|
||||||
|
}: UseDocumentTaggingArgs) => {
|
||||||
const bulkTagOperation = useCallback(
|
const bulkTagOperation = useCallback(
|
||||||
async ({ labels, action, documentIds }) => {
|
async ({ labels, action, documentIds }: BulkTagOperationArgs): Promise<BulkTagOperationResult> => {
|
||||||
const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
|
const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
|
||||||
if (!normalized.length) {
|
if (!normalized.length) {
|
||||||
return { ok: false, reason: 'no-labels' };
|
return { ok: false, reason: 'no-labels' };
|
||||||
@@ -22,7 +64,7 @@ const useDocumentTagging = ({
|
|||||||
return { ok: false, reason: 'no-selection' };
|
return { ok: false, reason: 'no-selection' };
|
||||||
}
|
}
|
||||||
|
|
||||||
let tagIds = [];
|
let tagIds: Identifier[] = [];
|
||||||
|
|
||||||
if (action === 'remove') {
|
if (action === 'remove') {
|
||||||
const missing = normalized.find(
|
const missing = normalized.find(
|
||||||
@@ -41,18 +83,55 @@ const useDocumentTagging = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (action === 'add') {
|
if (action === 'add') {
|
||||||
const createdIds = [];
|
const createdIds: Identifier[] = [];
|
||||||
|
const createdTags: TagRecord[] = [];
|
||||||
for (const label of normalized) {
|
for (const label of normalized) {
|
||||||
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
const payload = tagManager.buildPayload({ label });
|
const payload = tagManager.buildPayload({ label });
|
||||||
const { data } = await apiClient.post('/tags', payload);
|
const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload);
|
||||||
tag = data;
|
tag = 'data' in response ? response.data : response;
|
||||||
await refreshTags();
|
await refreshTags();
|
||||||
}
|
}
|
||||||
createdIds.push(tag.id);
|
createdIds.push(tag.id);
|
||||||
|
createdTags.push(tag);
|
||||||
}
|
}
|
||||||
tagIds = Array.from(new Set(createdIds));
|
tagIds = Array.from(new Set(createdIds));
|
||||||
|
|
||||||
|
if (updateDocumentCaches) {
|
||||||
|
const tagById = new Map<Identifier, TagRecord>();
|
||||||
|
tags.forEach((tag) => {
|
||||||
|
if (tag?.id != null) {
|
||||||
|
tagById.set(tag.id, tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
createdTags.forEach((tag) => {
|
||||||
|
if (tag?.id != null) {
|
||||||
|
tagById.set(tag.id, tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
targetDocumentIds.forEach((docId) => {
|
||||||
|
tagIds.forEach((tagId) => {
|
||||||
|
const cachedTag = tagById.get(tagId);
|
||||||
|
if (!cachedTag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateDocumentCaches(docId, (doc) => {
|
||||||
|
if (!doc) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const currentTags = Array.isArray((doc as any).tags) ? (doc as any).tags : [];
|
||||||
|
if (currentTags.some((entry: any) => entry?.id === tagId)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(doc as any),
|
||||||
|
tags: [...currentTags, { ...cachedTag }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tagIds = Array.from(new Set(tagIds));
|
tagIds = Array.from(new Set(tagIds));
|
||||||
@@ -67,7 +146,23 @@ const useDocumentTagging = ({
|
|||||||
action,
|
action,
|
||||||
});
|
});
|
||||||
|
|
||||||
await refreshCurrentFolder();
|
if (updateDocumentCaches) {
|
||||||
|
targetDocumentIds.forEach((docId) => {
|
||||||
|
tagIds.forEach((tagId) => {
|
||||||
|
updateDocumentCaches(docId, (doc) => {
|
||||||
|
if (!doc || !Array.isArray((doc as any).tags)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const currentTags = (doc as any).tags;
|
||||||
|
if (action === 'remove') {
|
||||||
|
const filtered = currentTags.filter((entry: any) => entry?.id !== tagId);
|
||||||
|
return filtered.length === currentTags.length ? doc : { ...(doc as any), tags: filtered };
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -88,17 +183,17 @@ const useDocumentTagging = ({
|
|||||||
resolveTargetDocumentIds,
|
resolveTargetDocumentIds,
|
||||||
tags,
|
tags,
|
||||||
refreshTags,
|
refreshTags,
|
||||||
refreshCurrentFolder,
|
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
setLoading,
|
setLoading,
|
||||||
tagManager,
|
tagManager,
|
||||||
apiClient,
|
apiClient,
|
||||||
|
updateDocumentCaches,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBulkTagAddFromDetail = useCallback(
|
const handleBulkTagAddFromDetail = useCallback(
|
||||||
async ({ label, input, documentIds }) => {
|
async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||||
const trimmed = typeof label === 'string' ? label.trim() : '';
|
const trimmed = label?.trim?.() || '';
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setStatusMessage('Enter a tag label.', 'error');
|
setStatusMessage('Enter a tag label.', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -130,8 +225,8 @@ const useDocumentTagging = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleBulkTagRemoveFromDetail = useCallback(
|
const handleBulkTagRemoveFromDetail = useCallback(
|
||||||
async ({ label, input, documentIds }) => {
|
async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||||
const trimmed = typeof label === 'string' ? label.trim() : '';
|
const trimmed = label?.trim?.() || '';
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setStatusMessage('Enter a tag label to remove.', 'error');
|
setStatusMessage('Enter a tag label to remove.', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -163,7 +258,7 @@ const useDocumentTagging = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleBulkSelectionReanalyze = useCallback(
|
const handleBulkSelectionReanalyze = useCallback(
|
||||||
async (documentIdsOverride = null) => {
|
async (documentIdsOverride: Identifier[] | null = null) => {
|
||||||
const targetIds = resolveTargetDocumentIds(documentIdsOverride);
|
const targetIds = resolveTargetDocumentIds(documentIdsOverride);
|
||||||
if (!targetIds.length) {
|
if (!targetIds.length) {
|
||||||
setStatusMessage('Select documents before requesting re-analysis.', 'error');
|
setStatusMessage('Select documents before requesting re-analysis.', 'error');
|
||||||
@@ -172,11 +267,17 @@ const useDocumentTagging = ({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.post('/documents/bulk/reanalyze', {
|
const response = await apiClient.post<{ queued?: number }>(
|
||||||
|
'/documents/bulk/reanalyze',
|
||||||
|
{
|
||||||
document_ids: targetIds,
|
document_ids: targetIds,
|
||||||
force: true,
|
force: true,
|
||||||
});
|
},
|
||||||
const queued = data?.queued ?? targetIds.length;
|
);
|
||||||
|
const payload = 'data' in response ? response.data : response;
|
||||||
|
const queued = Number.isFinite(payload?.queued)
|
||||||
|
? Number(payload.queued)
|
||||||
|
: targetIds.length;
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
`Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
|
`Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
|
||||||
'success',
|
'success',
|
||||||
@@ -201,4 +302,3 @@ const useDocumentTagging = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default useDocumentTagging;
|
export default useDocumentTagging;
|
||||||
|
|
||||||
+158
-52
@@ -1,8 +1,81 @@
|
|||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||||
import useFileDrop from './useFileDrop';
|
import useFileDrop from './useFileDrop';
|
||||||
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
|
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
|
||||||
|
|
||||||
const mapFilesToEntries = (filesInput) => {
|
type Identifier = string | number;
|
||||||
|
type FolderId = Identifier | 'root' | null;
|
||||||
|
|
||||||
|
type FileEntry = {
|
||||||
|
file: File;
|
||||||
|
segments: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error';
|
||||||
|
|
||||||
|
type UploadQueueItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number | null;
|
||||||
|
folderId: FolderId;
|
||||||
|
status: UploadStatus;
|
||||||
|
error: string | null;
|
||||||
|
code: number | null;
|
||||||
|
document: unknown;
|
||||||
|
conflictDocumentId: Identifier | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UploadResponse {
|
||||||
|
reused?: boolean;
|
||||||
|
document?: unknown;
|
||||||
|
folder?: { id?: FolderId };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiClient {
|
||||||
|
post<T = UploadResponse>(url: string, payload: unknown): Promise<{ data: T; status?: number }>;
|
||||||
|
get<T = { document?: unknown }>(url: string): Promise<{ data: T }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusLevel = 'success' | 'info' | 'warning' | 'error' | string;
|
||||||
|
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||||
|
type SetStatusMessage = (message: string, level?: StatusLevel) => void;
|
||||||
|
|
||||||
|
type DropOverlayState = {
|
||||||
|
active: boolean;
|
||||||
|
folderName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileSystemEntryLike = FileSystemEntry;
|
||||||
|
|
||||||
|
type ExtendedDataTransferItem = DataTransferItem & {
|
||||||
|
webkitGetAsEntry?: () => FileSystemEntry | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FileSystemDirectoryReaderLike {
|
||||||
|
readEntries: (
|
||||||
|
successCallback: (entries: FileSystemEntryLike[]) => void,
|
||||||
|
errorCallback: (error: DOMException) => void,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSystemFileEntryLike {
|
||||||
|
isFile: true;
|
||||||
|
isDirectory: false;
|
||||||
|
name: string;
|
||||||
|
file: (
|
||||||
|
successCallback: (file: File) => void,
|
||||||
|
errorCallback: (error: DOMException) => void,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSystemDirectoryEntryLike {
|
||||||
|
isFile: false;
|
||||||
|
isDirectory: true;
|
||||||
|
name: string;
|
||||||
|
createReader: () => FileSystemDirectoryReaderLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] => {
|
||||||
if (!filesInput) {
|
if (!filesInput) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -10,8 +83,7 @@ const mapFilesToEntries = (filesInput) => {
|
|||||||
return files
|
return files
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
const relativePath =
|
const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
|
||||||
typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : '';
|
|
||||||
const segments = relativePath
|
const segments = relativePath
|
||||||
? relativePath
|
? relativePath
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -22,6 +94,37 @@ const mapFilesToEntries = (filesInput) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface UseDocumentUploadsArgs {
|
||||||
|
apiClient: ApiClient;
|
||||||
|
token?: string | null;
|
||||||
|
selectedFolder?: FolderId;
|
||||||
|
currentFolderName?: string | null;
|
||||||
|
ensureFolderData: (folderId: FolderId, options?: { force?: boolean; prefetchDepth?: number }) => Promise<void>;
|
||||||
|
refreshCurrentFolder: () => Promise<void>;
|
||||||
|
setLoading: (state: boolean) => void;
|
||||||
|
shellRef: MutableRefObject<HTMLElement | null>;
|
||||||
|
notifyApiError?: NotifyApiError;
|
||||||
|
setStatusMessage?: SetStatusMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDocumentUploadsResult {
|
||||||
|
dropOverlayState: DropOverlayState;
|
||||||
|
setDropOverlayState: Dispatch<SetStateAction<DropOverlayState>>;
|
||||||
|
dragCounterRef: MutableRefObject<number>;
|
||||||
|
handleFileDrop: (dataTransfer: DataTransfer, targetFolderId?: FolderId) => Promise<void>;
|
||||||
|
handleFileSelection: (files?: FileList | null, targetFolderId?: FolderId) => Promise<void>;
|
||||||
|
uploadFile: (file: File, targetFolderId: FolderId) => Promise<{
|
||||||
|
document: unknown;
|
||||||
|
duplicate: boolean;
|
||||||
|
statusCode: number | null;
|
||||||
|
conflictDocumentId: Identifier | null;
|
||||||
|
}>;
|
||||||
|
extractFilesFromDataTransfer: (dataTransfer: DataTransfer) => Promise<FileEntry[]>;
|
||||||
|
resetUploadsState: () => void;
|
||||||
|
uploadQueue: UploadQueueItem[];
|
||||||
|
clearUploadQueue: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
const useDocumentUploads = ({
|
const useDocumentUploads = ({
|
||||||
apiClient,
|
apiClient,
|
||||||
token,
|
token,
|
||||||
@@ -31,26 +134,28 @@ const useDocumentUploads = ({
|
|||||||
refreshCurrentFolder,
|
refreshCurrentFolder,
|
||||||
setLoading,
|
setLoading,
|
||||||
shellRef,
|
shellRef,
|
||||||
}) => {
|
notifyApiError,
|
||||||
const [dropOverlayState, setDropOverlayState] = useState({
|
setStatusMessage,
|
||||||
|
}: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
|
||||||
|
const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({
|
||||||
active: false,
|
active: false,
|
||||||
folderName: DEFAULT_FOLDER_NAME,
|
folderName: currentFolderName || DEFAULT_FOLDER_NAME,
|
||||||
});
|
});
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const folderPathCacheRef = useRef(new Map());
|
const folderPathCacheRef = useRef<Map<string, FolderId>>(new Map());
|
||||||
const queueIdRef = useRef(0);
|
const queueIdRef = useRef(0);
|
||||||
const [uploadQueue, setUploadQueue] = useState([]);
|
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
|
||||||
|
|
||||||
const uploadFile = useCallback(
|
const uploadFile = useCallback(
|
||||||
async (file, targetFolderId) => {
|
async (file: File, targetFolderId: FolderId) => {
|
||||||
if (!file || file.size === 0) {
|
if (!file || file.size === 0) {
|
||||||
return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null };
|
return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file, file.name);
|
formData.append('file', file, file.name);
|
||||||
if (targetFolderId && targetFolderId !== 'root') {
|
if (targetFolderId != null && targetFolderId !== 'root') {
|
||||||
formData.append('folder_id', targetFolderId);
|
formData.append('folder_id', String(targetFolderId));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -63,14 +168,14 @@ const useDocumentUploads = ({
|
|||||||
statusCode: status ?? (duplicate ? 200 : 201),
|
statusCode: status ?? (duplicate ? 200 : 201),
|
||||||
conflictDocumentId: null,
|
conflictDocumentId: null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error.response?.status === 409) {
|
if (error.response?.status === 409) {
|
||||||
const conflictId = error.response?.data?.details?.conflict_document_id ?? null;
|
const conflictId = error.response?.data?.details?.conflict_document_id ?? null;
|
||||||
let conflictDocument = null;
|
let conflictDocument = null;
|
||||||
if (conflictId) {
|
if (conflictId) {
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.get(`/documents/${conflictId}`);
|
const { data } = await apiClient.get(`/documents/${conflictId}`);
|
||||||
conflictDocument = data?.document ?? data ?? null;
|
conflictDocument = (data as any)?.document ?? data ?? null;
|
||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
console.warn('[Uploads] failed to fetch conflict document', fetchError);
|
console.warn('[Uploads] failed to fetch conflict document', fetchError);
|
||||||
}
|
}
|
||||||
@@ -83,14 +188,16 @@ const useDocumentUploads = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
|
const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
|
||||||
|
notifyApiError?.(error, message);
|
||||||
|
setStatusMessage?.(message, 'error');
|
||||||
const wrapped = Object.assign(new Error(message), { response: error.response });
|
const wrapped = Object.assign(new Error(message), { response: error.response });
|
||||||
throw wrapped;
|
throw wrapped;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[apiClient],
|
[apiClient, notifyApiError, setStatusMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const appendQueueItems = useCallback((entries, targetFolderId) => {
|
const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => {
|
||||||
const baseId = Date.now();
|
const baseId = Date.now();
|
||||||
const items = entries.map(({ file }) => {
|
const items = entries.map(({ file }) => {
|
||||||
queueIdRef.current += 1;
|
queueIdRef.current += 1;
|
||||||
@@ -99,12 +206,12 @@ const useDocumentUploads = ({
|
|||||||
name: file?.name || 'Unnamed file',
|
name: file?.name || 'Unnamed file',
|
||||||
size: file?.size ?? null,
|
size: file?.size ?? null,
|
||||||
folderId: targetFolderId ?? selectedFolder ?? 'root',
|
folderId: targetFolderId ?? selectedFolder ?? 'root',
|
||||||
status: 'pending',
|
status: 'pending' as UploadStatus,
|
||||||
error: null,
|
error: null,
|
||||||
code: null,
|
code: null,
|
||||||
document: null,
|
document: null,
|
||||||
conflictDocumentId: null,
|
conflictDocumentId: null,
|
||||||
};
|
} satisfies UploadQueueItem;
|
||||||
});
|
});
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
setUploadQueue((current) => [...current, ...items]);
|
setUploadQueue((current) => [...current, ...items]);
|
||||||
@@ -112,7 +219,7 @@ const useDocumentUploads = ({
|
|||||||
return items;
|
return items;
|
||||||
}, [selectedFolder]);
|
}, [selectedFolder]);
|
||||||
|
|
||||||
const updateQueueItem = useCallback((id, patch) => {
|
const updateQueueItem = useCallback((id: string, patch: Partial<UploadQueueItem>) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -122,7 +229,7 @@ const useDocumentUploads = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const ensureFolderPathOnServer = useCallback(
|
const ensureFolderPathOnServer = useCallback(
|
||||||
async (baseFolderId, segments) => {
|
async (baseFolderId: FolderId, segments: string[]): Promise<FolderId> => {
|
||||||
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
|
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
|
||||||
if (trimmedSegments.length === 0) {
|
if (trimmedSegments.length === 0) {
|
||||||
return baseFolderId ?? null;
|
return baseFolderId ?? null;
|
||||||
@@ -131,7 +238,7 @@ const useDocumentUploads = ({
|
|||||||
const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
|
const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
|
||||||
const cache = folderPathCacheRef.current;
|
const cache = folderPathCacheRef.current;
|
||||||
if (cache.has(cacheKey)) {
|
if (cache.has(cacheKey)) {
|
||||||
return cache.get(cacheKey);
|
return cache.get(cacheKey) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -139,28 +246,32 @@ const useDocumentUploads = ({
|
|||||||
segments: trimmedSegments,
|
segments: trimmedSegments,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data } = await apiClient.post('/folders/path', payload);
|
const { data } = await apiClient.post<{ folder?: { id?: FolderId | null } }>(
|
||||||
cache.set(cacheKey, data.folder.id);
|
'/folders/path',
|
||||||
return data.folder.id;
|
payload,
|
||||||
|
);
|
||||||
|
const resolvedId = (data?.folder?.id ?? null) as FolderId;
|
||||||
|
cache.set(cacheKey, resolvedId);
|
||||||
|
return resolvedId;
|
||||||
},
|
},
|
||||||
[apiClient],
|
[apiClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
|
const extractFilesFromDataTransfer = useCallback(async (dataTransfer: DataTransfer) => {
|
||||||
if (!dataTransfer) {
|
if (!dataTransfer) {
|
||||||
throw new Error('No drop payload found.');
|
throw new Error('No drop payload found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = Array.from(dataTransfer.items || []);
|
const items = Array.from(dataTransfer.items || []) as ExtendedDataTransferItem[];
|
||||||
console.info('[Uploads] drop start', {
|
console.info('[Uploads] drop start', {
|
||||||
items: items.length,
|
items: items.length,
|
||||||
files: (dataTransfer.files || []).length,
|
files: (dataTransfer.files || []).length,
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = [];
|
const results: FileEntry[] = [];
|
||||||
const seenKeys = new Set();
|
const seenKeys = new Set();
|
||||||
|
|
||||||
const pushFile = (file, ancestors = []) => {
|
const pushFile = (file?: File | null, ancestors: string[] = []) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const segments = (ancestors || []).filter(Boolean);
|
const segments = (ancestors || []).filter(Boolean);
|
||||||
const key = `${segments.join('/')}/${file.name}:${file.size}`;
|
const key = `${segments.join('/')}/${file.name}:${file.size}`;
|
||||||
@@ -171,11 +282,11 @@ const useDocumentUploads = ({
|
|||||||
results.push({ file, segments });
|
results.push({ file, segments });
|
||||||
};
|
};
|
||||||
|
|
||||||
const readAllEntries = async (reader) => {
|
const readAllEntries = async (reader: FileSystemDirectoryReaderLike) => {
|
||||||
const entries = [];
|
const entries: FileSystemEntryLike[] = [];
|
||||||
let batch = [];
|
let batch: FileSystemEntryLike[] = [];
|
||||||
do {
|
do {
|
||||||
batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
batch = await new Promise<FileSystemEntryLike[]>((resolve, reject) => reader.readEntries(resolve, reject));
|
||||||
if (batch.length) {
|
if (batch.length) {
|
||||||
entries.push(...batch);
|
entries.push(...batch);
|
||||||
}
|
}
|
||||||
@@ -183,15 +294,15 @@ const useDocumentUploads = ({
|
|||||||
return entries;
|
return entries;
|
||||||
};
|
};
|
||||||
|
|
||||||
const walkEntry = async (entry, ancestors = []) => {
|
const walkEntry = async (entry: FileSystemEntryLike | null, ancestors: string[] = []) => {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (entry.isFile) {
|
if (entry.isFile) {
|
||||||
const file = await new Promise((resolve, reject) => {
|
const file = await new Promise<File>((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
entry.file(resolve, reject);
|
(entry as unknown as FileSystemFileEntryLike).file(resolve, reject);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Uploads] entry.file failed', error);
|
console.warn('[Uploads] entry.file failed', error);
|
||||||
reject(error);
|
reject(error as Error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
pushFile(file, ancestors);
|
pushFile(file, ancestors);
|
||||||
@@ -199,7 +310,7 @@ const useDocumentUploads = ({
|
|||||||
}
|
}
|
||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
|
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
|
||||||
const reader = entry.createReader();
|
const reader = (entry as unknown as FileSystemDirectoryEntryLike).createReader();
|
||||||
const entries = await readAllEntries(reader);
|
const entries = await readAllEntries(reader);
|
||||||
for (const child of entries) {
|
for (const child of entries) {
|
||||||
await walkEntry(child, nextAncestors);
|
await walkEntry(child, nextAncestors);
|
||||||
@@ -211,12 +322,9 @@ const useDocumentUploads = ({
|
|||||||
items.map(async (item, index) => {
|
items.map(async (item, index) => {
|
||||||
if (item.kind !== 'file') return;
|
if (item.kind !== 'file') return;
|
||||||
|
|
||||||
const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
|
const fileFromItem = item.getAsFile?.() ?? null;
|
||||||
if (fileFromItem) {
|
if (fileFromItem) {
|
||||||
const relativePath =
|
const relativePath = (fileFromItem as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
|
||||||
typeof fileFromItem.webkitRelativePath === 'string'
|
|
||||||
? fileFromItem.webkitRelativePath
|
|
||||||
: '';
|
|
||||||
const segments = relativePath
|
const segments = relativePath
|
||||||
? relativePath
|
? relativePath
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -226,9 +334,9 @@ const useDocumentUploads = ({
|
|||||||
pushFile(fileFromItem, segments);
|
pushFile(fileFromItem, segments);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof item.webkitGetAsEntry === 'function') {
|
if ((item as ExtendedDataTransferItem).webkitGetAsEntry) {
|
||||||
try {
|
try {
|
||||||
const entry = item.webkitGetAsEntry();
|
const entry = (item as ExtendedDataTransferItem).webkitGetAsEntry?.();
|
||||||
if (entry) {
|
if (entry) {
|
||||||
await walkEntry(entry, []);
|
await walkEntry(entry, []);
|
||||||
return;
|
return;
|
||||||
@@ -246,8 +354,7 @@ const useDocumentUploads = ({
|
|||||||
|
|
||||||
Array.from(dataTransfer.files || []).forEach((file) => {
|
Array.from(dataTransfer.files || []).forEach((file) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const relativePath =
|
const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
|
||||||
typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : '';
|
|
||||||
const segments = relativePath
|
const segments = relativePath
|
||||||
? relativePath
|
? relativePath
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -327,7 +434,7 @@ const useDocumentUploads = ({
|
|||||||
updateQueueItem(queueItem.id, patch);
|
updateQueueItem(queueItem.id, patch);
|
||||||
Object.assign(queueItem, patch);
|
Object.assign(queueItem, patch);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (queueItem) {
|
if (queueItem) {
|
||||||
const patch = {
|
const patch = {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@@ -350,7 +457,7 @@ const useDocumentUploads = ({
|
|||||||
) {
|
) {
|
||||||
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
const message = error.message || 'Failed to upload files.';
|
const message = error.message || 'Failed to upload files.';
|
||||||
queueItems.forEach((item) => {
|
queueItems.forEach((item) => {
|
||||||
if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') {
|
if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') {
|
||||||
@@ -383,8 +490,8 @@ const useDocumentUploads = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleFileDrop = useCallback(
|
const handleFileDrop = useCallback(
|
||||||
async (dataTransfer, targetFolderId) => {
|
async (dataTransfer: DataTransfer, targetFolderId?: FolderId) => {
|
||||||
let extracted;
|
let extracted: FileEntry[];
|
||||||
try {
|
try {
|
||||||
extracted = await extractFilesFromDataTransfer(dataTransfer);
|
extracted = await extractFilesFromDataTransfer(dataTransfer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -398,7 +505,7 @@ const useDocumentUploads = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleFileSelection = useCallback(
|
const handleFileSelection = useCallback(
|
||||||
async (files, targetFolderId) => {
|
async (files?: FileList | null, targetFolderId?: FolderId) => {
|
||||||
const entries = mapFilesToEntries(files);
|
const entries = mapFilesToEntries(files);
|
||||||
await uploadFileEntries(entries, targetFolderId);
|
await uploadFileEntries(entries, targetFolderId);
|
||||||
},
|
},
|
||||||
@@ -414,7 +521,6 @@ const useDocumentUploads = ({
|
|||||||
hasFiles,
|
hasFiles,
|
||||||
defaultFolderName: DEFAULT_FOLDER_NAME,
|
defaultFolderName: DEFAULT_FOLDER_NAME,
|
||||||
dragCounterRef,
|
dragCounterRef,
|
||||||
dropOverlayState,
|
|
||||||
setDropOverlayState,
|
setDropOverlayState,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -439,7 +545,7 @@ const useDocumentUploads = ({
|
|||||||
resetUploadsState,
|
resetUploadsState,
|
||||||
uploadQueue,
|
uploadQueue,
|
||||||
clearUploadQueue,
|
clearUploadQueue,
|
||||||
};
|
} satisfies UseDocumentUploadsResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useDocumentUploads;
|
export default useDocumentUploads;
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { useCallback, useState } from 'react';
|
|
||||||
|
|
||||||
const useDocuments = ({ setSearchResults, setFolderContents }) => {
|
|
||||||
const [documents, setDocuments] = useState([]);
|
|
||||||
|
|
||||||
const mapDocumentCaches = useCallback(
|
|
||||||
(mapper) => {
|
|
||||||
if (typeof mapper !== 'function') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyToList = (list) => {
|
|
||||||
let changed = false;
|
|
||||||
const next = list.map((doc) => {
|
|
||||||
const updated = mapper(doc);
|
|
||||||
if (updated === undefined || updated === doc) {
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
changed = true;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
return changed ? next : list;
|
|
||||||
};
|
|
||||||
|
|
||||||
setDocuments((prev) => applyToList(prev));
|
|
||||||
setSearchResults((prev) => {
|
|
||||||
if (!Array.isArray(prev)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return applyToList(prev);
|
|
||||||
});
|
|
||||||
setFolderContents((prev) => {
|
|
||||||
if (!prev.size) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
let changed = false;
|
|
||||||
const next = new Map();
|
|
||||||
prev.forEach((contents, key) => {
|
|
||||||
const docs = Array.isArray(contents?.documents) ? contents.documents : null;
|
|
||||||
if (!docs || docs.length === 0) {
|
|
||||||
next.set(key, contents);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let docsChanged = false;
|
|
||||||
const updatedDocs = docs.map((doc) => {
|
|
||||||
const updated = mapper(doc);
|
|
||||||
if (updated === undefined || updated === doc) {
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
docsChanged = true;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
if (docsChanged) {
|
|
||||||
changed = true;
|
|
||||||
next.set(key, { ...contents, documents: updatedDocs });
|
|
||||||
} else {
|
|
||||||
next.set(key, contents);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return changed ? next : prev;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setFolderContents, setSearchResults],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateDocumentCaches = useCallback(
|
|
||||||
(documentId, updater) => {
|
|
||||||
if (!documentId || typeof updater !== 'function') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
mapDocumentCaches((doc) => {
|
|
||||||
if (!doc || doc.id !== documentId) {
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
const updated = updater(doc);
|
|
||||||
return updated === undefined ? doc : updated;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[mapDocumentCaches],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
documents,
|
|
||||||
setDocuments,
|
|
||||||
mapDocumentCaches,
|
|
||||||
updateDocumentCaches,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useDocuments;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user