floating selection box

This commit is contained in:
2025-11-03 23:10:03 +01:00
parent 8d4adbea42
commit 9ad704f50a
6 changed files with 164 additions and 141 deletions
+1 -3
View File
@@ -132,8 +132,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
box-shadow: 2px 2px 4px var(--shadow-faint); background: color-mix(in oklch, var(--surface-subtle) 90%, transparent);
background: color-mix(in oklch, var(--surface-subtle) 88%, transparent);
color: var(--muted); color: var(--muted);
} }
@@ -150,7 +149,6 @@
pointer-events: auto; pointer-events: auto;
cursor: grab; cursor: grab;
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease; transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
box-shadow: 2px 2px 4px var(--shadow-medium);
} }
.desk-help-overlay { .desk-help-overlay {
+16 -46
View File
@@ -9,7 +9,7 @@ import React, {
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager'; import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import { useAssetNavigator } from './hooks/useAssetNavigator'; import { useAssetNavigator } from './hooks/useAssetNavigator';
import { ArrowLeftIcon, ArrowRightIcon, CloseIcon, FileIcon, FolderOutlineIcon } from './ui/icons'; import { ArrowLeftIcon, ArrowRightIcon, CloseIcon } from './ui/icons';
import SelectionFloatingActions from './documents/SelectionFloatingActions'; import SelectionFloatingActions from './documents/SelectionFloatingActions';
import { createDocumentsTableHeaderActions } from './documents/DocumentsPanel'; import { createDocumentsTableHeaderActions } from './documents/DocumentsPanel';
import createWorkspaceSurfaceConfig from './documents/workspaceHeader'; import createWorkspaceSurfaceConfig from './documents/workspaceHeader';
@@ -590,6 +590,7 @@ const DesktopWorkspace = ({
}) => { }) => {
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:'));
const containerRef = useRef(null); const containerRef = useRef(null);
const layoutRef = useRef(new Map()); const layoutRef = useRef(new Map());
@@ -1120,7 +1121,7 @@ const recalcVisibleDocIds = useCallback(() => {
const persistLayoutSnapshot = useCallback( const persistLayoutSnapshot = useCallback(
async (snapshot, force = false) => { async (snapshot, force = false) => {
if (!tenantId || !viewId) { if (!allowLayoutPersistence || !tenantId || !viewId) {
return; return;
} }
if (!force && !layoutDirtyRef.current) { if (!force && !layoutDirtyRef.current) {
@@ -1160,7 +1161,7 @@ const recalcVisibleDocIds = useCallback(() => {
await upsertLayoutRecords({ tenantId, viewId, entries: records }); await upsertLayoutRecords({ tenantId, viewId, entries: records });
}, },
[tenantId, viewId], [tenantId, viewId, allowLayoutPersistence],
); );
const syncLayoutSnapshot = useCallback((force = false) => { const syncLayoutSnapshot = useCallback((force = false) => {
@@ -1207,6 +1208,11 @@ const recalcVisibleDocIds = useCallback(() => {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!allowLayoutPersistence) {
persistedLayoutRef.current = new Map();
layoutDirtyRef.current = false;
return;
}
if (!tenantId || !viewId) { if (!tenantId || !viewId) {
return; return;
} }
@@ -1245,7 +1251,7 @@ const recalcVisibleDocIds = useCallback(() => {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [tenantId, viewId]); }, [tenantId, viewId, allowLayoutPersistence]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!containerRef.current || !canvasSize.width || !canvasSize.height) { if (!containerRef.current || !canvasSize.width || !canvasSize.height) {
@@ -2496,6 +2502,7 @@ export const createDesktopSurface = ({
selectedDocumentIds, selectedDocumentIds,
selectedFolderIds, selectedFolderIds,
onDeleteSelection, onDeleteSelection,
onClearSelection,
tags, tags,
correspondents, correspondents,
documentLookup, documentLookup,
@@ -2506,6 +2513,7 @@ export const createDesktopSurface = ({
onBulkCorrespondentRemove, onBulkCorrespondentRemove,
onBulkReanalyze, onBulkReanalyze,
} = workspaceProps; } = workspaceProps;
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
const subtitle = Array.isArray(searchResults) const subtitle = Array.isArray(searchResults)
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
@@ -2515,46 +2523,6 @@ export const createDesktopSurface = ({
const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0;
const selectionCount = documentSelectionCount + folderSelectionCount; const selectionCount = documentSelectionCount + folderSelectionCount;
let selectionLabel = null;
if (selectionCount) {
const tokens = [];
if (documentSelectionCount) {
tokens.push({
key: 'documents',
count: documentSelectionCount,
icon: <FileIcon className="selection-summary__icon" size={16} />,
});
}
if (folderSelectionCount) {
tokens.push({
key: 'folders',
count: folderSelectionCount,
icon: <FolderOutlineIcon className="selection-summary__icon" size={16} />,
});
}
if (tokens.length) {
selectionLabel = (
<span className="selection-summary">
{tokens.map((token, index) => (
<React.Fragment key={token.key}>
{index > 0 ? <span className="selection-summary__separator">·</span> : null}
<span className="selection-summary__token">
<span className="selection-summary__count">{token.count}</span>
<span className="selection-summary__multiply">×</span>
{token.icon}
</span>
</React.Fragment>
))}
</span>
);
} else {
selectionLabel = `${selectionCount} item${selectionCount === 1 ? '' : 's'}`;
}
}
const actions = createDocumentsTableHeaderActions({ const actions = createDocumentsTableHeaderActions({
viewMode: viewMode || 'desk', viewMode: viewMode || 'desk',
onViewModeChange, onViewModeChange,
@@ -2562,11 +2530,12 @@ export const createDesktopSurface = ({
onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null, onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null,
}); });
const floatingActions = selectionLabel const floatingActions = selectionCount > 0
? ( ? (
<SelectionFloatingActions <SelectionFloatingActions
selectionCount={selectionCount} selectionCount={selectionCount}
selectedDocumentIds={selectedDocumentIds} selectedDocumentIds={selectedDocumentIds}
selectedFolderIds={selectedFolderIds}
documentLookup={documentLookup} documentLookup={documentLookup}
tags={tags} tags={tags}
tagLookupById={tagLookupById} tagLookupById={tagLookupById}
@@ -2577,6 +2546,7 @@ export const createDesktopSurface = ({
onBulkCorrespondentRemove={onBulkCorrespondentRemove} onBulkCorrespondentRemove={onBulkCorrespondentRemove}
onBulkReanalyze={onBulkReanalyze} onBulkReanalyze={onBulkReanalyze}
onDeleteSelection={onDeleteSelection} onDeleteSelection={onDeleteSelection}
onClearSelection={onClearSelection}
/> />
) )
: null; : null;
@@ -2593,7 +2563,7 @@ export const createDesktopSurface = ({
onNavigateParent, onNavigateParent,
actions, actions,
breadcrumbs: workspaceProps?.breadcrumbs || null, breadcrumbs: workspaceProps?.breadcrumbs || null,
selectionLabel, selectionLabel: null,
floatingActions, floatingActions,
content: <DesktopWorkspace {...workspaceProps} />, content: <DesktopWorkspace {...workspaceProps} />,
detail, detail,
+5 -43
View File
@@ -6,8 +6,6 @@ import {
RefreshIcon, RefreshIcon,
MinusVerticalIcon, MinusVerticalIcon,
InfoIcon, InfoIcon,
FileIcon,
FolderOutlineIcon,
} from '../ui/icons'; } from '../ui/icons';
import BreadcrumbTrail from '../ui/BreadcrumbTrail'; import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import createWorkspaceSurfaceConfig from './workspaceHeader'; import createWorkspaceSurfaceConfig from './workspaceHeader';
@@ -609,6 +607,7 @@ export const createDocumentsSurface = ({
selectedDocumentIds, selectedDocumentIds,
selectedFolderIds, selectedFolderIds,
onDeleteSelection, onDeleteSelection,
onClearSelection,
tags, tags,
correspondents, correspondents,
documentLookup, documentLookup,
@@ -629,45 +628,6 @@ export const createDocumentsSurface = ({
const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0;
const selectionCount = documentSelectionCount + folderSelectionCount; const selectionCount = documentSelectionCount + folderSelectionCount;
let selectionLabel = null;
if (selectionCount) {
const tokens = [];
if (documentSelectionCount) {
tokens.push({
key: 'documents',
count: documentSelectionCount,
icon: <FileIcon className="selection-summary__icon" size={16} />,
});
}
if (folderSelectionCount) {
tokens.push({
key: 'folders',
count: folderSelectionCount,
icon: <FolderOutlineIcon className="selection-summary__icon" size={16} />,
});
}
if (tokens.length) {
selectionLabel = (
<span className="selection-summary">
{tokens.map((token, index) => (
<React.Fragment key={token.key}>
{index > 0 ? <span className="selection-summary__separator">·</span> : null}
<span className="selection-summary__token">
<span className="selection-summary__count">{token.count}</span>
<span className="selection-summary__multiply">×</span>
{token.icon}
</span>
</React.Fragment>
))}
</span>
);
} else {
selectionLabel = `${selectionCount} item${selectionCount === 1 ? '' : 's'}`;
}
}
const actions = createDocumentsTableHeaderActions({ const actions = createDocumentsTableHeaderActions({
viewMode, viewMode,
@@ -675,11 +635,12 @@ export const createDocumentsSurface = ({
onRefresh, onRefresh,
}); });
const floatingActions = selectionLabel const floatingActions = selectionCount > 0
? ( ? (
<SelectionFloatingActions <SelectionFloatingActions
selectionCount={selectionCount} selectionCount={selectionCount}
selectedDocumentIds={selectedDocumentIds} selectedDocumentIds={selectedDocumentIds}
selectedFolderIds={selectedFolderIds}
documentLookup={documentLookup} documentLookup={documentLookup}
tags={tags} tags={tags}
tagLookupById={tagLookupById} tagLookupById={tagLookupById}
@@ -690,6 +651,7 @@ export const createDocumentsSurface = ({
onBulkCorrespondentRemove={onBulkCorrespondentRemove} onBulkCorrespondentRemove={onBulkCorrespondentRemove}
onBulkReanalyze={onBulkReanalyze} onBulkReanalyze={onBulkReanalyze}
onDeleteSelection={onDeleteSelection} onDeleteSelection={onDeleteSelection}
onClearSelection={onClearSelection}
/> />
) )
: null; : null;
@@ -708,7 +670,7 @@ export const createDocumentsSurface = ({
onNavigateParent, onNavigateParent,
actions, actions,
breadcrumbs, breadcrumbs,
selectionLabel, selectionLabel: null,
floatingActions, floatingActions,
content: <DocumentsPanel {...tableProps} showHeader={false} />, content: <DocumentsPanel {...tableProps} showHeader={false} />,
detail, detail,
@@ -1,6 +1,7 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { TrashIcon, AnalyzeIcon } from '../ui/icons'; import { TrashIcon, AnalyzeIcon, IconX } from '../ui/icons';
import SelectionAssignmentMenu from './SelectionAssignmentMenu'; import SelectionAssignmentMenu from './SelectionAssignmentMenu';
import SelectionSummary from './SelectionSummary';
const normalizeDocumentList = (selectedDocumentIds) => const normalizeDocumentList = (selectedDocumentIds) =>
Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : []; Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : [];
@@ -112,6 +113,7 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total)
const SelectionFloatingActions = ({ const SelectionFloatingActions = ({
selectionCount = 0, selectionCount = 0,
selectedDocumentIds, selectedDocumentIds,
selectedFolderIds = [],
documentLookup, documentLookup,
tags, tags,
tagLookupById, tagLookupById,
@@ -122,12 +124,24 @@ const SelectionFloatingActions = ({
onBulkCorrespondentRemove, onBulkCorrespondentRemove,
onBulkReanalyze, onBulkReanalyze,
onDeleteSelection, onDeleteSelection,
onClearSelection = null,
}) => { }) => {
const documentIdList = useMemo( const documentIdList = useMemo(
() => normalizeDocumentList(selectedDocumentIds), () => normalizeDocumentList(selectedDocumentIds),
[selectedDocumentIds], [selectedDocumentIds],
); );
const folderIdList = useMemo(
() => normalizeDocumentList(selectedFolderIds),
[selectedFolderIds],
);
const documentCount = documentIdList.length;
const folderCount = folderIdList.length;
const totalCount = typeof selectionCount === 'number'
? selectionCount
: documentCount + folderCount;
const selectedDocuments = useMemo(() => { const selectedDocuments = useMemo(() => {
if (!documentIdList.length || !(documentLookup instanceof Map)) { if (!documentIdList.length || !(documentLookup instanceof Map)) {
return []; return [];
@@ -203,7 +217,19 @@ const SelectionFloatingActions = ({
[selectedDocCount, onBulkCorrespondentAdd, documentIdList], [selectedDocCount, onBulkCorrespondentAdd, documentIdList],
); );
const summaryNode = totalCount > 0 ? (
<SelectionSummary
documentCount={documentCount}
folderCount={folderCount}
totalCount={totalCount}
/>
) : null;
return ( return (
<>
{summaryNode ? (
<span className="panel-floating__label">{summaryNode}</span>
) : null}
<div className="panel-floating-actions"> <div className="panel-floating-actions">
<SelectionAssignmentMenu <SelectionAssignmentMenu
label="Tags" label="Tags"
@@ -213,7 +239,7 @@ const SelectionFloatingActions = ({
createLabel="Create" createLabel="Create"
onToggle={handleToggleTagAssignment} onToggle={handleToggleTagAssignment}
onCreate={handleCreateTagAssignment} onCreate={handleCreateTagAssignment}
disabled={!selectedDocCount} disabled={!documentCount}
/> />
<SelectionAssignmentMenu <SelectionAssignmentMenu
label="Correspondents" label="Correspondents"
@@ -223,7 +249,7 @@ const SelectionFloatingActions = ({
createLabel="Create" createLabel="Create"
onToggle={handleToggleCorrespondentAssignment} onToggle={handleToggleCorrespondentAssignment}
onCreate={handleCreateCorrespondentAssignment} onCreate={handleCreateCorrespondentAssignment}
disabled={!selectedDocCount} disabled={!documentCount}
/> />
{typeof onBulkReanalyze === 'function' ? ( {typeof onBulkReanalyze === 'function' ? (
<button <button
@@ -232,7 +258,7 @@ const SelectionFloatingActions = ({
onClick={() => onBulkReanalyze(documentIdList)} onClick={() => onBulkReanalyze(documentIdList)}
aria-label="Re-run analysis for selection" aria-label="Re-run analysis for selection"
title="Re-run analysis for selection" title="Re-run analysis for selection"
disabled={!documentIdList.length} disabled={documentIdList.length === 0}
> >
<AnalyzeIcon className="icon-inline" /> <AnalyzeIcon className="icon-inline" />
</button> </button>
@@ -243,12 +269,25 @@ const SelectionFloatingActions = ({
className="icon-button danger panel-floating-actions__button" className="icon-button danger panel-floating-actions__button"
onClick={onDeleteSelection} onClick={onDeleteSelection}
aria-label="Delete selected items" aria-label="Delete selected items"
disabled={selectionCount === 0} disabled={totalCount === 0}
> >
<TrashIcon className="icon-inline" /> <TrashIcon className="icon-inline" />
</button> </button>
) : null} ) : null}
{typeof onClearSelection === 'function' ? (
<button
type="button"
className="icon-button panel-floating-actions__button"
onClick={onClearSelection}
aria-label="Clear selection"
title="Clear selection"
disabled={totalCount === 0}
>
<IconX className="icon-inline" />
</button>
) : null}
</div> </div>
</>
); );
}; };
@@ -0,0 +1,56 @@
import React from 'react';
import { FileIcon, FolderOutlineIcon } from '../ui/icons';
const SelectionSummary = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => {
const docCount = Number(documentCount) || 0;
const folderCountNumber = Number(folderCount) || 0;
const aggregateCount = docCount + folderCountNumber;
const resolvedTotal = Number(totalCount) || aggregateCount;
if (!docCount && !folderCountNumber && !resolvedTotal) {
return null;
}
const tokens = [];
if (docCount) {
tokens.push({
key: 'documents',
count: docCount,
icon: <FileIcon className="selection-summary__icon" size={16} />,
});
}
if (folderCountNumber) {
tokens.push({
key: 'folders',
count: folderCountNumber,
icon: <FolderOutlineIcon className="selection-summary__icon" size={16} />,
});
}
if (!tokens.length) {
const count = resolvedTotal;
return (
<span className="selection-summary selection-summary--text">
{`${count} item${count === 1 ? '' : 's'}`}
</span>
);
}
return (
<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>
);
};
export default SelectionSummary;
+2 -4
View File
@@ -2076,10 +2076,8 @@ button.danger:hover:not([disabled]) {
height: 1rem; height: 1rem;
} }
.selection-summary__multiply { .selection-summary--text {
display: inline-flex; font-weight: 600;
align-items: center;
opacity: 0.6;
} }
.selection-summary__separator { .selection-summary__separator {