diff --git a/frontend/src/DesktopWorkspace.css b/frontend/src/DesktopWorkspace.css index 835c4f6..1eb3c07 100644 --- a/frontend/src/DesktopWorkspace.css +++ b/frontend/src/DesktopWorkspace.css @@ -32,6 +32,12 @@ position: relative; overflow: hidden; margin: 0; + outline: none; +} + +.desk-canvas:focus, +.desk-canvas:focus-visible { + outline: none; } .desk-empty { diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index 7bdebd9..ea34251 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -1966,6 +1966,7 @@ const DesktopWorkspaceView = () => { useDocumentDrag(); const deferredSelectionRef = useRef(null); + const pointerIntentRef = useRef(null); const pointerStartRef = useRef({ x: 0, y: 0 }); const pointerMovedRef = useRef(false); @@ -2103,19 +2104,14 @@ const DesktopWorkspaceView = () => { const allSizesReady = items.every((doc) => ensureDocumentSize(doc)); - useEffect(() => { - if (typeof window === 'undefined' || typeof onClearSelection !== 'function') { - return undefined; - } - - const handleKeyDown = (event) => { + const handleShellKeyDown = useCallback( + (event) => { if (!event || event.defaultPrevented) { return; } - const key = event.key; - const spacePressed = key === ' ' || key === 'Space' || key === 'Spacebar'; - if (!spacePressed) { + const { key } = event; + if (key !== ' ' && key !== 'Space' && key !== 'Spacebar') { return; } @@ -2133,10 +2129,9 @@ const DesktopWorkspaceView = () => { } } - const hasSelection = Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0; - if (hasSelection) { + if (Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0) { event.preventDefault(); - onClearSelection(); + onClearSelection?.(); return; } @@ -2144,30 +2139,28 @@ const DesktopWorkspaceView = () => { event.preventDefault(); onCloseDetailPanel(); } - }; - - window.addEventListener('keydown', handleKeyDown, true); - return () => window.removeEventListener('keydown', handleKeyDown, true); - }, [ - onClearSelection, - selectedDocumentIds, - detailPanelOpen, - onCloseDetailPanel, - ]); + }, + [ + detailPanelOpen, + onClearSelection, + onCloseDetailPanel, + selectedDocumentIds, + ], + ); return ( <> -
{ - if (event.target === event.currentTarget && typeof onClearSelection === 'function') { - onClearSelection(); - } - }} +
{ + if (event.target === event.currentTarget && typeof onClearSelection === 'function') { + onClearSelection(); + } + }} >
{ y: Number.isFinite(event.clientY) ? event.clientY : 0, }; pointerMovedRef.current = false; + const selectionCountAtDown = Array.isArray(selectedDocumentIds) + ? selectedDocumentIds.length + : 0; const alreadySelected = selectedDocumentIds.includes(doc.id); const metaOrCtrlOnly = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; @@ -2285,6 +2281,20 @@ const DesktopWorkspaceView = () => { const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); + const pointerButton = typeof event.button === 'number' ? event.button : 0; + pointerIntentRef.current = { + docId: doc.id, + selectedAtDown: alreadySelected, + selectionCountAtDown, + modifierActive, + pointerButton, + openDetailOnRelease: + !modifierActive + && alreadySelected + && pointerButton === 0 + && typeof onDocumentOpen === 'function', + }; + const deferSelection = !modifierActive && alreadySelected @@ -2302,9 +2312,8 @@ const DesktopWorkspaceView = () => { deferredSelectionRef.current = null; } else if (deferSelection) { deferredSelectionRef.current = { - type: 'document', - id: doc.id, - key: `document:${doc.id}`, + entry: { type: 'document', id: doc.id, key: `document:${doc.id}` }, + applySelection: false, }; } else { deferredSelectionRef.current = null; @@ -2319,6 +2328,8 @@ const DesktopWorkspaceView = () => { handlePointerDown(event, doc.id, { stackDocIds, stackSelectionApplied: appliedStackSelection, + wasSelected: alreadySelected, + modifierActive, }); }} onPointerMove={(event) => { @@ -2331,21 +2342,50 @@ const DesktopWorkspaceView = () => { handlePointerMove(event); }} onPointerUp={(event) => { - const deferredEntry = deferredSelectionRef.current; + const deferredInfo = deferredSelectionRef.current; + const pointerState = pointerIntentRef.current; const pointerMoved = pointerMovedRef.current; handlePointerUp(event); - if (!pointerMoved && deferredEntry && typeof onEntryPointer === 'function') { - onEntryPointer(deferredEntry, event); + if (!pointerMoved && deferredInfo && typeof onEntryPointer === 'function') { + const entry = deferredInfo.entry || deferredInfo; + const applySelection = deferredInfo.applySelection !== false; + if (applySelection && entry) { + onEntryPointer(entry, event); + } + } + + if ( + !pointerMoved + && pointerState + && pointerState.docId === doc.id + && pointerState.openDetailOnRelease + && typeof onDocumentOpen === 'function' + ) { + const expectedButton = + typeof pointerState.pointerButton === 'number' + ? pointerState.pointerButton + : 0; + const releasedButton = typeof event.button === 'number' ? event.button : expectedButton; + const isPrimaryRelease = expectedButton === 0 && releasedButton === 0; + const stillSelected = Array.isArray(selectedDocumentIds) + && selectedDocumentIds.includes(doc.id); + if (isPrimaryRelease && stillSelected) { + const useSelection = + pointerState.selectedAtDown && pointerState.selectionCountAtDown > 0; + onDocumentOpen(doc.id, { useSelection }); + } } deferredSelectionRef.current = null; + pointerIntentRef.current = null; pointerMovedRef.current = false; }} onPointerCancel={(event) => { deferredSelectionRef.current = null; pointerMovedRef.current = false; + pointerIntentRef.current = null; handlePointerCancel(event); }} onDragEnter={(event) => handleTagDragEnterDoc(event, doc.id)} diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index ad805d4..b7407f5 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -20,8 +20,8 @@ import DropOverlay from './DropOverlay'; import { useManagementModals } from './useManagementModals'; import { api, useAppDispatch, useAppState } from './appState'; import { useDetailPanel } from './useDetailPanel'; -import { useDocumentSelection } from './useDocumentSelection'; -import { useEntryPointerHandler } from '../documents/useEntryPointer'; +import useWorkspaceSelection from './useWorkspaceSelection'; +import { useEntryPointerHandler as useEntryPointerCore } from '../documents/useEntryPointer'; import { isTagTransferEvent } from '../documents/tagTransfer'; const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early @@ -218,7 +218,6 @@ const AppLayout = () => { setDeskHelpOpen(false); } }, [documentsViewMode, deskHelpOpen]); - const initialRowSelection = []; const tokenRef = useRef(token); const refreshPromiseRef = useRef(null); const breadcrumbFetchRef = useRef(new Set()); @@ -358,8 +357,18 @@ const AppLayout = () => { } const tagManager = tagManagerRef.current; + const selection = useWorkspaceSelection({ + resolveDocumentRowKey, + resolveFolderRowKey, + isDocumentRowKey, + isFolderRowKey, + getRowId, + }); + const { selectedEntries, + selectedDocumentIds, + selectedFolderIds, setSelectedEntries, selectionOrder, setSelectionOrder, @@ -371,18 +380,11 @@ const AppLayout = () => { focusedRowKey, setFocusedRowKey, applySelection, - clearSelection: clearSelectionInternal, - handleEntrySelection: handleEntrySelectionInternal, - promoteSelectionOrder: promoteSelectionOrderInternal, + clearSelection, + handleEntrySelection, + promoteSelectionOrder: promoteSelectionOrderRaw, configureSelectionEnvironment, - } = useDocumentSelection({ - resolveDocumentRowKey, - resolveFolderRowKey, - isDocumentRowKey, - isFolderRowKey, - getRowId, - initialEntries: initialRowSelection, - }); + } = selection; const getDocumentAsset = useCallback((doc, type) => { if (!doc || !type) return null; @@ -393,24 +395,6 @@ const AppLayout = () => { const dragCounterRef = useRef(0); const detailFolderFetchRef = useRef(new Set()); - const selectedDocumentIds = useMemo( - () => - selectedEntries - .filter(isDocumentRowKey) - .map((key) => getRowId(key)) - .filter(Boolean), - [selectedEntries], - ); - - const selectedFolderIds = useMemo( - () => - selectedEntries - .filter(isFolderRowKey) - .map((key) => getRowId(key)) - .filter(Boolean), - [selectedEntries], - ); - const resetWorkspaceState = useCallback(() => { const rootNode = createRootNode(); @@ -922,17 +906,10 @@ const AppLayout = () => { }); }, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]); - const handleEntrySelection = useCallback( - (rowKey, event) => { - handleEntrySelectionInternal(rowKey, event); - }, - [handleEntrySelectionInternal], - ); - const promoteSelectionOrder = useCallback( (docId) => { if (!docId) return; - promoteSelectionOrderInternal(docId); + promoteSelectionOrderRaw(docId); const rowKey = resolveDocumentRowKey(docId); if (rowKey) { selectionAnchorRef.current = rowKey; @@ -941,7 +918,7 @@ const AppLayout = () => { setActivePreviewId(docId); }, [ - promoteSelectionOrderInternal, + promoteSelectionOrderRaw, selectionAnchorRef, setFocusedDocumentId, setActivePreviewId, @@ -949,8 +926,8 @@ const AppLayout = () => { ); const clearDocumentSelection = useCallback(() => { - clearSelectionInternal(); - }, [clearSelectionInternal]); + clearSelection(); + }, [clearSelection]); const prevFocusedDocIdRef = useRef(focusedDocumentId); useEffect(() => { @@ -4225,17 +4202,14 @@ const AppLayout = () => { close: closeDetailPanel, }; - const handleEntryPointer = useEntryPointerHandler({ + const handleEntryPointerCore = useEntryPointerCore({ resolveDocumentRowKey, resolveFolderRowKey, - onSelectDocument: (documentId, event, { modifierClick, primaryClick, rowKey }) => { + onSelectDocument: (documentId, event, { rowKey }) => { const key = rowKey || resolveDocumentRowKey(documentId); if (key) { handleEntrySelection(key, event); } - if (!modifierClick && primaryClick) { - openDetailPanel({ documentIds: [documentId] }); - } }, onSelectFolder: (folderId, event, { modifierClick, primaryClick, rowKey }) => { const key = rowKey || resolveFolderRowKey(folderId); @@ -4248,6 +4222,16 @@ const AppLayout = () => { }, }); + const inspectDocument = useCallback( + (documentId) => { + if (!documentId) { + return; + } + openDetailPanel({ documentIds: [documentId] }); + }, + [openDetailPanel], + ); + const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => { const chain = []; const seen = new Set(); @@ -4580,7 +4564,8 @@ const AppLayout = () => { onViewModeChange: handleDocumentsViewModeChange, onClearSelection: clearDocumentSelection, onDeleteSelection: handleDeleteSelection, - onEntryPointer: handleEntryPointer, + onEntryPointer: handleEntryPointerCore, + onInspectDocument: inspectDocument, onEntrySelection: handleEntrySelection, tags, correspondents, @@ -4611,7 +4596,8 @@ const AppLayout = () => { handleFolderDragEnd, handleFolderDragStart, handleFolderRename, - handleEntryPointer, + handleEntryPointerCore, + inspectDocument, handleEntrySelection, handleDeleteSelection, isFilterActive, @@ -4777,20 +4763,6 @@ const AppLayout = () => { ], ); - const handleDeskInspectDocument = useCallback( - (docId) => { - if (!docId) { - return; - } - const rowKey = resolveDocumentRowKey(docId); - if (rowKey) { - applySelection([rowKey], { anchor: rowKey, interactedKeys: [rowKey] }); - } - openDetailPanel({ documentIds: [docId] }); - }, - [applySelection, openDetailPanel], - ); - const handleDeskDocumentStackSelect = useCallback( (docIds) => { if (!Array.isArray(docIds) || docIds.length === 0) { @@ -4822,6 +4794,28 @@ const AppLayout = () => { [applySelection, selectedEntries, selectionAnchorRef], ); + const handleDeskDocumentOpen = useCallback( + (docId, { useSelection = false } = {}) => { + const selectionDocIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : []; + let targetIds = []; + + if ((useSelection || selectionDocIds.includes(docId)) && selectionDocIds.length) { + targetIds = selectionDocIds; + } else if (selectionDocIds.length) { + targetIds = selectionDocIds; + } else if (docId) { + targetIds = [docId]; + } + + if (!targetIds.length) { + return; + } + + openDetailPanel({ documentIds: targetIds }); + }, + [openDetailPanel, selectedDocumentIds], + ); + const handleDeskHelpOpen = useCallback(() => { setDeskHelpOpen(true); }, []); @@ -4857,9 +4851,9 @@ const AppLayout = () => { onViewModeChange: handleDocumentsViewModeChange, onExit: handleDeskExit, onRefresh: refreshCurrentFolder, - onDocumentOpen: openDocumentPreview, - onInspectDocument: handleDeskInspectDocument, - onEntryPointer: handleEntryPointer, + onDocumentOpen: handleDeskDocumentOpen, + onInspectDocument: null, + onEntryPointer: handleEntryPointerCore, onDocumentStackSelect: handleDeskDocumentStackSelect, onPromoteSelection: promoteSelectionOrder, onOpenHelp: handleDeskHelpOpen, @@ -4898,10 +4892,8 @@ const AppLayout = () => { handleDocumentsViewModeChange, handleDeskExit, refreshCurrentFolder, - openDocumentPreview, - handleDeskInspectDocument, handleDeskDocumentStackSelect, - handleEntryPointer, + handleEntryPointerCore, promoteSelectionOrder, handleDeskHelpOpen, handleDeskHelpClose, @@ -4913,6 +4905,7 @@ const AppLayout = () => { clearDocumentSelection, detailPanelOpen, handleDetailPanelClose, + handleDeskDocumentOpen, resolveThumbnailUrlForDoc, handleDocumentTagAttach, handleTagRemove, diff --git a/frontend/src/app/useEntryPointerHandler.js b/frontend/src/app/useEntryPointerHandler.js new file mode 100644 index 0000000..3fa1c29 --- /dev/null +++ b/frontend/src/app/useEntryPointerHandler.js @@ -0,0 +1,48 @@ +import { useCallback } from 'react'; +import { useEntryPointerHandler as useEntryPointerCore, isPointerModifierEvent, isPrimaryPointerEvent } from '../documents/useEntryPointer'; + +export const useEntryPointer = ({ + resolveDocumentRowKey, + resolveFolderRowKey, + onSelectDocument, + onInspectDocument, + onSelectFolder, +}) => { + const coreHandler = useEntryPointerCore({ + resolveDocumentRowKey, + resolveFolderRowKey, + onSelectDocument: (documentId, event, meta) => { + const { modifierClick, primaryClick, rowKey } = meta; + onSelectDocument(documentId, event, { modifierClick, primaryClick, rowKey }); + if (!modifierClick && primaryClick && typeof onInspectDocument === 'function') { + onInspectDocument(documentId, meta); + } + }, + onSelectFolder, + }); + + return useCallback((entry, event) => { + if (!entry) { + return; + } + if (entry.type !== 'document') { + coreHandler(entry, event); + return; + } + + const modifierClick = isPointerModifierEvent(event); + const primaryClick = isPrimaryPointerEvent(event); + + onSelectDocument(entry.id, event, { + modifierClick, + primaryClick, + rowKey: entry.key, + }); + + if (!modifierClick && primaryClick) { + onInspectDocument?.(entry.id, { modifierClick, primaryClick, rowKey: entry.key }); + } + }, [coreHandler, onInspectDocument, onSelectDocument]); +}; + +export default useEntryPointer; diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.js index f8b53ed..4cd3722 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.js @@ -24,7 +24,6 @@ const useDocumentDrag = () => { recalcVisibleDocIds, settings, containerRef, - onDocumentOpen, onInspectDocument, onDocumentStackSelect, selectedDocumentIds, @@ -76,9 +75,7 @@ const useDocumentDrag = () => { } if (typeof onInspectDocument === 'function') { onInspectDocument(data.docId); - return; } - onDocumentOpen?.(data.docId); }, onDouble: ({ data, event }) => { if (!data || !data.docId) { @@ -146,17 +143,26 @@ const useDocumentDrag = () => { return; } - const stackDocIdsOption = Array.isArray(options?.stackDocIds) - ? options.stackDocIds + const stackDocIdsOptionRaw = options?.stackDocIds; + const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw) + ? stackDocIdsOptionRaw .map((value) => (value != null ? String(value) : null)) .filter(Boolean) : null; const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied); + const wasSelectedAtPointerDown = Boolean(options?.wasSelected); + const pointerModifierActive = typeof options?.modifierActive === 'boolean' + ? options.modifierActive + : Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); let selectionIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.map((id) => String(id)) : []; + if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) { + selectionIds = []; + } + if (stackDocIdsOption && stackDocIdsOption.length) { const selectionSet = new Set(selectionIds); stackDocIdsOption.forEach((value) => { @@ -204,7 +210,7 @@ const useDocumentDrag = () => { const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX; const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY; - const modifierPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; + const modifierPressed = pointerModifierActive; if (!modifierPressed) { if (isGroupDrag) { const layout = layoutRef.current; diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.jsx index df85732..2d05b19 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.jsx @@ -50,6 +50,7 @@ const DocumentsPanel = ({ onDocumentRename, onEntryPointer = null, onEntrySelection = null, + onInspectDocument = null, tagLookupById, activeCorrespondentIds = [], onFocusedRowChange, @@ -300,6 +301,7 @@ const DocumentsPanel = ({ const entry = getEntryByKey(activeRow.key); if (entry?.document) { handleDocumentActivate(entry.document, event); + onInspectDocument?.(entry.document.id, event); } } } @@ -341,6 +343,7 @@ const DocumentsPanel = ({ onEntrySelection, onFocusedRowChange, onFolderSelect, + onInspectDocument, selectedEntries, ], ); @@ -479,8 +482,16 @@ const DocumentsPanel = ({ event, ); } + + if ( + typeof onInspectDocument === 'function' + && !isPointerModifierEvent(event) + && isPrimaryPointerEvent(event) + ) { + onInspectDocument(doc.id, event); + } }, - [onEntryPointer], + [onEntryPointer, onInspectDocument], ); const handleFolderClick = useCallback(