From 152f824e2e5468cb727163abdd75f64b05c52bbf Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Mon, 3 Nov 2025 02:31:46 +0100 Subject: [PATCH] selection --- backend/src/schema.rs | 57 ++- frontend/src/DesktopWorkspace.jsx | 79 +++- frontend/src/app/AppLayout.jsx | 407 ++++++++++++++---- frontend/src/app/DocumentsRoute.jsx | 26 +- frontend/src/app/useWorkspaceSurface.js | 3 - frontend/src/detail/DetailPanel.jsx | 41 -- frontend/src/documents/DocumentsPanel.jsx | 82 ++++ .../src/documents/SelectionAssignmentMenu.jsx | 234 ++++++++++ .../documents/SelectionFloatingActions.jsx | 255 +++++++++++ frontend/src/documents/workspaceHeader.js | 4 + frontend/src/preview/DocumentViewerPanel.jsx | 14 +- frontend/src/styles.css | 187 +++++++- frontend/src/ui/QuickAddMenu.jsx | 12 + frontend/src/ui/icons.js | 30 ++ frontend/src/ui/useFloatingMenu.js | 64 ++- 15 files changed, 1331 insertions(+), 164 deletions(-) create mode 100644 frontend/src/documents/SelectionAssignmentMenu.jsx create mode 100644 frontend/src/documents/SelectionFloatingActions.jsx diff --git a/backend/src/schema.rs b/backend/src/schema.rs index cd8d215..9b3291f 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -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, } } @@ -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, expires_at -> Nullable, revoked_at -> Nullable, - capabilities -> Array, + 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, + 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, ); diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index 3df97ea..efd0194 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -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: , + }); + } + + if (folderSelectionCount) { + tokens.push({ + key: 'folders', + count: folderSelectionCount, + icon: , + }); + } + + if (tokens.length) { + selectionLabel = ( + + {tokens.map((token, index) => ( + + {index > 0 ? · : null} + + {token.count} + {token.icon} + + + ))} + + ); + } 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 + ? ( + + ) + : null; + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; const detail = detailOpen && detailProps ? : null; const surfaceConfig = createWorkspaceSurfaceConfig({ @@ -2517,6 +2592,8 @@ export const createDesktopSurface = ({ onNavigateParent, actions, breadcrumbs: workspaceProps?.breadcrumbs || null, + selectionLabel, + floatingActions, content: , detail, }); diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 0c79ee7..78b83d2 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -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, ], ); diff --git a/frontend/src/app/DocumentsRoute.jsx b/frontend/src/app/DocumentsRoute.jsx index e54a2a9..5778ac3 100644 --- a/frontend/src/app/DocumentsRoute.jsx +++ b/frontend/src/app/DocumentsRoute.jsx @@ -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 = () => {
{header ? ( - +
+ {(header.selectionLabel || header.floatingActions) ? ( +
+ {header.selectionLabel ? ( + {header.selectionLabel} + ) : null} + {header.floatingActions || null} +
+ ) : null} + +
) : null}
{surface.content}
{surface.detail || null} diff --git a/frontend/src/app/useWorkspaceSurface.js b/frontend/src/app/useWorkspaceSurface.js index 7912f16..7a9c099 100644 --- a/frontend/src/app/useWorkspaceSurface.js +++ b/frontend/src/app/useWorkspaceSurface.js @@ -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, diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 062b8d8..adc706f 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -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 = [ ( , - ); - } - if (singleDoc && singleDownloadHref) { headerActions.push( { - event.stopPropagation(); - onRegenerateThumbnails(singleDoc.id); - }} - aria-label="Re-run analysis" - title="Re-run analysis" - > - - , - ); - } - return ( <>
@@ -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: , + }); + } + + if (folderSelectionCount) { + tokens.push({ + key: 'folders', + count: folderSelectionCount, + icon: , + }); + } + + if (tokens.length) { + selectionLabel = ( + + {tokens.map((token, index) => ( + + {index > 0 ? · : null} + + {token.count} + × + {token.icon} + + + ))} + + ); + } else { + selectionLabel = `${selectionCount} item${selectionCount === 1 ? '' : 's'}`; + } + } + const actions = createDocumentsTableHeaderActions({ viewMode, onViewModeChange, onRefresh, }); + const floatingActions = selectionLabel + ? ( + + ) + : null; + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; const detail = detailOpen && detailProps ? : null; @@ -628,6 +708,8 @@ export const createDocumentsSurface = ({ onNavigateParent, actions, breadcrumbs, + selectionLabel, + floatingActions, content: , detail, }); diff --git a/frontend/src/documents/SelectionAssignmentMenu.jsx b/frontend/src/documents/SelectionAssignmentMenu.jsx new file mode 100644 index 0000000..8a77e24 --- /dev/null +++ b/frontend/src/documents/SelectionAssignmentMenu.jsx @@ -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 ( +
+ + {isOpen ? ( +
+
+ setQuery(event.target.value)} + placeholder={placeholder} + aria-label={placeholder} + disabled={pending} + /> +
+
+ {filteredItems.length ? ( + filteredItems.map((item) => { + const isAll = item.state === 'all'; + const isPartial = item.state === 'partial'; + const icon = isAll ? ( +
+ {showCreateOption ? ( + + ) : null} +
+ ) : null} +
+ ); +}; + +export default SelectionAssignmentMenu; diff --git a/frontend/src/documents/SelectionFloatingActions.jsx b/frontend/src/documents/SelectionFloatingActions.jsx new file mode 100644 index 0000000..c399eed --- /dev/null +++ b/frontend/src/documents/SelectionFloatingActions.jsx @@ -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 ( +
+ + + {typeof onBulkReanalyze === 'function' ? ( + + ) : null} + {typeof onDeleteSelection === 'function' ? ( + + ) : null} +
+ ); +}; + +export default SelectionFloatingActions; diff --git a/frontend/src/documents/workspaceHeader.js b/frontend/src/documents/workspaceHeader.js index b98b407..6ce7e10 100644 --- a/frontend/src/documents/workspaceHeader.js +++ b/frontend/src/documents/workspaceHeader.js @@ -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, diff --git a/frontend/src/preview/DocumentViewerPanel.jsx b/frontend/src/preview/DocumentViewerPanel.jsx index 46eb643..102b8f0 100644 --- a/frontend/src/preview/DocumentViewerPanel.jsx +++ b/frontend/src/preview/DocumentViewerPanel.jsx @@ -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 = ({
) : null} - ); }; @@ -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, }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7e19687..f5b6479 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; diff --git a/frontend/src/ui/QuickAddMenu.jsx b/frontend/src/ui/QuickAddMenu.jsx index f8b4dab..f239735 100644 --- a/frontend/src/ui/QuickAddMenu.jsx +++ b/frontend/src/ui/QuickAddMenu.jsx @@ -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 ?? } diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index cbaeb72..6c109d0 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -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 }) => ( + +); + +export const FileIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const FolderOutlineIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( { 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) => {