selection

This commit is contained in:
2025-11-03 02:31:46 +01:00
parent 1c261bf0a2
commit 152f824e2e
15 changed files with 1331 additions and 164 deletions
+48 -9
View File
@@ -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,
);
+78 -1
View File
@@ -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
View File
@@ -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,
],
);
+17 -9
View File
@@ -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}
-3
View File
@@ -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,
-41
View File
@@ -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">
+82
View File
@@ -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 -13
View File
@@ -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
View File
@@ -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;
+12
View File
@@ -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
View File
@@ -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)}
+58 -6
View File
@@ -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) => {