Merge remote-tracking branch 'ui/ui' into dev

This commit is contained in:
2025-11-20 16:57:49 +01:00
176 changed files with 14078 additions and 8189 deletions
+1
View File
@@ -12,5 +12,6 @@ module.exports = {
runtime: 'automatic', runtime: 'automatic',
}, },
], ],
'@babel/preset-typescript',
], ],
}; };
+33
View File
@@ -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: '^_',
},
],
},
},
]; ];
+881 -18
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -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
}
/> />
); );
} }
-18
View File
@@ -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;
-187
View File
@@ -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;
+173
View File
@@ -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,26 +57,24 @@ 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) { const hashQuery = hash.slice(queryIndex + 1);
const hashQuery = hash.slice(queryIndex + 1); const hashParams = extract(`?${hashQuery}`);
const hashParams = extract(`?${hashQuery}`); combined = {
combined = { token: combined.token || hashParams.token,
token: combined.token || hashParams.token, username: combined.username || hashParams.username,
username: combined.username || hashParams.username, preferredTenantId: combined.preferredTenantId || hashParams.preferredTenantId,
preferredTenantId: combined.preferredTenantId || hashParams.preferredTenantId, };
}; }
} if (!combined.token) {
if (!combined.token) { const searchParams = extract(window.location.search);
const searchParams = extract(window.location.search); combined = {
combined = { token: combined.token || searchParams.token,
token: combined.token || searchParams.token, username: combined.username || searchParams.username,
username: combined.username || searchParams.username, preferredTenantId: combined.preferredTenantId || searchParams.preferredTenantId,
preferredTenantId: combined.preferredTenantId || searchParams.preferredTenantId, };
};
}
} }
return combined; return combined;
@@ -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';
@@ -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,
-205
View File
@@ -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;
+208
View File
@@ -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,39 +152,29 @@ 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) {
doc = await documentsManager.ensure(documentId);
}
if (!doc) { if (!doc) {
const { data } = await api.get(`/documents/${documentId}`); const { data } = await api.get(`/documents/${documentId}`);
const hydratedDetail = assetManager.hydrateDetail(data); const fetched = (data as { document?: DocumentLike })?.document || data;
const fetched = hydratedDetail?.document || data.document || data; const { canonical } = documentsManager.ingest([fetched as unknown]);
doc = fetched ? assetManager.hydrateDocument(fetched) : null; doc = (canonical[0] as DocumentLike | undefined) || null;
if (!doc) { if (!doc) {
throw new Error('Document metadata unavailable.'); throw new Error('Document metadata unavailable.');
} }
const existsInDocuments = documents.some((item) => item.id === doc.id);
const existsInSearch = Array.isArray(searchResults)
? searchResults.some((item) => item.id === doc.id)
: false;
if (existsInDocuments || existsInSearch) {
removeCachedPreviewDocument(doc.id);
} else {
cachePreviewDocument(doc);
}
} }
if (!previewReturnPathRef.current) { if (!previewReturnPathRef.current) {
@@ -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,
}; };
}; };
@@ -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;
@@ -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,
}; };
}; };
@@ -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,
]); ]);
@@ -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);
}, },
-168
View File
@@ -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 };
};
+194
View File
@@ -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;
}; };
-419
View File
@@ -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;
+317
View File
@@ -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

+75
View File
@@ -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

@@ -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();
}, []); }, []);
@@ -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>
-146
View File
@@ -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;
+122
View File
@@ -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;
@@ -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;
} }
setTimeout(() => {
if (typeof window.requestAnimationFrame === 'function') { focusTarget();
window.requestAnimationFrame(() => { }, 0);
focusTarget();
});
} else {
setTimeout(() => {
focusTarget();
}, 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 () => {
cancelled = true;
};
} }
const snapshot = previewSnapshots.get(overlayDocId);
if (!snapshot || !snapshot.url) { const doc = documentLookup.get(overlayDocId) || null;
return null; const docIdentifier = doc?.id ?? null;
if (!docIdentifier || !doc) {
setOverlaySource(null);
return () => {
cancelled = true;
};
} }
const doc = documentLookup.get(overlayDocId);
const alt = snapshot.alt || doc?.title; const docContentType = doc?.content_type ?? null;
return { const versionContentType = (doc?.current_version as { version?: { content_type?: string | null } } | null)?.version?.content_type ?? null;
url: snapshot.url,
alt, const applyEntry = (entry?: DocumentLinkLike | null) => {
canGoPrev: snapshot.canGoPrev, if (!entry?.url) {
canGoNext: snapshot.canGoNext, setOverlaySource(null);
goPrev: snapshot.goPrev, return;
goNext: snapshot.goNext, }
setOverlaySource({
url: entry.url,
alt: doc.title as string | undefined,
contentType: entry.contentType || docContentType || versionContentType || undefined,
});
}; };
}, [overlayDocId, previewSnapshots, documentLookup]);
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,51 +33,56 @@ 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> =>
request.onsuccess = () => { new Promise((resolve, reject) => {
const { result } = request; request.onsuccess = () => {
resolve(result ?? defaultValue); const { result } = request;
}; resolve(result ?? defaultValue);
request.onerror = () => { };
reject(request.error || new Error('IndexedDB request failed')); request.onerror = () => {
}; reject(request.error || new Error('IndexedDB request failed'));
}); };
});
const iterateCursor = (request, iteratee) => new Promise((resolve, reject) => { const iterateCursor = (request: IDBRequest<IDBCursorWithValue | null>, iteratee: (cursor: IDBCursorWithValue) => void) =>
request.onsuccess = (event) => { new Promise<void>((resolve, reject) => {
const cursor = event.target.result; request.onsuccess = (event) => {
if (!cursor) { const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (!cursor) {
resolve();
return;
}
try {
iteratee(cursor);
cursor.continue();
} catch (error) {
reject(error);
}
};
request.onerror = () => {
reject(request.error || new Error('IndexedDB cursor failed'));
};
});
const transactionComplete = (transaction: IDBTransaction) =>
new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => {
resolve(); resolve();
return; };
} transaction.onerror = () => {
try { reject(transaction.error || new Error('IndexedDB transaction failed'));
iteratee(cursor); };
cursor.continue(); transaction.onabort = () => {
} catch (error) { reject(transaction.error || new Error('IndexedDB transaction aborted'));
reject(error); };
} });
};
request.onerror = () => {
reject(request.error || new Error('IndexedDB cursor failed'));
};
});
const transactionComplete = (transaction) => new Promise((resolve, reject) => { type TransactionMode = 'readonly' | 'readwrite' | 'versionchange';
transaction.oncomplete = () => {
resolve();
};
transaction.onerror = () => {
reject(transaction.error || new Error('IndexedDB transaction failed'));
};
transaction.onabort = () => {
reject(transaction.error || new Error('IndexedDB transaction aborted'));
};
});
const withStore = async (mode, handler) => { 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,15 +179,17 @@ 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) => {
try { if (db) {
db.close(); try {
} catch (error) { db.close();
console.warn('[desk] Failed to close IndexedDB', error); } catch (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 };
} }
@@ -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);
-4
View File
@@ -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})`;
+8
View File
@@ -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})`;
@@ -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 });
+11 -29
View File
@@ -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.setPendingRemovalTag(null);
engine.setTagDropTargetId(null); }, [engine]);
}, [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);
updateRemovalCursor(false);
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;
if (element) {
element.classList.remove('is-drag-hidden');
}
cleanupPreview(state.previewClone);
} }
const element = state.element;
if (element) {
element.classList.remove('is-drag-hidden');
}
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;
@@ -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;
} }
map.set(String(docKey), transform); if (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;
@@ -423,10 +616,66 @@ const useDocumentDrag = (options = {}) => {
if (state.pointerId !== event.pointerId) { if (state.pointerId !== event.pointerId) {
return; return;
} }
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);
@@ -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);
} }
simulationState.angularVelocity = angularVelocity; const isSettled = Math.abs(angularVelocity) < (SETTLE_ANGULAR_VELOCITY * 0.6)
simulationState.dynamicRotation = dynamicRotation; || Math.abs(dynamicRotation) < (MAX_DYNAMIC_ROTATION * 0.05);
simulationState.rotation = simulationState.restRotation + dynamicRotation;
if (isSettled) {
simulationState.angularVelocity = 0;
simulationState.dynamicRotation = 0;
simulationState.rotation = simulationState.restRotation;
} else {
simulationState.angularVelocity = angularVelocity;
simulationState.dynamicRotation = 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,
@@ -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; previouslyFocusedRef.current = active instanceof HTMLElement ? active : null;
if (active && typeof active.focus === 'function') {
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,65 +318,50 @@ const PreviewZoomOverlay = ({
aria-modal="true" aria-modal="true"
aria-label="Enlarged document preview" aria-label="Enlarged document preview"
onClick={onClose} onClick={onClose}
>
<div
className={stageClassName}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<div <div
className={containerClassName} className={stageClassName}
ref={scrollRef} onClick={(event) => {
tabIndex={-1} if (event.target === event.currentTarget) {
onClick={(event) => event.stopPropagation()} onClose();
}
}}
> >
<img <div
src={effectiveDisplay.url} className={containerClassName}
alt={effectiveDisplay.alt || 'Document preview'} ref={scrollRef}
className="preview-zoom__image" tabIndex={-1}
ref={imageRef} >
draggable={false} {isPdfDisplay ? (
onLoad={(event) => { <div className="preview-zoom__pdf">
setNaturalSize({ <PdfViewer
width: event.currentTarget.naturalWidth || null, src={effectiveDisplay.url}
height: event.currentTarget.naturalHeight || null, title={effectiveAlt}
}); className="preview-zoom__pdf-viewer"
}} viewportRef={scrollRef}
onClick={handleImageClick} />
style={imageStyle} </div>
/> ) : (
</div> <img
{navVisible ? ( src={effectiveDisplay.url}
<div className="preview-zoom__nav"> alt={effectiveAlt}
<button className="preview-zoom__image"
type="button" ref={(node) => {
className="preview-zoom__nav-button" mediaRef.current = node;
onClick={(event) => {
event.stopPropagation();
if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) {
effectiveDisplay.goPrev();
}
}} }}
aria-label="Previous preview" draggable={false}
disabled={!effectiveDisplay?.canGoPrev} onLoad={(event) => {
> setNaturalSize({
<ArrowLeftIcon /> width: event.currentTarget.naturalWidth || null,
</button> height: event.currentTarget.naturalHeight || null,
<button });
type="button"
className="preview-zoom__nav-button"
onClick={(event) => {
event.stopPropagation();
if (effectiveDisplay?.canGoNext && effectiveDisplay?.goNext) {
effectiveDisplay.goNext();
}
}} }}
aria-label="Next preview" onClick={handleContentClick}
disabled={!effectiveDisplay?.canGoNext} style={contentStyle}
> />
<ArrowRightIcon /> )}
</button> </div>
</div>
) : null}
</div> </div>
</div> </div>
), ),
@@ -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,
}; };
@@ -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"
@@ -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);
@@ -251,17 +297,17 @@ const DocumentInfoPanel = ({
} }
if (metadataPayload) { if (metadataPayload) {
tabsList.push({ tabsList.push({
id: 'metadata', id: 'metadata',
label: metadataTabLabel, label: metadataTabLabel,
render: () => ( render: () => (
<section className={`${base}__section ${base}__section--metadata-json`}> <section className={`${base}__section ${base}__section--metadata-json`}>
<pre className={`${base}__metadata-json`}> <pre className={`${base}__metadata-json`}>
{JSON.stringify(metadataPayload, null, 2)} {JSON.stringify(metadataPayload, null, 2)}
</pre> </pre>
</section> </section>
), ),
}); });
} }
if (normalizedTrailingTabs.length) { if (normalizedTrailingTabs.length) {
@@ -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 tab.render(context); return null;
} }
if (tab.component) { return tab.render(context);
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;
@@ -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' }
? option.label.trim() return `${option}`.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);
entry.state = 'all'; if (entry) {
entry.payload = payload; entry.state = 'all';
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);
item.state = 'all'; if (item) {
item.payload = payload; item.state = 'all';
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,57 +597,60 @@ 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) => { setTitleDraft(event.target.value);
setTitleDraft(event.target.value); if (titleError) {
if (titleError) { setTitleError(null);
setTitleError(null); }
} }}
}} onKeyDown={(event) => {
onKeyDown={(event) => { if (event.key === 'Escape') {
if (event.key === 'Escape') { event.preventDefault();
event.preventDefault(); cancelTitleEdit();
cancelTitleEdit(); }
} }}
}} aria-label="Document title"
aria-label="Document title" autoFocus
autoFocus disabled={titleSaving}
disabled={titleSaving} />
/> <button type="submit" className="icon-button icon-button--accent" disabled={titleSaving} aria-label="Save title">
<button type="submit" disabled={titleSaving}> <CheckIcon size={16} />
Save </button>
</button> <button
<button type="button"
type="button" className="icon-button"
className="secondary" onClick={cancelTitleEdit}
onClick={cancelTitleEdit} disabled={titleSaving}
disabled={titleSaving} aria-label="Cancel"
> >
Cancel <IconX size={16} />
</button> </button>
</form> </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}
</>
)
); );
const titleMetaDisplay = editableTitle && isTitleEditing
? renderTitleEditForm('doc-title-edit--inline')
: (
<>
<span className="detail-meta__value">{document?.title}</span>
{editableTitle ? (
<button
type="button"
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
);
const issuedDisplay = editableIssued && isIssuedEditing ? ( const issuedDisplay = editableIssued && isIssuedEditing ? (
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}> <form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
<input <input
@@ -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,184 +695,92 @@ const DocumentSummarySection = ({
</> </>
); );
const metaItems = [ const tagsValueContent = (
{ <TagSection
key: 'issued', tags={resolvedTags}
label: 'Issued', onRemove={
valueContent: issuedDisplay, onTagRemove
error: issuedError, ? (tag) => onTagRemove(document.id, tag.id)
}, : undefined
...metaRows.map((row) => ({ }
onAdd={
onTagAdd
? ({ value, option }) => onTagAdd(document, value, { option })
: undefined
}
datalistOptions={tagOptions}
className="document-summary__tags"
/>
);
const correspondentsValueContent = (
<CorrespondentSection
entries={resolvedCorrespondents}
onRemove={
onCorrespondentRemove
? (entry) =>
onCorrespondentRemove({
documentId: document.id,
correspondentId: entry.id,
})
: undefined
}
onAdd={
onCorrespondentAdd
? ({ name, option }) =>
onCorrespondentAdd({
document,
name,
option,
})
: undefined
}
showCount
datalistOptions={correspondentOptions}
className="document-summary__correspondents"
/>
);
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, key: row.key,
label: row.label, label: row.label,
fallbackValue: row.value, valueContent: overrides.valueContent ?? null,
})), fallbackValue: overrides.valueContent ? row.value : row.value,
]; error: overrides.error ?? null,
};
});
const detailRows = Array.isArray(detailItems) const allRows = baseRows;
? detailItems.map((item, index) => ({ const summaryClass = `document-summary${isCompactLayout ? ' document-summary--compact' : ''}`;
key: `detail-${item?.label || index}`, const sectionClass = `document-summary__section document-summary__meta${isCompactLayout ? ' document-summary__meta--compact' : ''}`;
label: item?.label || '', const listClass = `document-summary__details-list${isCompactLayout ? ' document-summary__details-list--meta' : ''}`;
fallbackValue: item?.value,
}))
: [];
const compactRows = [...metaItems, ...detailRows];
const renderTags = () => (
<section className="document-summary__section document-summary__section--tags">
<TagSection
tags={resolvedTags}
onRemove={
onTagRemove
? (tag) => onTagRemove(document.id, tag.id)
: undefined
}
onAdd={
onTagAdd
? ({ value, option }) => onTagAdd(document, value, { option })
: undefined
}
datalistOptions={tagOptions}
className="document-summary__tags"
/>
</section>
);
const renderCorrespondents = () => (
<section className="document-summary__section document-summary__section--correspondents">
<CorrespondentSection
entries={resolvedCorrespondents}
onRemove={
onCorrespondentRemove
? (entry) =>
onCorrespondentRemove({
documentId: document.id,
correspondentId: entry.id,
})
: undefined
}
onAdd={
onCorrespondentAdd
? ({ name, option }) =>
onCorrespondentAdd({
document,
name,
option,
})
: undefined
}
showCount
datalistOptions={correspondentOptions}
className="document-summary__correspondents"
/>
</section>
);
if (isCompactLayout) {
return (
<div className="document-summary document-summary--compact">
<section className="document-summary__section document-summary__title-row">
<TitleSection />
</section>
{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">
<dt>{item.label}</dt>
<dd>
{item.valueContent != null && item.valueContent !== ''
? item.valueContent
: item.fallbackValue || '—'}
</dd>
{item.error ? <div className="status-inline error">{item.error}</div> : null}
</div>
))}
</dl>
</section>
) : null}
</div>
);
}
return ( return (
<div className="document-summary"> <div className={summaryClass}>
<div className="doc-title-row"> <section className={sectionClass}>
<div className="doc-title-row__primary"> <dl className={listClass}>
{editableTitle && isTitleEditing ? ( {allRows.map((item) => (
<form className="doc-title-edit" onSubmit={submitTitleEdit}> <div key={item.key} className="document-summary__details-row">
<input <dt>{item.label}</dt>
value={titleDraft} <dd>
onChange={(event) => { {item.valueContent != null && item.valueContent !== ''
setTitleDraft(event.target.value); ? item.valueContent
if (titleError) { : item.fallbackValue || '—'}
setTitleError(null); </dd>
} {item.error ? <div className="status-inline error">{item.error}</div> : null}
}} </div>
onKeyDown={(event) => { ))}
if (event.key === 'Escape') { </dl>
event.preventDefault(); </section>
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>
); );
}; };
@@ -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);
@@ -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>
@@ -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>&nbsp;</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>
+171
View File
@@ -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';
@@ -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;
payload: item.payload ?? item, }
})); 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,
};
})
.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);
close(); if (closeOnSelection) {
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;
@@ -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;
-38
View File
@@ -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;
+50
View File
@@ -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;
@@ -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;
-113
View File
@@ -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,
};
};
+120
View File
@@ -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;
};
@@ -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;
@@ -39,21 +70,35 @@ const useBulkDocumentActions = ({
if (!target?.id) { if (!target?.id) {
setStatusMessage('Unable to resolve correspondent.', 'error'); setStatusMessage('Unable to resolve correspondent.', 'error');
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: [
{ {
correspondent_id: target.id, correspondent_id: target.id,
}, },
], ],
action: 'add', action: 'add',
});
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
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 { assigned = 0, removed = 0 } = response.data || {};
await refreshCurrentFolder();
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';
@@ -68,22 +113,22 @@ const useBulkDocumentActions = ({
); );
} }
if (input) { if (input) {
input.value = ''; input.value = '';
} }
}, },
[ [
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;
@@ -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;
@@ -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"
@@ -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;
-95
View File
@@ -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 };
+129
View File
@@ -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 };
-65
View File
@@ -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;
+79
View File
@@ -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;
-133
View File
@@ -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;
+164
View File
@@ -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;
-41
View File
@@ -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;
@@ -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;
@@ -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;
@@ -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,27 +145,42 @@ 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);
content.classList.add('document-drag-preview__folder-thumb'); if (clone instanceof HTMLElement) {
const svg = content.querySelector('svg'); content = clone;
if (svg) { content.classList.add('document-drag-preview__folder-thumb');
svg.setAttribute('width', '48'); const svg = content.querySelector('svg');
svg.setAttribute('height', '48'); if (svg) {
svg.setAttribute('width', '48');
svg.setAttribute('height', '48');
}
} }
} }
@@ -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');
} }
@@ -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;
type FolderId = DocumentId | 'root';
type NullableFolderId = FolderId | null;
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 }>;
}
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 (!value) return null;
if (typeof value === 'object' && value.id) { if (isPlainObject(value) && 'id' in value && value.id != null) {
return value.id; return value.id as DocumentId;
} }
return value; 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);
updateDocumentCaches(documentId, (doc) => { if (updatedDocument && ingestDocuments) {
if (updatedDocument) { ingestDocuments([updatedDocument]);
return { ...doc, ...updatedDocument }; } else {
} updateDocumentCaches(documentId, (doc) => {
return { ...doc, title: trimmed }; if (updatedDocument) {
}); return { ...doc, ...updatedDocument };
}
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);
updateDocumentCaches(documentId, (doc) => { if (updatedDocument && ingestDocuments) {
if (updatedDocument) { ingestDocuments([updatedDocument]);
return { ...doc, ...updatedDocument }; } else {
} updateDocumentCaches(documentId, (doc) => {
return { ...doc, issued_at: payload.issued_at }; if (updatedDocument) {
}); return { ...doc, ...updatedDocument };
}
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');
@@ -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(
@@ -40,22 +82,59 @@ const useDocumentTagging = ({
setLoading(true); setLoading(true);
try { try {
if (action === 'add') { if (action === 'add') {
const createdIds = []; const createdIds: Identifier[] = [];
for (const label of normalized) { const createdTags: TagRecord[] = [];
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null; for (const label of normalized) {
if (!tag) { let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
const payload = tagManager.buildPayload({ label }); if (!tag) {
const { data } = await apiClient.post('/tags', payload); const payload = tagManager.buildPayload({ label });
tag = data; const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload);
await refreshTags(); tag = 'data' in response ? response.data : response;
} await refreshTags();
createdIds.push(tag.id);
} }
tagIds = Array.from(new Set(createdIds)); createdIds.push(tag.id);
createdTags.push(tag);
} }
tagIds = Array.from(new Set(createdIds));
tagIds = Array.from(new Set(tagIds)); 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));
if (!tagIds.length) { if (!tagIds.length) {
return { ok: false, reason: 'no-tags' }; return { ok: false, reason: 'no-tags' };
@@ -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 }>(
document_ids: targetIds, '/documents/bulk/reanalyze',
force: true, {
}); document_ids: targetIds,
const queued = data?.queued ?? targetIds.length; force: true,
},
);
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;
@@ -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