selection
This commit is contained in:
+48
-9
@@ -10,8 +10,8 @@ pub mod sql_types {
|
|||||||
pub struct TenantStatus;
|
pub struct TenantStatus;
|
||||||
|
|
||||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||||
#[diesel(postgres_type(name = "api_token_capability"))]
|
#[diesel(postgres_type(name = "api_capability"))]
|
||||||
pub struct ApiTokenCapability;
|
pub struct ApiCapability;
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
@@ -204,6 +204,7 @@ diesel::table! {
|
|||||||
tenant_id -> Uuid,
|
tenant_id -> Uuid,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
|
capability_set_id -> Nullable<Uuid>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,9 +251,6 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
use diesel::sql_types::*;
|
|
||||||
use super::sql_types::ApiTokenCapability;
|
|
||||||
|
|
||||||
api_tokens (id) {
|
api_tokens (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
user_id -> Uuid,
|
user_id -> Uuid,
|
||||||
@@ -264,11 +262,46 @@ diesel::table! {
|
|||||||
last_used_at -> Nullable<Timestamptz>,
|
last_used_at -> Nullable<Timestamptz>,
|
||||||
expires_at -> Nullable<Timestamptz>,
|
expires_at -> Nullable<Timestamptz>,
|
||||||
revoked_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 -> document_assets (asset_id));
|
||||||
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
|
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
|
||||||
diesel::joinable!(document_assets -> document_versions (document_version_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!(documents -> tenants (tenant_id));
|
||||||
diesel::joinable!(folders -> tenants (tenant_id));
|
diesel::joinable!(folders -> tenants (tenant_id));
|
||||||
diesel::joinable!(jobs -> 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 -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_sessions -> users (user_id));
|
diesel::joinable!(user_sessions -> users (user_id));
|
||||||
diesel::joinable!(tags -> tenants (tenant_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 -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> users (user_id));
|
diesel::joinable!(user_memberships -> users (user_id));
|
||||||
diesel::joinable!(user_passkeys -> users (user_id));
|
diesel::joinable!(user_passkeys -> users (user_id));
|
||||||
diesel::joinable!(webauthn_challenges -> 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 -> tenants (tenant_id));
|
||||||
diesel::joinable!(api_tokens -> users (user_id));
|
diesel::joinable!(api_tokens -> users (user_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
|
api_tokens,
|
||||||
|
capability_set_capabilities,
|
||||||
|
capability_sets,
|
||||||
correspondents,
|
correspondents,
|
||||||
document_asset_objects,
|
document_asset_objects,
|
||||||
document_assets,
|
document_assets,
|
||||||
|
document_assets_v2,
|
||||||
document_correspondents,
|
document_correspondents,
|
||||||
document_tags,
|
document_tags,
|
||||||
document_versions,
|
document_versions,
|
||||||
@@ -307,12 +347,11 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
folders,
|
folders,
|
||||||
jobs,
|
jobs,
|
||||||
magic_tokens,
|
magic_tokens,
|
||||||
user_sessions,
|
|
||||||
tags,
|
tags,
|
||||||
tenants,
|
tenants,
|
||||||
user_memberships,
|
user_memberships,
|
||||||
user_passkeys,
|
user_passkeys,
|
||||||
|
user_sessions,
|
||||||
users,
|
users,
|
||||||
webauthn_challenges,
|
webauthn_challenges,
|
||||||
api_tokens,
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import React, {
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
||||||
import { useAssetNavigator } from './hooks/useAssetNavigator';
|
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 { createDocumentsTableHeaderActions } from './documents/DocumentsPanel';
|
||||||
import createWorkspaceSurfaceConfig from './documents/workspaceHeader';
|
import createWorkspaceSurfaceConfig from './documents/workspaceHeader';
|
||||||
import { resolveCorrespondents } from './documents/correspondents';
|
import { resolveCorrespondents } from './documents/correspondents';
|
||||||
@@ -2492,12 +2493,67 @@ export const createDesktopSurface = ({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
onDeleteSelection,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
tagLookupById,
|
||||||
|
onBulkTagAdd,
|
||||||
|
onBulkTagRemove,
|
||||||
|
onBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze,
|
||||||
} = workspaceProps;
|
} = workspaceProps;
|
||||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||||
const subtitle = Array.isArray(searchResults)
|
const subtitle = Array.isArray(searchResults)
|
||||||
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||||
: null;
|
: 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({
|
const actions = createDocumentsTableHeaderActions({
|
||||||
viewMode: viewMode || 'desk',
|
viewMode: viewMode || 'desk',
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
@@ -2505,6 +2561,25 @@ export const createDesktopSurface = ({
|
|||||||
onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null,
|
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 sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
||||||
const surfaceConfig = createWorkspaceSurfaceConfig({
|
const surfaceConfig = createWorkspaceSurfaceConfig({
|
||||||
@@ -2517,6 +2592,8 @@ export const createDesktopSurface = ({
|
|||||||
onNavigateParent,
|
onNavigateParent,
|
||||||
actions,
|
actions,
|
||||||
breadcrumbs: workspaceProps?.breadcrumbs || null,
|
breadcrumbs: workspaceProps?.breadcrumbs || null,
|
||||||
|
selectionLabel,
|
||||||
|
floatingActions,
|
||||||
content: <DesktopWorkspace {...workspaceProps} />,
|
content: <DesktopWorkspace {...workspaceProps} />,
|
||||||
detail,
|
detail,
|
||||||
});
|
});
|
||||||
|
|||||||
+326
-81
@@ -784,7 +784,66 @@ const AppLayout = () => {
|
|||||||
[mapDocumentCaches],
|
[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 folderOptions = useMemo(() => {
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
@@ -3020,6 +3079,59 @@ const AppLayout = () => {
|
|||||||
[navigate, selectedFolder],
|
[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(() => {
|
const handleDocumentListFocus = useCallback(() => {
|
||||||
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
|
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
|
||||||
return;
|
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(
|
const handleDocumentTagDrop = useCallback(
|
||||||
async (documentId, tag) => {
|
async (documentId, tag) => {
|
||||||
if (!documentId || !tag?.id) {
|
if (!documentId || !tag?.id) {
|
||||||
@@ -3434,83 +3720,6 @@ const AppLayout = () => {
|
|||||||
[handleDocumentTagAttach, handleTagRemove],
|
[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(
|
const handleFolderRename = useCallback(
|
||||||
async (folderId, nextName) => {
|
async (folderId, nextName) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -4507,7 +4716,16 @@ const AppLayout = () => {
|
|||||||
onViewModeChange: handleDocumentsViewModeChange,
|
onViewModeChange: handleDocumentsViewModeChange,
|
||||||
onClearSelection: clearDocumentSelection,
|
onClearSelection: clearDocumentSelection,
|
||||||
onEntrySelection: handleEntrySelection,
|
onEntrySelection: handleEntrySelection,
|
||||||
|
onDeleteSelection: handleDeleteSelection,
|
||||||
onOpenDetailPanel: openDetailPanel,
|
onOpenDetailPanel: openDetailPanel,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||||
|
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||||
|
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
activeCorrespondentFilters,
|
activeCorrespondentFilters,
|
||||||
@@ -4532,6 +4750,7 @@ const AppLayout = () => {
|
|||||||
handleFolderDragStart,
|
handleFolderDragStart,
|
||||||
handleFolderRename,
|
handleFolderRename,
|
||||||
handleEntrySelection,
|
handleEntrySelection,
|
||||||
|
handleDeleteSelection,
|
||||||
isFilterActive,
|
isFilterActive,
|
||||||
openDetailPanel,
|
openDetailPanel,
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
@@ -4547,6 +4766,14 @@ const AppLayout = () => {
|
|||||||
toggleTagFilter,
|
toggleTagFilter,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
handleBulkTagAddFromDetail,
|
||||||
|
handleBulkTagRemoveFromDetail,
|
||||||
|
handleBulkCorrespondentAdd,
|
||||||
|
handleBulkCorrespondentRemove,
|
||||||
|
handleBulkSelectionReanalyze,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4638,12 +4865,10 @@ const AppLayout = () => {
|
|||||||
tagLookupById,
|
tagLookupById,
|
||||||
onTagAdd: handleTagAdd,
|
onTagAdd: handleTagAdd,
|
||||||
onTagRemove: handleTagRemove,
|
onTagRemove: handleTagRemove,
|
||||||
onRegenerateThumbnails: handleThumbnailRegeneration,
|
|
||||||
previewEntry: selectedPreviewEntry,
|
previewEntry: selectedPreviewEntry,
|
||||||
onOpenPreview: openDocumentPreview,
|
onOpenPreview: openDocumentPreview,
|
||||||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||||
onBulkReanalyze: handleBulkSelectionReanalyze,
|
|
||||||
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||||
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||||
onPromoteSelection: promoteSelectionOrder,
|
onPromoteSelection: promoteSelectionOrder,
|
||||||
@@ -4670,7 +4895,6 @@ const AppLayout = () => {
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
handleBulkCorrespondentAdd,
|
handleBulkCorrespondentAdd,
|
||||||
handleBulkCorrespondentRemove,
|
handleBulkCorrespondentRemove,
|
||||||
handleBulkSelectionReanalyze,
|
|
||||||
handleBulkTagAddFromDetail,
|
handleBulkTagAddFromDetail,
|
||||||
handleBulkTagRemoveFromDetail,
|
handleBulkTagRemoveFromDetail,
|
||||||
handleCorrespondentAdd,
|
handleCorrespondentAdd,
|
||||||
@@ -4680,7 +4904,6 @@ const AppLayout = () => {
|
|||||||
handleDocumentIssuedUpdate,
|
handleDocumentIssuedUpdate,
|
||||||
handleTagAdd,
|
handleTagAdd,
|
||||||
handleTagRemove,
|
handleTagRemove,
|
||||||
handleThumbnailRegeneration,
|
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
promoteSelectionOrder,
|
promoteSelectionOrder,
|
||||||
resolveFolderPath,
|
resolveFolderPath,
|
||||||
@@ -4786,6 +5009,7 @@ const AppLayout = () => {
|
|||||||
tenantId: currentTenantId,
|
tenantId: currentTenantId,
|
||||||
viewId: deskViewId,
|
viewId: deskViewId,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
onClearSelection: clearDocumentSelection,
|
onClearSelection: clearDocumentSelection,
|
||||||
detailPanelOpen,
|
detailPanelOpen,
|
||||||
onCloseDetailPanel: handleDetailPanelClose,
|
onCloseDetailPanel: handleDetailPanelClose,
|
||||||
@@ -4795,6 +5019,16 @@ const AppLayout = () => {
|
|||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
activeTagIds: activeTagFilters,
|
activeTagIds: activeTagFilters,
|
||||||
|
onDeleteSelection: handleDeleteSelection,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
tagLookupById,
|
||||||
|
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||||
|
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||||
|
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
documents,
|
documents,
|
||||||
@@ -4815,6 +5049,7 @@ const AppLayout = () => {
|
|||||||
deskViewId,
|
deskViewId,
|
||||||
deskHelpOpen,
|
deskHelpOpen,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
clearDocumentSelection,
|
clearDocumentSelection,
|
||||||
detailPanelOpen,
|
detailPanelOpen,
|
||||||
handleDetailPanelClose,
|
handleDetailPanelClose,
|
||||||
@@ -4824,6 +5059,16 @@ const AppLayout = () => {
|
|||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
activeTagFilters,
|
activeTagFilters,
|
||||||
|
handleDeleteSelection,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
tagLookupById,
|
||||||
|
handleBulkTagAddFromDetail,
|
||||||
|
handleBulkTagRemoveFromDetail,
|
||||||
|
handleBulkCorrespondentAdd,
|
||||||
|
handleBulkCorrespondentRemove,
|
||||||
|
handleBulkSelectionReanalyze,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const DocumentsRouteContent = () => {
|
|||||||
previewWorkspaceEntry,
|
previewWorkspaceEntry,
|
||||||
previewDocumentId,
|
previewDocumentId,
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
handleThumbnailRegeneration,
|
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
@@ -84,7 +83,6 @@ const DocumentsRouteContent = () => {
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
handleThumbnailRegeneration,
|
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
parentBreadcrumb,
|
parentBreadcrumb,
|
||||||
onNavigateParent: handleNavigateParent,
|
onNavigateParent: handleNavigateParent,
|
||||||
@@ -141,13 +139,23 @@ const DocumentsRouteContent = () => {
|
|||||||
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
||||||
<div className={mainContentClass}>
|
<div className={mainContentClass}>
|
||||||
{header ? (
|
{header ? (
|
||||||
<PanelHeader
|
<div className="main-content__header-wrapper">
|
||||||
className="main-content__header"
|
{(header.selectionLabel || header.floatingActions) ? (
|
||||||
leading={header.leading}
|
<div className="panel-floating" aria-live="polite" aria-atomic="true">
|
||||||
title={headerTitle}
|
{header.selectionLabel ? (
|
||||||
titleTag="h2"
|
<span className="panel-floating__label">{header.selectionLabel}</span>
|
||||||
actions={header.actions}
|
) : null}
|
||||||
/>
|
{header.floatingActions || null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<PanelHeader
|
||||||
|
className="main-content__header"
|
||||||
|
leading={header.leading}
|
||||||
|
title={headerTitle}
|
||||||
|
titleTag="h2"
|
||||||
|
actions={header.actions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={bodyClass}>{surface.content}</div>
|
<div className={bodyClass}>{surface.content}</div>
|
||||||
{surface.detail || null}
|
{surface.detail || null}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const useWorkspaceSurface = ({
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
handleThumbnailRegeneration,
|
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
parentBreadcrumb,
|
parentBreadcrumb,
|
||||||
onNavigateParent,
|
onNavigateParent,
|
||||||
@@ -92,7 +91,6 @@ export const useWorkspaceSurface = ({
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
onRegenerate: handleThumbnailRegeneration,
|
|
||||||
onClose: closeDocumentPreview,
|
onClose: closeDocumentPreview,
|
||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
@@ -117,7 +115,6 @@ export const useWorkspaceSurface = ({
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
handleThumbnailRegeneration,
|
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
detailPanelProps,
|
detailPanelProps,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
DetailPanelCollapseIcon,
|
DetailPanelCollapseIcon,
|
||||||
AnalyzeIcon,
|
|
||||||
WindowMaximizeIcon,
|
WindowMaximizeIcon,
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
import PanelHeader from '../ui/PanelHeader';
|
import PanelHeader from '../ui/PanelHeader';
|
||||||
@@ -144,11 +143,9 @@ const DetailPanel = ({
|
|||||||
tagLookupById = new Map(),
|
tagLookupById = new Map(),
|
||||||
onTagAdd,
|
onTagAdd,
|
||||||
onTagRemove,
|
onTagRemove,
|
||||||
onRegenerateThumbnails,
|
|
||||||
onOpenPreview,
|
onOpenPreview,
|
||||||
onBulkTagAdd,
|
onBulkTagAdd,
|
||||||
onBulkTagRemove,
|
onBulkTagRemove,
|
||||||
onBulkReanalyze,
|
|
||||||
onBulkCorrespondentAdd,
|
onBulkCorrespondentAdd,
|
||||||
onBulkCorrespondentRemove,
|
onBulkCorrespondentRemove,
|
||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
@@ -878,8 +875,6 @@ const DetailPanel = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBulkSelection = selectedCount > 1;
|
|
||||||
|
|
||||||
const headerLeading = [
|
const headerLeading = [
|
||||||
(
|
(
|
||||||
<button
|
<button
|
||||||
@@ -915,24 +910,6 @@ const DetailPanel = ({
|
|||||||
|
|
||||||
const headerActions = [];
|
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) {
|
if (singleDoc && singleDownloadHref) {
|
||||||
headerActions.push(
|
headerActions.push(
|
||||||
<a
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<aside className="detail-panel panel">
|
<aside className="detail-panel panel">
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
MinusVerticalIcon,
|
MinusVerticalIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
|
FileIcon,
|
||||||
|
FolderOutlineIcon,
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||||
import createWorkspaceSurfaceConfig from './workspaceHeader';
|
import createWorkspaceSurfaceConfig from './workspaceHeader';
|
||||||
@@ -13,6 +15,7 @@ import DetailPanel from '../detail/DetailPanel';
|
|||||||
import DocumentsGrid from './DocumentsGrid';
|
import DocumentsGrid from './DocumentsGrid';
|
||||||
import DocumentsList from './DocumentsList';
|
import DocumentsList from './DocumentsList';
|
||||||
import { isTagTransferEvent } from './tagTransfer';
|
import { isTagTransferEvent } from './tagTransfer';
|
||||||
|
import SelectionFloatingActions from './SelectionFloatingActions';
|
||||||
|
|
||||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||||
|
|
||||||
@@ -503,6 +506,7 @@ const DocumentsPanel = ({
|
|||||||
onCorrespondentClick={onCorrespondentClick}
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
scrollRef={scrollRef}
|
scrollRef={scrollRef}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -602,6 +606,18 @@ export const createDocumentsSurface = ({
|
|||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
onDeleteSelection,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
tagLookupById,
|
||||||
|
onBulkTagAdd,
|
||||||
|
onBulkTagRemove,
|
||||||
|
onBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze,
|
||||||
} = tableProps;
|
} = tableProps;
|
||||||
|
|
||||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||||
@@ -609,12 +625,76 @@ export const createDocumentsSurface = ({
|
|||||||
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||||
: null;
|
: 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({
|
const actions = createDocumentsTableHeaderActions({
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onRefresh,
|
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 sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
||||||
|
|
||||||
@@ -628,6 +708,8 @@ export const createDocumentsSurface = ({
|
|||||||
onNavigateParent,
|
onNavigateParent,
|
||||||
actions,
|
actions,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
|
selectionLabel,
|
||||||
|
floatingActions,
|
||||||
content: <DocumentsPanel {...tableProps} showHeader={false} />,
|
content: <DocumentsPanel {...tableProps} showHeader={false} />,
|
||||||
detail,
|
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,
|
sidebarToggle = null,
|
||||||
actions = null,
|
actions = null,
|
||||||
breadcrumbs = null,
|
breadcrumbs = null,
|
||||||
|
selectionLabel = null,
|
||||||
|
floatingActions = null,
|
||||||
content = null,
|
content = null,
|
||||||
detail = null,
|
detail = null,
|
||||||
variant = 'documents',
|
variant = 'documents',
|
||||||
@@ -28,6 +30,8 @@ export const createWorkspaceSurfaceConfig = ({
|
|||||||
leading,
|
leading,
|
||||||
actions,
|
actions,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
|
selectionLabel,
|
||||||
|
floatingActions,
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
detail,
|
detail,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { DownloadIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
import { DownloadIcon, CloseIcon } from '../ui/icons';
|
||||||
import DocumentSummarySection, {
|
import DocumentSummarySection, {
|
||||||
buildCorrespondentOptions,
|
buildCorrespondentOptions,
|
||||||
sortCorrespondents,
|
sortCorrespondents,
|
||||||
@@ -350,7 +350,6 @@ export default DocumentViewerPanel;
|
|||||||
export const createDocumentViewerHeaderActions = ({
|
export const createDocumentViewerHeaderActions = ({
|
||||||
document,
|
document,
|
||||||
actionState,
|
actionState,
|
||||||
onRegenerate,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!document || !actionState) {
|
if (!document || !actionState) {
|
||||||
return null;
|
return null;
|
||||||
@@ -372,15 +371,6 @@ export const createDocumentViewerHeaderActions = ({
|
|||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : 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,
|
getDocumentAsset,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
onRegenerate,
|
|
||||||
onClose,
|
onClose,
|
||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
@@ -469,7 +458,6 @@ export const createDocumentViewerSurface = ({
|
|||||||
actions: createDocumentViewerHeaderActions({
|
actions: createDocumentViewerHeaderActions({
|
||||||
document,
|
document,
|
||||||
actionState,
|
actionState,
|
||||||
onRegenerate,
|
|
||||||
}),
|
}),
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
};
|
};
|
||||||
|
|||||||
+186
-1
@@ -323,7 +323,6 @@ a.button-link {
|
|||||||
|
|
||||||
a.button-link[aria-disabled='true'] {
|
a.button-link[aria-disabled='true'] {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a.button-link:hover:not([aria-disabled='true']) {
|
a.button-link:hover:not([aria-disabled='true']) {
|
||||||
@@ -661,6 +660,10 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-content__header-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.main-content__body {
|
.main-content__body {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -1991,6 +1994,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.documents-panel .panel-section__header .header-actions {
|
.documents-panel .panel-section__header .header-actions {
|
||||||
@@ -2021,6 +2025,96 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.5rem;
|
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 {
|
.documents-panel .documents-scroll {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -2770,6 +2864,97 @@ button.danger:hover:not([disabled]) {
|
|||||||
background: var(--surface-subtle);
|
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 {
|
.preview-pane {
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ const QuickAddMenu = ({
|
|||||||
menuMinWidth = 220,
|
menuMinWidth = 220,
|
||||||
triggerClassName = 'icon-button quick-add__trigger',
|
triggerClassName = 'icon-button quick-add__trigger',
|
||||||
triggerContent = null,
|
triggerContent = null,
|
||||||
|
disabled = false,
|
||||||
|
align = 'start',
|
||||||
|
positionStrategy = 'fixed',
|
||||||
}) => {
|
}) => {
|
||||||
const anchorRef = useRef(null);
|
const anchorRef = useRef(null);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
@@ -57,8 +60,16 @@ const QuickAddMenu = ({
|
|||||||
anchorRef,
|
anchorRef,
|
||||||
minWidth: menuMinWidth,
|
minWidth: menuMinWidth,
|
||||||
matchAnchorWidth: false,
|
matchAnchorWidth: false,
|
||||||
|
align,
|
||||||
|
positionStrategy,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled && isOpen) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}, [disabled, isOpen, close]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -145,6 +156,7 @@ const QuickAddMenu = ({
|
|||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label={triggerAriaLabel}
|
aria-label={triggerAriaLabel}
|
||||||
title={triggerTitle}
|
title={triggerTitle}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{triggerContent ?? <PlusIcon />}
|
{triggerContent ?? <PlusIcon />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import {
|
|||||||
IconLayoutSidebarLeftExpand,
|
IconLayoutSidebarLeftExpand,
|
||||||
IconLayoutSidebarRightCollapse,
|
IconLayoutSidebarRightCollapse,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconCircleDashedCheck,
|
||||||
|
IconFile,
|
||||||
|
IconFolder,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import FolderSvg from '../assets/folder.svg';
|
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 }) => (
|
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconAnalyze
|
<IconAnalyze
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const formatStyle = (metrics) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const style = {
|
const style = {
|
||||||
position: 'fixed',
|
position: metrics.strategy === 'absolute' ? 'absolute' : 'fixed',
|
||||||
top: metrics.top,
|
top: metrics.top,
|
||||||
left: metrics.left,
|
left: metrics.left,
|
||||||
minWidth: metrics.minWidth,
|
minWidth: metrics.minWidth,
|
||||||
@@ -45,6 +45,7 @@ const useFloatingMenu = ({
|
|||||||
align = 'start',
|
align = 'start',
|
||||||
viewportMargin = DEFAULT_VIEWPORT_MARGIN,
|
viewportMargin = DEFAULT_VIEWPORT_MARGIN,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
positionStrategy = 'fixed',
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
const [menuMetrics, setMenuMetrics] = useState(null);
|
const [menuMetrics, setMenuMetrics] = useState(null);
|
||||||
@@ -63,22 +64,64 @@ const useFloatingMenu = ({
|
|||||||
|
|
||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
|
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 viewportWidth = resolveViewportWidth();
|
||||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||||
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
||||||
const menu = menuRef.current;
|
|
||||||
const menuHeight = menu?.offsetHeight ?? 0;
|
const menuHeight = menu?.offsetHeight ?? 0;
|
||||||
|
|
||||||
let left;
|
let left;
|
||||||
if (align === 'end') {
|
if (align === 'end') {
|
||||||
left = rect.right - desiredWidth;
|
left = rect.right - widthForAlignment;
|
||||||
} else if (align === 'center') {
|
} else if (align === 'center') {
|
||||||
left = rect.left + rect.width / 2 - desiredWidth / 2;
|
left = rect.left + rect.width / 2 - widthForAlignment / 2;
|
||||||
} else {
|
} else {
|
||||||
left = rect.left;
|
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;
|
const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left;
|
||||||
|
|
||||||
let top = rect.bottom + offset;
|
let top = rect.bottom + offset;
|
||||||
@@ -91,6 +134,7 @@ const useFloatingMenu = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMenuMetrics({
|
setMenuMetrics({
|
||||||
|
strategy: 'fixed',
|
||||||
top,
|
top,
|
||||||
left: clampedLeft,
|
left: clampedLeft,
|
||||||
minWidth: desiredWidth,
|
minWidth: desiredWidth,
|
||||||
@@ -98,7 +142,15 @@ const useFloatingMenu = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}, [anchorRef, align, matchAnchorWidth, minWidth, offset, viewportMargin]);
|
}, [
|
||||||
|
anchorRef,
|
||||||
|
align,
|
||||||
|
matchAnchorWidth,
|
||||||
|
minWidth,
|
||||||
|
offset,
|
||||||
|
positionStrategy,
|
||||||
|
viewportMargin,
|
||||||
|
]);
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
setIsOpen((prev) => {
|
setIsOpen((prev) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user