selection
This commit is contained in:
+48
-9
@@ -10,8 +10,8 @@ pub mod sql_types {
|
||||
pub struct TenantStatus;
|
||||
|
||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||
#[diesel(postgres_type(name = "api_token_capability"))]
|
||||
pub struct ApiTokenCapability;
|
||||
#[diesel(postgres_type(name = "api_capability"))]
|
||||
pub struct ApiCapability;
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
@@ -204,6 +204,7 @@ diesel::table! {
|
||||
tenant_id -> Uuid,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
capability_set_id -> Nullable<Uuid>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,9 +251,6 @@ diesel::table! {
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
use diesel::sql_types::*;
|
||||
use super::sql_types::ApiTokenCapability;
|
||||
|
||||
api_tokens (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
@@ -264,11 +262,46 @@ diesel::table! {
|
||||
last_used_at -> Nullable<Timestamptz>,
|
||||
expires_at -> Nullable<Timestamptz>,
|
||||
revoked_at -> Nullable<Timestamptz>,
|
||||
capabilities -> Array<ApiTokenCapability>,
|
||||
capability_set_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(correspondents -> tenants (tenant_id));
|
||||
diesel::table! {
|
||||
use diesel::sql_types::Uuid;
|
||||
use super::sql_types::ApiCapability;
|
||||
|
||||
capability_set_capabilities (capability_set_id, capability) {
|
||||
capability_set_id -> Uuid,
|
||||
capability -> ApiCapability,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
capability_sets (id) {
|
||||
id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
slug -> Text,
|
||||
cap_version -> Int4,
|
||||
is_system -> Bool,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_assets_v2 (id) {
|
||||
id -> Uuid,
|
||||
document_version_id -> Uuid,
|
||||
asset_type -> Text,
|
||||
mime_type -> Text,
|
||||
metadata -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
cardinality -> Nullable<Int4>,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(capability_set_capabilities -> capability_sets (capability_set_id));
|
||||
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
|
||||
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
|
||||
diesel::joinable!(document_assets -> document_versions (document_version_id));
|
||||
@@ -286,20 +319,27 @@ diesel::joinable!(documents -> folders (folder_id));
|
||||
diesel::joinable!(documents -> tenants (tenant_id));
|
||||
diesel::joinable!(folders -> tenants (tenant_id));
|
||||
diesel::joinable!(jobs -> tenants (tenant_id));
|
||||
diesel::joinable!(magic_tokens -> users (user_id));
|
||||
diesel::joinable!(user_sessions -> tenants (tenant_id));
|
||||
diesel::joinable!(user_sessions -> users (user_id));
|
||||
diesel::joinable!(tags -> tenants (tenant_id));
|
||||
diesel::joinable!(user_memberships -> capability_sets (capability_set_id));
|
||||
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
||||
diesel::joinable!(user_memberships -> users (user_id));
|
||||
diesel::joinable!(user_passkeys -> users (user_id));
|
||||
diesel::joinable!(webauthn_challenges -> users (user_id));
|
||||
diesel::joinable!(api_tokens -> capability_sets (capability_set_id));
|
||||
diesel::joinable!(api_tokens -> tenants (tenant_id));
|
||||
diesel::joinable!(api_tokens -> users (user_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
api_tokens,
|
||||
capability_set_capabilities,
|
||||
capability_sets,
|
||||
correspondents,
|
||||
document_asset_objects,
|
||||
document_assets,
|
||||
document_assets_v2,
|
||||
document_correspondents,
|
||||
document_tags,
|
||||
document_versions,
|
||||
@@ -307,12 +347,11 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
folders,
|
||||
jobs,
|
||||
magic_tokens,
|
||||
user_sessions,
|
||||
tags,
|
||||
tenants,
|
||||
user_memberships,
|
||||
user_passkeys,
|
||||
user_sessions,
|
||||
users,
|
||||
webauthn_challenges,
|
||||
api_tokens,
|
||||
);
|
||||
|
||||
@@ -9,7 +9,8 @@ import React, {
|
||||
import { createPortal } from 'react-dom';
|
||||
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
||||
import { useAssetNavigator } from './hooks/useAssetNavigator';
|
||||
import { ArrowLeftIcon, ArrowRightIcon, CloseIcon } from './ui/icons';
|
||||
import { ArrowLeftIcon, ArrowRightIcon, CloseIcon, FileIcon, FolderIcon } from './ui/icons';
|
||||
import SelectionFloatingActions from './documents/SelectionFloatingActions';
|
||||
import { createDocumentsTableHeaderActions } from './documents/DocumentsPanel';
|
||||
import createWorkspaceSurfaceConfig from './documents/workspaceHeader';
|
||||
import { resolveCorrespondents } from './documents/correspondents';
|
||||
@@ -2492,12 +2493,67 @@ export const createDesktopSurface = ({
|
||||
onRefresh,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
onDeleteSelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
tagLookupById,
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
onBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove,
|
||||
onBulkReanalyze,
|
||||
} = 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;
|
||||
let selectionLabel = null;
|
||||
if (selectionCount) {
|
||||
const tokens = [];
|
||||
|
||||
if (documentSelectionCount) {
|
||||
tokens.push({
|
||||
key: 'documents',
|
||||
count: documentSelectionCount,
|
||||
icon: <FileIcon className="selection-summary__icon" size={16} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (folderSelectionCount) {
|
||||
tokens.push({
|
||||
key: 'folders',
|
||||
count: folderSelectionCount,
|
||||
icon: <FolderOutlineIcon className="selection-summary__icon" size={16} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (tokens.length) {
|
||||
selectionLabel = (
|
||||
<span className="selection-summary">
|
||||
{tokens.map((token, index) => (
|
||||
<React.Fragment key={token.key}>
|
||||
{index > 0 ? <span className="selection-summary__separator">·</span> : null}
|
||||
<span className="selection-summary__token">
|
||||
<span className="selection-summary__count">{token.count}</span>
|
||||
{token.icon}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
selectionLabel = `${selectionCount} item${selectionCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
|
||||
const actions = createDocumentsTableHeaderActions({
|
||||
viewMode: viewMode || 'desk',
|
||||
onViewModeChange,
|
||||
@@ -2505,6 +2561,25 @@ export const createDesktopSurface = ({
|
||||
onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null,
|
||||
});
|
||||
|
||||
const floatingActions = selectionLabel
|
||||
? (
|
||||
<SelectionFloatingActions
|
||||
selectionCount={selectionCount}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
documentLookup={documentLookup}
|
||||
tags={tags}
|
||||
tagLookupById={tagLookupById}
|
||||
correspondents={correspondents}
|
||||
onBulkTagAdd={onBulkTagAdd}
|
||||
onBulkTagRemove={onBulkTagRemove}
|
||||
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
|
||||
onBulkCorrespondentRemove={onBulkCorrespondentRemove}
|
||||
onBulkReanalyze={onBulkReanalyze}
|
||||
onDeleteSelection={onDeleteSelection}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
||||
const surfaceConfig = createWorkspaceSurfaceConfig({
|
||||
@@ -2517,6 +2592,8 @@ export const createDesktopSurface = ({
|
||||
onNavigateParent,
|
||||
actions,
|
||||
breadcrumbs: workspaceProps?.breadcrumbs || null,
|
||||
selectionLabel,
|
||||
floatingActions,
|
||||
content: <DesktopWorkspace {...workspaceProps} />,
|
||||
detail,
|
||||
});
|
||||
|
||||
+326
-81
@@ -784,7 +784,66 @@ const AppLayout = () => {
|
||||
[mapDocumentCaches],
|
||||
);
|
||||
|
||||
const removeDocumentsFromCaches = useCallback(
|
||||
(documentIds) => {
|
||||
if (!documentIds || documentIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idSet = new Set(documentIds);
|
||||
|
||||
setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id)));
|
||||
setSearchResults((prev) => {
|
||||
if (!Array.isArray(prev)) {
|
||||
return prev;
|
||||
}
|
||||
const filtered = prev.filter((doc) => !idSet.has(doc.id));
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
const filtered = docs.filter((doc) => !idSet.has(doc.id));
|
||||
if (filtered.length !== docs.length) {
|
||||
changed = true;
|
||||
next.set(key, { ...contents, documents: filtered });
|
||||
} else {
|
||||
next.set(key, contents);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
setPreviewEntries((prev) => {
|
||||
if (!prev.size) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
idSet.forEach((id) => {
|
||||
if (next.delete(id)) {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
idSet.forEach((id) => {
|
||||
previewInflightRef.current.delete(id);
|
||||
});
|
||||
},
|
||||
[setDocuments, setSearchResults, setFolderContents, setPreviewEntries],
|
||||
);
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
const cache = new Map();
|
||||
@@ -3020,6 +3079,59 @@ const AppLayout = () => {
|
||||
[navigate, selectedFolder],
|
||||
);
|
||||
|
||||
const handleDocumentsDelete = useCallback(
|
||||
async (documentIds, { showMessage = true, manageLoading = true } = {}) => {
|
||||
if (!documentIds || documentIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setStatusMessage('Log in to manage documents.', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manageLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
documentIds.map((documentId) => api.delete(`/documents/${documentId}`)),
|
||||
);
|
||||
|
||||
removeDocumentsFromCaches(documentIds);
|
||||
|
||||
if (documentIds.includes(previewDocumentId)) {
|
||||
closeDocumentPreview();
|
||||
}
|
||||
|
||||
if (showMessage) {
|
||||
const message = documentIds.length === 1 ? 'Document deleted.' : 'Documents deleted.';
|
||||
setStatusMessage(message, 'success');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to delete document.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
} finally {
|
||||
if (manageLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
token,
|
||||
previewDocumentId,
|
||||
closeDocumentPreview,
|
||||
removeDocumentsFromCaches,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDocumentListFocus = useCallback(() => {
|
||||
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
|
||||
return;
|
||||
@@ -3409,6 +3521,180 @@ const AppLayout = () => {
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
const handleFolderDelete = useCallback(
|
||||
async (folderId, { showMessage = true, manageLoading = true } = {}) => {
|
||||
if (!token) {
|
||||
if (showMessage) {
|
||||
setStatusMessage('Log in to manage folders.', 'error');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!folderId || folderId === 'root') {
|
||||
if (showMessage) {
|
||||
setStatusMessage('The root folder cannot be removed.', 'error');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manageLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = await ensureFolderData(folderId, {
|
||||
force: true,
|
||||
prefetchDepth: 1,
|
||||
});
|
||||
const hasChildren = (contents.subfolders || []).length > 0;
|
||||
const hasDocs = (contents.documents || []).length > 0;
|
||||
if (hasChildren || hasDocs) {
|
||||
if (showMessage) {
|
||||
setStatusMessage('Folder must be empty before it can be deleted.', 'error');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
await api.delete(`/folders/${folderId}`);
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
const node = next.get(folderId);
|
||||
next.delete(folderId);
|
||||
if (node) {
|
||||
const parentId = node.parentId || 'root';
|
||||
const parentNode = next.get(parentId);
|
||||
if (parentNode) {
|
||||
const remaining = parentNode.children.filter((id) => id !== folderId);
|
||||
next.set(parentId, {
|
||||
...parentNode,
|
||||
children: remaining,
|
||||
hasChildren: remaining.length > 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
setFolderContents((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(folderId);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (selectedFolder === folderId) {
|
||||
const node = folderNodes.get(folderId);
|
||||
const parentId = node?.parentId || 'root';
|
||||
setSelectedFolder(parentId);
|
||||
const parentContents = await ensureFolderData(parentId, {
|
||||
force: true,
|
||||
prefetchDepth: 1,
|
||||
});
|
||||
applySelectedFolder(parentId, parentContents);
|
||||
} else if (selectedFolder !== 'root') {
|
||||
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
|
||||
}
|
||||
|
||||
if (showMessage) {
|
||||
setStatusMessage('Folder deleted.', 'success');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to delete folder.';
|
||||
notifyApiError(error, message);
|
||||
if (showMessage) {
|
||||
setStatusMessage(message, 'error');
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (manageLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
token,
|
||||
ensureFolderData,
|
||||
selectedFolder,
|
||||
folderNodes,
|
||||
applySelectedFolder,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDeleteSelection = useCallback(async () => {
|
||||
const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
|
||||
const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : [];
|
||||
|
||||
if (docIds.length === 0 && folderIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (docIds.length) {
|
||||
parts.push(`${docIds.length} document${docIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (folderIds.length) {
|
||||
parts.push(`${folderIds.length} folder${folderIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
const descriptor = parts.join(' and ');
|
||||
const confirmation = parts.length === 1
|
||||
? `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`
|
||||
: `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`;
|
||||
|
||||
if (!window.confirm(confirmation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
let docsOk = true;
|
||||
let foldersOk = true;
|
||||
|
||||
try {
|
||||
if (docIds.length) {
|
||||
docsOk = await handleDocumentsDelete(docIds, { showMessage: false, manageLoading: false });
|
||||
}
|
||||
|
||||
if (folderIds.length) {
|
||||
for (const folderId of folderIds) {
|
||||
const success = await handleFolderDelete(folderId, { showMessage: false, manageLoading: false });
|
||||
if (!success) {
|
||||
foldersOk = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (!docsOk || !foldersOk) {
|
||||
setStatusMessage('Some items could not be deleted. Ensure folders are empty before deletion.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
clearDocumentSelection();
|
||||
|
||||
const successParts = [];
|
||||
if (docIds.length) {
|
||||
successParts.push(docIds.length === 1 ? 'Document deleted.' : 'Documents deleted.');
|
||||
}
|
||||
if (folderIds.length) {
|
||||
successParts.push(folderIds.length === 1 ? 'Folder deleted.' : 'Folders deleted.');
|
||||
}
|
||||
|
||||
setStatusMessage(successParts.join(' '), 'success');
|
||||
}, [
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
handleDocumentsDelete,
|
||||
handleFolderDelete,
|
||||
clearDocumentSelection,
|
||||
setLoading,
|
||||
setStatusMessage,
|
||||
]);
|
||||
|
||||
const handleDocumentTagDrop = useCallback(
|
||||
async (documentId, tag) => {
|
||||
if (!documentId || !tag?.id) {
|
||||
@@ -3434,83 +3720,6 @@ const AppLayout = () => {
|
||||
[handleDocumentTagAttach, handleTagRemove],
|
||||
);
|
||||
|
||||
const handleFolderDelete = useCallback(
|
||||
async (folderId) => {
|
||||
if (!token) {
|
||||
setStatusMessage('Log in to manage folders.', 'error');
|
||||
return;
|
||||
}
|
||||
if (folderId === 'root') {
|
||||
setStatusMessage('The root folder cannot be removed.', 'error');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const contents = await ensureFolderData(folderId, {
|
||||
force: true,
|
||||
prefetchDepth: 1,
|
||||
});
|
||||
const hasChildren = (contents.subfolders || []).length > 0;
|
||||
const hasDocs = (contents.documents || []).length > 0;
|
||||
if (hasChildren || hasDocs) {
|
||||
setStatusMessage('Folder must be empty before it can be deleted.', 'error');
|
||||
return;
|
||||
}
|
||||
await api.delete(`/folders/${folderId}`);
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
const node = next.get(folderId);
|
||||
next.delete(folderId);
|
||||
if (node) {
|
||||
const parentId = node.parentId || 'root';
|
||||
const parentNode = next.get(parentId);
|
||||
if (parentNode) {
|
||||
const remaining = parentNode.children.filter((id) => id !== folderId);
|
||||
next.set(parentId, {
|
||||
...parentNode,
|
||||
children: remaining,
|
||||
hasChildren: remaining.length > 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setFolderContents((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(folderId);
|
||||
return next;
|
||||
});
|
||||
if (selectedFolder === folderId) {
|
||||
const node = folderNodes.get(folderId);
|
||||
const parentId = node?.parentId || 'root';
|
||||
setSelectedFolder(parentId);
|
||||
const parentContents = await ensureFolderData(parentId, {
|
||||
force: true,
|
||||
prefetchDepth: 1,
|
||||
});
|
||||
applySelectedFolder(parentId, parentContents);
|
||||
} else if (selectedFolder !== 'root') {
|
||||
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
|
||||
}
|
||||
setStatusMessage('Folder deleted.', 'success');
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to delete folder.';
|
||||
notifyApiError(error, message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
token,
|
||||
ensureFolderData,
|
||||
selectedFolder,
|
||||
folderNodes,
|
||||
applySelectedFolder,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
],
|
||||
);
|
||||
|
||||
const handleFolderRename = useCallback(
|
||||
async (folderId, nextName) => {
|
||||
if (!token) {
|
||||
@@ -4507,7 +4716,16 @@ const AppLayout = () => {
|
||||
onViewModeChange: handleDocumentsViewModeChange,
|
||||
onClearSelection: clearDocumentSelection,
|
||||
onEntrySelection: handleEntrySelection,
|
||||
onDeleteSelection: handleDeleteSelection,
|
||||
onOpenDetailPanel: openDetailPanel,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||||
}),
|
||||
[
|
||||
activeCorrespondentFilters,
|
||||
@@ -4532,6 +4750,7 @@ const AppLayout = () => {
|
||||
handleFolderDragStart,
|
||||
handleFolderRename,
|
||||
handleEntrySelection,
|
||||
handleDeleteSelection,
|
||||
isFilterActive,
|
||||
openDetailPanel,
|
||||
openDocumentPreview,
|
||||
@@ -4547,6 +4766,14 @@ const AppLayout = () => {
|
||||
toggleTagFilter,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkSelectionReanalyze,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4638,12 +4865,10 @@ const AppLayout = () => {
|
||||
tagLookupById,
|
||||
onTagAdd: handleTagAdd,
|
||||
onTagRemove: handleTagRemove,
|
||||
onRegenerateThumbnails: handleThumbnailRegeneration,
|
||||
previewEntry: selectedPreviewEntry,
|
||||
onOpenPreview: openDocumentPreview,
|
||||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||||
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||
onPromoteSelection: promoteSelectionOrder,
|
||||
@@ -4670,7 +4895,6 @@ const AppLayout = () => {
|
||||
getDocumentAsset,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkSelectionReanalyze,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleCorrespondentAdd,
|
||||
@@ -4680,7 +4904,6 @@ const AppLayout = () => {
|
||||
handleDocumentIssuedUpdate,
|
||||
handleTagAdd,
|
||||
handleTagRemove,
|
||||
handleThumbnailRegeneration,
|
||||
openDocumentPreview,
|
||||
promoteSelectionOrder,
|
||||
resolveFolderPath,
|
||||
@@ -4786,6 +5009,7 @@ const AppLayout = () => {
|
||||
tenantId: currentTenantId,
|
||||
viewId: deskViewId,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
onClearSelection: clearDocumentSelection,
|
||||
detailPanelOpen,
|
||||
onCloseDetailPanel: handleDetailPanelClose,
|
||||
@@ -4795,6 +5019,16 @@ const AppLayout = () => {
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
activeTagIds: activeTagFilters,
|
||||
onDeleteSelection: handleDeleteSelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
tagLookupById,
|
||||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||||
}),
|
||||
[
|
||||
documents,
|
||||
@@ -4815,6 +5049,7 @@ const AppLayout = () => {
|
||||
deskViewId,
|
||||
deskHelpOpen,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
clearDocumentSelection,
|
||||
detailPanelOpen,
|
||||
handleDetailPanelClose,
|
||||
@@ -4824,6 +5059,16 @@ const AppLayout = () => {
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
activeTagFilters,
|
||||
handleDeleteSelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
tagLookupById,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkSelectionReanalyze,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ const DocumentsRouteContent = () => {
|
||||
previewWorkspaceEntry,
|
||||
previewDocumentId,
|
||||
closeDocumentPreview,
|
||||
handleThumbnailRegeneration,
|
||||
ensurePreviewData,
|
||||
resolveApiPath,
|
||||
ensureAssetUrl,
|
||||
@@ -84,7 +83,6 @@ const DocumentsRouteContent = () => {
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
handleThumbnailRegeneration,
|
||||
closeDocumentPreview,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent: handleNavigateParent,
|
||||
@@ -141,13 +139,23 @@ const DocumentsRouteContent = () => {
|
||||
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
||||
<div className={mainContentClass}>
|
||||
{header ? (
|
||||
<PanelHeader
|
||||
className="main-content__header"
|
||||
leading={header.leading}
|
||||
title={headerTitle}
|
||||
titleTag="h2"
|
||||
actions={header.actions}
|
||||
/>
|
||||
<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}
|
||||
|
||||
@@ -20,7 +20,6 @@ export const useWorkspaceSurface = ({
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
handleThumbnailRegeneration,
|
||||
closeDocumentPreview,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent,
|
||||
@@ -92,7 +91,6 @@ export const useWorkspaceSurface = ({
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
onRegenerate: handleThumbnailRegeneration,
|
||||
onClose: closeDocumentPreview,
|
||||
renderSidebarToggle,
|
||||
tagLookupById,
|
||||
@@ -117,7 +115,6 @@ export const useWorkspaceSurface = ({
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
handleThumbnailRegeneration,
|
||||
closeDocumentPreview,
|
||||
renderSidebarToggle,
|
||||
detailPanelProps,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
DetailPanelCollapseIcon,
|
||||
AnalyzeIcon,
|
||||
WindowMaximizeIcon,
|
||||
} from '../ui/icons';
|
||||
import PanelHeader from '../ui/PanelHeader';
|
||||
@@ -144,11 +143,9 @@ const DetailPanel = ({
|
||||
tagLookupById = new Map(),
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
onRegenerateThumbnails,
|
||||
onOpenPreview,
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
onBulkReanalyze,
|
||||
onBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove,
|
||||
onPromoteSelection,
|
||||
@@ -878,8 +875,6 @@ const DetailPanel = ({
|
||||
);
|
||||
};
|
||||
|
||||
const isBulkSelection = selectedCount > 1;
|
||||
|
||||
const headerLeading = [
|
||||
(
|
||||
<button
|
||||
@@ -915,24 +910,6 @@ const DetailPanel = ({
|
||||
|
||||
const headerActions = [];
|
||||
|
||||
if (isBulkSelection && onBulkReanalyze) {
|
||||
headerActions.push(
|
||||
<button
|
||||
key="bulk-reanalyze"
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onBulkReanalyze(bulkDocumentIds);
|
||||
}}
|
||||
aria-label="Re-run analysis for selection"
|
||||
title="Re-run analysis for selection"
|
||||
>
|
||||
<AnalyzeIcon />
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
|
||||
if (singleDoc && singleDownloadHref) {
|
||||
headerActions.push(
|
||||
<a
|
||||
@@ -950,24 +927,6 @@ const DetailPanel = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (singleDoc) {
|
||||
headerActions.push(
|
||||
<button
|
||||
key="reanalyze"
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRegenerateThumbnails(singleDoc.id);
|
||||
}}
|
||||
aria-label="Re-run analysis"
|
||||
title="Re-run analysis"
|
||||
>
|
||||
<AnalyzeIcon />
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="detail-panel panel">
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
RefreshIcon,
|
||||
MinusVerticalIcon,
|
||||
InfoIcon,
|
||||
FileIcon,
|
||||
FolderOutlineIcon,
|
||||
} from '../ui/icons';
|
||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||
import createWorkspaceSurfaceConfig from './workspaceHeader';
|
||||
@@ -13,6 +15,7 @@ import DetailPanel from '../detail/DetailPanel';
|
||||
import DocumentsGrid from './DocumentsGrid';
|
||||
import DocumentsList from './DocumentsList';
|
||||
import { isTagTransferEvent } from './tagTransfer';
|
||||
import SelectionFloatingActions from './SelectionFloatingActions';
|
||||
|
||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||
|
||||
@@ -503,6 +506,7 @@ const DocumentsPanel = ({
|
||||
onCorrespondentClick={onCorrespondentClick}
|
||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||
scrollRef={scrollRef}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -602,6 +606,18 @@ export const createDocumentsSurface = ({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRefresh,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
onDeleteSelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
tagLookupById,
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
onBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove,
|
||||
onBulkReanalyze,
|
||||
} = tableProps;
|
||||
|
||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||
@@ -609,12 +625,76 @@ export const createDocumentsSurface = ({
|
||||
? `${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;
|
||||
let selectionLabel = null;
|
||||
if (selectionCount) {
|
||||
const tokens = [];
|
||||
|
||||
if (documentSelectionCount) {
|
||||
tokens.push({
|
||||
key: 'documents',
|
||||
count: documentSelectionCount,
|
||||
icon: <FileIcon className="selection-summary__icon" size={16} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (folderSelectionCount) {
|
||||
tokens.push({
|
||||
key: 'folders',
|
||||
count: folderSelectionCount,
|
||||
icon: <FolderOutlineIcon className="selection-summary__icon" size={16} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (tokens.length) {
|
||||
selectionLabel = (
|
||||
<span className="selection-summary">
|
||||
{tokens.map((token, index) => (
|
||||
<React.Fragment key={token.key}>
|
||||
{index > 0 ? <span className="selection-summary__separator">·</span> : null}
|
||||
<span className="selection-summary__token">
|
||||
<span className="selection-summary__count">{token.count}</span>
|
||||
<span className="selection-summary__multiply">×</span>
|
||||
{token.icon}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
selectionLabel = `${selectionCount} item${selectionCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
|
||||
const actions = createDocumentsTableHeaderActions({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRefresh,
|
||||
});
|
||||
|
||||
const floatingActions = selectionLabel
|
||||
? (
|
||||
<SelectionFloatingActions
|
||||
selectionCount={selectionCount}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
documentLookup={documentLookup}
|
||||
tags={tags}
|
||||
tagLookupById={tagLookupById}
|
||||
correspondents={correspondents}
|
||||
onBulkTagAdd={onBulkTagAdd}
|
||||
onBulkTagRemove={onBulkTagRemove}
|
||||
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
|
||||
onBulkCorrespondentRemove={onBulkCorrespondentRemove}
|
||||
onBulkReanalyze={onBulkReanalyze}
|
||||
onDeleteSelection={onDeleteSelection}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
|
||||
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
||||
|
||||
@@ -628,6 +708,8 @@ export const createDocumentsSurface = ({
|
||||
onNavigateParent,
|
||||
actions,
|
||||
breadcrumbs,
|
||||
selectionLabel,
|
||||
floatingActions,
|
||||
content: <DocumentsPanel {...tableProps} showHeader={false} />,
|
||||
detail,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useFloatingMenu from '../ui/useFloatingMenu';
|
||||
import {
|
||||
PlusIcon,
|
||||
CheckIcon,
|
||||
CircleDashedCheckIcon,
|
||||
} from '../ui/icons';
|
||||
|
||||
const STATE_ORDER = {
|
||||
all: 0,
|
||||
partial: 1,
|
||||
none: 2,
|
||||
};
|
||||
|
||||
const normalizeItems = (items) =>
|
||||
(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,
|
||||
}));
|
||||
|
||||
const SelectionAssignmentMenu = ({
|
||||
label,
|
||||
items = [],
|
||||
placeholder = 'Search…',
|
||||
emptyMessage = 'No entries',
|
||||
createLabel = null,
|
||||
onToggle,
|
||||
onCreate,
|
||||
disabled = false,
|
||||
className,
|
||||
}) => {
|
||||
const anchorRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
toggle,
|
||||
close,
|
||||
menuRef,
|
||||
menuStyle,
|
||||
updatePosition,
|
||||
} = useFloatingMenu({
|
||||
anchorRef,
|
||||
align: 'center',
|
||||
positionStrategy: 'absolute',
|
||||
minWidth: 220,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled && isOpen) {
|
||||
close();
|
||||
}
|
||||
}, [disabled, isOpen, close]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return undefined;
|
||||
}
|
||||
setQuery('');
|
||||
setPending(false);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
updatePosition();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select?.();
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [isOpen, updatePosition]);
|
||||
|
||||
const normalizedItems = useMemo(() => normalizeItems(items), [items]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const search = query.trim().toLowerCase();
|
||||
const sorted = 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;
|
||||
}
|
||||
return sorted.filter((item) => item.label.toLowerCase().includes(search));
|
||||
}, [normalizedItems, query]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (item) => {
|
||||
if (!item || typeof onToggle !== 'function') {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
try {
|
||||
await onToggle(item);
|
||||
setPending(false);
|
||||
close();
|
||||
} catch (error) {
|
||||
setPending(false);
|
||||
console.error('[selection-assignment] toggle failed', error);
|
||||
}
|
||||
},
|
||||
[onToggle, close],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async () => {
|
||||
if (typeof onCreate !== 'function') {
|
||||
return;
|
||||
}
|
||||
const value = query.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
try {
|
||||
await onCreate(value);
|
||||
setPending(false);
|
||||
close();
|
||||
} catch (error) {
|
||||
setPending(false);
|
||||
console.error('[selection-assignment] creation failed', error);
|
||||
}
|
||||
},
|
||||
[onCreate, query, close],
|
||||
);
|
||||
|
||||
const existingLabels = useMemo(
|
||||
() => new Set(normalizedItems.map((item) => item.label.toLowerCase())),
|
||||
[normalizedItems],
|
||||
);
|
||||
|
||||
const canCreate = Boolean(onCreate);
|
||||
const showCreateOption = canCreate
|
||||
&& query.trim().length > 0
|
||||
&& !existingLabels.has(query.trim().toLowerCase());
|
||||
|
||||
return (
|
||||
<div className={className ? `selection-assignment ${className}` : 'selection-assignment'}>
|
||||
<button
|
||||
type="button"
|
||||
ref={anchorRef}
|
||||
className="quick-add__chip quick-add__trigger panel-floating-actions__trigger"
|
||||
onClick={toggle}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="quick-add__chip-label">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div
|
||||
className="menu menu--floating selection-assignment__menu"
|
||||
ref={menuRef}
|
||||
style={menuStyle || undefined}
|
||||
role="menu"
|
||||
>
|
||||
<div className="selection-assignment__header">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="selection-assignment__list" role="presentation">
|
||||
{filteredItems.length ? (
|
||||
filteredItems.map((item) => {
|
||||
const isAll = item.state === 'all';
|
||||
const isPartial = item.state === 'partial';
|
||||
const icon = isAll ? (
|
||||
<CheckIcon className="selection-assignment__icon" aria-hidden="true" />
|
||||
) : isPartial ? (
|
||||
<CircleDashedCheckIcon className="selection-assignment__icon" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="selection-assignment__icon selection-assignment__icon--empty" aria-hidden="true" />
|
||||
);
|
||||
const countLabel = item.total && (isPartial || isAll)
|
||||
? `${item.count ?? 0}/${item.total}`
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`menu__item selection-assignment__item selection-assignment__item--${item.state}`}
|
||||
onClick={() => handleToggle(item)}
|
||||
disabled={pending}
|
||||
role="menuitem"
|
||||
>
|
||||
{icon}
|
||||
<span className="selection-assignment__label">{item.label}</span>
|
||||
{countLabel ? (
|
||||
<span className="selection-assignment__count">{countLabel}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="menu__empty selection-assignment__empty">{emptyMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
{showCreateOption ? (
|
||||
<button
|
||||
type="button"
|
||||
className="menu__item selection-assignment__create"
|
||||
onClick={handleCreate}
|
||||
disabled={pending}
|
||||
>
|
||||
<PlusIcon className="selection-assignment__icon" aria-hidden="true" />
|
||||
<span className="selection-assignment__label">
|
||||
{createLabel ? `${createLabel} “${query.trim()}”` : `Create “${query.trim()}”`}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionAssignmentMenu;
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { TrashIcon, AnalyzeIcon } from '../ui/icons';
|
||||
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
|
||||
|
||||
const normalizeDocumentList = (selectedDocumentIds) =>
|
||||
Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : [];
|
||||
|
||||
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,
|
||||
documentLookup,
|
||||
tags,
|
||||
tagLookupById,
|
||||
correspondents,
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
onBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove,
|
||||
onBulkReanalyze,
|
||||
onDeleteSelection,
|
||||
}) => {
|
||||
const documentIdList = useMemo(
|
||||
() => normalizeDocumentList(selectedDocumentIds),
|
||||
[selectedDocumentIds],
|
||||
);
|
||||
|
||||
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 tagAssignments = useMemo(
|
||||
() => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount),
|
||||
[selectedDocuments, tagLookupById, tags, selectedDocCount],
|
||||
);
|
||||
|
||||
const correspondentAssignments = useMemo(
|
||||
() => buildCorrespondentAssignments(selectedDocuments, correspondents, selectedDocCount),
|
||||
[selectedDocuments, correspondents, selectedDocCount],
|
||||
);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel-floating-actions">
|
||||
<SelectionAssignmentMenu
|
||||
label="Tags"
|
||||
items={tagAssignments}
|
||||
placeholder="Search tags…"
|
||||
emptyMessage="No tags"
|
||||
createLabel="Create"
|
||||
onToggle={handleToggleTagAssignment}
|
||||
onCreate={handleCreateTagAssignment}
|
||||
disabled={!selectedDocCount}
|
||||
/>
|
||||
<SelectionAssignmentMenu
|
||||
label="Correspondents"
|
||||
items={correspondentAssignments}
|
||||
placeholder="Search correspondents…"
|
||||
emptyMessage="No correspondents"
|
||||
createLabel="Create"
|
||||
onToggle={handleToggleCorrespondentAssignment}
|
||||
onCreate={handleCreateCorrespondentAssignment}
|
||||
disabled={!selectedDocCount}
|
||||
/>
|
||||
{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}
|
||||
>
|
||||
<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={selectionCount === 0}
|
||||
>
|
||||
<TrashIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionFloatingActions;
|
||||
@@ -6,6 +6,8 @@ export const createWorkspaceSurfaceConfig = ({
|
||||
sidebarToggle = null,
|
||||
actions = null,
|
||||
breadcrumbs = null,
|
||||
selectionLabel = null,
|
||||
floatingActions = null,
|
||||
content = null,
|
||||
detail = null,
|
||||
variant = 'documents',
|
||||
@@ -28,6 +30,8 @@ export const createWorkspaceSurfaceConfig = ({
|
||||
leading,
|
||||
actions,
|
||||
breadcrumbs,
|
||||
selectionLabel,
|
||||
floatingActions,
|
||||
},
|
||||
content,
|
||||
detail,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { DownloadIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
||||
import { DownloadIcon, CloseIcon } from '../ui/icons';
|
||||
import DocumentSummarySection, {
|
||||
buildCorrespondentOptions,
|
||||
sortCorrespondents,
|
||||
@@ -350,7 +350,6 @@ export default DocumentViewerPanel;
|
||||
export const createDocumentViewerHeaderActions = ({
|
||||
document,
|
||||
actionState,
|
||||
onRegenerate,
|
||||
}) => {
|
||||
if (!document || !actionState) {
|
||||
return null;
|
||||
@@ -372,15 +371,6 @@ export const createDocumentViewerHeaderActions = ({
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => onRegenerate(document.id)}
|
||||
aria-label="Re-run analysis"
|
||||
title="Re-run analysis"
|
||||
>
|
||||
<AnalyzeIcon />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -394,7 +384,6 @@ export const createDocumentViewerSurface = ({
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
onRegenerate,
|
||||
onClose,
|
||||
renderSidebarToggle,
|
||||
tagLookupById,
|
||||
@@ -469,7 +458,6 @@ export const createDocumentViewerSurface = ({
|
||||
actions: createDocumentViewerHeaderActions({
|
||||
document,
|
||||
actionState,
|
||||
onRegenerate,
|
||||
}),
|
||||
breadcrumbs,
|
||||
};
|
||||
|
||||
+186
-1
@@ -323,7 +323,6 @@ a.button-link {
|
||||
|
||||
a.button-link[aria-disabled='true'] {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
a.button-link:hover:not([aria-disabled='true']) {
|
||||
@@ -661,6 +660,10 @@ button.danger:hover:not([disabled]) {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.main-content__header-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.main-content__body {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
@@ -1991,6 +1994,7 @@ button.danger:hover:not([disabled]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.documents-panel .panel-section__header .header-actions {
|
||||
@@ -2021,6 +2025,96 @@ button.danger:hover:not([disabled]) {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-floating {
|
||||
position: absolute;
|
||||
top: calc(50% + 0.25rem);
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: color-mix(in oklch, var(--surface) 80%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 85%, transparent);
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
color: var(--fg);
|
||||
box-shadow: 0 2px 6px color-mix(in oklch, var(--shadow-soft) 60%, transparent);
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.75rem;
|
||||
z-index: 3000000;
|
||||
}
|
||||
|
||||
.panel-floating__label {
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
font-size: 0.95rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.selection-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.selection-summary__token {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.selection-summary__count {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.selection-summary__icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.selection-summary__separator {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.panel-floating-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: nowrap;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel-floating-actions .quick-add {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel-floating-actions .quick-add__trigger {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel-floating-actions .quick-add__trigger[disabled] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel-floating-actions__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
pointer-events: auto;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.panel-floating-actions__button .icon-inline {
|
||||
display: inline-flex;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
}
|
||||
|
||||
.documents-panel .documents-scroll {
|
||||
overflow-y: auto;
|
||||
background: transparent;
|
||||
@@ -2770,6 +2864,97 @@ button.danger:hover:not([disabled]) {
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.selection-assignment {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selection-assignment__menu {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
.selection-assignment__header {
|
||||
padding: 0.4rem 0.75rem 0.3rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.selection-assignment__header input {
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--fg);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.selection-assignment__header input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--selection-border);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklch, var(--selection) 25%, transparent);
|
||||
}
|
||||
|
||||
.selection-assignment__list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.selection-assignment__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.selection-assignment__item--all .selection-assignment__icon {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.selection-assignment__item--partial .selection-assignment__icon {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.selection-assignment__icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selection-assignment__icon--empty {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 999px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.selection-assignment__label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.selection-assignment__count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.selection-assignment__empty {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.selection-assignment__create {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.preview-pane {
|
||||
margin-top: 0.4rem;
|
||||
|
||||
@@ -40,6 +40,9 @@ const QuickAddMenu = ({
|
||||
menuMinWidth = 220,
|
||||
triggerClassName = 'icon-button quick-add__trigger',
|
||||
triggerContent = null,
|
||||
disabled = false,
|
||||
align = 'start',
|
||||
positionStrategy = 'fixed',
|
||||
}) => {
|
||||
const anchorRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
@@ -57,8 +60,16 @@ const QuickAddMenu = ({
|
||||
anchorRef,
|
||||
minWidth: menuMinWidth,
|
||||
matchAnchorWidth: false,
|
||||
align,
|
||||
positionStrategy,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled && isOpen) {
|
||||
close();
|
||||
}
|
||||
}, [disabled, isOpen, close]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return undefined;
|
||||
@@ -145,6 +156,7 @@ const QuickAddMenu = ({
|
||||
onClick={toggle}
|
||||
aria-label={triggerAriaLabel}
|
||||
title={triggerTitle}
|
||||
disabled={disabled}
|
||||
>
|
||||
{triggerContent ?? <PlusIcon />}
|
||||
</button>
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
IconLayoutSidebarLeftExpand,
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconInfoCircle,
|
||||
IconCircleDashedCheck,
|
||||
IconFile,
|
||||
IconFolder,
|
||||
} from '@tabler/icons-react';
|
||||
import FolderSvg from '../assets/folder.svg';
|
||||
|
||||
@@ -330,6 +333,33 @@ export const CheckIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) =>
|
||||
/>
|
||||
);
|
||||
|
||||
export const CircleDashedCheckIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconCircleDashedCheck
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const FileIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconFile
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const FolderOutlineIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconFolder
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconAnalyze
|
||||
className={composeClassName('icon', className)}
|
||||
|
||||
@@ -26,7 +26,7 @@ const formatStyle = (metrics) => {
|
||||
return null;
|
||||
}
|
||||
const style = {
|
||||
position: 'fixed',
|
||||
position: metrics.strategy === 'absolute' ? 'absolute' : 'fixed',
|
||||
top: metrics.top,
|
||||
left: metrics.left,
|
||||
minWidth: metrics.minWidth,
|
||||
@@ -45,6 +45,7 @@ const useFloatingMenu = ({
|
||||
align = 'start',
|
||||
viewportMargin = DEFAULT_VIEWPORT_MARGIN,
|
||||
onOpenChange,
|
||||
positionStrategy = 'fixed',
|
||||
} = {}) => {
|
||||
const menuRef = useRef(null);
|
||||
const [menuMetrics, setMenuMetrics] = useState(null);
|
||||
@@ -63,22 +64,64 @@ const useFloatingMenu = ({
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
|
||||
const menu = menuRef.current;
|
||||
const measuredWidth = menu?.offsetWidth ?? desiredWidth;
|
||||
const widthForAlignment = matchAnchorWidth ? desiredWidth : Math.max(desiredWidth, measuredWidth);
|
||||
|
||||
if (positionStrategy === 'absolute') {
|
||||
const anchor = anchorRef?.current;
|
||||
if (!anchor) {
|
||||
return false;
|
||||
}
|
||||
const offsetParent = (menu && menu.offsetParent) || anchor.offsetParent || anchor.parentElement;
|
||||
if (!offsetParent) {
|
||||
// Fall back to fixed positioning if we cannot resolve a relative parent.
|
||||
setMenuMetrics({
|
||||
strategy: 'fixed',
|
||||
top: rect.bottom + offset,
|
||||
left: rect.left,
|
||||
minWidth: desiredWidth,
|
||||
width: matchAnchorWidth ? desiredWidth : undefined,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
let left;
|
||||
if (align === 'end') {
|
||||
left = anchor.offsetLeft + anchor.offsetWidth - widthForAlignment;
|
||||
} else if (align === 'center') {
|
||||
left = anchor.offsetLeft + anchor.offsetWidth / 2 - widthForAlignment / 2;
|
||||
} else {
|
||||
left = anchor.offsetLeft;
|
||||
}
|
||||
|
||||
const top = anchor.offsetTop + anchor.offsetHeight + offset;
|
||||
|
||||
setMenuMetrics({
|
||||
strategy: 'absolute',
|
||||
top,
|
||||
left,
|
||||
minWidth: desiredWidth,
|
||||
width: matchAnchorWidth ? desiredWidth : undefined,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const viewportWidth = resolveViewportWidth();
|
||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
||||
const menu = menuRef.current;
|
||||
const menuHeight = menu?.offsetHeight ?? 0;
|
||||
|
||||
let left;
|
||||
if (align === 'end') {
|
||||
left = rect.right - desiredWidth;
|
||||
left = rect.right - widthForAlignment;
|
||||
} else if (align === 'center') {
|
||||
left = rect.left + rect.width / 2 - desiredWidth / 2;
|
||||
left = rect.left + rect.width / 2 - widthForAlignment / 2;
|
||||
} else {
|
||||
left = rect.left;
|
||||
}
|
||||
|
||||
const maxLeft = viewportWidth > 0 ? viewportWidth - desiredWidth - safeMargin : left;
|
||||
const maxLeft = viewportWidth > 0 ? viewportWidth - widthForAlignment - safeMargin : left;
|
||||
const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left;
|
||||
|
||||
let top = rect.bottom + offset;
|
||||
@@ -91,6 +134,7 @@ const useFloatingMenu = ({
|
||||
}
|
||||
|
||||
setMenuMetrics({
|
||||
strategy: 'fixed',
|
||||
top,
|
||||
left: clampedLeft,
|
||||
minWidth: desiredWidth,
|
||||
@@ -98,7 +142,15 @@ const useFloatingMenu = ({
|
||||
});
|
||||
|
||||
return true;
|
||||
}, [anchorRef, align, matchAnchorWidth, minWidth, offset, viewportMargin]);
|
||||
}, [
|
||||
anchorRef,
|
||||
align,
|
||||
matchAnchorWidth,
|
||||
minWidth,
|
||||
offset,
|
||||
positionStrategy,
|
||||
viewportMargin,
|
||||
]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen((prev) => {
|
||||
|
||||
Reference in New Issue
Block a user