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',
},
],
'@babel/preset-typescript',
],
};
+33
View File
@@ -2,6 +2,10 @@ import js from '@eslint/js';
import pluginReact from 'eslint-plugin-react';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
import tsParser from '@typescript-eslint/parser';
import tsPluginImport from '@typescript-eslint/eslint-plugin';
const tsPlugin = tsPluginImport.default ?? tsPluginImport;
const sharedRules = {
...js.configs.recommended.rules,
@@ -42,6 +46,7 @@ export default [
plugins: {
react: pluginReact,
'react-hooks': pluginReactHooks,
'@typescript-eslint': tsPlugin,
},
settings: {
react: {
@@ -50,4 +55,32 @@ export default [
},
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": {
"dev": "webpack serve --mode development --open",
"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"
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@tabler/icons-react": "^3.35.0",
"axios": "^1.13.2",
"pdfjs-dist": "^5.4.394",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.5"
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@svgr/webpack": "^8.1.0",
"@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/parser": "^8.18.1",
"babel-loader": "^10.0.0",
"copy-webpack-plugin": "^13.0.1",
"css-loader": "^7.1.2",
"dotenv": "^17.2.3",
"eslint": "^9.39.1",
@@ -31,6 +37,7 @@
"globals": "^16.5.0",
"html-webpack-plugin": "^5.6.4",
"style-loader": "^4.0.0",
"typescript": "^5.7.3",
"webpack": "^5.102.1",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2"
@@ -7,7 +7,7 @@ import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace';
import { useDocumentsPreferences } from './useDocumentsPreferences';
import SettingsRoute from './SettingsRoute';
const AppLayout = () => {
const AppLayout: React.FC = () => {
const documentsPreferences = useDocumentsPreferences();
const {
appStatus,
@@ -28,23 +28,17 @@ const AppLayout = () => {
onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange,
onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle,
searchIncludeDescendants: documentsPreferences.searchIncludeDescendants,
onToggleSearchIncludeDescendants: documentsPreferences.toggleSearchIncludeDescendants,
onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants,
sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef,
handleDeskExit: documentsPreferences.handleDeskExit,
});
if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
const shouldRememberLastLocation = appStatus !== 'logged-out';
const redirectTarget = `${location.pathname}${location.search}${location.hash || ''}`;
return (
<Navigate
to="/account/login"
replace
state={
shouldRememberLastLocation
? { from: location.pathname + location.search }
: undefined
}
state={{ from: redirectTarget }}
/>
);
}
-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';
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__content">
Drop files to upload to <strong>{folderName || 'this location'}</strong>
@@ -11,11 +11,31 @@ import {
} from '../utils/webauthn';
import { api, useAppDispatch, useAppState } from './appState';
const LoginRoute = () => {
const { status: appStatus, tenantSelection } = useAppState();
type StatusVariant = 'info' | 'success' | 'error';
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 location = useLocation();
const [status, setStatus] = useState(null);
const [status, setStatus] = useState<StatusMessage | null>(null);
const [selectingTenantId, setSelectingTenantId] = useState(null);
const passkeySupported = isWebAuthnAvailable();
const [passkeyLoading, setPasskeyLoading] = useState(false);
@@ -37,26 +57,24 @@ const LoginRoute = () => {
let combined = extract(location.search);
if (typeof window !== 'undefined') {
const hash = window.location.hash || '';
const queryIndex = hash.indexOf('?');
if (queryIndex !== -1) {
const hashQuery = hash.slice(queryIndex + 1);
const hashParams = extract(`?${hashQuery}`);
combined = {
token: combined.token || hashParams.token,
username: combined.username || hashParams.username,
preferredTenantId: combined.preferredTenantId || hashParams.preferredTenantId,
};
}
if (!combined.token) {
const searchParams = extract(window.location.search);
combined = {
token: combined.token || searchParams.token,
username: combined.username || searchParams.username,
preferredTenantId: combined.preferredTenantId || searchParams.preferredTenantId,
};
}
const hash = window.location.hash || '';
const queryIndex = hash.indexOf('?');
if (queryIndex !== -1) {
const hashQuery = hash.slice(queryIndex + 1);
const hashParams = extract(`?${hashQuery}`);
combined = {
token: combined.token || hashParams.token,
username: combined.username || hashParams.username,
preferredTenantId: combined.preferredTenantId || hashParams.preferredTenantId,
};
}
if (!combined.token) {
const searchParams = extract(window.location.search);
combined = {
token: combined.token || searchParams.token,
username: combined.username || searchParams.username,
preferredTenantId: combined.preferredTenantId || searchParams.preferredTenantId,
};
}
return combined;
@@ -96,10 +114,6 @@ const LoginRoute = () => {
);
const clearMagicParamsFromUrl = useCallback(() => {
if (typeof window === 'undefined') {
return;
}
const removableKeys = ['magic_token', 'username', 'preferred_tenant_id'];
const currentSearch = new URLSearchParams(window.location.search);
let searchChanged = false;
@@ -184,7 +198,7 @@ const LoginRoute = () => {
const handlePasskeyLogin = useCallback(
async (rawUsername) => {
const username = typeof rawUsername === 'string' ? rawUsername.trim() : '';
const username = rawUsername?.trim?.() || '';
if (!username) {
setStatusMessage('Enter your username before using a passkey.', 'error');
return;
@@ -207,13 +221,18 @@ const LoginRoute = () => {
const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions });
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) {
setStatusMessage('Passkey login cancelled.', 'info');
return;
}
if (!(assertion instanceof PublicKeyCredential)) {
setStatusMessage('Unexpected credential response.', 'error');
return;
}
const serialized = serializeAuthenticationCredential(assertion);
const finishPayload = {
challengeId,
@@ -289,7 +308,11 @@ const LoginRoute = () => {
setStatusMessage('Signing you in…', 'info');
try {
const payload = {
const payload: {
magic_token: string;
username?: string;
preferred_tenant_id?: string | number;
} = {
magic_token: magicToken,
};
if (magicUsername) {
@@ -381,7 +404,7 @@ const LoginRoute = () => {
const handleSignup = useCallback(
async (rawUsername) => {
const username = typeof rawUsername === 'string' ? rawUsername.trim() : '';
const username = rawUsername?.trim?.() || '';
if (!username) {
setStatusMessage('Choose a username to create your account.', 'error');
return;
@@ -405,13 +428,18 @@ const LoginRoute = () => {
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
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) {
setStatusMessage('Signup cancelled.', 'info');
return;
}
if (!(credential instanceof PublicKeyCredential)) {
setStatusMessage('Unexpected credential response.', 'error');
return;
}
const serialized = serializeRegistrationCredential(credential);
const finishPayload = {
signup_token: signupToken,
@@ -459,8 +487,8 @@ const LoginRoute = () => {
);
const redirectTarget = useMemo(() => {
const target = location.state?.from;
if (typeof target === 'string' && target.startsWith('/')) {
const target = String(location.state?.from ?? '');
if (target.startsWith('/')) {
return target;
}
return '/documents';
@@ -1,4 +1,5 @@
import React, {
ReactNode,
createContext,
useCallback,
useContext,
@@ -7,38 +8,85 @@ import React, {
useRef,
useState,
} from 'react';
import type { CSSProperties, PointerEvent as ReactPointerEvent, RefObject } from 'react';
import { useSidebarContext } from '../sidebar/SidebarContext';
const PanelManagerContext = createContext(null);
type PanelKey = 'sidebar' | 'detail';
const PANEL_LIMITS = {
sidebar: { minRatio: 1 / 6, maxRatio: 1 / 4 },
detail: { minRatio: 1 / 4, maxRatio: 3 / 4 },
interface PanelLimits {
maxRatio: number;
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',
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 limits = PANEL_LIMITS[panel];
if (!limits) {
return numeric;
}
const viewport = window.innerWidth;
const minLimit = Math.max(0, Math.round(viewport * limits.minRatio));
const rawMax = Math.max(minLimit, Math.round(viewport * limits.maxRatio));
const maxAllowed = Math.min(rawMax, viewport - 160);
const targetMax = Math.max(minLimit, maxAllowed);
return Math.min(Math.max(numeric, minLimit), targetMax);
const minLimit = Math.max(0, limits.minPx);
const ratioMax = Math.round(viewport * limits.maxRatio);
const rawMax = Math.max(ratioMax, minLimit);
const maxAllowed = Math.min(rawMax, viewport - MINIMUM_MAIN_CONTENT_WIDTH);
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 raw = window?.localStorage?.getItem(STORAGE_KEYS[panel]);
const readStoredWidth = (panel: PanelKey, fallback: number): number => {
const raw = window.localStorage.getItem(STORAGE_KEYS[panel]);
if (!raw) {
return fallback;
}
@@ -46,22 +94,27 @@ const readStoredWidth = (panel, fallback) => {
return Number.isFinite(parsed) ? parsed : fallback;
};
const persistWidth = (panel, value) => {
window?.localStorage?.setItem(STORAGE_KEYS[panel], String(Math.round(value)));
const persistWidth = (panel: PanelKey, value: number): void => {
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)) {
return;
}
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 initialSidebarWidth = readStoredWidth('sidebar', 320);
const initialDetailWidth = readStoredWidth('detail', 420);
const initialSidebarWidth = readStoredWidth('sidebar', DEFAULT_SIDEBAR_WIDTH);
const initialDetailWidth = readStoredWidth('detail', DEFAULT_DETAIL_WIDTH);
const [sidebarWidth, setSidebarWidthState] = useState(() => clampPanelWidth('sidebar', initialSidebarWidth));
const [detailWidth, setDetailWidthState] = useState(() => clampPanelWidth('detail', initialDetailWidth));
@@ -71,6 +124,7 @@ export const PanelManagerProvider = ({ children }) => {
const detailCloseHandlerRef = useRef(null);
const panelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
const preferredPanelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
const closeDetailPanel = useCallback(() => {
const handler = detailCloseHandlerRef.current;
@@ -79,23 +133,20 @@ export const PanelManagerProvider = ({ children }) => {
useEffect(() => {
panelWidthsRef.current.sidebar = sidebarWidth;
applyPanelWidthToRoot('sidebar', sidebarWidth);
applyPanelWidthToRoot('sidebar', sidebarWidth, true);
}, [sidebarWidth]);
useEffect(() => {
panelWidthsRef.current.detail = detailWidth;
applyPanelWidthToRoot('detail', detailWidth);
}, [detailWidth]);
applyPanelWidthToRoot('detail', detailWidth, detailPanelOpen);
}, [detailWidth, detailPanelOpen]);
useEffect(() => {
persistWidth('sidebar', sidebarWidth);
}, [sidebarWidth]);
useEffect(() => {
persistWidth('detail', detailWidth);
}, [detailWidth]);
const logPanelState = useCallback((panel, action, value) => {
const handlePanelLayoutChange = useCallback((
panel: PanelKey,
action: 'opened' | 'closed' | 'resized',
value?: number,
{ detailOpen }: { detailOpen?: boolean } = {},
) => {
const viewportWidth = window.innerWidth;
const sidebarWidth = panelWidthsRef.current.sidebar;
const detailWidth = panelWidthsRef.current.detail;
@@ -103,6 +154,7 @@ export const PanelManagerProvider = ({ children }) => {
const freeRatio = viewportWidth > 0 ? freeSpace / viewportWidth : 0;
const meetsThreshold = freeRatio >= MINIMAL_FREE_RATIO;
const effectiveDetailOpen = detailOpen ?? detailPanelOpen;
if (panel === 'detail' && !collapsed) {
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();
}
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]);
}, [collapsed, closeDetailPanel, detailPanelOpen]);
const collapseSidebar = useCallback(() => {
if (!collapsed) {
setCollapsed(true);
setSidebarSuppressed(false);
logPanelState('sidebar', 'closed');
handlePanelLayoutChange('sidebar', 'closed');
}
}, [collapsed, setCollapsed, logPanelState]);
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
const setPanelWidth = useCallback(
(panel, width, commit = true) => {
(panel: PanelKey, width: number, { commit = true, log = true }: SetPanelWidthOptions = {}) => {
const clamped = clampPanelWidth(panel, width);
if (!Number.isFinite(clamped)) {
return panelWidthsRef.current[panel];
@@ -150,36 +188,79 @@ export const PanelManagerProvider = ({ children }) => {
setDetailWidthState((prev) => (prev === clamped ? prev : clamped));
}
panelWidthsRef.current[panel] = clamped;
applyPanelWidthToRoot(panel, clamped);
if (commit) {
preferredPanelWidthsRef.current[panel] = clamped;
persistWidth(panel, clamped);
}
logPanelState(panel, 'resized', clamped);
if (log) {
handlePanelLayoutChange(panel, 'resized', clamped);
}
return clamped;
},
[collapsed, logPanelState, sidebarSuppressed],
[handlePanelLayoutChange],
);
const registerDetailCloseHandler = useCallback((handler = null) => {
detailCloseHandlerRef.current = typeof handler === 'function' ? handler : null;
const resetSidebarPreferredWidth = useCallback(() => {
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(
(isOpen) => {
setDetailPanelOpen(Boolean(isOpen));
logPanelState('detail', isOpen ? 'opened' : 'closed');
handlePanelLayoutChange('detail', isOpen ? 'opened' : 'closed');
},
[logPanelState],
[handlePanelLayoutChange],
);
const expandSidebar = useCallback(() => {
if (collapsed) {
setCollapsed(false);
}
setSidebarSuppressed(false);
logPanelState('sidebar', 'opened');
}, [collapsed, setCollapsed, logPanelState]);
handlePanelLayoutChange('sidebar', 'opened');
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
const startPanelResize = useCallback((panel) => {
setResizingPanel(panel);
@@ -237,7 +318,10 @@ export const usePanelManager = () => {
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 {
sidebarWidth,
detailWidth,
@@ -267,7 +351,7 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true
useEffect(() => () => teardownListeners(), [teardownListeners]);
const handlePointerDown = useCallback(
(event) => {
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!enabled || !panelRef?.current) {
return;
}
@@ -284,18 +368,18 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true
event.currentTarget?.setPointerCapture?.(pointerId);
let lastWidth = startWidth;
const handlePointerMove = (moveEvent) => {
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) {
return;
}
const delta = panel === 'sidebar'
? moveEvent.clientX - startX
: startX - moveEvent.clientX;
lastWidth = setPanelWidth(panel, startWidth + delta, false);
lastWidth = setPanelWidth(panel, startWidth + delta, { commit: false });
latestWidthRef.current = lastWidth;
};
const handlePointerUp = (upEvent) => {
const handlePointerUp = (upEvent: PointerEvent) => {
if (upEvent.pointerId !== pointerId) {
return;
}
@@ -6,7 +6,12 @@ import useCapabilitySets from '../settings/useCapabilitySets';
import useCapabilities from '../settings/useCapabilities';
import { api } from './appState';
const SettingsRoute = ({ open = true, onClose }) => {
interface SettingsRouteProps {
open?: boolean;
onClose?: () => void;
}
const SettingsRoute: React.FC<SettingsRouteProps> = ({ open = true, onClose }) => {
const {
token,
notifyApiError,
@@ -19,7 +24,7 @@ const SettingsRoute = ({ open = true, onClose }) => {
refreshPasskeys,
registerPasskey,
revokePasskey,
} = useAppShell();
} = useAppShell() as Record<string, any>;
const {
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 DEFAULT_FOLDER_NAME = 'Documents';
@@ -16,13 +16,15 @@ export const resolveApiPath = (path = '') => path;
const makeRowKey = (type, id) =>
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) => {
if (typeof key !== 'string') return '';
const separatorIndex = key.indexOf(ROW_KEY_SEPARATOR);
if (separatorIndex === -1) return key;
return key.slice(separatorIndex + 1);
const normalized = normalizeRowKey(key);
const separatorIndex = normalized.indexOf(ROW_KEY_SEPARATOR);
if (separatorIndex === -1) return normalized;
return normalized.slice(separatorIndex + 1);
};
export const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX;
@@ -43,34 +45,17 @@ const isAssetEquivalent = (lhs, rhs) => {
const rhsView = createAssetView(rhs);
const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata;
const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata;
const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null;
const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null;
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;
});
const lhsExpiresAt = resolveAssetExpiresAt(lhs);
const rhsExpiresAt = resolveAssetExpiresAt(rhs);
return (
lhs.id === rhs.id
&& lhs.url === rhs.url
&& lhsExpiresAt === rhsExpiresAt
&& lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width
&& lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height
&& lhs.mime_type === rhs.mime_type
&& lhs.asset_type === rhs.asset_type
&& lhs.updated_at === rhs.updated_at
&& lhsCardinality === rhsCardinality
&& objectsComparable
);
};
@@ -1,10 +1,52 @@
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
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') ?? '';
let STORED_TENANT = null;
let STORED_TENANT: Tenant = null;
if (storage) {
try {
@@ -21,7 +63,7 @@ if (STORED_TOKEN) {
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
}
const initialAppState = {
const initialAppState: AppState = {
status: STORED_TOKEN ? 'authenticated' : 'logged-out',
token: STORED_TOKEN,
error: null,
@@ -31,10 +73,10 @@ const initialAppState = {
tenants: [],
};
const AppStateContext = React.createContext(null);
const AppDispatchContext = React.createContext(null);
const AppStateContext = React.createContext<AppState | null>(null);
const AppDispatchContext = React.createContext<React.Dispatch<AppAction> | null>(null);
const appStateReducer = (state, action) => {
const appStateReducer = (state: AppState, action: AppAction): AppState => {
switch (action.type) {
case 'LOGIN_REQUEST':
return {
@@ -52,14 +94,14 @@ const appStateReducer = (state, action) => {
token: action.token,
error: null,
tenantSelection: null,
tenant: action.tenant || null,
tenant: action.tenant ?? null,
tenants: state.tenants,
};
case 'LOGIN_FAILURE':
return {
status: 'logged-out',
token: '',
error: action.error || null,
error: action.error ?? null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
@@ -89,6 +131,7 @@ const appStateReducer = (state, action) => {
tenants: [],
};
case 'LOGOUT_SUCCESS':
case 'LOGOUT':
return {
status: 'logged-out',
token: '',
@@ -103,7 +146,7 @@ const appStateReducer = (state, action) => {
case 'BOOTSTRAP_SUCCESS':
return { ...state, status: 'ready', error: null };
case 'BOOTSTRAP_FAILURE':
return { ...state, status: 'authenticated', error: action.error || null };
return { ...state, status: 'authenticated', error: action.error ?? null };
case 'TOKEN_REFRESH_START':
return { ...state, isRefreshing: true, error: null };
case 'TOKEN_REFRESH_SUCCESS':
@@ -113,24 +156,14 @@ const appStateReducer = (state, action) => {
isRefreshing: false,
status: state.status === 'logged-out' ? 'authenticated' : state.status,
tenantSelection: null,
tenant: action.tenant || state.tenant || null,
tenant: action.tenant ?? state.tenant ?? null,
tenants: state.tenants,
};
case 'TOKEN_REFRESH_FAILURE':
return {
status: 'logged-out',
token: '',
error: action.error || null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'LOGOUT':
return {
status: 'logged-out',
token: '',
error: null,
error: action.error ?? null,
isRefreshing: false,
tenantSelection: 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);
useEffect(() => {
@@ -216,7 +249,7 @@ const AppStateProvider = ({ children }) => {
);
};
const useAppState = () => {
const useAppState = (): AppState => {
const context = useContext(AppStateContext);
if (!context) {
throw new Error('useAppState must be used within an AppStateProvider.');
@@ -224,7 +257,7 @@ const useAppState = () => {
return context;
};
const useAppDispatch = () => {
const useAppDispatch = (): React.Dispatch<AppAction> => {
const context = useContext(AppDispatchContext);
if (!context) {
throw new Error('useAppDispatch must be used within an AppStateProvider.');
@@ -1,13 +1,30 @@
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 = ({
documentLookup,
orderedSelectedDocuments,
}) => {
}: UseDetailPanelOptions) => {
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
const [detailPanelDocId, setDetailPanelDocId] = useState(null);
const [detailPanelDocument, setDetailPanelDocument] = useState(null);
const latestOrderedDocsRef = useRef([]);
const [detailPanelDocId, setDetailPanelDocId] = useState<string | number | null>(null);
const [detailPanelDocument, setDetailPanelDocument] = useState<DetailDocument | null>(null);
const latestOrderedDocsRef = useRef<DetailDocument[]>([]);
useEffect(() => {
latestOrderedDocsRef.current = orderedSelectedDocuments;
@@ -34,7 +51,7 @@ export const useDetailPanel = ({
}, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]);
const openDetailPanel = useCallback(
({ documentId, document, documentIds, documents } = {}) => {
({ documentId, document, documentIds, documents }: OpenDetailPanelArgs = {}) => {
let targetDoc = document || null;
let targetId = documentId ?? document?.id ?? null;
@@ -66,7 +83,7 @@ export const useDetailPanel = ({
return;
}
setDetailPanelDocId(targetDoc?.id || targetId || null);
setDetailPanelDocId(targetDoc?.id ?? targetId ?? null);
setDetailPanelDocument(targetDoc || null);
setDetailPanelOpen(Boolean(targetDoc || targetId));
},
@@ -1,11 +1,71 @@
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 = ({
routeDocumentId,
documents,
searchResults,
documentsManager,
selectedFolder,
assetManager,
api,
resolveApiPath,
notifyApiError,
@@ -14,23 +74,22 @@ const useDocumentPreview = ({
locationSearch,
detailPanelControlRef,
setActivePreviewId,
}) => {
const [previewEntries, setPreviewEntries] = useState(() => new Map());
const [previewDocuments, setPreviewDocuments] = useState(() => new Map());
const previewInflightRef = useRef(new Map());
const previewReturnPathRef = useRef(null);
}: UseDocumentPreviewArgs): UseDocumentPreviewResult => {
const [documentLinks, setDocumentLinks] = useState<Map<DocumentId, DocumentLink>>(() => new Map());
const previewInflightRef = useRef<Map<DocumentId, Promise<DocumentLink | null>>>(new Map());
const previewReturnPathRef = useRef<string | null>(null);
const resetPreviewState = useCallback(() => {
setPreviewEntries(() => new Map());
setDocumentLinks(() => new Map());
previewInflightRef.current = new Map();
previewReturnPathRef.current = null;
}, []);
const removePreviewEntries = useCallback((ids) => {
const removeDocumentLinks = useCallback((ids: DocumentId[]) => {
if (!Array.isArray(ids) || ids.length === 0) {
return;
}
setPreviewEntries((prev) => {
setDocumentLinks((prev) => {
if (!prev.size) {
return prev;
}
@@ -46,66 +105,37 @@ const useDocumentPreview = ({
});
}, []);
const cachePreviewDocument = useCallback((doc) => {
if (!doc?.id) {
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 } = {}) => {
const ensureDownloadUrl = useCallback(
async (documentId: DocumentId, { force = false }: { force?: boolean } = {}): Promise<DocumentLink | null> => {
if (!documentId) return null;
const existing = previewEntries.get(documentId) || null;
const existing = documentLinks.get(documentId) || null;
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)) {
return existing;
}
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 {
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;
if (!downloadPath || !resolveApiPath) {
throw new Error('Document missing download path');
}
const href = resolveApiPath(downloadPath);
const entry = {
const entry: DocumentLink = {
url: href,
contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
filename: docResponse.data?.document?.filename,
expiresAt: Date.now() + 5 * 60 * 1000,
};
setPreviewEntries((prev) => {
setDocumentLinks((prev) => {
const next = new Map(prev);
next.set(documentId, entry);
return next;
@@ -122,39 +152,29 @@ const useDocumentPreview = ({
previewInflightRef.current.set(documentId, request);
return request;
},
[previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries],
[documentLinks, api, resolveApiPath, notifyApiError],
);
const ensurePreviewData = useCallback(
async (documentId) => {
async (documentId: DocumentId): Promise<DocumentLike | null> => {
if (!documentId) return null;
const findInCache = () => {
const pool = searchResults ?? documents;
return pool.find((item) => item.id === documentId) || null;
};
const findInCache = () => documentsManager.getById(documentId);
let doc = findInCache();
if (!doc) {
doc = await documentsManager.ensure(documentId);
}
if (!doc) {
const { data } = await api.get(`/documents/${documentId}`);
const hydratedDetail = assetManager.hydrateDetail(data);
const fetched = hydratedDetail?.document || data.document || data;
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
const fetched = (data as { document?: DocumentLike })?.document || data;
const { canonical } = documentsManager.ingest([fetched as unknown]);
doc = (canonical[0] as DocumentLike | undefined) || null;
if (!doc) {
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) {
@@ -163,26 +183,22 @@ const useDocumentPreview = ({
fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`;
}
await ensurePreviewUrl(documentId, { force: false });
await ensureDownloadUrl(documentId, { force: false });
setActivePreviewId(documentId);
return doc;
},
[
searchResults,
documents,
assetManager,
ensurePreviewUrl,
documentsManager,
ensureDownloadUrl,
setActivePreviewId,
api,
cachePreviewDocument,
removeCachedPreviewDocument,
],
);
const openDocumentPreview = useCallback(
(documentId, { replace = false } = {}) => {
(documentId: DocumentId, { replace = false }: { replace?: boolean } = {}) => {
if (!documentId) return;
detailPanelControlRef.current.close();
detailPanelControlRef.current?.close?.();
previewReturnPathRef.current = `${locationPathname}${locationSearch}`;
navigate(`/documents/${documentId}`, { replace });
},
@@ -190,7 +206,7 @@ const useDocumentPreview = ({
);
const closeDocumentPreview = useCallback(
(folderId = null) => {
(folderId?: FolderId) => {
const fallbackPath = previewReturnPathRef.current;
previewReturnPathRef.current = null;
@@ -206,21 +222,6 @@ const useDocumentPreview = ({
[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(() => {
if (!routeDocumentId) {
return undefined;
@@ -241,39 +242,14 @@ const useDocumentPreview = ({
};
}, [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 {
previewEntries,
previewDocuments,
ensurePreviewUrl,
documentLinks,
ensureDownloadUrl,
ensurePreviewData,
openDocumentPreview,
closeDocumentPreview,
resetPreviewState,
removePreviewEntries,
removeDocumentLinks,
};
};
@@ -1,6 +1,30 @@
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 = ({
resolveDocumentRowKey,
@@ -9,21 +33,24 @@ export const useDocumentSelection = ({
isFolderRowKey,
getRowId,
initialEntries = DEFAULT_INITIAL_ENTRIES,
}) => {
const [selectedEntries, setSelectedEntries] = useState(initialEntries);
const [selectionOrder, setSelectionOrder] = useState(initialEntries);
const selectionOrderRef = useRef(initialEntries);
const selectionAnchorRef = useRef(null);
}: UseDocumentSelectionOptions) => {
const [selectedEntries, setSelectedEntries] = useState<RowKey[]>(initialEntries);
const [selectionOrder, setSelectionOrder] = useState<RowKey[]>(initialEntries);
const selectionOrderRef = useRef<RowKey[]>(initialEntries);
const selectionAnchorRef = useRef<RowKey | null>(null);
const selectionInitializedRef = useRef(false);
const [focusedDocumentId, setFocusedDocumentId] = useState(null);
const [focusedRowKey, setFocusedRowKey] = useState(null);
const [focusedDocumentId, setFocusedDocumentId] = useState<DocumentId | null>(null);
const [focusedRowKey, setFocusedRowKey] = useState<RowKey | null>(null);
const visibleRowKeySetRef = useRef(new Set());
const navigableRowKeysRef = useRef([]);
const visibleRowKeySetRef = useRef<Set<RowKey>>(new Set());
const navigableRowKeysRef = useRef<RowKey[]>([]);
const configureSelectionEnvironment = useCallback(({
visibleRowKeySet,
navigableRowKeys,
}: {
visibleRowKeySet?: Set<RowKey>;
navigableRowKeys?: RowKey[];
}) => {
if (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 previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index);
@@ -65,13 +92,16 @@ export const useDocumentSelection = ({
}, []);
const applySelection = useCallback(
(rowKeys, { anchor, interactedKeys = [] } = {}) => {
(
rowKeys: Array<RowKey | null>,
{ anchor, interactedKeys = [] }: ApplySelectionOptions = {},
) => {
const visibleRowKeySet = visibleRowKeySetRef.current;
const unique = [];
const unique: RowKey[] = [];
(rowKeys || []).forEach((key) => {
(rowKeys || []).forEach((key) => {
if (!key) return;
let canonicalKey = null;
let canonicalKey: RowKey | null = null;
if (visibleRowKeySet.has(key)) {
canonicalKey = key;
} else if (isDocumentRowKey(key)) {
@@ -99,7 +129,7 @@ export const useDocumentSelection = ({
setSelectedEntries(unique);
updateSelectionOrder(unique, interactedKeys);
const nextFocusedDocumentId = (() => {
const nextFocusedDocumentId: DocumentId | null = (() => {
if (focusedDocumentId) {
const focusKey = resolveDocumentRowKey(focusedDocumentId);
if (focusKey && unique.includes(focusKey)) {
@@ -108,11 +138,11 @@ export const useDocumentSelection = ({
}
if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) {
return getRowId(resolvedAnchor) || null;
return getRowId(resolvedAnchor) ?? null;
}
const lastDocKey = [...unique].reverse().find(isDocumentRowKey);
return lastDocKey ? getRowId(lastDocKey) || null : null;
const lastDocKey = [...unique].reverse().find((key) => isDocumentRowKey(key)) ?? null;
return lastDocKey ? getRowId(lastDocKey) ?? null : null;
})();
setFocusedDocumentId(nextFocusedDocumentId);
@@ -144,7 +174,7 @@ export const useDocumentSelection = ({
}, [applySelection]);
const handleEntrySelection = useCallback(
(rowKey, event) => {
(rowKey: RowKey | null, event?: SelectionEventLike) => {
const visibleRowKeySet = visibleRowKeySetRef.current;
const navigableRowKeys = navigableRowKeysRef.current;
if (!rowKey || !visibleRowKeySet.has(rowKey)) {
@@ -170,8 +200,8 @@ export const useDocumentSelection = ({
anchorKey = rowKey;
}
let nextKeys = [];
let interactedKeys = [];
let nextKeys: RowKey[] = [];
let interactedKeys: RowKey[] = [];
if (shiftKey && anchorKey) {
const anchorIndex = navigableRowKeys.indexOf(anchorKey);
@@ -213,7 +243,7 @@ export const useDocumentSelection = ({
);
const promoteSelectionOrder = useCallback(
(docId) => {
(docId?: DocumentId | null) => {
if (!docId) return;
const rowKey = resolveDocumentRowKey(docId);
if (!rowKey) return;
@@ -10,10 +10,7 @@ const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field';
const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction';
const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants';
const readSessionStorage = (key) => {
if (typeof window === 'undefined') {
return null;
}
const readSessionStorage = (key: string): string | null => {
try {
return window.sessionStorage.getItem(key);
} catch (error) {
@@ -22,10 +19,7 @@ const readSessionStorage = (key) => {
}
};
const writeSessionStorage = (key, value) => {
if (typeof window === 'undefined') {
return;
}
const writeSessionStorage = (key: string, value: string): void => {
try {
window.sessionStorage.setItem(key, value);
} catch (error) {
@@ -34,7 +28,7 @@ const writeSessionStorage = (key, value) => {
};
export const useDocumentsPreferences = () => {
const [documentsViewMode, setDocumentsViewModeState] = useState(() => {
const [documentsViewMode, setDocumentsViewModeState] = useState<'list' | 'grid' | 'desk'>(() => {
const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY);
if (stored === 'grid' || stored === 'desk') {
return stored;
@@ -42,9 +36,9 @@ export const useDocumentsPreferences = () => {
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';
setDocumentsViewModeState((previous) => {
if (next !== previous) {
@@ -81,7 +75,7 @@ export const useDocumentsPreferences = () => {
writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection);
}, [documentsSortDirection]);
const handleDocumentsSortFieldChange = useCallback((field) => {
const handleDocumentsSortFieldChange = useCallback((field: string) => {
const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
}, []);
@@ -1,33 +1,91 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
TAG_FILTER_UNTAGGED,
resolveDocumentRowKey,
isDocumentRowKey,
} from './appLayoutUtils';
import type { Dispatch, SetStateAction } from 'react';
import { TAG_FILTER_UNTAGGED } from './appLayoutUtils';
type Identifier = string | number;
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 = ({
api,
assetManager,
token,
selectedFolder,
navigate,
locationPathname,
isDocumentsRoute,
selectionHelpers,
searchIncludeDescendants,
documentsSortField,
documentsSortDirection,
notifyApiError,
setLoading,
setSearchIncludeDescendants,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [activeTagFilters, setActiveTagFilters] = useState([]);
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
const [searchResults, setSearchResults] = useState(null);
const [searchLoading, setSearchLoading] = useState(false);
documentsManager,
}: UseDocumentsSearchArgs): UseDocumentsSearchResult => {
const [searchQuery, setSearchQuery] = useState<string>('');
const [activeTagFilters, setActiveTagFilters] = useState<Identifier[]>([]);
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState<Identifier[]>([]);
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;
setActiveTagFilters((previous) => {
if (tagId === TAG_FILTER_UNTAGGED) {
@@ -41,7 +99,7 @@ const useDocumentsSearch = ({
});
}, []);
const toggleCorrespondentFilter = useCallback((correspondentId) => {
const toggleCorrespondentFilter = useCallback((correspondentId?: Identifier | null) => {
setActiveCorrespondentFilters((previous) => {
if (!correspondentId) {
return [];
@@ -64,11 +122,12 @@ const useDocumentsSearch = ({
setActiveCorrespondentFilters([]);
setSearchLoading(false);
setSearchIncludeDescendants(true);
setSearchResultIds(null);
}, [
setSearchIncludeDescendants,
]);
const handleSearchChange = useCallback((value) => {
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
}, []);
@@ -81,11 +140,48 @@ const useDocumentsSearch = ({
}
}, [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(() => {
if (!token) return undefined;
if (!isFilterActive) {
setSearchResults(null);
setSearchResultIds(null);
setSearchLoading(false);
return undefined;
}
@@ -98,7 +194,7 @@ const useDocumentsSearch = ({
started = true;
setLoading(true);
try {
const params = {};
const params: Record<string, unknown> = {};
const trimmedQuery = searchQuery.trim();
if (trimmedQuery.length) {
params.query = trimmedQuery;
@@ -131,57 +227,24 @@ const useDocumentsSearch = ({
if (documentsSortDirection) {
params.dir = documentsSortDirection;
}
const { data } = await api.get('/documents', { params });
const { data } = await api.get<unknown[]>('/documents', { params });
if (cancelled) return;
const results = assetManager.hydrateDocuments(data || []);
setSearchResults(results);
const results = Array.isArray(data) ? data : [];
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);
selectionHelpers.setSelectedEntries([]);
selectionHelpers.setFocusedDocumentId(null);
selectionHelpers.selectionOrderRef.current = [];
selectionHelpers.setSelectionOrder([]);
selectionHelpers.selectionAnchorRef.current = null;
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) {
if (cancelled) return;
notifyApiError(error, 'Search failed. Please try again.');
setSearchResults(null);
setSearchResultIds(null);
} finally {
if (!cancelled && started) {
setLoading(false);
@@ -210,16 +273,16 @@ const useDocumentsSearch = ({
documentsSortDirection,
selectedFolder,
notifyApiError,
assetManager,
selectionHelpers,
setLoading,
documentsManager,
searchTrigger,
]);
return {
searchQuery,
setSearchQuery,
searchResults,
setSearchResults,
searchResultIds,
setSearchResultIds,
searchLoading,
setSearchLoading,
activeTagFilters,
@@ -232,6 +295,8 @@ const useDocumentsSearch = ({
clearFilters,
handleSearchChange,
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 CorrespondentsPanel from '../correspondents/CorrespondentsPanel';
import CorrespondentsPanel, { CorrespondentsPanelProps } from '../correspondents/CorrespondentsPanel';
import PanelHeader from '../ui/PanelHeader';
import { CloseIcon } from '../ui/icons';
const TAGS_MODAL = 'tags';
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 = ({
locationPathname,
tags,
tags = [],
refreshTags,
onTagCreate,
onTagUpdate,
onTagDelete,
correspondents,
correspondents = [],
refreshCorrespondents,
onCorrespondentCreate,
onCorrespondentUpdate,
onCorrespondentDelete,
setStatusMessage,
}) => {
const [activeModal, setActiveModal] = useState(null);
}: UseManagementModalsArgs): UseManagementModalsResult => {
const [activeModal, setActiveModal] = useState<string | null>(null);
const openTagsModal = useCallback(() => setActiveModal(TAGS_MODAL), []);
const openCorrespondentsModal = useCallback(
@@ -37,7 +74,7 @@ export const useManagementModals = ({
if (!activeModal) {
return undefined;
}
const handleKeyDown = (event) => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
setActiveModal(null);
@@ -70,8 +107,8 @@ export const useManagementModals = ({
titleTag="h3"
titleProps={{ id: 'tags-modal-title' }}
actions={(
<button type="button" className="secondary" onClick={closeActiveModal}>
Close
<button type="button" className="icon-button" onClick={closeActiveModal} aria-label="Close">
<CloseIcon size={16} />
</button>
)}
/>
@@ -99,6 +136,36 @@ export const useManagementModals = ({
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(() => {
if (activeModal !== CORRESPONDENTS_MODAL) {
return null;
@@ -122,8 +189,8 @@ export const useManagementModals = ({
titleTag="h3"
titleProps={{ id: 'correspondents-modal-title' }}
actions={(
<button type="button" className="secondary" onClick={closeActiveModal}>
Close
<button type="button" className="icon-button" onClick={closeActiveModal} aria-label="Close">
<CloseIcon size={16} />
</button>
)}
/>
@@ -131,9 +198,9 @@ export const useManagementModals = ({
<CorrespondentsPanel
correspondents={correspondents}
onRefresh={refreshCorrespondents}
onCreate={onCorrespondentCreate}
onUpdate={onCorrespondentUpdate}
onDelete={onCorrespondentDelete}
onCreate={handleCorrespondentCreateSafe}
onUpdate={handleCorrespondentUpdateSafe}
onDelete={handleCorrespondentDeleteSafe}
onNotify={setStatusMessage}
/>
</div>
@@ -144,9 +211,9 @@ export const useManagementModals = ({
activeModal,
closeActiveModal,
correspondents,
onCorrespondentCreate,
onCorrespondentDelete,
onCorrespondentUpdate,
handleCorrespondentCreateSafe,
handleCorrespondentDeleteSafe,
handleCorrespondentUpdateSafe,
refreshCorrespondents,
setStatusMessage,
]);
@@ -1,17 +1,34 @@
import { useCallback, useMemo } from 'react';
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 = ({
resolveDocumentRowKey,
resolveFolderRowKey,
isDocumentRowKey,
isFolderRowKey,
getRowId,
isDocumentRowKey = () => false,
isFolderRowKey = () => false,
getRowId = () => null,
onInspectDocument = identity,
onInspectFolder = identity,
} = {}) => {
}: WorkspaceSelectionOptions = {}) => {
const selection = useDocumentSelection({
resolveDocumentRowKey,
resolveFolderRowKey,
@@ -58,8 +75,10 @@ export const useWorkspaceSelection = ({
);
const selectEntry = useCallback(
(entry, event) => {
const rowKey = typeof entry === 'string' ? entry : entry?.rowKey;
(entry: SelectionEntry | string | null, event?: unknown) => {
const rowKey = entry && Object(entry) === entry
? (entry as SelectionEntry).rowKey ?? null
: (entry as string | null);
if (!rowKey) return;
handleEntrySelection(rowKey, event);
},
@@ -67,7 +86,7 @@ export const useWorkspaceSelection = ({
);
const inspectDocument = useCallback(
(documentId) => {
(documentId?: string | number | null) => {
if (!documentId) return;
onInspectDocument(documentId);
},
@@ -75,7 +94,7 @@ export const useWorkspaceSelection = ({
);
const inspectFolder = useCallback(
(folderId) => {
(folderId?: string | number | null) => {
if (!folderId) return;
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';
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);
if (!context) {
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
}
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({
correspondents = [],
@@ -7,15 +29,15 @@ function CorrespondentsPanel({
onUpdate,
onDelete,
onNotify,
}) {
const [editingId, setEditingId] = useState(null);
}: CorrespondentsPanelProps) {
const [editingId, setEditingId] = useState<string | number | null>(null);
const [draftName, setDraftName] = useState('');
const [createName, setCreateName] = useState('');
const [saving, setSaving] = 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);
setDraftName(correspondent.name);
}, []);
@@ -46,7 +68,7 @@ function CorrespondentsPanel({
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
const handleDelete = useCallback(
async (correspondent) => {
async (correspondent: CorrespondentEntry) => {
if (!correspondent?.id) return;
setDeletingId(correspondent.id);
try {
@@ -65,7 +87,7 @@ function CorrespondentsPanel({
);
const handleCreate = useCallback(
async (event) => {
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = createName.trim();
if (!trimmed) {
@@ -87,7 +109,7 @@ function CorrespondentsPanel({
);
const handleKeyDown = useCallback(
(event) => {
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSave();
@@ -99,11 +121,11 @@ function CorrespondentsPanel({
[handleSave, cancelEdit],
);
const renderUsage = useCallback((usage) => {
const renderUsage = useCallback((usage?: CorrespondentUsage) => {
if (!usage) {
return '0';
}
const total = typeof usage.total === 'number' ? usage.total : 0;
const total = Number.isFinite(usage.total) ? Number(usage.total) : 0;
return total.toString();
}, []);
@@ -4,7 +4,46 @@ import { resolveCorrespondents } from '../documents/correspondents';
import { getTagColorStyle } from '../utils/colors';
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,
style,
shouldLoad,
@@ -18,7 +57,7 @@ const DesktopDocumentCard = ({
getDocumentAsset,
handleNavigatorSnapshot,
cardPointerHandlers,
onDocumentOpen,
onInspectDocument,
onTagDragEnter,
onTagDragOver,
onTagDragLeave,
@@ -52,16 +91,33 @@ const DesktopDocumentCard = ({
data-doc-id={doc.id}
data-tag-ids={dataTagIds}
aria-hidden={ariaHidden}
ref={registerNode}
ref={registerNode ?? undefined}
{...cardPointerHandlers}
onDragEnter={(event) => onTagDragEnter(event, doc.id)}
onDragOver={(event) => onTagDragOver(event, doc.id)}
onDragLeave={(event) => onTagDragLeave(event, doc.id)}
onDrop={(event) => onTagDrop(event, doc)}
onDragEnter={(event) => {
if (doc?.id == null) {
return;
}
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) => {
if (event.key === 'Enter' || event.key === ' ') {
preventAll(event);
onDocumentOpen?.(doc.id);
onInspectDocument?.(doc.id);
}
}}
>
@@ -107,11 +163,11 @@ const DesktopDocumentCard = ({
draggable
data-desk-tag-chip="true"
onPointerDownCapture={(event) => {
onDocTagPointerDown(event, doc, tag);
onDocTagPointerDown?.(event, doc, tag);
}}
onDragStart={(event) => onDocTagDragStart(event, doc, tag)}
onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)}
onDrag={onDocTagDrag}
onDragEnd={(event) => onDocTagDragEnd(event)}
onDragEnd={(event) => onDocTagDragEnd?.(event)}
>
<span className="tag-chip__label">{tag.label}</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,
} from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import type { EnsureAssetUrl, GetAsset } from '../asset_manager';
import { formatTransform } from './math';
import useDocumentDrag from './useDocumentDrag';
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-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_FOCUS = false;
const DesktopWorkspace = ({
const defaultGetDocumentAsset: GetAsset = () => null;
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
documents = [],
searchResults = null,
onDocumentOpen,
onInspectDocument = null,
onEntryPointer = null,
onDocumentStackSelect = null,
onPromoteSelection = null,
onAssignTagToDocument = null,
onRemoveTagFromDocument = null,
ensureAssetUrl = null,
getDocumentAsset = () => null,
getDocumentAsset = defaultGetDocumentAsset,
activeTagIds = [],
selectedDocumentIds = [],
onClearSelection = null,
detailPanelOpen = false,
onCloseDetailPanel = null,
tenantId = null,
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 documentLinkMap = documentLinks instanceof Map ? documentLinks : null;
const containerRef = useRef(null);
const itemRefs = useRef(new Map());
const dragTransformsRef = useRef(new Map());
const [overlayDocId, setOverlayDocId] = useState(null);
const [overlayOriginRect, setOverlayOriginRect] = useState(null);
const [overlayOriginTransform, setOverlayOriginTransform] = useState(null);
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map());
const containerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
const dragTransformsRef = useRef<Map<string, DragTransformOverride>>(new Map());
const [overlayDocId, setOverlayDocId] = useState<string | null>(null);
const [overlayOriginRect, setOverlayOriginRect] = useState<DOMRect | null>(null);
const [overlayOriginTransform, setOverlayOriginTransform] = useState<OverlayOriginTransform | null>(
null,
);
const [overlaySource, setOverlaySource] = useState<OverlaySource | null>(null);
const [, setPreviewSnapshots] = useState<Map<string, NavigatorSnapshot>>(() => new Map());
const [docSizeVersion, setDocSizeVersion] = useState(0);
const docSizeMapRef = useRef(new Map());
const ensureDocumentSize = useCallback((doc) => {
const docSizeMapRef = useRef<Map<string, DocumentSizeInfo>>(new Map());
const ensureDocumentSize = useCallback((doc: DeskDocument | null): DocumentSizeInfo | null => {
if (!doc?.id) {
return null;
}
return docSizeMapRef.current.get(String(doc.id)) || null;
}, []);
const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl);
const documentLookup = useMemo(() => {
const map = new Map();
const documentLookup = useMemo<Map<string, DeskDocument>>(() => {
const map = new Map<string, DeskDocument>();
items.forEach((doc) => {
const key = doc?.id != null ? String(doc.id) : null;
if (key) {
@@ -81,7 +244,7 @@ const DesktopWorkspace = ({
return map;
}, [items]);
const engineRef = useRef(null);
const engineRef = useRef<WorkspaceEngine | null>(null);
if (!engineRef.current) {
engineRef.current = new WorkspaceEngine({
allowLayoutPersistence,
@@ -89,16 +252,15 @@ const DesktopWorkspace = ({
viewId,
});
}
const engine = engineRef.current;
const engine = engineRef.current as WorkspaceEngine;
useEffect(() => {
engine.updateConfig({ allowLayoutPersistence, tenantId, viewId });
}, [engine, allowLayoutPersistence, tenantId, viewId]);
useEffect(() => {
const nextItems = searchResults ? searchResults : documents;
engine.setItems(nextItems || []);
}, [engine, documents, searchResults]);
engine.setItems(items || []);
}, [engine, items]);
useEffect(() => {
const map = new Map();
@@ -115,7 +277,7 @@ const DesktopWorkspace = ({
engine.setEnsureDocumentSize(ensureDocumentSize);
}, [engine, ensureDocumentSize]);
const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore);
const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore) as WorkspaceSnapshotState;
const {
layout: layoutSnapshot,
canvasSize,
@@ -141,10 +303,10 @@ const DesktopWorkspace = ({
engine.setItemRefs(itemRefs);
}, [engine, itemRefs]);
const layoutRef = useRef(layoutSnapshot);
layoutRef.current = engine.layout;
const layoutRef = useRef<Map<string, LayoutEntry>>(layoutSnapshot);
layoutRef.current = engine.layout as Map<string, LayoutEntry>;
const bringToFront = useCallback((docId) => {
const bringToFront = useCallback((docId: Identifier | null) => {
engine.bringToFront(docId);
}, [engine]);
@@ -156,11 +318,11 @@ const DesktopWorkspace = ({
engine.recalcVisibleDocIds();
}, [engine]);
const setDraggingId = useCallback((value) => {
const setDraggingId = useCallback((value: string | number | null) => {
engine.setDraggingId(value);
}, [engine]);
const applySnapshotDimensions = useCallback((docKey, snapshot) => {
const applySnapshotDimensions = useCallback((docKey: string, snapshot: NavigatorSnapshot | null) => {
const width = Number(snapshot?.width);
const height = Number(snapshot?.height);
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) {
return;
}
const next = new Map(docSizeMapRef.current);
const next = new Map<string, DocumentSizeInfo>(docSizeMapRef.current);
next.set(docKey, { ...normalized, source: 'snapshot' });
docSizeMapRef.current = next;
setDocSizeVersion((value) => value + 1);
}, []);
const handleNavigatorSnapshot = useCallback(
(docId, snapshot) => {
(docId: Identifier | null, snapshot: NavigatorSnapshot | null) => {
const docKey = docId != null ? String(docId) : null;
if (!docKey) {
return;
@@ -202,11 +364,6 @@ const DesktopWorkspace = ({
prevSnapshot &&
prevSnapshot.url === snapshot.url &&
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.height === snapshot.height;
if (sameSnapshot) {
@@ -222,11 +379,11 @@ const DesktopWorkspace = ({
},
[applySnapshotDimensions],
);
const activeTagSet = useMemo(() => {
const activeTagSet = useMemo<Set<string>>(() => {
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
return new Set();
}
const set = new Set();
const set = new Set<string>();
activeTagIds.forEach((id) => {
if (id != null) {
set.add(String(id));
@@ -251,20 +408,13 @@ const DesktopWorkspace = ({
commitSize();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', commitSize);
return () => {
window.removeEventListener('resize', commitSize);
};
}
const observer = new ResizeObserver(commitSize);
observer.observe(container);
return () => observer.disconnect();
}, [engine]);
const resolvePreviewDimensions = useCallback(
(doc) => {
(doc: DeskDocument | null): PreviewMetadataEntry | null => {
if (!doc?.id) {
return null;
}
@@ -283,7 +433,7 @@ const DesktopWorkspace = ({
if (!doc) {
return;
}
resolveDocumentAssetUrl(doc, 'preview', {
resolveDocumentAssetUrl(doc, 'thumbnail', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
@@ -292,40 +442,33 @@ const DesktopWorkspace = ({
const requestCanvasFocus = useCallback(() => {
const canvas = containerRef.current;
if (!canvas || typeof canvas.focus !== 'function') {
if (!canvas?.focus) {
return;
}
const focusTarget = () => {
try {
canvas.focus({ preventScroll: true });
} catch (error) {
} catch (error: unknown) {
if (DEBUG_FOCUS) {
void error;
}
}
};
if (typeof window === 'undefined') {
focusTarget();
const raf = window.requestAnimationFrame;
if (raf) {
raf(() => focusTarget());
return;
}
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(() => {
focusTarget();
});
} else {
setTimeout(() => {
focusTarget();
}, 0);
}
setTimeout(() => {
focusTarget();
}, 0);
}, []);
const tagInteractions = useDeskTagInteractions({
engine,
onAssignTagToDocument,
onRemoveTagFromDocument,
requestCanvasFocus,
});
@@ -345,7 +488,7 @@ const DesktopWorkspace = ({
useEffect(() => {
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)));
let changed = false;
@@ -392,30 +535,77 @@ const DesktopWorkspace = ({
}
}, [items, previewMetadata]);
const overlayDisplay = useMemo(() => {
useEffect(() => {
let cancelled = false;
if (!overlayDocId) {
return null;
setOverlaySource(null);
return () => {
cancelled = true;
};
}
const snapshot = previewSnapshots.get(overlayDocId);
if (!snapshot || !snapshot.url) {
return null;
const doc = documentLookup.get(overlayDocId) || 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;
return {
url: snapshot.url,
alt,
canGoPrev: snapshot.canGoPrev,
canGoNext: snapshot.canGoNext,
goPrev: snapshot.goPrev,
goNext: snapshot.goNext,
const docContentType = doc?.content_type ?? null;
const versionContentType = (doc?.current_version as { version?: { content_type?: string | null } } | null)?.version?.content_type ?? null;
const applyEntry = (entry?: DocumentLinkLike | null) => {
if (!entry?.url) {
setOverlaySource(null);
return;
}
setOverlaySource({
url: entry.url,
alt: doc.title as string | undefined,
contentType: entry.contentType || docContentType || versionContentType || undefined,
});
};
}, [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(() => {
setOverlayDocId(null);
setOverlayOriginRect(null);
setOverlayOriginTransform(null);
setOverlaySource(null);
}, []);
useEffect(() => {
@@ -427,7 +617,7 @@ const DesktopWorkspace = ({
}, [overlayDocId, documentLookup]);
const resolveBaseMetrics = useCallback(
(doc, cardWidth, cardHeight) => {
(doc: DeskDocument | null, cardWidth: number, cardHeight: number) => {
const previewDims = doc ? resolvePreviewDimensions(doc) : null;
if (previewDims?.width && previewDims?.height) {
const baseWidth = Math.max(previewDims.width, cardWidth);
@@ -457,22 +647,39 @@ const DesktopWorkspace = ({
}
}, [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(
(docId, originInfo = null) => {
(docId: Identifier | null, originInfo: OverlayOriginHint | null = null) => {
if (!docId) {
return;
}
const docKey = String(docId);
const snapshot = previewSnapshots.get(docKey);
if (!snapshot || !snapshot.url) {
return;
}
const container = itemRefs.current.get(docKey);
const imageNode = container?.querySelector?.('.desk-item__card img');
if (!container || !imageNode) {
if (!container) {
return;
}
const imageNode = container.querySelector<HTMLImageElement>('.desk-item__card img');
const rect = (imageNode || container).getBoundingClientRect();
if (!rect) {
return;
}
const rect = imageNode.getBoundingClientRect();
let originTransform = null;
if (originInfo) {
const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo;
@@ -510,7 +717,6 @@ const DesktopWorkspace = ({
},
[
bringToFront,
previewSnapshots,
itemRefs,
setOverlayOriginTransform,
ensureDocumentSize,
@@ -520,7 +726,7 @@ const DesktopWorkspace = ({
],
);
const dragSettings = useMemo(
const dragSettings = useMemo<DragSettings>(
() => ({
canvasPadding: DESK_CANVAS_PADDING,
defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH,
@@ -530,7 +736,7 @@ const DesktopWorkspace = ({
[],
);
const viewProps = useMemo(
const viewProps = useMemo<DesktopWorkspaceViewProps>(
() => ({
engine,
items,
@@ -548,7 +754,6 @@ const DesktopWorkspace = ({
tagDropTargetId,
pendingTagDocId,
pendingRemovalTag,
onDocumentOpen,
ensureAssetUrl,
getDocumentAsset,
handleNavigatorSnapshot,
@@ -565,13 +770,12 @@ const DesktopWorkspace = ({
closeOverlay,
overlayOriginRect,
overlayOriginTransform,
overlayDocument,
onEntryPointer,
onDocumentStackSelect,
onPromoteSelection,
selectedDocumentIds,
onClearSelection,
detailPanelOpen,
onCloseDetailPanel,
documentLookup,
resolveBaseMetrics,
bringToFront,
@@ -613,8 +817,6 @@ const DesktopWorkspace = ({
layoutRef,
layoutSnapshot,
onClearSelection,
onCloseDetailPanel,
onDocumentOpen,
onDocumentStackSelect,
onEntryPointer,
onPromoteSelection,
@@ -622,6 +824,7 @@ const DesktopWorkspace = ({
overlayDisplay,
overlayOriginRect,
overlayOriginTransform,
overlayDocument,
documentLookup,
pendingRemovalTag,
pendingTagDocId,
@@ -629,7 +832,6 @@ const DesktopWorkspace = ({
resolveBaseMetrics,
setDraggingId,
selectedDocumentIds,
detailPanelOpen,
onInspectDocument,
markLayoutDirty,
tagDropTargetId,
@@ -639,7 +841,7 @@ const DesktopWorkspace = ({
return <DesktopWorkspaceView {...viewProps} />;
};
const DesktopWorkspaceView = ({
function DesktopWorkspaceView({
engine,
items,
containerRef,
@@ -655,7 +857,6 @@ const DesktopWorkspaceView = ({
tagDropTargetId,
pendingTagDocId,
pendingRemovalTag,
onDocumentOpen,
ensureAssetUrl,
getDocumentAsset,
handleNavigatorSnapshot,
@@ -672,13 +873,12 @@ const DesktopWorkspaceView = ({
closeOverlay,
overlayOriginRect,
overlayOriginTransform,
overlayDocument,
onEntryPointer,
onDocumentStackSelect,
onPromoteSelection,
selectedDocumentIds,
onClearSelection,
detailPanelOpen,
onCloseDetailPanel,
documentLookup,
resolveBaseMetrics,
bringToFront,
@@ -690,7 +890,7 @@ const DesktopWorkspaceView = ({
onInspectDocument,
markLayoutDirty,
dragTransformsRef,
}) => {
}: DesktopWorkspaceViewProps) {
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
useDocumentDrag({
engine,
@@ -711,7 +911,12 @@ const DesktopWorkspaceView = ({
onDocumentStackSelect,
selectedDocumentIds,
markLayoutDirty,
});
}) as {
handlePointerDown: React.PointerEventHandler<HTMLElement>;
handlePointerMove: React.PointerEventHandler<HTMLElement>;
handlePointerUp: React.PointerEventHandler<HTMLElement>;
handlePointerCancel: React.PointerEventHandler<HTMLElement>;
};
const { getCardPointerHandlers, handleShellKeyDown, focusShell } = useDeskPointer({
containerRef,
@@ -726,12 +931,14 @@ const DesktopWorkspaceView = ({
onEntryPointer,
onDocumentStackSelect,
onPromoteSelection,
onDocumentOpen,
onInspectDocument,
selectedDocumentIds,
detailPanelOpen,
onCloseDetailPanel,
openOverlayForDoc,
});
}) as {
getCardPointerHandlers: (doc: DeskDocument) => React.HTMLAttributes<HTMLDivElement>;
handleShellKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
focusShell: () => void;
};
useEffect(() => {
focusShell();
@@ -743,21 +950,13 @@ const DesktopWorkspaceView = ({
}
}, [focusShell, selectedDocumentIds.length]);
useEffect(() => {
if (!detailPanelOpen) {
focusShell();
}
}, [detailPanelOpen, focusShell]);
const allSizesReady = items.every((doc) => ensureDocumentSize(doc));
const allSizesReady = items.every((doc) => Boolean(ensureDocumentSize(doc)));
return (
<>
<div className="desk-shell" onPointerDown={(event) => {
if (event.target === event.currentTarget && typeof onClearSelection === 'function') {
onClearSelection();
if (event.target === event.currentTarget) {
onClearSelection?.();
}
focusShell();
}}
@@ -771,8 +970,8 @@ const DesktopWorkspaceView = ({
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
onPointerDown={(event) => {
if (event.target === event.currentTarget && typeof onClearSelection === 'function') {
onClearSelection();
if (event.target === event.currentTarget) {
onClearSelection?.();
}
focusShell();
}}
@@ -786,7 +985,7 @@ const DesktopWorkspaceView = ({
<p>No documents to show here yet. Drop files to make this space come alive.</p>
</div>
) : (
items.map((doc) => {
items.map((doc, index) => {
const sizeInfo = ensureDocumentSize(doc);
if (!sizeInfo) {
return null;
@@ -803,10 +1002,12 @@ const DesktopWorkspaceView = ({
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
return null;
}
const resolvedCenterX = centerX as number;
const resolvedCenterY = centerY as number;
const rotation = dragOverride?.rotation ?? layout?.rotation ?? 0;
const scale = dragOverride?.scale ?? 1;
const originX = centerX - cardWidth / 2;
const originY = centerY - cardHeight / 2;
const originX = resolvedCenterX - cardWidth / 2;
const originY = resolvedCenterY - cardHeight / 2;
const transform = formatTransform(
Math.round(originX),
Math.round(originY),
@@ -822,16 +1023,19 @@ const DesktopWorkspaceView = ({
const shouldLoad = docKey ? visibleDocIds.has(docKey) : false;
const dragging = docKey ? draggingId === docKey : false;
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 =
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
const dropActive = docKey ? tagDropTargetId === 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 cardPointerHandlers = getCardPointerHandlers(doc);
const registerNode = (node) => {
const cardPointerHandlers = getCardPointerHandlers(doc) as React.HTMLAttributes<HTMLDivElement>;
const registerNode = (node: HTMLDivElement | null) => {
if (!docKey) {
return;
}
@@ -844,7 +1048,7 @@ const DesktopWorkspaceView = ({
return (
<DesktopDocumentCard
key={doc.id}
key={docKey ?? `desk-doc-${index}`}
doc={doc}
style={style}
shouldLoad={shouldLoad}
@@ -858,7 +1062,7 @@ const DesktopWorkspaceView = ({
getDocumentAsset={getDocumentAsset}
handleNavigatorSnapshot={handleNavigatorSnapshot}
cardPointerHandlers={cardPointerHandlers}
onDocumentOpen={onDocumentOpen}
onInspectDocument={onInspectDocument}
onTagDragEnter={handleTagDragEnterDoc}
onTagDragOver={handleTagDragOverDoc}
onTagDragLeave={handleTagDragLeaveDoc}
@@ -877,13 +1081,13 @@ const DesktopWorkspaceView = ({
</div>
<PreviewZoomOverlay
open={Boolean(overlayDisplay?.url)}
display={overlayDisplay}
onClose={closeOverlay}
document={overlayDocument}
originRect={overlayOriginRect}
originTransform={overlayOriginTransform}
/>
</>
);
};
}
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 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) {
return currentDbPromise.value;
return currentDbPromise.value as Promise<IDBDatabase>;
}
currentDbPromise.value = new Promise((resolve, reject) => {
if (typeof indexedDB === 'undefined') {
reject(new Error('IndexedDB not available'));
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
const request = window.indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
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) => {
request.onsuccess = () => {
const { result } = request;
resolve(result ?? defaultValue);
};
request.onerror = () => {
reject(request.error || new Error('IndexedDB request failed'));
};
});
const requestToPromise = <T>(request: IDBRequest<T>, defaultValue: T): Promise<T> =>
new Promise((resolve, reject) => {
request.onsuccess = () => {
const { result } = request;
resolve(result ?? defaultValue);
};
request.onerror = () => {
reject(request.error || new Error('IndexedDB request failed'));
};
});
const iterateCursor = (request, iteratee) => new Promise((resolve, reject) => {
request.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
const iterateCursor = (request: IDBRequest<IDBCursorWithValue | null>, iteratee: (cursor: IDBCursorWithValue) => void) =>
new Promise<void>((resolve, reject) => {
request.onsuccess = (event) => {
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();
return;
}
try {
iteratee(cursor);
cursor.continue();
} catch (error) {
reject(error);
}
};
request.onerror = () => {
reject(request.error || new Error('IndexedDB cursor failed'));
};
});
};
transaction.onerror = () => {
reject(transaction.error || new Error('IndexedDB transaction failed'));
};
transaction.onabort = () => {
reject(transaction.error || new Error('IndexedDB transaction aborted'));
};
});
const transactionComplete = (transaction) => new Promise((resolve, reject) => {
transaction.oncomplete = () => {
resolve();
};
transaction.onerror = () => {
reject(transaction.error || new Error('IndexedDB transaction failed'));
};
transaction.onabort = () => {
reject(transaction.error || new Error('IndexedDB transaction aborted'));
};
});
type TransactionMode = 'readonly' | 'readwrite' | 'versionchange';
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 transaction = db.transaction(LAYOUT_STORE, mode);
const store = transaction.objectStore(LAYOUT_STORE);
@@ -100,13 +100,24 @@ const withStore = async (mode, handler) => {
try {
await done;
} catch {
// noop prefer original error
// ignore
}
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) {
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) {
return;
}
@@ -143,7 +154,7 @@ export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
rotation: Number(entry.rotation) || 0,
zIndex: Number(entry.zIndex) || 0,
updatedAt: entry.updatedAt || timestamp,
});
} satisfies LayoutRecord);
});
});
} 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) {
return;
}
@@ -168,15 +179,17 @@ export const deleteTenantLayouts = async (tenantId) => {
}
};
export const closeDeskDatabase = () => {
export const closeDeskDatabase = (): void => {
if (!currentDbPromise.value) {
return;
}
currentDbPromise.value = currentDbPromise.value.then((db) => {
try {
db.close();
} catch (error) {
console.warn('[desk] Failed to close IndexedDB', error);
if (db) {
try {
db.close();
} catch (error) {
console.warn('[desk] Failed to close IndexedDB', error);
}
}
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) {
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) {
return { x: 0, y: 0 };
}
@@ -1,8 +1,32 @@
import { useEffect, useState } from 'react';
import { createAssetView } from '../../asset_manager';
const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
const [metadataMap, setMetadataMap] = useState(() => new Map());
interface DocumentLike {
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(() => {
let cancelled = false;
@@ -14,20 +38,19 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
};
}
const fetchMetadataForDoc = async (doc) => {
const fetchMetadataForDoc = async (doc: DocumentLike) => {
if (!doc?.id) {
return null;
}
const docId = String(doc.id);
const resolveAsset = (type) =>
(typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null);
const resolveAsset = (type: string) => getDocumentAsset?.(doc, type) ?? null;
let asset = resolveAsset('preview') || resolveAsset('thumbnail');
let view = createAssetView(asset);
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?.height)) &&
Number(meta.width) > 0 &&
@@ -35,7 +58,7 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) {
try {
const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 });
const ensured = await ensureAssetUrl(doc.id, asset, { force: true });
if (ensured) {
asset = ensured;
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 = {
selectSingle: 'selectSingle',
@@ -6,20 +6,52 @@ export const CLICK_ACTIONS = {
addCard: 'addCard',
addStack: 'addStack',
none: 'none',
};
} as const;
export const DRAG_ACTIONS = {
dragSelectSingle: 'dragSelectSingle',
dragSelection: 'dragSelection',
dragSelectStack: 'dragSelectStack',
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 POINTER_DRAG_THRESHOLD_SQUARED = 16;
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 = ({
doc,
@@ -29,12 +61,12 @@ export const createPointerIntent = ({
pointerButton,
pointerType,
stackHits,
}) => {
}: PointerIntentArgs): PointerIntent => {
const alreadySelected = selectedDocumentIds.includes(doc.id);
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
let clickAction = CLICK_ACTIONS.none;
let dragAction = DRAG_ACTIONS.none;
let clickAction: ClickAction = CLICK_ACTIONS.none;
let dragAction: DragAction = DRAG_ACTIONS.none;
if (metaKey) {
clickAction = CLICK_ACTIONS.addStack;
@@ -47,8 +79,8 @@ export const createPointerIntent = ({
dragAction = DRAG_ACTIONS.dragSelectSingle;
}
const stackList = Array.isArray(stackHits) && stackHits.length > 0
? stackHits.slice()
const stackList: string[] = Array.isArray(stackHits) && stackHits.length > 0
? stackHits.map((value) => String(value))
: [String(doc.id)];
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) {
case CLICK_ACTIONS.selectSingle:
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) {
return;
}
@@ -108,14 +150,19 @@ export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocume
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) {
return;
}
const stackCopy = Array.isArray(stackDocIds) && stackDocIds.length > 0
? stackDocIds.slice()
: [intent.docId];
const stackCopy: string[] = Array.isArray(stackDocIds) && stackDocIds.length > 0
? stackDocIds.map((value) => String(value))
: [String(intent.docId)];
safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true });
+11 -29
View File
@@ -14,7 +14,7 @@ import {
finalizeClickSelection,
withinThreshold,
} from './pointerUtils';
import { getPointerPosition, safeInvoke } from '../events.js';
import { getPointerPosition, safeInvoke } from '../events';
const buildEntryDescriptor = (docId) => ({
type: 'document',
@@ -35,10 +35,8 @@ export const useDeskPointer = ({
onEntryPointer,
onDocumentStackSelect,
onPromoteSelection,
onDocumentOpen,
onInspectDocument,
selectedDocumentIds,
detailPanelOpen,
onCloseDetailPanel,
openOverlayForDoc = null,
}) => {
const pointerIntentRef = useRef(null);
@@ -183,9 +181,6 @@ export const useDeskPointer = ({
}
longPressActiveRef.current = true;
if (typeof window === 'undefined') {
return;
}
longPressTimerRef.current = window.setTimeout(() => {
if (!longPressActiveRef.current || pointerMovedRef.current) {
@@ -229,8 +224,8 @@ export const useDeskPointer = ({
pointerMovedRef.current = false;
resetLongPressState();
const pointerButton = typeof event.button === 'number' ? event.button : 0;
const pointerType = typeof event.pointerType === 'string' ? event.pointerType : '';
const pointerButton = Number.isFinite(event?.button) ? event.button : 0;
const pointerType = String(event?.pointerType ?? '');
const metaKey = (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.docId === doc.id
) {
const expectedButton = typeof pointerState.pointerButton === 'number'
const expectedButton = Number.isFinite(pointerState?.pointerButton)
? pointerState.pointerButton
: 0;
const releasedButton = typeof event.button === 'number'
? event.button
: expectedButton;
const releasedButton = Number.isFinite(event?.button) ? event.button : expectedButton;
const isPrimaryRelease = expectedButton === 0 && releasedButton === 0;
const stillSelected = Array.isArray(selectedDocumentIds)
&& selectedDocumentIds.includes(doc.id);
if (isPrimaryRelease && stillSelected) {
const useSelection = pointerState.selectedAtDown && pointerState.selectionCountAtDown > 0;
safeInvoke(onDocumentOpen, doc.id, { useSelection });
safeInvoke(onInspectDocument, doc.id);
}
}
}
@@ -343,7 +335,7 @@ export const useDeskPointer = ({
},
[
handlePointerUp,
onDocumentOpen,
onInspectDocument,
onDocumentStackSelect,
onEntryPointer,
resetLongPressState,
@@ -401,11 +393,7 @@ export const useDeskPointer = ({
}
}
if (
typeof openOverlayForDoc === 'function'
&& Array.isArray(selectedDocumentIds)
&& selectedDocumentIds.length > 0
) {
if (openOverlayForDoc && Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0) {
event.preventDefault();
const targetId = selectedDocumentIds[selectedDocumentIds.length - 1];
if (targetId) {
@@ -414,12 +402,8 @@ export const useDeskPointer = ({
return;
}
if (detailPanelOpen) {
event.preventDefault();
safeInvoke(onCloseDetailPanel);
}
},
[detailPanelOpen, onCloseDetailPanel, openOverlayForDoc, selectedDocumentIds],
[openOverlayForDoc, selectedDocumentIds],
);
return {
@@ -427,9 +411,7 @@ export const useDeskPointer = ({
handleShellKeyDown,
focusShell: () => {
const shell = containerRef.current;
if (shell && typeof shell.focus === 'function') {
shell.focus({ preventScroll: true });
}
shell?.focus?.({ preventScroll: true });
},
};
};
@@ -3,7 +3,7 @@ import {
useEffect,
useRef,
} from 'react';
import { getPointerPosition, preventAll, safeInvoke } from '../events.js';
import { getPointerPosition, preventAll, safeInvoke } from '../events';
import {
isTagTransferEvent,
parseTagTransferPayload,
@@ -11,7 +11,6 @@ import {
} from '../../documents/tagTransfer';
const TAG_REMOVE_DISTANCE = 160;
const DEBUG_DROP = false;
const createDragPreview = (node, clientX, clientY) => {
if (!(node instanceof HTMLElement)) {
@@ -42,147 +41,22 @@ const cleanupPreview = (previewNode) => {
export const useDeskTagInteractions = ({
engine,
onAssignTagToDocument,
onRemoveTagFromDocument,
requestCanvasFocus,
}) => {
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 handleTagDragEnd = useCallback(() => {
updateRemovalCursor(false);
engine.setTagDropTargetId(null);
}, [engine, updateRemovalCursor]);
const finalizeTagDrag = useCallback(
(dropEffect = 'none') => {
const state = draggingTagRef.current;
if (!state) {
updateRemovalCursor(false);
return;
}
draggingTagRef.current = null;
const node = state.element;
const showNode = () => {
if (node instanceof HTMLElement) {
node.classList.remove('is-drag-hidden');
}
};
const scheduleShowNode = () => {
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(showNode);
} else {
setTimeout(showNode, 0);
}
};
cleanupPreview(state.previewClone);
const shouldRemove =
!state.dropHandled
&& dropEffect === 'none'
&& state.sourceDocId
&& (state.distance || 0) >= TAG_REMOVE_DISTANCE;
if (!shouldRemove) {
scheduleShowNode();
updateRemovalCursor(false);
return;
}
updateRemovalCursor(false);
engine.setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId });
const removePromise = safeInvoke(onRemoveTagFromDocument, state.sourceDocId, state.tagId);
if (!removePromise || typeof removePromise.then !== 'function') {
scheduleShowNode();
engine.setPendingRemovalTag(null);
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 handleDocTagPointerDown = useCallback(() => {
engine.setPendingRemovalTag(null);
}, [engine]);
const runTagHoverTransition = useCallback(
(event, docId, { applyTarget = false, applyPending = false, updateCursor = true } = {}) => {
(event, docId, { applyTarget = false, applyPending = false } = {}) => {
if (!isTagTransfer(event)) {
return;
}
preventAll(event);
if (updateCursor) {
updateRemovalCursor(false);
}
const stringId = docId != null ? String(docId) : null;
if (applyTarget) {
engine.setTagDropTargetId(stringId);
@@ -191,7 +65,7 @@ export const useDeskTagInteractions = ({
engine.setPendingTagDocId(stringId);
}
},
[engine, isTagTransfer, updateRemovalCursor],
[engine, isTagTransfer],
);
const handleTagDragEnterDoc = useCallback(
@@ -240,7 +114,7 @@ export const useDeskTagInteractions = ({
if (!payload || !payload.id) {
return;
}
markActiveTagDropHandled(payload.id, payload.sourceDocId);
engine.setPendingRemovalTag(null);
if (payload.sourceDocId === doc.id) {
return;
@@ -254,7 +128,7 @@ export const useDeskTagInteractions = ({
sourceDocId: payload.sourceDocId ?? null,
});
},
[engine, isTagTransfer, markActiveTagDropHandled, onAssignTagToDocument, requestCanvasFocus],
[engine, isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
);
const handleDocTagDragStart = useCallback(
@@ -267,7 +141,7 @@ export const useDeskTagInteractions = ({
const { x: pointerX, y: pointerY } = getPointerPosition(event, { fallbackToPage: false });
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);
}
@@ -284,12 +158,11 @@ export const useDeskTagInteractions = ({
initialX: pointerX,
initialY: pointerY,
distance: 0,
dropHandled: false,
};
updateRemovalCursor(false);
engine.setPendingRemovalTag(null);
},
[updateRemovalCursor],
[engine],
);
const handleDocTagDrag = useCallback((event) => {
@@ -302,35 +175,36 @@ export const useDeskTagInteractions = ({
const dy = y - (state.initialY || 0);
state.distance = Math.sqrt(dx * dx + dy * dy);
if (state.distance >= TAG_REMOVE_DISTANCE) {
updateRemovalCursor(true);
engine.setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId });
} else {
updateRemovalCursor(false);
engine.setPendingRemovalTag(null);
}
}, [updateRemovalCursor]);
}, [engine]);
const handleDocTagDragEnd = useCallback(
(event) => {
finalizeTagDrag(event?.dataTransfer?.dropEffect || 'none');
() => {
const state = draggingTagRef.current;
if (!state) {
return;
if (state) {
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;
engine.setPendingRemovalTag(null);
engine.setTagDropTargetId(null);
engine.setPendingTagDocId(null);
},
[finalizeTagDrag],
[engine],
);
useEffect(() => {
return () => {
draggingTagRef.current = null;
pendingDocTagDragRef.current = null;
engine.setPendingRemovalTag(null);
};
}, []);
}, [engine]);
return {
handleTagDragEnterDoc,
@@ -341,8 +215,6 @@ export const useDeskTagInteractions = ({
handleDocTagDragStart,
handleDocTagDrag,
handleDocTagDragEnd,
handleTagDragEnd,
markActiveTagDropHandled,
handleCanvasDragOver,
handleCanvasDragLeave,
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 { clamp } from './math';
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_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
const EDGE_COLLISION_THRESHOLD = 0.5;
const getEventTargetElement = (event) => {
if (typeof Element === 'undefined' || !event) {
const getDocumentPageCount = (doc?: DocumentLike | null): number | null => {
const raw = doc?.current_version?.metadata?.page_count ?? doc?.metadata?.page_count;
if (raw == 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;
};
const useDocumentDrag = (options = {}) => {
const useDocumentDrag = (options: UseDocumentDragOptions) => {
const {
engine,
layoutRef,
@@ -31,13 +211,16 @@ const useDocumentDrag = (options = {}) => {
openOverlayForDoc,
recalcVisibleDocIds,
settings,
containerRef,
containerRef: providedContainerRef,
onInspectDocument,
onDocumentStackSelect,
selectedDocumentIds,
selectedDocumentIds = [],
markLayoutDirty,
} = options;
const fallbackContainerRef = useRef<HTMLElement | null>(null);
const containerRef = providedContainerRef ?? fallbackContainerRef;
const {
canvasPadding = 24,
defaultCanvasWidth = 1024,
@@ -52,25 +235,23 @@ const useDocumentDrag = (options = {}) => {
[engine],
);
const tapHandler = usePointerTap({
const tapHandler = usePointerTap<DragTapMetadata>({
delay: 220,
onSingle: () => {},
onDouble: ({ data, event }) => {
if (!data || !data.docId) {
if (!data?.docId) {
return;
}
if (event?.altKey) {
openOverlayForDoc(data.docId, data.originInfo);
openOverlayForDoc?.(data.docId, data.originInfo);
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) {
return;
}
@@ -78,24 +259,30 @@ const useDocumentDrag = (options = {}) => {
if (!map) {
return;
}
map.set(String(docKey), transform);
if (transform) {
map.set(String(docKey), transform);
} else {
map.delete(String(docKey));
}
}, [dragTransformsRef]);
const clearDragTransforms = useCallback(() => {
const map = dragTransformsRef?.current;
if (!map || typeof map.clear !== 'function') {
if (!map?.clear) {
return;
}
map.clear();
}, [dragTransformsRef]);
const commitActiveDragTransforms = useCallback((docIds = null) => {
const commitActiveDragTransforms = useCallback((docIds: Array<Identifier | null> | null = null) => {
const map = dragTransformsRef?.current;
if (!map || !map.size) {
return;
}
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());
keys.forEach((key) => {
const transform = map.get(key);
@@ -114,11 +301,11 @@ const useDocumentDrag = (options = {}) => {
}, [dragTransformsRef, layoutRef, markLayoutDirty]);
const finishDrag = useCallback(
(pointerId, { clearTransforms = true } = {}) => {
(pointerId: number, { clearTransforms = true }: { clearTransforms?: boolean } = {}) => {
const state = dragStateRef.current;
if (state && state.pointerId === pointerId) {
const capturedTarget = state.capturedTarget;
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
if (capturedTarget?.releasePointerCapture) {
try {
capturedTarget.releasePointerCapture(pointerId);
} catch (error) {
@@ -139,9 +326,9 @@ const useDocumentDrag = (options = {}) => {
);
const handlePointerDown = useCallback(
(event, docIdInput, options = {}) => {
(event: PointerEventLike, docIdInput?: Identifier | null, options: PointerDownOptions = {}) => {
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;
}
preventAll(event);
@@ -158,22 +345,24 @@ const useDocumentDrag = (options = {}) => {
if (!doc) {
return;
}
const massGrams = computeDocumentMassGrams(doc);
const stackDocIdsOptionRaw = options?.stackDocIds;
const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw)
? stackDocIdsOptionRaw
.map((value) => (value != null ? String(value) : null))
.filter(Boolean)
.filter((value): value is string => Boolean(value))
: null;
const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied);
const wasSelectedAtPointerDown = Boolean(options?.wasSelected);
const pointerModifierActive = typeof options?.modifierActive === 'boolean'
? options.modifierActive
: Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
const pointerModifierActive =
options?.modifierActive ?? Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
const stackReplace = Boolean(options?.stackReplace);
let selectionIds = Array.isArray(selectedDocumentIds)
? selectedDocumentIds.map((id) => String(id))
let selectionIds: string[] = Array.isArray(selectedDocumentIds)
? selectedDocumentIds
.map((id) => (id != null ? String(id) : null))
.filter((id): id is string => Boolean(id))
: [];
if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) {
@@ -195,9 +384,7 @@ const useDocumentDrag = (options = {}) => {
selectionIds = [...selectionIds, docKey];
}
selectionIds = selectionIds
.map((id) => String(id))
.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
if (!selectionIds.includes(docKey)) {
selectionIds.unshift(docKey);
@@ -227,8 +414,8 @@ const useDocumentDrag = (options = {}) => {
const entry = layoutRef.current.get(docKey) || null;
const defaultCenterX = canvasPadding + docWidth / 2;
const defaultCenterY = canvasPadding + docHeight / 2;
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
const centerX = Number.isFinite(entry?.centerX) ? entry.centerX : defaultCenterX;
const centerY = Number.isFinite(entry?.centerY) ? entry.centerY : defaultCenterY;
const modifierPressed = pointerModifierActive;
if (!modifierPressed) {
@@ -255,7 +442,7 @@ const useDocumentDrag = (options = {}) => {
}
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
if (capturedTarget?.setPointerCapture) {
try {
capturedTarget.setPointerCapture(event.pointerId);
} 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 containerTop = containerRect?.top || 0;
const pointerCanvasX = event.clientX - containerLeft;
@@ -279,20 +466,19 @@ const useDocumentDrag = (options = {}) => {
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
const groupItems = selectionIds.map((id) => {
const groupItems: DragGroupItemInternal[] = selectionIds.map((id) => {
const itemDoc = documentLookup.get(id);
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
const itemWidth = itemSize.width || docWidth;
const itemHeight = itemSize.height || docHeight;
const itemEntry = layoutRef.current.get(id) || null;
const itemCenterX =
typeof itemEntry?.centerX === 'number' ? itemEntry.centerX : canvasPadding + itemWidth / 2;
Number.isFinite(itemEntry?.centerX) ? itemEntry.centerX : canvasPadding + itemWidth / 2;
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 baseOffsetY = itemCenterY - centerY;
const initialRotation = itemEntry?.rotation ?? 0;
const targetRotation = initialRotation;
return {
docId: id,
width: itemWidth,
@@ -303,16 +489,16 @@ const useDocumentDrag = (options = {}) => {
baseOffsetY,
offsetX: baseOffsetX,
offsetY: baseOffsetY,
initialRotation,
targetRotation: initialRotation,
displayRotation: initialRotation,
targetRotation,
};
} satisfies DragGroupItemInternal;
});
const eventTimestamp =
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: typeof performance !== 'undefined' && typeof performance.now === 'function'
: performance?.now
? performance.now()
: Date.now();
@@ -353,9 +539,16 @@ const useDocumentDrag = (options = {}) => {
stackDocIds: hasStackSource ? stackDocIdsOption : null,
stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource,
stackReplace,
};
massGrams,
pointerRadiusScale: 1,
lastPointerCanvasX: pointerCanvasX,
lastPointerCanvasY: pointerCanvasY,
} satisfies DragStateInternal;
const state = dragStateRef.current;
if (!state) {
return;
}
clearDragTransforms();
state.groupItems.forEach((item) => {
@@ -415,7 +608,7 @@ const useDocumentDrag = (options = {}) => {
]);
const handlePointerMove = useCallback(
(event) => {
(event: PointerEventLike) => {
const state = dragStateRef.current;
if (!state) {
return;
@@ -423,10 +616,66 @@ const useDocumentDrag = (options = {}) => {
if (state.pointerId !== event.pointerId) {
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) {
const containerRect = containerRef?.current?.getBoundingClientRect?.();
const containerRect = containerRef.current?.getBoundingClientRect?.();
if (containerRect) {
state.containerRectLeft = containerRect.left;
state.containerRectTop = containerRect.top;
@@ -448,7 +697,9 @@ const useDocumentDrag = (options = {}) => {
&& Array.isArray(state.stackDocIds)
&& 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;
}
if (!state.groupElevated) {
@@ -537,14 +788,30 @@ const useDocumentDrag = (options = {}) => {
applyDomTransform(node, payload);
});
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
state.lastTimestamp =
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
const currentTimestampGroup =
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: typeof performance !== 'undefined' && typeof performance.now === 'function'
: performance?.now
? performance.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;
}
@@ -560,7 +827,7 @@ const useDocumentDrag = (options = {}) => {
const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2;
const containerRect = containerRef?.current?.getBoundingClientRect?.();
const containerRect = containerRef.current?.getBoundingClientRect?.();
if (containerRect) {
state.containerRectLeft = containerRect.left;
state.containerRectTop = containerRect.top;
@@ -572,6 +839,35 @@ const useDocumentDrag = (options = {}) => {
const pointerCanvasY = event.clientY - containerTop;
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 rotationRad = (rotationDeg * Math.PI) / 180;
const cosRot = Math.cos(rotationRad);
@@ -581,13 +877,6 @@ const useDocumentDrag = (options = {}) => {
const rotatedOffsetY =
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 absSin = Math.abs(sinRot);
const rotatedHalfWidth = absCos * halfWidth + absSin * halfHeight;
@@ -662,23 +951,6 @@ const useDocumentDrag = (options = {}) => {
const pointerInsideCard =
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 rotationForOffsetRad = (rotationForOffsetDeg * Math.PI) / 180;
const cosInverse = Math.cos(-rotationForOffsetRad);
@@ -711,7 +983,7 @@ const useDocumentDrag = (options = {}) => {
);
const handlePointerUp = useCallback(
(event) => {
(event: PointerEventLike) => {
const state = dragStateRef.current;
if (!state || state.pointerId !== event.pointerId) {
finishDrag(event.pointerId);
@@ -728,14 +1000,18 @@ const useDocumentDrag = (options = {}) => {
if (state.moved) {
commitActiveDragTransforms([state.docKey]);
const inertiaState = {
restRotation: state.restRotation,
dynamicRotation: state.dynamicRotation,
const finalRotation = state.rotation ?? state.restRotation;
const inertiaState: EngineInertiaState = {
docId: state.docKey,
restRotation: finalRotation,
dynamicRotation: 0,
angularVelocity: state.angularVelocity,
rotation: state.rotation,
rotation: finalRotation,
width: state.width,
height: state.height,
dragScale: state.dragScale || 1,
lastTimestamp: state.lastTimestamp,
massGrams: state.massGrams,
};
const docId = state.docKey;
finishDrag(event.pointerId);
@@ -775,7 +1051,7 @@ const useDocumentDrag = (options = {}) => {
);
const handlePointerCancel = useCallback(
(event) => {
(event: PointerEventLike) => {
const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId && state.moved) {
if (state.isGroup) {
@@ -787,14 +1063,18 @@ const useDocumentDrag = (options = {}) => {
}
commitActiveDragTransforms([state.docKey]);
const inertiaState = {
restRotation: state.restRotation,
dynamicRotation: state.dynamicRotation,
const finalRotation = state.rotation ?? state.restRotation;
const inertiaState: EngineInertiaState = {
docId: state.docKey,
restRotation: finalRotation,
dynamicRotation: 0,
angularVelocity: state.angularVelocity,
rotation: state.rotation,
rotation: finalRotation,
width: state.width,
height: state.height,
dragScale: state.dragScale || 1,
lastTimestamp: state.lastTimestamp,
massGrams: state.massGrams,
};
const docId = state.docKey;
finishDrag(event.pointerId);
@@ -1,5 +1,132 @@
import { clamp, formatTransform } from './math.js';
import { fetchLayoutRecords, upsertLayoutRecords } from './db.js';
import { clamp, formatTransform } from './math';
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_ROTATION_RANGE = 7;
@@ -7,18 +134,28 @@ export const DESK_DEFAULT_CANVAS_WIDTH = 1024;
export const DESK_DEFAULT_CANVAS_HEIGHT = 680;
export const DESK_CARD_MIN = 240;
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;
export const MIN_TIMESTEP = 1 / 120;
export const MAX_TIMESTEP = 1 / 20;
export const MAX_DYNAMIC_ROTATION = 4;
export const MAX_ANGULAR_VELOCITY = 180;
export const ANGULAR_DAMPING = 11;
export const TORQUE_TO_ACCELERATION = 0.006;
export const SETTLE_ANGULAR_VELOCITY = 1.2;
export const MAX_DYNAMIC_ROTATION = 7;
export const MAX_ANGULAR_VELOCITY = 210;
export const ANGULAR_DAMPING = 10;
export const TORQUE_TO_ACCELERATION = 0.007;
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 = (
node,
node: HTMLElement | null,
{
centerX,
centerY,
@@ -27,8 +164,8 @@ export const applyDomTransform = (
rotation = 0,
scale = 1,
zIndex,
} = {},
) => {
}: TransformOptions = {},
): void => {
if (!node) {
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 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 candidates = [];
const addCandidate = (scale) => {
const addCandidate = (scale: number) => {
if (Number.isFinite(scale) && scale > 0) {
candidates.push(scale);
}
@@ -66,7 +203,7 @@ export const clampCardDimensions = (width, height) => {
addCandidate(low);
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 scaledHeight = h * scale;
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 aspectSeed = seededRandom(`${docId}:fallback-aspect`);
@@ -105,7 +242,7 @@ export const computeFallbackCardSize = (docId) => {
return clampCardDimensions(width, height);
};
function seededRandom(input) {
function seededRandom(input: unknown): number {
const text = String(input);
let hash = 2166136261;
for (let index = 0; index < text.length; index += 1) {
@@ -115,22 +252,25 @@ function seededRandom(input) {
return (hash >>> 0) / 4294967295;
}
function randomRangeFromSeed(seedKey, min, max) {
function randomRangeFromSeed(seedKey: string, min: number, max: number): number {
const span = max - min;
if (span <= 0) return min;
const seed = seededRandom(seedKey);
return min + seed * span;
}
function buildKey(docId, suffix) {
function buildKey(docId: string | number, suffix: string): string {
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.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) {
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) {
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 B1 = p1.x - p2.x;
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) {
return [];
}
@@ -204,7 +347,7 @@ const clipPolygon = (subject, clipper) => {
return output;
};
const isPointInsideConvex = (point, polygon) => {
const isPointInsideConvex = (point: Point, polygon: Polygon): boolean => {
if (!polygon?.length) {
return false;
}
@@ -229,7 +372,7 @@ const isPointInsideConvex = (point, polygon) => {
return inside;
};
const polygonCentroid = (polygon) => {
const polygonCentroid = (polygon: Polygon): Point => {
if (!polygon?.length) {
return { x: 0, y: 0 };
}
@@ -261,7 +404,7 @@ const polygonCentroid = (polygon) => {
};
};
const generateInitialLayout = (
entries,
entries: LayoutGenerationEntry[],
{
canvasWidth,
canvasHeight,
@@ -270,9 +413,9 @@ const generateInitialLayout = (
rotationRange = DESK_ROTATION_RANGE,
minSpacing = 48,
shelfWidth = 0,
},
) => {
const layout = new Map();
}: LayoutGenerationOptions,
): { layout: Map<string, LayoutEntry>; maxZ: number } => {
const layout = new Map<string, LayoutEntry>();
let currentZ = startZ;
let maxZ = startZ;
@@ -282,9 +425,9 @@ const generateInitialLayout = (
const shelfOffset = Math.max(shelfWidth, 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 halfHeight = height / 2;
return {
@@ -298,7 +441,7 @@ const generateInitialLayout = (
};
};
const evaluateCandidateSpacing = (x, y, radius) => {
const evaluateCandidateSpacing = (x: number, y: number, radius: number) => {
if (!placed.length) {
return Number.POSITIVE_INFINITY;
}
@@ -381,11 +524,71 @@ const generateInitialLayout = (
};
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({
allowLayoutPersistence = false,
tenantId = null,
viewId = null,
} = {}) {
}: WorkspaceEngineOptions = {}) {
this.allowLayoutPersistence = allowLayoutPersistence;
this.tenantId = tenantId;
this.viewId = viewId;
@@ -424,9 +627,9 @@ export class WorkspaceEngine {
this.initialLoadDone = false;
}
updateConfig({ allowLayoutPersistence, tenantId, viewId }) {
updateConfig({ allowLayoutPersistence, tenantId, viewId }: WorkspaceEngineOptions): void {
const allowChanged =
typeof allowLayoutPersistence === 'boolean'
allowLayoutPersistence !== undefined
&& allowLayoutPersistence !== this.allowLayoutPersistence;
const tenantChanged = tenantId !== undefined && tenantId !== this.tenantId;
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 : [];
this.items = normalized;
const canGenerateLayoutImmediately =
@@ -484,28 +687,24 @@ export class WorkspaceEngine {
this.recalcVisibleDocIds();
}
setDocumentLookup(map) {
setDocumentLookup(map: Map<string, DeskDocument>): void {
this.documentLookup = map instanceof Map ? map : new Map();
this.recalcVisibleDocIds();
}
setEnsureDocumentSize(fn) {
if (typeof fn === 'function') {
this.ensureDocumentSize = fn;
}
setEnsureDocumentSize(fn: EnsureDocumentSize): void {
this.ensureDocumentSize = fn;
}
setResolveBaseMetrics(fn) {
if (typeof fn === 'function') {
this.resolveBaseMetrics = fn;
}
setResolveBaseMetrics(fn: ResolveBaseMetrics): void {
this.resolveBaseMetrics = fn;
}
setItemRefs(ref) {
setItemRefs(ref: ItemRefs | null): void {
this.itemRefs = ref || { current: new Map() };
}
setCanvasSize(size) {
setCanvasSize(size: { width?: number | null; height?: number | null }): void {
const width = Number(size?.width) || 0;
const height = Number(size?.height) || 0;
if (this.canvasSize.width === width && this.canvasSize.height === height) {
@@ -517,7 +716,7 @@ export class WorkspaceEngine {
this.emit();
}
setDraggingId(docId) {
setDraggingId(docId: string | number | null): void {
const normalized = docId != null ? String(docId) : null;
if (this.draggingId === normalized) {
return;
@@ -526,7 +725,7 @@ export class WorkspaceEngine {
this.emit();
}
beginDrag(docIds = []) {
beginDrag(docIds: Array<string | number | null> = []): void {
this.dragInProgress = true;
if (Array.isArray(docIds)) {
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.activeDragDocIds.clear();
this.flushPendingLayoutOps();
}
flushPendingLayoutOps() {
flushPendingLayoutOps(): void {
if (this.pendingSnapshotSync) {
this.syncLayoutSnapshot();
}
@@ -550,7 +749,7 @@ export class WorkspaceEngine {
}
}
setTagDropTargetId(docId) {
setTagDropTargetId(docId: string | number | null): void {
const normalized = docId != null ? String(docId) : null;
if (this.tagDropTargetId === normalized) {
return;
@@ -559,7 +758,7 @@ export class WorkspaceEngine {
this.emit();
}
setPendingTagDocId(docId) {
setPendingTagDocId(docId: string | number | null): void {
const normalized = docId != null ? String(docId) : null;
if (this.pendingTagDocId === normalized) {
return;
@@ -568,7 +767,7 @@ export class WorkspaceEngine {
this.emit();
}
setPendingRemovalTag(payload) {
setPendingRemovalTag(payload: unknown): void {
if (payload === this.pendingRemovalTag) {
return;
}
@@ -576,11 +775,11 @@ export class WorkspaceEngine {
this.emit();
}
markLayoutDirty() {
markLayoutDirty(): void {
this.layoutDirty = true;
}
getLayout(docId) {
getLayout(docId: string | number | null): LayoutEntry | null {
if (docId == null) {
return null;
}
@@ -588,13 +787,16 @@ export class WorkspaceEngine {
return this.layout.get(key) || null;
}
updateLayoutEntry(docId, updater) {
updateLayoutEntry(
docId: string | number | null,
updater: (previous: LayoutEntry | null) => LayoutEntry | null,
): void {
if (docId == null) {
return;
}
const key = String(docId);
const previous = this.layout.get(key) || null;
const next = typeof updater === 'function' ? updater(previous || {}) : updater;
const next = updater(previous);
if (!next) {
this.layout.delete(key);
} else {
@@ -605,7 +807,7 @@ export class WorkspaceEngine {
this.persistLayoutSnapshot();
}
bringToFront(docId) {
bringToFront(docId: string | number | null): void {
if (docId == null) {
return;
}
@@ -622,7 +824,16 @@ export class WorkspaceEngine {
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;
if (!key) {
return;
@@ -639,7 +850,7 @@ export class WorkspaceEngine {
});
}
finalizeGroupDrag(dragState) {
finalizeGroupDrag(dragState: DragState): void {
if (!dragState?.groupItems) {
return;
}
@@ -652,16 +863,18 @@ export class WorkspaceEngine {
if (!key) {
return;
}
const entry = this.layout.get(key) || {};
const centerX = item.currentCenterX ?? entry.centerX ?? dragState.originCenterX;
const centerY = item.currentCenterY ?? entry.centerY ?? dragState.originCenterY;
const rotation = item.displayRotation ?? entry.rotation ?? 0;
const entry = this.layout.get(key);
const centerX = item.currentCenterX ?? entry?.centerX ?? dragState.originCenterX ?? 0;
const centerY = item.currentCenterY ?? entry?.centerY ?? dragState.originCenterY ?? 0;
const rotation = item.displayRotation ?? entry?.rotation ?? 0;
const nextEntry = {
...entry,
const nextEntry: LayoutEntry = {
centerX,
centerY,
rotation,
z: entry?.z ?? this.zCounter,
width: entry?.width ?? item.width,
height: entry?.height ?? item.height,
};
this.layout.set(key, nextEntry);
@@ -683,34 +896,33 @@ export class WorkspaceEngine {
this.persistLayoutSnapshot();
}
cancelInertiaAnimation(docId) {
cancelInertiaAnimation(docId: string | number | null): void {
const key = docId != null ? String(docId) : null;
if (!key) {
return;
}
if (typeof window === 'undefined') {
this.inertiaAnimations.delete(key);
return;
}
const existing = this.inertiaAnimations.get(key);
if (existing && typeof window.cancelAnimationFrame === 'function') {
if (existing?.frameId != null) {
window.cancelAnimationFrame(existing.frameId);
}
this.inertiaAnimations.delete(key);
}
disposeInertiaAnimations() {
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
this.inertiaAnimations.forEach((animation) => {
if (animation?.frameId != null) {
window.cancelAnimationFrame(animation.frameId);
}
});
}
disposeInertiaAnimations(): void {
this.inertiaAnimations.forEach((animation) => {
if (animation?.frameId != null) {
window.cancelAnimationFrame(animation.frameId);
}
});
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;
if (!key) {
return true;
@@ -726,26 +938,43 @@ export class WorkspaceEngine {
return true;
}
const massScale = getMassScale(simulationState.massGrams);
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
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);
angularVelocity *= dampingFactor;
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
if (dynamicRotation > MAX_DYNAMIC_ROTATION) {
dynamicRotation = MAX_DYNAMIC_ROTATION;
const dynamicLimit = MAX_DYNAMIC_ROTATION / Math.sqrt(massScale);
if (dynamicRotation > dynamicLimit) {
dynamicRotation = dynamicLimit;
angularVelocity = Math.min(angularVelocity, 0);
} else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) {
dynamicRotation = -MAX_DYNAMIC_ROTATION;
} else if (dynamicRotation < -dynamicLimit) {
dynamicRotation = -dynamicLimit;
angularVelocity = Math.max(angularVelocity, 0);
}
simulationState.angularVelocity = angularVelocity;
simulationState.dynamicRotation = dynamicRotation;
simulationState.rotation = simulationState.restRotation + dynamicRotation;
const isSettled = Math.abs(angularVelocity) < (SETTLE_ANGULAR_VELOCITY * 0.6)
|| Math.abs(dynamicRotation) < (MAX_DYNAMIC_ROTATION * 0.05);
if (isSettled) {
simulationState.angularVelocity = 0;
simulationState.dynamicRotation = 0;
simulationState.rotation = simulationState.restRotation;
} else {
simulationState.angularVelocity = angularVelocity;
simulationState.dynamicRotation = dynamicRotation;
simulationState.rotation = simulationState.restRotation + dynamicRotation;
if (Math.sign(simulationState.angularVelocity) !== Math.sign(angularVelocity)) {
simulationState.angularVelocity = angularVelocity;
}
}
const rotation = simulationState.rotation;
const nextEntry = { ...entry, rotation };
@@ -763,12 +992,12 @@ export class WorkspaceEngine {
nextEntry.z,
);
const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY;
return isSettled;
}
startInertiaAnimation(docId, baseState) {
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
startInertiaAnimation(docId: string | number | null, baseState: InertiaSimulationState): void {
const raf = window.requestAnimationFrame;
if (!raf) {
return;
}
const key = docId != null ? String(docId) : null;
@@ -778,19 +1007,19 @@ export class WorkspaceEngine {
this.cancelInertiaAnimation(key);
const now =
typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now();
const now = performance?.now ? performance.now() : Date.now();
const simulationState = {
...baseState,
docId: key,
dragScale: baseState.dragScale || 1,
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 previous = simulationState.lastTimestamp;
let dt = (safeTimestamp - previous) / 1000;
@@ -807,14 +1036,14 @@ export class WorkspaceEngine {
this.persistLayoutSnapshot();
return;
}
simulationState.frameId = window.requestAnimationFrame(step);
simulationState.frameId = raf(step);
};
simulationState.frameId = window.requestAnimationFrame(step);
simulationState.frameId = raf(step);
this.inertiaAnimations.set(key, simulationState);
}
syncLayoutSnapshot() {
syncLayoutSnapshot(): void {
if (this.dragInProgress) {
this.pendingSnapshotSync = true;
return;
@@ -824,7 +1053,7 @@ export class WorkspaceEngine {
this.emit();
}
async persistLayoutSnapshot() {
async persistLayoutSnapshot(): Promise<void> {
if (this.dragInProgress) {
this.pendingPersistSync = true;
return;
@@ -885,17 +1114,13 @@ export class WorkspaceEngine {
}
};
if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
this.persistDebounceId = window.setTimeout(() => {
this.persistDebounceId = null;
void persistTask();
}, 100);
} else {
await persistTask();
}
this.persistDebounceId = window.setTimeout(() => {
this.persistDebounceId = null;
void persistTask();
}, 100);
}
ensureLayoutForItems() {
ensureLayoutForItems(): void {
const persistenceReady = !this.allowLayoutPersistence || !this.tenantId || !this.viewId || this.initialLoadDone;
const canvasReady = Boolean(this.canvasSize.width && this.canvasSize.height);
const sizesReady = !this.items.some((doc) => !this.ensureDocumentSize(doc));
@@ -919,11 +1144,11 @@ export class WorkspaceEngine {
return;
}
const next = new Map();
const next = new Map<DocumentId, LayoutEntry>();
let maxZ = this.zCounter;
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT;
const docsNeedingLayout = [];
const docsNeedingLayout: LayoutGenerationEntry[] = [];
const currentEntries = new Map(this.layout);
@@ -951,8 +1176,12 @@ export class WorkspaceEngine {
if (existing) {
const defaultCenterX = (minCenterX + maxCenterX) / 2;
const defaultCenterY = (minCenterY + maxCenterY) / 2;
const prevCenterX = typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX;
const prevCenterY = typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY;
const prevCenterX = Number.isFinite(existing.centerX)
? Number(existing.centerX)
: defaultCenterX;
const prevCenterY = Number.isFinite(existing.centerY)
? Number(existing.centerY)
: defaultCenterY;
const centerX = clamp(prevCenterX, minCenterX, maxCenterX);
const centerY = clamp(prevCenterY, minCenterY, maxCenterY);
const rotation = existing.rotation ?? 0;
@@ -996,11 +1225,8 @@ export class WorkspaceEngine {
this.recalcVisibleDocIds();
}
recalcVisibleDocIds() {
recalcVisibleDocIds(): void {
const ensureSize = this.ensureDocumentSize;
if (typeof ensureSize !== 'function') {
return;
}
const layoutMap = this.layout;
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
@@ -1021,7 +1247,7 @@ export class WorkspaceEngine {
{ x: 0, y: canvasHeight },
];
const entries = [];
const entries: Array<{ key: string; z: number; polygon: Polygon }> = [];
layoutMap.forEach((entry, docKey) => {
if (!docKey) {
return;
@@ -1074,8 +1300,8 @@ export class WorkspaceEngine {
entries.sort((a, b) => (b.z || 0) - (a.z || 0));
const visiblePolygons = [];
const result = new Set();
const visiblePolygons: Polygon[] = [];
const result = new Set<DocumentId>();
entries.forEach(({ key, polygon }) => {
if (polygon.length < 3) {
@@ -1129,16 +1355,16 @@ export class WorkspaceEngine {
this.emit();
}
subscribe(listener) {
subscribe(listener: WorkspaceSubscriber): () => void {
this.subscribers.add(listener);
return () => {
this.subscribers.delete(listener);
};
}
getSnapshot = () => this.snapshotCache;
getSnapshot = (): WorkspaceSnapshot => this.snapshotCache;
buildSnapshot() {
buildSnapshot(): WorkspaceSnapshot {
return {
layout: this.layoutSnapshot,
canvasSize: this.canvasSize,
@@ -1151,7 +1377,7 @@ export class WorkspaceEngine {
};
}
emit() {
emit(): void {
this.snapshotCache = this.buildSnapshot();
this.subscribers.forEach((listener) => {
try {
@@ -1162,7 +1388,7 @@ export class WorkspaceEngine {
});
}
async loadPersistedLayout() {
async loadPersistedLayout(): Promise<void> {
if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) {
return;
}
@@ -1210,9 +1436,12 @@ export class WorkspaceEngine {
}
}
export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => {
export const useWorkspaceSnapshot = (
engine: WorkspaceEngine,
useSyncExternalStoreHook: UseSyncExternalStoreHook,
): WorkspaceSnapshot => {
const useSyncExternalStore = useSyncExternalStoreHook;
if (typeof useSyncExternalStore !== 'function') {
if (!useSyncExternalStore) {
throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook');
}
return useSyncExternalStore(
@@ -1223,8 +1452,11 @@ export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => {
};
/* istanbul ignore next */
if (typeof module !== 'undefined' && module && module.exports) {
module.exports = {
const commonJsModule = (globalThis as typeof globalThis & {
module?: { exports?: Record<string, unknown> };
}).module;
if (commonJsModule?.exports) {
commonJsModule.exports = {
WorkspaceEngine,
DESK_CANVAS_PADDING,
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 { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
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 ensureDocumentRoot = () => {
if (typeof document === 'undefined') {
return null;
}
return document.body;
};
const PreviewZoomOverlay = ({
const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
open = false,
display = null,
onClose = noop,
document: overlayDocument = null,
}) => {
const portalTarget = ensureDocumentRoot();
const portalTarget = document.body;
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 [isBackdropVisible, setBackdropVisible] = useState(false);
const [displaySnapshot, setDisplaySnapshot] = useState(null);
const scrollRef = useRef(null);
const imageRef = useRef(null);
const focusRef = useRef(null);
const previouslyFocusedRef = useRef(null);
const visibilityTimerRef = useRef(null);
const displayTimerRef = useRef(null);
const [documentSnapshot, setDocumentSnapshot] = useState<DocumentLikeWithPreview | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const mediaRef = useRef<HTMLImageElement | null>(null);
const focusRef = useRef<FocusPoint>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const visibilityTimerRef = useRef<number | null>(null);
const displayTimerRef = useRef<number | null>(null);
const currentDocument = overlayDocument as DocumentLikeWithPreview | null;
useEffect(() => {
if (display?.url) {
setDisplaySnapshot(display);
if (currentDocument?.documentLink?.url) {
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(() => {
if (visibilityTimerRef.current) {
@@ -46,7 +88,7 @@ const PreviewZoomOverlay = ({
displayTimerRef.current = null;
}
if (open && display?.url) {
if (open && documentLink?.url) {
setRenderBackdrop(true);
displayTimerRef.current = requestAnimationFrame(() => {
displayTimerRef.current = requestAnimationFrame(() => {
@@ -62,7 +104,7 @@ const PreviewZoomOverlay = ({
}
setBackdropVisible(false);
visibilityTimerRef.current = setTimeout(() => {
visibilityTimerRef.current = window.setTimeout(() => {
setRenderBackdrop(false);
}, 260);
@@ -72,7 +114,7 @@ const PreviewZoomOverlay = ({
visibilityTimerRef.current = null;
}
};
}, [open, display?.url]);
}, [open, documentLink?.url]);
useEffect(() => () => {
if (visibilityTimerRef.current) {
@@ -83,6 +125,9 @@ const PreviewZoomOverlay = ({
}
}, []);
useEffect(() => {
}, [isPdfDisplay, open, documentLink?.url]);
useEffect(() => {
setIsNativeScale(false);
setNaturalSize({ width: null, height: null });
@@ -92,7 +137,7 @@ const PreviewZoomOverlay = ({
scrollEl.scrollLeft = 0;
scrollEl.scrollTop = 0;
}
}, [open]);
}, [open, documentLink?.url, displayKind]);
useEffect(() => {
@@ -101,13 +146,13 @@ const PreviewZoomOverlay = ({
}
const scrollEl = scrollRef.current;
const imageEl = imageRef.current;
if (!scrollEl || !imageEl) {
const mediaEl = mediaRef.current;
if (!scrollEl || !mediaEl) {
return;
}
const imageWidth = imageEl.naturalWidth || imageEl.clientWidth;
const imageHeight = imageEl.naturalHeight || imageEl.clientHeight;
const imageWidth = naturalSize.width || mediaEl.clientWidth;
const imageHeight = naturalSize.height || mediaEl.clientHeight;
if (!(imageWidth > 0 && imageHeight > 0)) {
return;
}
@@ -125,27 +170,17 @@ const PreviewZoomOverlay = ({
useEffect(() => {
if (!open) {
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
previouslyFocusedRef.current.focus();
}
previouslyFocusedRef.current?.focus?.();
previouslyFocusedRef.current = null;
return;
}
if (typeof document !== 'undefined') {
const active = document.activeElement;
if (active && typeof active.focus === 'function') {
previouslyFocusedRef.current = active;
} else {
previouslyFocusedRef.current = null;
}
}
const active = document.activeElement;
previouslyFocusedRef.current = active instanceof HTMLElement ? active : null;
}, [open]);
const activeDisplay = open && display?.url ? display : displaySnapshot;
useEffect(() => {
if (!activeDisplay?.url) {
if (!documentLink?.url) {
return;
}
@@ -155,24 +190,21 @@ const PreviewZoomOverlay = ({
scrollEl.scrollTop = 0;
}
focusRef.current = null;
}, [activeDisplay?.url]);
}, [documentLink?.url]);
useEffect(() => {
if (!renderBackdrop || !activeDisplay?.url) {
if (!renderBackdrop || !documentLink?.url) {
return undefined;
}
const frame = requestAnimationFrame(() => {
const scrollEl = scrollRef.current;
if (scrollEl && typeof scrollEl.focus === 'function') {
scrollEl.focus({ preventScroll: true });
}
scrollRef.current?.focus?.({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}, [renderBackdrop, activeDisplay?.url]);
}, [renderBackdrop, documentLink?.url]);
const handleKeyDown = (event) => {
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
event.stopPropagation();
if (!open) {
@@ -205,27 +237,16 @@ const PreviewZoomOverlay = ({
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 img = imageRef.current;
const toggleZoomAtPoint = (clientX: number, clientY: number) => {
if (isPdfDisplay) {
return;
}
const media = mediaRef.current;
setIsNativeScale((current) => {
if (!current && img) {
const rect = img.getBoundingClientRect();
if (!current && media) {
const rect = media.getBoundingClientRect();
const xRatio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5;
const yRatio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5;
focusRef.current = {
@@ -239,26 +260,23 @@ const PreviewZoomOverlay = ({
});
};
const handleImageClick = (event) => {
event.stopPropagation();
const handleContentClick = (event: React.MouseEvent<HTMLElement>) => {
if (isPdfDisplay) {
return;
}
toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0);
};
if (!renderBackdrop || !activeDisplay?.url || !portalTarget) {
return null;
}
const shouldRender = renderBackdrop && Boolean(documentLink?.url);
const effectiveDisplay = activeDisplay;
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
const stageClassName = [
'preview-zoom__stage',
]
.filter(Boolean)
.join(' ');
const stageClassName = isPdfDisplay
? 'preview-zoom__stage preview-zoom__stage--pdf'
: 'preview-zoom__stage';
const containerClassName = [
'preview-zoom__scroll',
isNativeScale ? 'preview-zoom__scroll--native' : '',
isNativeScale ? 'preview-zoom__scroll--native' : null,
isPdfDisplay ? 'preview-zoom__scroll--pdf' : null,
]
.filter(Boolean)
.join(' ');
@@ -270,7 +288,7 @@ const PreviewZoomOverlay = ({
.filter(Boolean)
.join(' ');
const imageStyle = isNativeScale
const contentStyle: CSSProperties = isNativeScale
? {
cursor: 'zoom-out',
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
@@ -286,6 +304,12 @@ const PreviewZoomOverlay = ({
touchAction: 'manipulation',
};
if (!shouldRender) {
return null;
}
const effectiveDisplay = documentLink;
return createPortal(
(
<div
@@ -294,65 +318,50 @@ const PreviewZoomOverlay = ({
aria-modal="true"
aria-label="Enlarged document preview"
onClick={onClose}
>
<div
className={stageClassName}
onKeyDown={handleKeyDown}
>
<div
className={containerClassName}
ref={scrollRef}
tabIndex={-1}
onClick={(event) => event.stopPropagation()}
className={stageClassName}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<img
src={effectiveDisplay.url}
alt={effectiveDisplay.alt || 'Document preview'}
className="preview-zoom__image"
ref={imageRef}
draggable={false}
onLoad={(event) => {
setNaturalSize({
width: event.currentTarget.naturalWidth || null,
height: event.currentTarget.naturalHeight || null,
});
}}
onClick={handleImageClick}
style={imageStyle}
/>
</div>
{navVisible ? (
<div className="preview-zoom__nav">
<button
type="button"
className="preview-zoom__nav-button"
onClick={(event) => {
event.stopPropagation();
if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) {
effectiveDisplay.goPrev();
}
<div
className={containerClassName}
ref={scrollRef}
tabIndex={-1}
>
{isPdfDisplay ? (
<div className="preview-zoom__pdf">
<PdfViewer
src={effectiveDisplay.url}
title={effectiveAlt}
className="preview-zoom__pdf-viewer"
viewportRef={scrollRef}
/>
</div>
) : (
<img
src={effectiveDisplay.url}
alt={effectiveAlt}
className="preview-zoom__image"
ref={(node) => {
mediaRef.current = node;
}}
aria-label="Previous preview"
disabled={!effectiveDisplay?.canGoPrev}
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-zoom__nav-button"
onClick={(event) => {
event.stopPropagation();
if (effectiveDisplay?.canGoNext && effectiveDisplay?.goNext) {
effectiveDisplay.goNext();
}
draggable={false}
onLoad={(event) => {
setNaturalSize({
width: event.currentTarget.naturalWidth || null,
height: event.currentTarget.naturalHeight || null,
});
}}
aria-label="Next preview"
disabled={!effectiveDisplay?.canGoNext}
>
<ArrowRightIcon />
</button>
</div>
) : null}
onClick={handleContentClick}
style={contentStyle}
/>
)}
</div>
</div>
</div>
),
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo } from 'react';
import type { MutableRefObject } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { useDetailPanel } from '../app/useDetailPanel';
import {
@@ -6,11 +7,73 @@ import {
getRowId,
isDocumentRowKey,
} 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 = ({
documents,
searchResults,
previewDocuments,
selectionOrder,
selectedDocumentIds,
documentLookup,
@@ -18,7 +81,7 @@ const useDetailWorkspace = ({
ensureFolderData,
detailPanelControlRef,
detailFolderFetchRef,
previewEntries,
documentLinks,
previewDocumentId,
activePreviewId,
openDocumentPreview,
@@ -28,7 +91,6 @@ const useDetailWorkspace = ({
handleTagRemove,
ensureAssetUrl,
getDocumentAsset,
ensurePreviewData,
correspondents,
handleCorrespondentAdd,
handleCorrespondentRemove,
@@ -36,7 +98,7 @@ const useDetailWorkspace = ({
selectFolder,
tags,
tagLookupById,
}) => {
}: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => {
const orderedSelectedDocuments = useMemo(() => {
const ordered = [];
const seen = new Set();
@@ -177,29 +239,19 @@ const useDetailWorkspace = ({
[folderNodes],
);
const detailPanelPreviewEntry = useMemo(() => {
if (!detailPanelDocument) {
return null;
}
return previewEntries.get(detailPanelDocument.id) || null;
}, [detailPanelDocument, previewEntries]);
const previewWorkspaceEntry = useMemo(() => {
if (!previewDocumentId) {
return null;
}
return previewEntries.get(previewDocumentId) || null;
}, [previewDocumentId, previewEntries]);
const documentLink = useMemo(
() => (detailPanelDocument ? documentLinks.get(detailPanelDocument.id) || null : null),
[detailPanelDocument, documentLinks],
);
const previewWorkspaceDocument = useMemo(() => {
if (!previewDocumentId) {
return null;
}
const pool = searchResults ?? documents;
return pool.find((doc) => doc.id === previewDocumentId)
|| previewDocuments?.get?.(previewDocumentId)
return documentLookup.get(previewDocumentId)
|| documents.find((doc) => doc.id === previewDocumentId)
|| null;
}, [previewDocumentId, searchResults, documents, previewDocuments]);
}, [previewDocumentId, documentLookup, documents]);
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
@@ -233,14 +285,13 @@ const useDetailWorkspace = ({
tagLookupById,
onTagAdd: handleDocumentTagAdd,
onTagRemove: handleTagRemove,
previewEntry: detailPanelPreviewEntry,
documentLink,
onOpenPreview: openDocumentPreview,
activePreviewId,
onUpdateTitle: handleDocumentTitleUpdate,
onUpdateIssued: handleDocumentIssuedUpdate,
ensureAssetUrl,
getDocumentAsset,
hydrateDocument: ensurePreviewData,
correspondents,
onCorrespondentAdd: handleCorrespondentAdd,
onCorrespondentRemove: handleCorrespondentRemove,
@@ -254,7 +305,6 @@ const useDetailWorkspace = ({
correspondents,
detailPanelDocument,
ensureAssetUrl,
ensurePreviewData,
getDocumentAsset,
handleCorrespondentAdd,
handleCorrespondentRemove,
@@ -267,7 +317,7 @@ const useDetailWorkspace = ({
resolveApiPath,
resolveFolderPath,
selectFolder,
detailPanelPreviewEntry,
documentLink,
tags,
tagLookupById,
],
@@ -282,7 +332,7 @@ const useDetailWorkspace = ({
inspectDocument,
previewActive,
previewWorkspaceDocument,
previewWorkspaceEntry,
documentLink,
resolveThumbnailUrlForDoc,
resolveFolderPath,
};
@@ -2,7 +2,19 @@ import React from 'react';
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,
activeCorrespondentIdSet,
onCorrespondentClick,
@@ -11,8 +23,8 @@ const CorrespondentLinks = ({
return null;
}
const activeSet = activeCorrespondentIdSet || new Set();
const handleClick = (event, correspondent) => {
const activeSet = activeCorrespondentIdSet || new Set<string | number>();
const handleClick = (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>, correspondent: CorrespondentLinkEntry) => {
if (!onCorrespondentClick || correspondent.id == null) {
return;
}
@@ -27,11 +39,12 @@ const CorrespondentLinks = ({
if (isActive) classNames.push('is-active');
if (!hasHandler) classNames.push('is-static');
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 (
<React.Fragment
key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}
key={correspondent.key ?? correspondent.id ?? `${fallbackLabel}-${index}`}
>
<button
type="button"
@@ -1,8 +1,54 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import DocumentSummarySection from './DocumentSummarySection';
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
import type { ReactNode } from 'react';
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,
summaryProps = {},
metadataItems: metadataItemsProp,
@@ -30,7 +76,7 @@ const DocumentInfoPanel = ({
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
return metadataItemsProp;
}
return buildDocumentMetadataItems(document);
return describeDocumentSummary(document);
}, [metadataItemsProp, document]);
const metadataPayload = useMemo(() => {
@@ -42,13 +88,14 @@ const DocumentInfoPanel = ({
const contentConfig = contentConfigProp || null;
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) {
return null;
}
if (!contentEnabled || typeof contentConfig.loadContent !== 'function') {
if (!contentEnabled || !loadContent) {
return { status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null };
}
return { status: 'idle', data: null, error: null };
@@ -60,7 +107,7 @@ const DocumentInfoPanel = ({
return undefined;
}
if (!contentEnabled || typeof contentConfig.loadContent !== 'function') {
if (!contentEnabled || !loadContent) {
setContentState({ status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null });
return undefined;
}
@@ -70,7 +117,7 @@ const DocumentInfoPanel = ({
setContentState({ status: 'loading', data: null, error: null });
Promise.resolve(contentConfig.loadContent({ signal: controller.signal }))
Promise.resolve(loadContent({ signal: controller.signal }))
.then((result) => {
if (cancelled) {
return;
@@ -97,23 +144,22 @@ const DocumentInfoPanel = ({
controller.abort();
contentConfig.onCancel?.();
};
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey, loadContent]);
const renderSummarySection = useCallback(() => (
<DocumentSummarySection
document={document}
detailItems={metadataItems}
layout={summaryLayout}
{...summaryProps}
/>
), [document, summaryLayout, summaryProps, metadataItems]);
), [document, summaryLayout, summaryProps]);
const renderDetailsSection = useCallback(() => (
<section className={`${base}__section`}>
{metadataItems.length ? (
<dl className={`${base}__section-list`}>
{metadataItems.map(({ label, value }) => (
<div className={`${base}__section-item`} key={label}>
{metadataItems.map(({ key, label, value }) => (
<div className={`${base}__section-item`} key={key || label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
@@ -144,14 +190,14 @@ const DocumentInfoPanel = ({
const normalizedLeadingTabs = useMemo(
() => (Array.isArray(leadingTabs)
? leadingTabs.filter((tab) => tab && tab.id && tab.label)
? leadingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
: []),
[leadingTabs],
);
const normalizedTrailingTabs = useMemo(
() => (Array.isArray(trailingTabs)
? trailingTabs.filter((tab) => tab && tab.id && tab.label)
? trailingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
: []),
[trailingTabs],
);
@@ -166,7 +212,7 @@ const DocumentInfoPanel = ({
: null;
const visibleTabs = useMemo(() => {
const tabsList = [];
const tabsList: PanelTab[] = [];
if (normalizedLeadingTabs.length) {
tabsList.push(...normalizedLeadingTabs);
@@ -251,17 +297,17 @@ const DocumentInfoPanel = ({
}
if (metadataPayload) {
tabsList.push({
id: 'metadata',
label: metadataTabLabel,
render: () => (
<section className={`${base}__section ${base}__section--metadata-json`}>
<pre className={`${base}__metadata-json`}>
{JSON.stringify(metadataPayload, null, 2)}
</pre>
</section>
),
});
tabsList.push({
id: 'metadata',
label: metadataTabLabel,
render: () => (
<section className={`${base}__section ${base}__section--metadata-json`}>
<pre className={`${base}__metadata-json`}>
{JSON.stringify(metadataPayload, null, 2)}
</pre>
</section>
),
});
}
if (normalizedTrailingTabs.length) {
@@ -295,18 +341,14 @@ const DocumentInfoPanel = ({
return visibleTabs[0].id;
}, [visibleTabs, defaultTabId]);
const renderTabContent = (tab, context = {}) => {
const renderTabContent = (tab?: PanelTab | null, context: Record<string, unknown> = {}) => {
if (!tab) {
return null;
}
if (typeof tab.render === 'function') {
return tab.render(context);
if (!tab.render) {
return null;
}
if (tab.component) {
const TabComponent = tab.component;
return <TabComponent {...context} />;
}
return React.isValidElement(tab.render) ? tab.render : null;
return tab.render(context);
};
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
@@ -1,13 +1,86 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EditIcon, IconX, PlusIcon } from '../ui/icons';
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
import React, { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
import { EditIcon, IconX, CheckIcon, PlusIcon } from '../ui/icons';
import SelectionAssignmentMenu, {
SelectionAssignmentMenuItem,
type NormalizedSelectionAssignmentItem,
} from './SelectionAssignmentMenu';
import { getTagColorStyle } from '../utils/colors';
import {
formatDate,
toDateInputValue,
toIssuedTimestamp,
} 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 = []) =>
entries
@@ -18,7 +91,7 @@ export const sortCorrespondents = (entries = []) =>
export const buildCorrespondentOptions = (entries = []) => {
const seen = new Set();
return entries.reduce((options, entry) => {
const name = typeof entry?.name === 'string' ? entry.name.trim() : '';
const name = entry?.name?.trim?.() || '';
if (!name) {
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) {
return null;
}
if (typeof option === 'string') {
const label = option.trim();
return label ? { id: label, label, original: option } : null;
}
const label = typeof option.label === 'string'
? option.label.trim()
: typeof option.name === 'string'
? option.name.trim()
: '';
const label = (() => {
if (isPlainObject(option)) {
const sourceLabel = option.label ?? option.name ?? '';
return `${sourceLabel}`.trim();
}
return `${option}`.trim();
})();
if (!label) {
return null;
}
return {
id: option.id ?? label,
id: isPlainObject(option) && option.id ? option.id : label,
label,
original: option,
};
};
export const TagSection = ({
export const TagSection: React.FC<TagSectionProps> = ({
tags = [],
onRemove,
onAdd,
@@ -68,16 +163,14 @@ export const TagSection = ({
className,
}) => {
const handleCreate = useCallback(
(label) => onAdd?.({ value: label, input: null }),
(label: string) => onAdd?.({ value: label, input: null }),
[onAdd],
);
const handleSelect = useCallback(
(option) => {
(option: { label?: string; name?: string } | string | null) => {
if (!onAdd) return;
const label =
(option && typeof option === 'object' && option.label) ||
(typeof option === 'string' ? option : '');
const label = resolveOptionName(option as QuickAddOption | string | null);
if (!label) {
return;
}
@@ -90,14 +183,14 @@ export const TagSection = ({
() =>
normalizeOptions(datalistOptions)
.map((option) => normalizeQuickAddOption(option))
.filter(Boolean),
.filter((option): option is QuickAddEntry => Boolean(option)),
[datalistOptions],
);
const containerClass = className ? `tag-list ${className}` : 'tag-list';
const showQuickAdd = Boolean(onAdd);
const assignmentItems = useMemo(() => {
const map = new Map();
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
const map = new Map<string, SelectionAssignmentMenuItem>();
normalizedOptions.forEach((option) => {
const label = option?.label?.trim();
@@ -117,7 +210,7 @@ export const TagSection = ({
});
tags.forEach((tag) => {
const label = typeof tag?.label === 'string' ? tag.label.trim() : '';
const label = tag?.label?.trim?.() || '';
if (!label) {
return;
}
@@ -125,8 +218,10 @@ export const TagSection = ({
const payload = { id: tag.id, label, color: tag.color ?? null };
if (map.has(key)) {
const entry = map.get(key);
entry.state = 'all';
entry.payload = payload;
if (entry) {
entry.state = 'all';
entry.payload = payload;
}
return;
}
map.set(key, {
@@ -141,14 +236,21 @@ export const TagSection = ({
}, [normalizedOptions, tags]);
const handleAssignmentSelect = useCallback(
(item) => {
(item: NormalizedSelectionAssignmentItem) => {
if (!item) {
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 };
handleSelect(payload);
},
[handleSelect],
[handleSelect, onRemove, tags],
);
return (
@@ -185,11 +287,9 @@ export const TagSection = ({
showCounts={false}
positionStrategy="fixed"
triggerClassName="quick-add__chip quick-add__trigger"
triggerContent={(
<span className="quick-add__chip-label">
<PlusIcon className="icon-inline" aria-hidden="true" /> Add tag
</span>
)}
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
closeOnSelection={false}
freezeSortOnOpen
/>
) : 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 = [],
onRemove,
onAdd,
@@ -208,7 +308,7 @@ export const CorrespondentSection = ({
className,
}) => {
const handleCreate = useCallback(
(name) => onAdd?.({ name, input: null }),
(name: string) => onAdd?.({ name, input: null }),
[onAdd],
);
@@ -216,15 +316,15 @@ export const CorrespondentSection = ({
() =>
normalizeOptions(datalistOptions)
.map((option) => normalizeQuickAddOption(option))
.filter(Boolean),
.filter((option): option is QuickAddEntry => Boolean(option)),
[datalistOptions],
);
const hasEntries = entries && entries.length > 0;
const showQuickAdd = Boolean(onAdd);
const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
const assignmentItems = useMemo(() => {
const map = new Map();
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
const map = new Map<string, SelectionAssignmentMenuItem>();
normalizedOptions.forEach((option) => {
const label = option?.label?.trim();
@@ -244,7 +344,7 @@ export const CorrespondentSection = ({
});
entries.forEach((entry) => {
const label = typeof entry?.name === 'string' ? entry.name.trim() : '';
const label = entry?.name?.trim?.() || '';
if (!label) {
return;
}
@@ -252,8 +352,10 @@ export const CorrespondentSection = ({
const payload = { id: entry.id, name: label };
if (map.has(key)) {
const item = map.get(key);
item.state = 'all';
item.payload = payload;
if (item) {
item.state = 'all';
item.payload = payload;
}
return;
}
map.set(key, {
@@ -268,24 +370,31 @@ export const CorrespondentSection = ({
}, [normalizedOptions, entries]);
const handleAssignmentSelect = useCallback(
(item) => {
if (!onAdd || !item) {
(item: NormalizedSelectionAssignmentItem) => {
if (!item) {
return;
}
const source = item.payload ?? item;
const resolvedName =
(source && typeof source.name === 'string' && source.name.trim())
|| (typeof source === 'string' ? source.trim() : '')
|| (source && typeof source.label === 'string' ? source.label.trim() : '');
if (item.state === 'all' && onRemove) {
const payload = isPlainObject(item.payload)
? (item.payload as CorrespondentEntry)
: entries.find((entry) => (entry.id ?? entry.name) === item.id) ?? { id: item.id, name: item.label };
onRemove(payload);
return;
}
if (!onAdd) {
return;
}
const source = (item.payload ?? item) as QuickAddOption | string | null;
const resolvedName = resolveOptionName(source);
if (!resolvedName) {
return;
}
const payload = typeof source === 'object'
const payload = isPlainObject(source)
? { ...source, name: resolvedName }
: { id: null, name: resolvedName };
onAdd({ name: resolvedName, option: payload, input: null });
},
[onAdd],
[entries, onAdd, onRemove],
);
return (
@@ -326,18 +435,16 @@ export const CorrespondentSection = ({
showCounts={false}
positionStrategy="fixed"
triggerClassName="quick-add__chip quick-add__trigger"
triggerContent={(
<span className="quick-add__chip-label">
<PlusIcon className="icon-inline" aria-hidden="true" /> Add correspondent
</span>
)}
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
closeOnSelection={false}
freezeSortOnOpen
/>
) : null}
</div>
);
};
const DocumentSummarySection = ({
const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
document,
tagLookupById = new Map(),
tagOptions = [],
@@ -350,20 +457,9 @@ const DocumentSummarySection = ({
onUpdateTitle,
onUpdateIssued,
layout = 'default',
detailItems = [],
}) => {
const isCompactLayout = layout === 'compact';
const summary = useMemo(() => {
if (!document) {
return {
title: '',
originalName: '',
sizeLabel: '—',
pageCount: null,
};
}
return describeDocumentSummary(document);
}, [document]);
const summaryRows = useMemo(() => describeDocumentSummary(document), [document]);
const issuedDateLabel = useMemo(
() => formatDate(document?.issued_at, { fallback: null }),
[document?.issued_at],
@@ -390,8 +486,8 @@ const DocumentSummarySection = ({
return sortCorrespondents(document?.correspondents || []);
}, [correspondents, document?.correspondents]);
const metaRows = useMemo(() => {
const rows = [];
const extraSummaryRows = useMemo(() => {
const rows: DocumentSummaryRow[] = [];
const currentVersionNumber = document?.current_version?.version_number;
if (Number.isFinite(currentVersionNumber)) {
rows.push({
@@ -400,17 +496,8 @@ const DocumentSummarySection = ({
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;
}, [document?.current_version?.version_number, summary]);
}, [document?.current_version?.version_number]);
const [titleDraft, setTitleDraft] = useState('');
const [titleSaving, setTitleSaving] = useState(false);
@@ -437,7 +524,7 @@ const DocumentSummarySection = ({
const startTitleEdit = useCallback(() => {
if (!editableTitle || !document) return;
setIsTitleEditing(true);
setTitleDraft(document.title);
setTitleDraft(document.title || '');
setTitleError(null);
}, [document, editableTitle]);
@@ -449,9 +536,9 @@ const DocumentSummarySection = ({
}, []);
const submitTitleEdit = useCallback(
async (event) => {
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editableTitle || !document) return;
if (!editableTitle || !document || !onUpdateTitle) return;
const trimmed = titleDraft.trim();
if (!trimmed) {
setTitleError('Title cannot be empty.');
@@ -487,9 +574,9 @@ const DocumentSummarySection = ({
}, []);
const submitIssuedEdit = useCallback(
async (event) => {
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editableIssued || !document) return;
if (!editableIssued || !document || !onUpdateIssued) return;
const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null;
setIssuedSaving(true);
try {
@@ -510,57 +597,60 @@ const DocumentSummarySection = ({
return null;
}
const TitleSection = () => (
editableTitle && isTitleEditing ? (
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
<input
value={titleDraft}
onChange={(event) => {
setTitleDraft(event.target.value);
if (titleError) {
setTitleError(null);
}
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelTitleEdit();
}
}}
aria-label="Document title"
autoFocus
disabled={titleSaving}
/>
<button type="submit" disabled={titleSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelTitleEdit}
disabled={titleSaving}
>
Cancel
</button>
</form>
) : (
<>
<h3 className="doc-title-row__title">{summary.title}</h3>
{editableTitle ? (
<button
type="button"
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)
const renderTitleEditForm = (extraClassName?: string) => (
<form className={`doc-title-edit${extraClassName ? ` ${extraClassName}` : ''}`} onSubmit={submitTitleEdit}>
<input
value={titleDraft}
onChange={(event) => {
setTitleDraft(event.target.value);
if (titleError) {
setTitleError(null);
}
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelTitleEdit();
}
}}
aria-label="Document title"
autoFocus
disabled={titleSaving}
/>
<button type="submit" className="icon-button icon-button--accent" disabled={titleSaving} aria-label="Save title">
<CheckIcon size={16} />
</button>
<button
type="button"
className="icon-button"
onClick={cancelTitleEdit}
disabled={titleSaving}
aria-label="Cancel"
>
<IconX size={16} />
</button>
</form>
);
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 ? (
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
<input
@@ -575,16 +665,17 @@ const DocumentSummarySection = ({
aria-label="Issued on"
disabled={issuedSaving}
/>
<button type="submit" disabled={issuedSaving}>
Save
<button type="submit" className="icon-button icon-button--accent" disabled={issuedSaving} aria-label="Save issued date">
<CheckIcon size={16} />
</button>
<button
type="button"
className="secondary"
className="icon-button"
onClick={cancelIssuedEdit}
disabled={issuedSaving}
aria-label="Cancel"
>
Cancel
<IconX size={16} />
</button>
</form>
) : (
@@ -604,184 +695,92 @@ const DocumentSummarySection = ({
</>
);
const metaItems = [
{
key: 'issued',
label: 'Issued',
valueContent: issuedDisplay,
error: issuedError,
},
...metaRows.map((row) => ({
const tagsValueContent = (
<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"
/>
);
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,
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)
? detailItems.map((item, index) => ({
key: `detail-${item?.label || index}`,
label: item?.label || '',
fallbackValue: item?.value,
}))
: [];
const compactRows = [...metaItems, ...detailRows];
const renderTags = () => (
<section className="document-summary__section document-summary__section--tags">
<TagSection
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>
);
}
const allRows = baseRows;
const summaryClass = `document-summary${isCompactLayout ? ' document-summary--compact' : ''}`;
const sectionClass = `document-summary__section document-summary__meta${isCompactLayout ? ' document-summary__meta--compact' : ''}`;
const listClass = `document-summary__details-list${isCompactLayout ? ' document-summary__details-list--meta' : ''}`;
return (
<div className="document-summary">
<div className="doc-title-row">
<div className="doc-title-row__primary">
{editableTitle && isTitleEditing ? (
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
<input
value={titleDraft}
onChange={(event) => {
setTitleDraft(event.target.value);
if (titleError) {
setTitleError(null);
}
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelTitleEdit();
}
}}
aria-label="Document title"
autoFocus
disabled={titleSaving}
/>
<button type="submit" disabled={titleSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelTitleEdit}
disabled={titleSaving}
>
Cancel
</button>
</form>
) : (
<>
<h3 className="doc-title-row__title">{summary.title}</h3>
{editableTitle ? (
<button
type="button"
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
</div>
</div>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
<div className="detail-meta">
<div className="detail-meta__row">
<span className="detail-meta__label">Issued:</span>
{issuedDisplay}
</div>
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
{metaRows.map((row) => (
<div key={row.key} className="detail-meta__row">
<span className="detail-meta__label">{row.label}:</span>
<span className="detail-meta__value">{row.value}</span>
</div>
))}
</div>
{renderTags()}
{renderCorrespondents()}
<div className={summaryClass}>
<section className={sectionClass}>
<dl className={listClass}>
{allRows.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>
</div>
);
};
@@ -1,11 +1,25 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { useEffect, useMemo, useRef, useState } from 'react';
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;
// Detect when an element becomes visible within a scroll container so we can delay loading.
const useLazyVisibility = (rootRef, resetKey) => {
const targetRef = useRef(null);
const useLazyVisibility = (
rootRef: MutableRefObject<Element | null> | null,
resetKey?: string | number | null,
) => {
const targetRef = useRef<HTMLDivElement | null>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
@@ -24,12 +38,12 @@ const useLazyVisibility = (rootRef, resetKey) => {
return undefined;
}
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
if (!window.IntersectionObserver) {
setIsVisible(true);
return undefined;
}
const observer = new IntersectionObserver(
const observer = new window.IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
@@ -52,10 +66,24 @@ const useLazyVisibility = (rootRef, resetKey) => {
return { ref: targetRef, isVisible };
};
const getPageCount = (doc) =>
Number.isFinite(doc?.current_version?.metadata?.page_count)
? doc.current_version.metadata.page_count
: null;
const getPageCount = (doc?: DocumentLike | null) => {
const count = doc?.current_version?.metadata?.page_count;
return Number.isFinite(count) ? Number(count) : 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 = ({
document,
@@ -64,12 +92,12 @@ const DocumentThumbnailImage = ({
alt = '',
maxSize = DEFAULT_THUMBNAIL_SIZE,
scrollRootRef = null,
}) => {
}: DocumentThumbnailImageProps): JSX.Element => {
const documentId = document?.id;
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId);
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo(
const thumbnailAsset = useMemo<AssetLike | null>(
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
[document?.current_version],
);
@@ -89,7 +117,7 @@ const DocumentThumbnailImage = ({
};
}, [assetWidth, assetHeight, resolvedMaxSize]);
const innerStyle = useMemo(
const innerStyle = useMemo<CSSProperties>(
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
[dimensions.height, dimensions.width],
);
@@ -98,10 +126,17 @@ const DocumentThumbnailImage = ({
if (!isVisible) {
return null;
}
return resolveDocumentAssetUrl(document, 'thumbnail', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
const options: {
ensureAssetUrl?: EnsureAssetUrl;
getAsset?: GetDocumentAsset;
} = {};
if (ensureAssetUrl) {
options.ensureAssetUrl = ensureAssetUrl;
}
if (getDocumentAsset) {
options.getAsset = getDocumentAsset;
}
return resolveDocumentAssetUrl(document, 'thumbnail', options);
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
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 DocumentThumbnailImage from './DocumentThumbnailImage';
import CorrespondentLinks from './CorrespondentLinks';
@@ -6,11 +7,85 @@ import { getTagColorStyle } from '../utils/colors';
import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer';
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,
selectedDocumentIdsSet,
selectedFolderIdsSet,
draggingDocumentIdsSet,
draggedFolderId,
onFolderClick,
@@ -35,10 +110,16 @@ const DocumentsGrid = ({
scrollRef,
onCorrespondentClick,
activeCorrespondentIdSet,
onClearSelection,
onDocumentRename,
onFolderRename,
}) => {
const {
selectedDocumentIds,
selectedFolderIds,
clearSelection,
} = useWorkspaceSelectionContext();
const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]);
const selectedFolderIdsSet = useMemo(() => new Set(selectedFolderIds || []), [selectedFolderIds]);
const {
editingId: editingDocumentId,
draftValue: documentDraft,
@@ -48,9 +129,9 @@ const DocumentsGrid = ({
submitEditing: submitDocumentEditing,
savingId: savingDocumentId,
attachInputRef: attachDocumentInputRef,
} = useInlineRename(onDocumentRename, {
getCurrentValue: (doc) => doc?.title ?? '',
getEntityId: (doc) => doc?.id ?? null,
} = useInlineRename<DocumentLike>(onDocumentRename, {
getCurrentValue: (doc: DocumentLike) => doc?.title ?? '',
getEntityId: (doc: DocumentLike) => doc?.id ?? null,
});
const {
@@ -62,9 +143,9 @@ const DocumentsGrid = ({
submitEditing: submitFolderEditing,
savingId: savingFolderId,
attachInputRef: attachFolderInputRef,
} = useInlineRename(onFolderRename, {
getCurrentValue: (folder) => folder?.name ?? '',
getEntityId: (folder) => folder?.id ?? null,
} = useInlineRename<FolderLike>(onFolderRename, {
getCurrentValue: (folder: FolderLike) => folder?.name ?? '',
getEntityId: (folder: FolderLike) => folder?.id ?? null,
});
const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0;
@@ -78,7 +159,7 @@ const DocumentsGrid = ({
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClearSelection?.();
clearSelection();
}
}}
>
@@ -94,7 +175,7 @@ const DocumentsGrid = ({
const classes = ['document-card', 'folder-card'];
if (isDraggingFolder) classes.push('is-dragging');
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 folderDraftValue = isFolderEditing ? folderDraft : folder.name;
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
@@ -353,20 +434,26 @@ const DocumentsGrid = ({
</div>
{visibleTags.length > 0 && (
<div className="document-card__tags">
{visibleTags.map((tag) => {
{visibleTags.map((tag, index) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
const tagId = tag?.id ?? null;
const clickable = tagId != null && typeof onTagClick === 'function';
const key = tagId ?? `${doc.id}-tag-${index}`;
return (
<span
key={tag.id}
key={key}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role="button"
onClick={(event) => {
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
event.stopPropagation();
onTagClick?.(tag.id);
}}
if (tagId == null) {
return;
}
onTagClick?.(tagId);
} : undefined}
draggable
onDragStart={(event) => {
event.stopPropagation();
@@ -382,13 +469,16 @@ const DocumentsGrid = ({
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
onKeyDown={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
onTagClick?.(tag.id);
if (tagId == null) {
return;
}
onTagClick?.(tagId);
}
}}
} : undefined}
>
{tag.label}
</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 { getTagColorStyle } from '../utils/colors';
import { formatDate } from '../utils/date';
@@ -7,12 +8,90 @@ import CorrespondentLinks from './CorrespondentLinks';
import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer';
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,
focusedRowKey,
selectedDocumentIdsSet,
selectedFolderIdsSet,
draggingDocumentIdsSet,
draggedFolderId,
ensureAssetUrl,
@@ -38,8 +117,17 @@ const DocumentsList = ({
onCorrespondentClick,
activeCorrespondentIdSet,
scrollRef,
onClearSelection,
}) => {
const {
selectedDocumentIds,
selectedFolderIds,
clearSelection,
} = useWorkspaceSelectionContext();
const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]);
const selectedFolderIdsSet = useMemo(
() => new Set(selectedFolderIds || []),
[selectedFolderIds],
);
const {
editingId: editingDocumentId,
draftValue: documentDraft,
@@ -49,9 +137,9 @@ const DocumentsList = ({
submitEditing: submitDocumentEditing,
savingId: savingDocumentId,
attachInputRef: attachDocumentInputRef,
} = useInlineRename(onDocumentRename, {
getCurrentValue: (doc) => doc?.title ?? '',
getEntityId: (doc) => doc?.id ?? null,
} = useInlineRename<DocumentLike>(onDocumentRename, {
getCurrentValue: (doc: DocumentLike) => doc?.title ?? '',
getEntityId: (doc: DocumentLike) => doc?.id ?? null,
});
const {
@@ -63,9 +151,9 @@ const DocumentsList = ({
submitEditing: submitFolderEditing,
savingId: savingFolderId,
attachInputRef: attachFolderInputRef,
} = useInlineRename(onFolderRename, {
getCurrentValue: (folder) => folder?.name ?? '',
getEntityId: (folder) => folder?.id ?? null,
} = useInlineRename<FolderLike>(onFolderRename, {
getCurrentValue: (folder: FolderLike) => folder?.name ?? '',
getEntityId: (folder: FolderLike) => folder?.id ?? null,
});
@@ -77,11 +165,11 @@ const DocumentsList = ({
<table aria-multiselectable="true">
<thead
onClick={() => {
onClearSelection?.();
clearSelection();
}}
>
<tr>
<th className="thumb-column"></th>
<th>&nbsp;</th>
<th>Name</th>
<th>Issued</th>
<th>Added</th>
@@ -98,7 +186,7 @@ const DocumentsList = ({
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderIdsSet?.has(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 folderDraftValue = isFolderEditing ? folderDraft : folder.name;
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
@@ -368,20 +456,24 @@ const DocumentsList = ({
</div>
{(doc.tags || []).length > 0 && (
<div className="doc-name__tags">
{(doc.tags || []).map((tag) => {
{(doc.tags || []).map((tag, index) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
const tagId = tag?.id ?? null;
const clickable = tagId != null && typeof onTagClick === 'function';
const key = tagId ?? `${doc.id}-tag-${index}`;
return (
<span
key={tag.id}
key={key}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role="button"
onClick={(event) => {
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
event.stopPropagation();
onTagClick?.(tag.id);
}}
if (tagId == null) return;
onTagClick?.(tagId);
} : undefined}
draggable
onDragStart={(event) => {
event.stopPropagation();
@@ -397,13 +489,16 @@ const DocumentsList = ({
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
onKeyDown={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
onTagClick?.(tag.id);
if (tagId == null) {
return;
}
onTagClick?.(tagId);
}
}}
} : undefined}
>
{tag.label}
</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 as createDocumentsSurface } from './panel/createDocumentsSurface';
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 { 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,
partial: 1,
none: 2,
};
const normalizeItems = (items) =>
const normalizeItems = (items?: SelectionAssignmentMenuItem[]): NormalizedSelectionAssignmentItem[] =>
(Array.isArray(items) ? items : [])
.filter((item) => item && typeof item.label === 'string' && item.label.trim().length > 0)
.map((item) => ({
id: item.id ?? item.label,
label: item.label.trim(),
state: item.state === 'all' ? 'all' : item.state === 'partial' ? 'partial' : 'none',
count: typeof item.count === 'number' ? item.count : null,
total: typeof item.total === 'number' ? item.total : null,
payload: item.payload ?? item,
}));
.map<NormalizedSelectionAssignmentItem | null>((item) => {
if (!item) {
return null;
}
const trimmedLabel = item.label?.trim?.() || '';
if (!trimmedLabel) {
return null;
}
const state: AssignmentState = item.state === 'all'
? 'all'
: item.state === 'partial'
? 'partial'
: 'none';
const numericCount = Number.isFinite(item.count) ? Number(item.count) : null;
const numericTotal = Number.isFinite(item.total) ? Number(item.total) : null;
return {
id: item.id ?? trimmedLabel,
label: trimmedLabel,
state,
count: numericCount,
total: numericTotal,
payload: item.payload ?? item,
};
})
.filter((item): item is NormalizedSelectionAssignmentItem => Boolean(item));
const SelectionAssignmentMenu = ({
const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
label,
items = [],
placeholder = 'Search…',
emptyMessage = 'No entries',
createLabel = null,
createLabel = 'Add',
onToggle,
onCreate,
disabled = false,
@@ -34,14 +94,18 @@ const SelectionAssignmentMenu = ({
triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger',
showStateIndicators = true,
showCounts = true,
onOpenMenu = null,
renderItemLabel = null,
onOpenMenu,
renderItemLabel,
positionStrategy = 'absolute',
closeOnSelection = true,
sortByState = true,
freezeSortOnOpen = false,
}) => {
const anchorRef = useRef(null);
const inputRef = useRef(null);
const anchorRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState('');
const [pending, setPending] = useState(false);
const [sortSnapshot, setSortSnapshot] = useState<Array<string | number> | null>(null);
const {
isOpen,
@@ -55,7 +119,14 @@ const SelectionAssignmentMenu = ({
align: 'center',
positionStrategy,
minWidth: 220,
});
}) as {
isOpen: boolean;
toggle: () => void;
close: () => void;
menuRef: React.MutableRefObject<HTMLDivElement | null>;
menuStyle: CSSProperties | null;
updatePosition: () => void;
};
useEffect(() => {
if (disabled && isOpen) {
@@ -81,43 +152,80 @@ const SelectionAssignmentMenu = ({
const normalizedItems = useMemo(() => normalizeItems(items), [items]);
const filteredItems = useMemo(() => {
const search = query.trim().toLowerCase();
const sorted = normalizedItems.slice().sort((a, b) => {
const sortedByStateItems = useMemo(() => {
if (!sortByState) {
return normalizedItems;
}
return normalizedItems.slice().sort((a, b) => {
const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state];
if (stateDiff !== 0) {
return stateDiff;
}
return a.label.localeCompare(b.label);
});
if (!search) {
return sorted;
}, [normalizedItems, sortByState]);
useEffect(() => {
if (!isOpen || !freezeSortOnOpen || !sortByState) {
setSortSnapshot(null);
return;
}
return sorted.filter((item) => item.label.toLowerCase().includes(search));
}, [normalizedItems, query]);
setSortSnapshot((prev) => prev ?? sortedByStateItems.map((item) => item.id));
}, [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(
async (item) => {
if (!item || typeof onToggle !== 'function') {
async (item: NormalizedSelectionAssignmentItem) => {
if (!onToggle) {
return;
}
setPending(true);
try {
await onToggle(item);
setPending(false);
close();
if (closeOnSelection) {
close();
}
} catch (error) {
setPending(false);
console.error('[selection-assignment] toggle failed', error);
}
},
[onToggle, close],
[onToggle, close, closeOnSelection],
);
const handleCreate = useCallback(
async (event) => {
async (event: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault?.();
if (typeof onCreate !== 'function') {
if (!onCreate) {
return;
}
const value = query.trim();
@@ -201,8 +309,8 @@ const SelectionAssignmentMenu = ({
type="submit"
className="icon-button selection-assignment__add"
disabled={!canSubmitCreate}
aria-label={createLabel || 'Add'}
title={createLabel || 'Add'}
aria-label={createLabel}
title={createLabel}
>
<PlusIcon aria-hidden="true" />
</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 { 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 folderCountNumber = Number(folderCount) || 0;
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 type {
EnsureAssetUrl,
EnsurePreviewData,
GetDocumentAsset,
DocumentLike as OcrDocumentLike,
} from '../utils/ocr';
type ResolveApiPath = (path: string) => string;
export type DocumentLike = OcrDocumentLike;
const asyncFalse = async () => false;
const resolveDocumentDownloadHref = (document, resolveApiPath) => {
if (!document || typeof resolveApiPath !== 'function') {
const resolveDocumentDownloadHref = (document?: DocumentLike | null, resolveApiPath?: ResolveApiPath | null): string | null => {
if (!document || !resolveApiPath) {
return null;
}
const downloadPath = document.current_version?.download_path;
const downloadPath = (document.current_version as { download_path?: string | null } | null)?.download_path;
if (!downloadPath) {
return null;
}
return resolveApiPath(downloadPath);
};
const hasDocumentOcrAsset = (document, getDocumentAsset) => {
if (!document || typeof getDocumentAsset !== 'function') {
const hasDocumentOcrAsset = (document?: DocumentLike | null, getDocumentAsset?: GetDocumentAsset | null): boolean => {
if (!document || !getDocumentAsset) {
return false;
}
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 = ({
document,
resolveApiPath,
@@ -28,7 +48,7 @@ export const createDocumentActionState = ({
getDocumentAsset,
notifyApiError,
ocrErrorMessage = 'Unable to open OCR text.',
}) => {
}: CreateDocumentActionStateArgs) => {
if (!document) {
return {
downloadHref: null,
@@ -49,14 +69,12 @@ export const createDocumentActionState = ({
getDocumentAsset,
ensureAssetUrl,
});
if (!success && typeof notifyApiError === 'function') {
notifyApiError(new Error('OCR text URL unavailable.'), ocrErrorMessage);
if (!success) {
notifyApiError?.(new Error('OCR text URL unavailable.'), ocrErrorMessage);
}
return success;
} catch (error) {
if (typeof notifyApiError === 'function') {
notifyApiError(error, ocrErrorMessage);
}
notifyApiError?.(error, ocrErrorMessage);
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';
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 = ({
api,
resolveTargetDocumentIds,
correspondentLookupByName,
handleCorrespondentCreate,
refreshCurrentFolder,
setStatusMessage,
selectedDocumentIds,
selectedFolderIds,
@@ -13,10 +43,11 @@ const useBulkDocumentActions = ({
handleFolderDelete,
clearDocumentSelection,
setLoading,
}) => {
updateDocumentCaches,
}: UseBulkDocumentActionsArgs) => {
const handleBulkCorrespondentAdd = useCallback(
async ({ name, input, documentIds }) => {
const trimmed = typeof name === 'string' ? name.trim() : '';
async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
const trimmed = name?.trim?.() || '';
if (!trimmed) {
setStatusMessage('Correspondent name is required.', 'error');
return;
@@ -39,21 +70,35 @@ const useBulkDocumentActions = ({
if (!target?.id) {
setStatusMessage('Unable to resolve correspondent.', 'error');
return;
}
}
const response = await api.post('/documents/bulk/correspondents', {
document_ids: targets,
assignments: [
{
correspondent_id: target.id,
},
],
action: 'add',
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
document_ids: targets,
assignments: [
{
correspondent_id: target.id,
},
],
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';
if (removed > 0) {
const removedSuffix = removed === 1 ? '' : 's';
@@ -68,22 +113,22 @@ const useBulkDocumentActions = ({
);
}
if (input) {
input.value = '';
}
},
[
api,
correspondentLookupByName,
handleCorrespondentCreate,
refreshCurrentFolder,
resolveTargetDocumentIds,
setStatusMessage,
],
);
if (input) {
input.value = '';
}
},
[
api,
correspondentLookupByName,
handleCorrespondentCreate,
resolveTargetDocumentIds,
setStatusMessage,
updateDocumentCaches,
],
);
const handleBulkCorrespondentRemove = useCallback(
async ({ assignments = [], documentIds }) => {
async ({ assignments = [], documentIds }: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => {
if (!assignments.length) {
setStatusMessage('Select a correspondent to remove.', 'error');
return;
@@ -100,14 +145,29 @@ const useBulkDocumentActions = ({
correspondent_id: entry.correspondent_id,
}));
const response = await api.post('/documents/bulk/correspondents', {
document_ids: targets,
assignments: normalizedAssignments,
action: 'remove',
});
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
document_ids: targets,
assignments: normalizedAssignments,
action: 'remove',
});
const { assigned = 0, removed = 0 } = response.data || {};
await refreshCurrentFolder();
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
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) {
const removedSuffix = removed === 1 ? '' : 's';
@@ -122,7 +182,7 @@ const useBulkDocumentActions = ({
setStatusMessage('No correspondents changed.', 'info');
}
},
[api, refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage],
[api, resolveTargetDocumentIds, setStatusMessage, updateDocumentCaches],
);
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';
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 = ({
showingSearchResults,
currentSubfolders,
@@ -18,9 +54,9 @@ const useDocumentsSelection = ({
setFocusedRowKey,
focusedRowKey,
isFolderRowKey,
}) => {
const navigableRows = useMemo(() => {
const entries = [];
}: UseDocumentsSelectionOptions) => {
const navigableRows = useMemo<NavigableRow[]>(() => {
const entries: NavigableRow[] = [];
if (!showingSearchResults) {
currentSubfolders.forEach((folder) => {
const key = resolveFolderRowKey(folder.id);
@@ -51,7 +87,7 @@ const useDocumentsSelection = ({
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
const promoteSelectionOrder = useCallback(
(docId) => {
(docId: string | number | null) => {
if (!docId) return;
promoteSelectionOrderRaw(docId);
const rowKey = resolveDocumentRowKey(docId);
@@ -68,7 +104,7 @@ const useDocumentsSelection = ({
clearSelection();
}, [clearSelection]);
const prevFocusedDocIdRef = useRef(focusedDocumentId);
const prevFocusedDocIdRef = useRef<string | number | null>(focusedDocumentId);
useEffect(() => {
const previous = prevFocusedDocIdRef.current;
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 {
ViewListIcon,
ViewGridIcon,
@@ -12,18 +12,34 @@ import {
} from '../../ui/icons';
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 = ({
viewMode,
viewMode = 'list',
onViewModeChange,
onRefresh,
sortField = 'title',
onSortFieldChange = null,
onSortFieldChange,
sortDirection = 'asc',
onSortDirectionToggle = null,
onSortDirectionToggle,
isFilterActive = false,
includeDescendants = true,
onToggleIncludeDescendants = null,
}) => {
onToggleIncludeDescendants,
}: DocumentsTableHeaderActionOptions): JSX.Element => {
const isListView = viewMode === 'list';
const isGridView = viewMode === 'grid';
const isDeskView = viewMode === 'desk';
@@ -33,7 +49,7 @@ export const createDocumentsTableHeaderActions = ({
? 'Sorting Z → A. Click to switch to ascending.'
: 'Sorting A → Z. Click to switch to descending.';
const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function'
const includeDescendantsToggle = isFilterActive && onToggleIncludeDescendants
? (
<button
type="button"
@@ -50,11 +66,11 @@ export const createDocumentsTableHeaderActions = ({
)
: null;
const sortControls = typeof onSortFieldChange === 'function'
const sortControls = onSortFieldChange
? (
<div className="documents-actions__sort-group">
<SortFieldQuickMenu sortField={sortField} onChange={onSortFieldChange} />
{typeof onSortDirectionToggle === 'function' ? (
{onSortDirectionToggle ? (
<button
type="button"
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">
<button
type="button"
className={`view-toggle__button${isListView ? ' active' : ''}`}
className={`toggle-button${isListView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('list')}
aria-pressed={isListView}
title="List view"
@@ -104,7 +120,7 @@ export const createDocumentsTableHeaderActions = ({
</button>
<button
type="button"
className={`view-toggle__button${isGridView ? ' active' : ''}`}
className={`toggle-button${isGridView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('grid')}
aria-pressed={isGridView}
title="Icons view"
@@ -113,7 +129,7 @@ export const createDocumentsTableHeaderActions = ({
</button>
<button
type="button"
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
className={`toggle-button${isDeskView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('desk')}
aria-pressed={isDeskView}
title="Desk view"
@@ -14,7 +14,12 @@ const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => {
return next;
}, {});
const SortFieldQuickMenu = ({ sortField, onChange }) => {
interface SortFieldQuickMenuProps {
sortField: string;
onChange?: (value: string) => void;
}
const SortFieldQuickMenu: React.FC<SortFieldQuickMenuProps> = ({ sortField, onChange }) => {
const currentOption = useMemo(
() => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0],
[sortField],
@@ -26,8 +31,8 @@ const SortFieldQuickMenu = ({ sortField, onChange }) => {
);
const handleSelect = useCallback(
(value, option) => {
if (typeof onChange !== 'function') {
(value: string, option?: { id?: string; original?: { id?: string } }) => {
if (!onChange) {
return;
}
const nextValue = option?.id || option?.original?.id || value;
@@ -45,7 +50,7 @@ const SortFieldQuickMenu = ({ sortField, onChange }) => {
className="documents-sort__quickmenu"
options={options}
onSelectOption={handleSelect}
triggerClassName="view-toggle__button documents-sort__trigger quick-add__trigger"
triggerClassName="toggle-button documents-sort__trigger quick-add__trigger"
triggerContent={(
<span className="documents-sort__trigger-content">
<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 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 = ({
apiClient,
@@ -8,16 +56,16 @@ const useAuthManager = ({
notifyApiError,
setStatusMessage,
setLoading,
}) => {
const tokenRef = useRef(token);
const refreshPromiseRef = useRef(null);
}: UseAuthManagerArgs): UseAuthManagerResult => {
const tokenRef = useRef<string | null>(token);
const refreshPromiseRef = useRef<Promise<string> | null>(null);
const initialRefreshAttemptedRef = useRef(Boolean(token));
const refreshAccessToken = useCallback(async () => {
const refreshAccessToken = useCallback(async (): Promise<string> => {
console.log('[Auth] Attempting to refresh access token…');
appDispatch({ type: 'TOKEN_REFRESH_START' });
try {
const { data } = await apiClient.post('/auth/refresh');
const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh');
if (data?.access_token) {
appDispatch({
type: 'TOKEN_REFRESH_SUCCESS',
@@ -30,7 +78,7 @@ const useAuthManager = ({
throw new Error('Missing access token in refresh response');
} catch (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;
}
}, [apiClient, appDispatch]);
@@ -51,10 +99,11 @@ const useAuthManager = ({
const requestInterceptor = apiClient.interceptors.request.use((config) => {
const currentToken = tokenRef.current;
if (currentToken) {
config.headers = config.headers || {};
if (!config.headers.Authorization) {
config.headers.Authorization = `Bearer ${currentToken}`;
const headers = ensureAxiosHeaders(config.headers);
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${currentToken}`);
}
config.headers = headers;
}
return config;
});
@@ -62,13 +111,14 @@ const useAuthManager = ({
const responseInterceptor = apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const { response, config } = error;
const axiosError = error as AxiosError & { config?: RetryableAxiosRequestConfig };
const { response, config } = axiosError;
if (!response || !config) {
return Promise.reject(error);
}
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');
if (status === 401 && !config._retry && !isAuthRoute) {
@@ -90,13 +140,12 @@ const useAuthManager = ({
throw new Error('No token returned from refresh');
}
config._retry = true;
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${newToken}`;
setHeaderAuthorization(config, newToken);
console.log('[Auth] Retrying original request', url);
try {
return await apiClient(config);
} catch (retryError) {
if (retryError?.response?.status === 401) {
if ((retryError as AxiosError)?.response?.status === 401) {
notifyApiError(retryError, 'Session expired. Please log in again.');
}
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 = ({
apiClient,
@@ -6,8 +27,8 @@ const useCorrespondents = ({
setStatusMessage,
tenantIdRef,
mapDocumentCaches,
}) => {
const [correspondents, setCorrespondents] = useState([]);
}: UseCorrespondentsOptions) => {
const [correspondents, setCorrespondents] = useState<CorrespondentEntry[]>([]);
const refreshCorrespondents = useCallback(async () => {
const requestTenantId = tenantIdRef.current;
@@ -26,13 +47,13 @@ const useCorrespondents = ({
}, [apiClient, notifyApiError, tenantIdRef]);
const handleCorrespondentUpdate = useCallback(
async (correspondentId, changes) => {
if (!correspondentId) {
async (correspondentId: string | number, changes: { name?: string }) => {
if (correspondentId == null) {
throw new Error('Missing correspondent identifier.');
}
const payload = {};
if (typeof changes.name === 'string') {
const payload: Record<string, unknown> = {};
if (changes?.name != null) {
const trimmed = changes.name.trim();
if (!trimmed) {
throw new Error('Correspondent name cannot be empty.');
@@ -59,8 +80,8 @@ const useCorrespondents = ({
);
const handleCorrespondentCreate = useCallback(
async ({ name }) => {
const trimmed = typeof name === 'string' ? name.trim() : '';
async ({ name }: { name?: string }) => {
const trimmed = name?.trim?.() || '';
if (!trimmed) {
throw new Error('Correspondent name is required.');
}
@@ -79,12 +100,12 @@ const useCorrespondents = ({
);
const handleCorrespondentDelete = useCallback(
async (correspondentId) => {
if (!correspondentId) {
async (correspondentId: string | number) => {
if (correspondentId == null) {
throw new Error('Missing correspondent identifier.');
}
const stripFromDoc = (doc) => {
const stripFromDoc = (doc: any) => {
if (!doc || !Array.isArray(doc.correspondents)) {
return doc;
}
@@ -99,9 +120,7 @@ const useCorrespondents = ({
await apiClient.delete(`/correspondents/${correspondentId}`);
await refreshCorrespondents();
if (typeof mapDocumentCaches === 'function') {
mapDocumentCaches(stripFromDoc);
}
mapDocumentCaches?.(stripFromDoc);
setStatusMessage('Correspondent deleted.', 'success');
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 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 = ({
selectedEntries,
@@ -12,8 +47,8 @@ const useDocumentDragHandlers = ({
resolveDocumentRowKey,
resolveFolderRowKey,
documentsViewMode,
}) => {
const dragPreviewRef = useRef(null);
}: UseDocumentDragHandlersOptions) => {
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
const destroyDragPreview = useCallback(() => {
const node = dragPreviewRef.current;
@@ -26,13 +61,9 @@ const useDocumentDragHandlers = ({
useEffect(() => destroyDragPreview, [destroyDragPreview]);
const createDragPreview = useCallback(
({ documents = [], folders = [] } = {}) => {
({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: Array<FolderIdentifier | Identifier> } = {}) => {
destroyDragPreview();
if (typeof document === 'undefined') {
return null;
}
const docEntries = (documents || []).filter(Boolean);
const folderEntries = (folders || []).filter(Boolean);
const totalCount = docEntries.length + folderEntries.length;
@@ -72,12 +103,18 @@ const useDocumentDragHandlers = ({
if (item.type === 'document') {
const doc = item.payload;
const rowEl = doc?.id
? document.getElementById(`document-row-${doc.id}`)
|| document.getElementById(`document-card-${doc.id}`)
? (document.getElementById(`document-row-${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;
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 aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null;
@@ -100,7 +137,7 @@ const useDocumentDragHandlers = ({
layer.classList.add('document-drag-preview__item--image');
layer.style.backgroundImage = `url("${thumbSrc}")`;
} else if (placeholderEl instanceof HTMLElement) {
const clone = placeholderEl.cloneNode(true);
const clone = placeholderEl.cloneNode(true) as HTMLElement;
clone.style.pointerEvents = 'none';
layer.appendChild(clone);
} else {
@@ -108,27 +145,42 @@ const useDocumentDragHandlers = ({
}
} else {
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
? document.getElementById(`folder-row-${folderId}`)
|| document.getElementById(`folder-card-${folderId}`)
? (document.getElementById(`folder-row-${folderId}`)
|| document.getElementById(`folder-card-${folderId}`))
: null;
const iconEl = rowEl instanceof HTMLElement
? rowEl.querySelector('.thumb-icon, .folder-card__icon')
: null;
const iconEl = rowEl?.querySelector('.thumb-icon, .folder-card__icon');
layer.style.width = `${size}px`;
layer.style.height = `${size}px`;
layer.classList.add('document-drag-preview__item--folder');
let content = null;
let content: HTMLElement | null = null;
if (iconEl instanceof HTMLElement) {
const cloneSource = iconEl.classList.contains('folder-card__icon')
? iconEl.querySelector('svg') || iconEl
: iconEl;
content = cloneSource.cloneNode(true);
content.classList.add('document-drag-preview__folder-thumb');
const svg = content.querySelector('svg');
if (svg) {
svg.setAttribute('width', '48');
svg.setAttribute('height', '48');
const clone = cloneSource.cloneNode(true);
if (clone instanceof HTMLElement) {
content = clone;
content.classList.add('document-drag-preview__folder-thumb');
const svg = content.querySelector('svg');
if (svg) {
svg.setAttribute('width', '48');
svg.setAttribute('height', '48');
}
}
}
@@ -159,8 +211,10 @@ const useDocumentDragHandlers = ({
);
const handleDocumentDragStart = useCallback(
(event, documentOrId) => {
const documentId = typeof documentOrId === 'string' ? documentOrId : documentOrId?.id;
(event: DragEvent<HTMLElement>, documentOrId: DocumentLike | Identifier | null) => {
const documentId: Identifier | null = Object(documentOrId) === documentOrId
? (documentOrId as DocumentLike)?.id ?? null
: (documentOrId as Identifier | null);
if (!documentId) {
return;
}
@@ -172,12 +226,12 @@ const useDocumentDragHandlers = ({
const isGridView = documentsViewMode === 'grid';
const isAlreadySelected = selectedDocumentIds.includes(documentId);
const selection = isAlreadySelected
const selection: Identifier[] = isAlreadySelected
? [...selectedDocumentIds]
: isGridView
? [...selectedDocumentIds, documentId]
: [documentId];
const folderSelection = [];
const folderSelection: FolderIdentifier[] = [];
if (!isAlreadySelected && !isGridView) {
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({
documents: previewDocs,
folders: folderSelection,
@@ -234,7 +290,7 @@ const useDocumentDragHandlers = ({
);
const handleDocumentDragEnd = useCallback(
(event) => {
(event: DragEvent<HTMLElement>) => {
setDraggedDocumentIds([]);
event.currentTarget.classList.remove('dragging');
destroyDragPreview();
@@ -244,7 +300,7 @@ const useDocumentDragHandlers = ({
);
const handleFolderDragStart = useCallback(
(event, folderId) => {
(event: DragEvent<HTMLElement>, folderId: FolderIdentifier) => {
if (folderId === 'root') {
return;
}
@@ -252,8 +308,8 @@ const useDocumentDragHandlers = ({
const folderKey = resolveFolderRowKey(folderId);
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
let effectiveFolderSelection = selectedFolderIds;
let effectiveDocumentSelection = selectedDocumentIds;
let effectiveFolderSelection: FolderIdentifier[] = selectedFolderIds;
let effectiveDocumentSelection: Identifier[] = selectedDocumentIds;
if (!isAlreadySelected && folderKey) {
effectiveFolderSelection = [folderId];
@@ -291,7 +347,7 @@ const useDocumentDragHandlers = ({
const previewNode = createDragPreview({
documents: effectiveDocumentSelection
.map((id) => documentLookup.get(id) || null)
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
.filter(Boolean),
folders: uniqueFolders,
});
@@ -317,7 +373,7 @@ const useDocumentDragHandlers = ({
);
const handleFolderDragEnd = useCallback(
(event) => {
(event?: DragEvent<HTMLElement>) => {
if (event?.currentTarget) {
event.currentTarget.classList.remove('dragging');
}
@@ -1,12 +1,183 @@
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';
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 (typeof value === 'object' && value.id) {
return value.id;
if (isPlainObject(value) && 'id' in value && value.id != null) {
return value.id as DocumentId;
}
return value;
return value as DocumentId;
};
const useDocumentMutations = ({
@@ -19,7 +190,7 @@ const useDocumentMutations = ({
setSelectedFolder,
setDocuments,
setFolderContents,
setSearchResults,
setSearchResultIds,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
@@ -39,43 +210,41 @@ const useDocumentMutations = ({
closeDocumentPreview,
previewDocumentId,
refreshCurrentFolder,
documentsViewMode,
updateDocumentCaches,
tagLookupById,
tags,
refreshTags,
tagManager,
extractDocumentFromResponse,
}) => {
ingestDocuments,
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
const moveDocumentsToFolder = useCallback(
async (documentIds, targetFolderId) => {
async (documentIds: Array<DocumentId | DocumentLike>, targetFolderId?: NullableFolderId) => {
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;
const uniqueIdSet = new Set(uniqueIds);
const target = targetFolderId === 'root' ? null : targetFolderId;
const target = targetFolderId === 'root' ? null : targetFolderId ?? null;
const targetLabel =
target === null
? DEFAULT_FOLDER_NAME
: folderLabelMap.get(targetFolderId) || 'target folder';
target === null ? DEFAULT_FOLDER_NAME : folderLabelMap.get(targetFolderId as FolderId) || 'target folder';
const movedDocs = uniqueIds
.map((id) => {
const doc = documentLookup.get(id);
const doc = documentLookup.get(id) || null;
if (!doc) {
return null;
}
return {
id,
sourceFolderId: doc.folder_id ?? null,
sourceFolderId: (doc.folder_id ?? null) as NullableFolderId,
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 = () => {
if (!targetLabel) {
return null;
@@ -89,7 +258,7 @@ const useDocumentMutations = ({
if (!document) {
return;
}
const updated = {
const updated: DocumentLike = {
...document,
folder_id: target,
};
@@ -105,13 +274,13 @@ const useDocumentMutations = ({
updatedDocsMap.set(id, updated);
});
const pruneRow = (collection) =>
const pruneRow = (collection: string[]): string[] =>
collection.filter((key) => {
if (!isDocumentRowKey(key)) {
return true;
}
const id = getRowId(key);
return id ? !uniqueIdSet.has(id) : true;
return id ? !uniqueIdSet.has(id as DocumentId) : true;
});
setLoading(true);
@@ -131,10 +300,10 @@ const useDocumentMutations = ({
if (updatedDocsMap.size) {
mapDocumentCaches((doc) => {
if (!doc || !uniqueIdSet.has(doc.id)) {
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
return doc;
}
const updated = updatedDocsMap.get(doc.id);
const updated = updatedDocsMap.get(doc.id as DocumentId);
if (updated) {
return updated;
}
@@ -142,7 +311,7 @@ const useDocumentMutations = ({
});
} else {
mapDocumentCaches((doc) => {
if (!doc || !uniqueIdSet.has(doc.id)) {
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
return doc;
}
return { ...doc, folder_id: target };
@@ -150,22 +319,22 @@ const useDocumentMutations = ({
}
if (uniqueIdSet.size) {
setSearchResults((prev) => {
setSearchResultIds((prev) => {
if (!Array.isArray(prev) || !prev.length) {
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;
});
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id)));
setFolderContents((prev) => {
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
setFolderContents((prev: Map<FolderId, FolderContents>) => {
if (!prev.size) {
return prev;
}
let changed = false;
const next = new Map(prev);
const next = new Map<FolderId, FolderContents>(prev);
movedDocs.forEach(({ id, sourceFolderId }) => {
const sourceKey = sourceFolderId || 'root';
const sourceKey = (sourceFolderId || 'root') as FolderId;
const entry = next.get(sourceKey);
if (!entry?.documents?.length) {
return;
@@ -179,13 +348,14 @@ const useDocumentMutations = ({
return changed ? next : prev;
});
setSelectedEntries((prev) => pruneRow(prev, uniqueIdSet));
setSelectionOrder((prev) => pruneRow(prev, uniqueIdSet));
selectionOrderRef.current = pruneRow(selectionOrderRef.current || [], uniqueIdSet);
setSelectedEntries((prev) => pruneRow(prev));
setSelectionOrder((prev) => pruneRow(prev));
const nextSelectionOrder = pruneRow(selectionOrderRef.current || []);
selectionOrderRef.current = nextSelectionOrder;
if (
selectionAnchorRef.current &&
isDocumentRowKey(selectionAnchorRef.current) &&
uniqueIdSet.has(getRowId(selectionAnchorRef.current))
uniqueIdSet.has(getRowId(selectionAnchorRef.current) as DocumentId)
) {
selectionAnchorRef.current = null;
}
@@ -195,17 +365,17 @@ const useDocumentMutations = ({
if (
focusedRowKey &&
isDocumentRowKey(focusedRowKey) &&
uniqueIdSet.has(getRowId(focusedRowKey))
uniqueIdSet.has(getRowId(focusedRowKey) as DocumentId)
) {
setFocusedRowKey(null);
}
}
if (targetFolderId && targetFolderId !== selectedFolder) {
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
await ensureFolderData(targetFolderId as FolderId, { force: true, prefetchDepth: 1 });
}
} 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);
} finally {
setLoading(false);
@@ -217,7 +387,7 @@ const useDocumentMutations = ({
folderLabelMap,
ensureFolderData,
selectedFolder,
setSearchResults,
setSearchResultIds,
setDocuments,
setFolderContents,
setSelectedEntries,
@@ -236,7 +406,7 @@ const useDocumentMutations = ({
);
const handleThumbnailRegeneration = useCallback(
async (documentId) => {
async (documentId: DocumentId) => {
if (!token) {
setStatusMessage('Log in to manage assets.', 'error');
return;
@@ -249,7 +419,7 @@ const useDocumentMutations = ({
setStatusMessage('Document re-analysis queued.', 'info');
await refreshCurrentFolder();
} 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);
} finally {
setLoading(false);
@@ -259,7 +429,7 @@ const useDocumentMutations = ({
);
const handleDocumentsDelete = useCallback(
async (documentIds, { showMessage = true, manageLoading = true } = {}) => {
async (documentIds: DocumentId[], { showMessage = true, manageLoading = true }: DeleteOptions = {}) => {
if (!documentIds || documentIds.length === 0) {
return false;
}
@@ -274,43 +444,11 @@ const useDocumentMutations = ({
}
try {
const softDeleteTargets = [];
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);
await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)));
removeDocumentsFromCaches(documentIds);
if (documentIds.includes(previewDocumentId)) {
if (previewDocumentId && documentIds.includes(previewDocumentId)) {
closeDocumentPreview();
}
@@ -320,7 +458,7 @@ const useDocumentMutations = ({
}
return true;
} 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);
return false;
} finally {
@@ -343,8 +481,8 @@ const useDocumentMutations = ({
);
const handleDocumentTitleUpdate = useCallback(
async (documentId, nextTitle) => {
const trimmed = typeof nextTitle === 'string' ? nextTitle.trim() : '';
async (documentId: DocumentId, nextTitle: string) => {
const trimmed = nextTitle?.trim?.() || '';
if (!trimmed) {
setStatusMessage('Document title cannot be empty.', 'error');
return false;
@@ -355,159 +493,200 @@ const useDocumentMutations = ({
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
const updatedDocument = extractDocumentFromResponse?.(data);
updateDocumentCaches(documentId, (doc) => {
if (updatedDocument) {
return { ...doc, ...updatedDocument };
}
return { ...doc, title: trimmed };
});
if (updatedDocument && ingestDocuments) {
ingestDocuments([updatedDocument]);
} else {
updateDocumentCaches(documentId, (doc) => {
if (updatedDocument) {
return { ...doc, ...updatedDocument };
}
return { ...doc, title: trimmed };
});
}
setStatusMessage('Document title updated.', 'success');
return true;
} 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);
return false;
} finally {
setLoading(false);
}
},
[api, extractDocumentFromResponse, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches],
[
api,
extractDocumentFromResponse,
ingestDocuments,
notifyApiError,
setLoading,
setStatusMessage,
updateDocumentCaches,
],
);
const handleDocumentIssuedUpdate = useCallback(
async (documentId, nextIssuedDate) => {
async (documentId: DocumentId, nextIssuedDate: number | null) => {
setLoading(true);
const payload = { issued_at: nextIssuedDate || null };
try {
const { data } = await api.patch(`/documents/${documentId}`, payload);
const updatedDocument = extractDocumentFromResponse?.(data);
updateDocumentCaches(documentId, (doc) => {
if (updatedDocument) {
return { ...doc, ...updatedDocument };
}
return { ...doc, issued_at: payload.issued_at };
});
if (updatedDocument && ingestDocuments) {
ingestDocuments([updatedDocument]);
} else {
updateDocumentCaches(documentId, (doc) => {
if (updatedDocument) {
return { ...doc, ...updatedDocument };
}
return { ...doc, issued_at: payload.issued_at };
});
}
const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.';
setStatusMessage(message, 'success');
return true;
} 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);
return false;
} finally {
setLoading(false);
}
},
[api, extractDocumentFromResponse, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches],
[
api,
extractDocumentFromResponse,
ingestDocuments,
notifyApiError,
setLoading,
setStatusMessage,
updateDocumentCaches,
],
);
const handleDocumentTagAdd = useCallback(
async (document, label, extras = null) => {
const normalizedLabel = tagManager.normalizeLabel(label);
const optionCandidate =
extras && typeof extras === 'object' && 'option' in extras ? extras.option : null;
const input =
extras && typeof extras === 'object' && 'input' in extras ? extras.input : null;
let tag = 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;
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) {
const attachTagToDocument = useCallback(
async ({
documentId,
tag,
}: {
documentId?: DocumentId;
tag?: Tag | null;
}) => {
if (!documentId || !tag?.id) {
return false;
}
const resolveTagForCache = () => {
const lookupTag = tagLookupById.get(tagId);
const source = lookupTag ?? tagData;
if (!source || source.id == null || typeof source.label !== 'string') {
return null;
}
return {
id: source.id,
label: source.label,
color: Object.prototype.hasOwnProperty.call(source, 'color') ? source.color : null,
};
const cachedTag: Tag = {
id: tag.id,
label: tag.label,
color: Object.prototype.hasOwnProperty.call(tag, 'color') ? tag.color ?? null : null,
};
try {
await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] });
await api.post(`/documents/${documentId}/tags`, { tag_ids: [cachedTag.id] });
updateDocumentCaches(documentId, (doc) => {
if (!doc) {
return doc;
}
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;
}
const resolvedTag = resolveTagForCache();
if (!resolvedTag) {
return doc;
}
return { ...doc, tags: [...currentTags, resolvedTag] };
return { ...doc, tags: [...currentTags, cachedTag] };
});
setStatusMessage('Tag assigned.', 'success');
if (documentsViewMode !== 'desk') {
await refreshCurrentFolder();
}
return true;
} 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);
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,
refreshCurrentFolder,
documentsViewMode,
notifyApiError,
setStatusMessage,
updateDocumentCaches,
attachTagToDocument,
tagLookupById,
],
);
const applyTagRemovalToCaches = useCallback(
(documentId, tagId) => {
(documentId?: DocumentId, tagId?: DocumentId) => {
if (!documentId || !tagId) {
return;
}
updateDocumentCaches(documentId, (doc) => {
if (!Array.isArray(doc.tags)) {
if (!doc || !Array.isArray(doc.tags)) {
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) {
return doc;
}
@@ -518,7 +697,11 @@ const useDocumentMutations = ({
);
const handleTagRemove = useCallback(
async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => {
async (
documentId?: DocumentId,
tagId?: DocumentId,
{ refreshTagList = true, showMessage = true }: TagRemoveOptions = {},
) => {
if (!documentId || !tagId) {
return false;
}
@@ -534,7 +717,7 @@ const useDocumentMutations = ({
}
return true;
} 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);
return false;
}
@@ -543,7 +726,7 @@ const useDocumentMutations = ({
);
const handleFolderDelete = useCallback(
async (folderId, { showMessage = true, manageLoading = true } = {}) => {
async (folderId?: FolderId, { showMessage = true, manageLoading = true }: FolderDeleteOptions = {}) => {
if (!token) {
if (showMessage) {
setStatusMessage('Log in to manage folders.', 'error');
@@ -577,8 +760,8 @@ const useDocumentMutations = ({
await api.delete(`/folders/${folderId}`);
setFolderNodes((prev) => {
const next = new Map(prev);
setFolderNodes((prev: Map<FolderId, FolderNode>) => {
const next = new Map<FolderId, FolderNode>(prev);
const node = next.get(folderId);
next.delete(folderId);
if (node) {
@@ -596,8 +779,8 @@ const useDocumentMutations = ({
return next;
});
setFolderContents((prev) => {
const next = new Map(prev);
setFolderContents((prev: Map<FolderId, FolderContents>) => {
const next = new Map<FolderId, FolderContents>(prev);
next.delete(folderId);
return next;
});
@@ -620,7 +803,7 @@ const useDocumentMutations = ({
}
return true;
} 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);
if (showMessage) {
setStatusMessage(message, 'error');
@@ -1,18 +1,60 @@
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 = ({
apiClient,
tags,
tagManager,
refreshTags,
refreshCurrentFolder,
resolveTargetDocumentIds,
notifyApiError,
setStatusMessage,
setLoading,
}) => {
updateDocumentCaches,
}: UseDocumentTaggingArgs) => {
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);
if (!normalized.length) {
return { ok: false, reason: 'no-labels' };
@@ -22,7 +64,7 @@ const useDocumentTagging = ({
return { ok: false, reason: 'no-selection' };
}
let tagIds = [];
let tagIds: Identifier[] = [];
if (action === 'remove') {
const missing = normalized.find(
@@ -40,22 +82,59 @@ const useDocumentTagging = ({
setLoading(true);
try {
if (action === 'add') {
const createdIds = [];
for (const label of normalized) {
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
if (!tag) {
const payload = tagManager.buildPayload({ label });
const { data } = await apiClient.post('/tags', payload);
tag = data;
await refreshTags();
}
createdIds.push(tag.id);
if (action === 'add') {
const createdIds: Identifier[] = [];
const createdTags: TagRecord[] = [];
for (const label of normalized) {
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
if (!tag) {
const payload = tagManager.buildPayload({ label });
const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload);
tag = 'data' in response ? response.data : response;
await refreshTags();
}
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) {
return { ok: false, reason: 'no-tags' };
@@ -67,7 +146,23 @@ const useDocumentTagging = ({
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 {
ok: true,
@@ -88,17 +183,17 @@ const useDocumentTagging = ({
resolveTargetDocumentIds,
tags,
refreshTags,
refreshCurrentFolder,
notifyApiError,
setLoading,
tagManager,
apiClient,
updateDocumentCaches,
],
);
const handleBulkTagAddFromDetail = useCallback(
async ({ label, input, documentIds }) => {
const trimmed = typeof label === 'string' ? label.trim() : '';
async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
const trimmed = label?.trim?.() || '';
if (!trimmed) {
setStatusMessage('Enter a tag label.', 'error');
return;
@@ -130,8 +225,8 @@ const useDocumentTagging = ({
);
const handleBulkTagRemoveFromDetail = useCallback(
async ({ label, input, documentIds }) => {
const trimmed = typeof label === 'string' ? label.trim() : '';
async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
const trimmed = label?.trim?.() || '';
if (!trimmed) {
setStatusMessage('Enter a tag label to remove.', 'error');
return;
@@ -163,7 +258,7 @@ const useDocumentTagging = ({
);
const handleBulkSelectionReanalyze = useCallback(
async (documentIdsOverride = null) => {
async (documentIdsOverride: Identifier[] | null = null) => {
const targetIds = resolveTargetDocumentIds(documentIdsOverride);
if (!targetIds.length) {
setStatusMessage('Select documents before requesting re-analysis.', 'error');
@@ -172,11 +267,17 @@ const useDocumentTagging = ({
setLoading(true);
try {
const { data } = await apiClient.post('/documents/bulk/reanalyze', {
document_ids: targetIds,
force: true,
});
const queued = data?.queued ?? targetIds.length;
const response = await apiClient.post<{ queued?: number }>(
'/documents/bulk/reanalyze',
{
document_ids: targetIds,
force: true,
},
);
const payload = 'data' in response ? response.data : response;
const queued = Number.isFinite(payload?.queued)
? Number(payload.queued)
: targetIds.length;
setStatusMessage(
`Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
'success',
@@ -201,4 +302,3 @@ const useDocumentTagging = ({
};
export default useDocumentTagging;
@@ -1,8 +1,81 @@
import { useCallback, useRef, useState } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import useFileDrop from './useFileDrop';
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) {
return [];
}
@@ -10,8 +83,7 @@ const mapFilesToEntries = (filesInput) => {
return files
.filter(Boolean)
.map((file) => {
const relativePath =
typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : '';
const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath
? relativePath
.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 = ({
apiClient,
token,
@@ -31,26 +134,28 @@ const useDocumentUploads = ({
refreshCurrentFolder,
setLoading,
shellRef,
}) => {
const [dropOverlayState, setDropOverlayState] = useState({
notifyApiError,
setStatusMessage,
}: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({
active: false,
folderName: DEFAULT_FOLDER_NAME,
folderName: currentFolderName || DEFAULT_FOLDER_NAME,
});
const dragCounterRef = useRef(0);
const folderPathCacheRef = useRef(new Map());
const folderPathCacheRef = useRef<Map<string, FolderId>>(new Map());
const queueIdRef = useRef(0);
const [uploadQueue, setUploadQueue] = useState([]);
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
const uploadFile = useCallback(
async (file, targetFolderId) => {
async (file: File, targetFolderId: FolderId) => {
if (!file || file.size === 0) {
return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null };
}
const formData = new FormData();
formData.append('file', file, file.name);
if (targetFolderId && targetFolderId !== 'root') {
formData.append('folder_id', targetFolderId);
if (targetFolderId != null && targetFolderId !== 'root') {
formData.append('folder_id', String(targetFolderId));
}
try {
@@ -63,14 +168,14 @@ const useDocumentUploads = ({
statusCode: status ?? (duplicate ? 200 : 201),
conflictDocumentId: null,
};
} catch (error) {
} catch (error: any) {
if (error.response?.status === 409) {
const conflictId = error.response?.data?.details?.conflict_document_id ?? null;
let conflictDocument = null;
if (conflictId) {
try {
const { data } = await apiClient.get(`/documents/${conflictId}`);
conflictDocument = data?.document ?? data ?? null;
conflictDocument = (data as any)?.document ?? data ?? null;
} catch (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}.`;
notifyApiError?.(error, message);
setStatusMessage?.(message, 'error');
const wrapped = Object.assign(new Error(message), { response: error.response });
throw wrapped;
}
},
[apiClient],
[apiClient, notifyApiError, setStatusMessage],
);
const appendQueueItems = useCallback((entries, targetFolderId) => {
const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => {
const baseId = Date.now();
const items = entries.map(({ file }) => {
queueIdRef.current += 1;
@@ -99,12 +206,12 @@ const useDocumentUploads = ({
name: file?.name || 'Unnamed file',
size: file?.size ?? null,
folderId: targetFolderId ?? selectedFolder ?? 'root',
status: 'pending',
status: 'pending' as UploadStatus,
error: null,
code: null,
document: null,
conflictDocumentId: null,
};
} satisfies UploadQueueItem;
});
if (items.length) {
setUploadQueue((current) => [...current, ...items]);
@@ -112,7 +219,7 @@ const useDocumentUploads = ({
return items;
}, [selectedFolder]);
const updateQueueItem = useCallback((id, patch) => {
const updateQueueItem = useCallback((id: string, patch: Partial<UploadQueueItem>) => {
if (!id) {
return;
}
@@ -122,7 +229,7 @@ const useDocumentUploads = ({
}, []);
const ensureFolderPathOnServer = useCallback(
async (baseFolderId, segments) => {
async (baseFolderId: FolderId, segments: string[]): Promise<FolderId> => {
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
if (trimmedSegments.length === 0) {
return baseFolderId ?? null;
@@ -131,7 +238,7 @@ const useDocumentUploads = ({
const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
const cache = folderPathCacheRef.current;
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
return cache.get(cacheKey) ?? null;
}
const payload = {
@@ -139,28 +246,32 @@ const useDocumentUploads = ({
segments: trimmedSegments,
};
const { data } = await apiClient.post('/folders/path', payload);
cache.set(cacheKey, data.folder.id);
return data.folder.id;
const { data } = await apiClient.post<{ folder?: { id?: FolderId | null } }>(
'/folders/path',
payload,
);
const resolvedId = (data?.folder?.id ?? null) as FolderId;
cache.set(cacheKey, resolvedId);
return resolvedId;
},
[apiClient],
);
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
const extractFilesFromDataTransfer = useCallback(async (dataTransfer: DataTransfer) => {
if (!dataTransfer) {
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', {
items: items.length,
files: (dataTransfer.files || []).length,
});
const results = [];
const results: FileEntry[] = [];
const seenKeys = new Set();
const pushFile = (file, ancestors = []) => {
const pushFile = (file?: File | null, ancestors: string[] = []) => {
if (!file) return;
const segments = (ancestors || []).filter(Boolean);
const key = `${segments.join('/')}/${file.name}:${file.size}`;
@@ -171,11 +282,11 @@ const useDocumentUploads = ({
results.push({ file, segments });
};
const readAllEntries = async (reader) => {
const entries = [];
let batch = [];
const readAllEntries = async (reader: FileSystemDirectoryReaderLike) => {
const entries: FileSystemEntryLike[] = [];
let batch: FileSystemEntryLike[] = [];
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) {
entries.push(...batch);
}
@@ -183,15 +294,15 @@ const useDocumentUploads = ({
return entries;
};
const walkEntry = async (entry, ancestors = []) => {
const walkEntry = async (entry: FileSystemEntryLike | null, ancestors: string[] = []) => {
if (!entry) return;
if (entry.isFile) {
const file = await new Promise((resolve, reject) => {
const file = await new Promise<File>((resolve, reject) => {
try {
entry.file(resolve, reject);
(entry as unknown as FileSystemFileEntryLike).file(resolve, reject);
} catch (error) {
console.warn('[Uploads] entry.file failed', error);
reject(error);
reject(error as Error);
}
});
pushFile(file, ancestors);
@@ -199,7 +310,7 @@ const useDocumentUploads = ({
}
if (entry.isDirectory) {
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);
for (const child of entries) {
await walkEntry(child, nextAncestors);
@@ -211,12 +322,9 @@ const useDocumentUploads = ({
items.map(async (item, index) => {
if (item.kind !== 'file') return;
const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
const fileFromItem = item.getAsFile?.() ?? null;
if (fileFromItem) {
const relativePath =
typeof fileFromItem.webkitRelativePath === 'string'
? fileFromItem.webkitRelativePath
: '';
const relativePath = (fileFromItem as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath
? relativePath
.split('/')
@@ -226,9 +334,9 @@ const useDocumentUploads = ({
pushFile(fileFromItem, segments);
}
if (typeof item.webkitGetAsEntry === 'function') {
if ((item as ExtendedDataTransferItem).webkitGetAsEntry) {
try {
const entry = item.webkitGetAsEntry();
const entry = (item as ExtendedDataTransferItem).webkitGetAsEntry?.();
if (entry) {
await walkEntry(entry, []);
return;
@@ -246,8 +354,7 @@ const useDocumentUploads = ({
Array.from(dataTransfer.files || []).forEach((file) => {
if (!file) return;
const relativePath =
typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : '';
const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath
? relativePath
.split('/')
@@ -327,7 +434,7 @@ const useDocumentUploads = ({
updateQueueItem(queueItem.id, patch);
Object.assign(queueItem, patch);
}
} catch (error) {
} catch (error: any) {
if (queueItem) {
const patch = {
status: 'error',
@@ -350,7 +457,7 @@ const useDocumentUploads = ({
) {
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
}
} catch (error) {
} catch (error: any) {
const message = error.message || 'Failed to upload files.';
queueItems.forEach((item) => {
if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') {
@@ -383,8 +490,8 @@ const useDocumentUploads = ({
);
const handleFileDrop = useCallback(
async (dataTransfer, targetFolderId) => {
let extracted;
async (dataTransfer: DataTransfer, targetFolderId?: FolderId) => {
let extracted: FileEntry[];
try {
extracted = await extractFilesFromDataTransfer(dataTransfer);
} catch (error) {
@@ -398,7 +505,7 @@ const useDocumentUploads = ({
);
const handleFileSelection = useCallback(
async (files, targetFolderId) => {
async (files?: FileList | null, targetFolderId?: FolderId) => {
const entries = mapFilesToEntries(files);
await uploadFileEntries(entries, targetFolderId);
},
@@ -414,7 +521,6 @@ const useDocumentUploads = ({
hasFiles,
defaultFolderName: DEFAULT_FOLDER_NAME,
dragCounterRef,
dropOverlayState,
setDropOverlayState,
});
@@ -439,7 +545,7 @@ const useDocumentUploads = ({
resetUploadsState,
uploadQueue,
clearUploadQueue,
};
} satisfies UseDocumentUploadsResult;
};
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