From f6ceff54449218d5f197909535273830435a6eef Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 1 Nov 2025 20:28:00 +0100 Subject: [PATCH] selection --- frontend/src/app/AppLayout.jsx | 412 +++++++---------------- frontend/src/app/useDetailPanel.js | 128 +++++++ frontend/src/app/useDocumentSelection.js | 250 ++++++++++++++ 3 files changed, 493 insertions(+), 297 deletions(-) create mode 100644 frontend/src/app/useDetailPanel.js create mode 100644 frontend/src/app/useDocumentSelection.js diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 98fc74c..5534e74 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -19,6 +19,8 @@ import { AppShellContext } from '../appShellContext'; import DropOverlay from './DropOverlay'; import { useManagementModals } from './useManagementModals'; import { api, useAppDispatch, useAppState } from './appState'; +import { useDetailPanel } from './useDetailPanel'; +import { useDocumentSelection } from './useDocumentSelection'; const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; @@ -204,15 +206,12 @@ const AppLayout = () => { } }, [documentsViewMode]); const initialRowSelection = []; - const [selectedRowKeys, setSelectedRowKeys] = useState(initialRowSelection); - const [selectionOrder, setSelectionOrder] = useState(initialRowSelection); - const [focusedDocumentId, setFocusedDocumentId] = useState(null); - const [focusedRowKey, setFocusedRowKey] = useState(null); const tokenRef = useRef(token); const refreshPromiseRef = useRef(null); const breadcrumbFetchRef = useRef(new Set()); const tagRemovalCursorActiveRef = useRef(false); const tenantIdRef = useRef(currentTenantId); + const detailPanelControlRef = useRef({ open: () => {}, close: () => {} }); const setTagRemovalCursor = useCallback((active) => { if (typeof document === 'undefined') { return; @@ -352,17 +351,40 @@ const AppLayout = () => { } const tagManager = tagManagerRef.current; + const { + selectedRowKeys, + setSelectedRowKeys, + selectionOrder, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + selectionInitializedRef, + focusedDocumentId, + setFocusedDocumentId, + focusedRowKey, + setFocusedRowKey, + applySelection, + clearSelection: clearSelectionInternal, + handleRowSelection: handleRowSelectionInternal, + promoteSelectionOrder: promoteSelectionOrderInternal, + configureSelectionEnvironment, + } = useDocumentSelection({ + resolveDocumentRowKey, + resolveFolderRowKey, + isDocumentRowKey, + isFolderRowKey, + getRowId, + initialSelection: initialRowSelection, + }); + const getDocumentAsset = useCallback((doc, type) => { if (!doc || !type) return null; return getAssetFromVersion(doc.current_version || null, type); }, []); const bootstrapInitializedRef = useRef(false); - const selectionInitializedRef = useRef(false); const dragCounterRef = useRef(0); const detailFolderFetchRef = useRef(new Set()); - const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null); - const selectionOrderRef = useRef(initialRowSelection); const selectedDocumentIds = useMemo( () => @@ -384,9 +406,6 @@ const AppLayout = () => { [selectedRowKeys], ); - const [detailPanelOpen, setDetailPanelOpen] = useState(false); - const [detailPanelDocIds, setDetailPanelDocIds] = useState([]); - const [detailPanelDocs, setDetailPanelDocs] = useState([]); const resetWorkspaceState = useCallback(() => { const rootNode = createRootNode(); @@ -416,9 +435,7 @@ const AppLayout = () => { setActiveCorrespondentFilters([]); setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); setActivePreviewId(null); - setDetailPanelOpen(false); - setDetailPanelDocIds([]); - setDetailPanelDocs([]); + detailPanelControlRef.current.close(); assetManager.reset(); setPreviewEntries(() => new Map()); previewInflightRef.current = new Map(); @@ -428,7 +445,15 @@ const AppLayout = () => { bootstrapInitializedRef.current = false; selectionInitializedRef.current = false; tenantIdRef.current = null; - }, [assetManager]); + }, [ + assetManager, + selectionAnchorRef, + selectionInitializedRef, + selectionOrderRef, + setFocusedDocumentId, + setSelectedRowKeys, + setSelectionOrder, + ]); const tagLookupById = useMemo(() => { const map = new Map(); @@ -449,37 +474,6 @@ const AppLayout = () => { }); return map; }, [correspondents]); - const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => { - const nextSet = new Set(nextSelection); - const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id)); - const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index); - - const base = previousOrder.filter((id) => !interacted.includes(id)); - const result = [...base]; - - interacted.forEach((id) => { - if (nextSet.has(id) && !result.includes(id)) { - result.push(id); - } - }); - - nextSelection.forEach((id) => { - if (!result.includes(id)) { - result.push(id); - } - }); - - if ( - result.length !== selectionOrderRef.current.length || - result.some((id, index) => selectionOrderRef.current[index] !== id) - ) { - selectionOrderRef.current = result; - setSelectionOrder(result); - } else { - selectionOrderRef.current = result; - } - }, []); - useEffect(() => { if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') { resetWorkspaceState(); @@ -573,7 +567,7 @@ const AppLayout = () => { setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]); } selectionInitializedRef.current = true; - }, [selectedDocumentIds, activePreviewId]); + }, [selectedDocumentIds, activePreviewId, selectionInitializedRef]); const currentFolderName = useMemo(() => { if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME; @@ -648,7 +642,16 @@ const AppLayout = () => { return nextFocus; }, - [assetManager, focusedDocumentId, setSelectionOrder], + [ + assetManager, + focusedDocumentId, + selectionAnchorRef, + selectionInitializedRef, + selectionOrderRef, + setFocusedDocumentId, + setSelectedRowKeys, + setSelectionOrder, + ], ); const showingSearchResults = searchResults !== null; @@ -821,82 +824,6 @@ const AppLayout = () => { [setDocuments, setSearchResults, setFolderContents], ); - const applySelection = useCallback( - (rowKeys, { anchor, interactedKeys = [] } = {}) => { - const unique = []; - - (rowKeys || []).forEach((key) => { - if (!key) return; - const canonicalKey = visibleRowKeySet.has(key) - ? key - : isDocumentRowKey(key) - ? resolveDocumentRowKey(getRowId(key)) - : isFolderRowKey(key) - ? resolveFolderRowKey(getRowId(key)) - : null; - - if (!canonicalKey || !visibleRowKeySet.has(canonicalKey)) { - return; - } - - if (!unique.includes(canonicalKey)) { - unique.push(canonicalKey); - } - }); - - let resolvedAnchor = anchor; - if (resolvedAnchor && !unique.includes(resolvedAnchor)) { - resolvedAnchor = null; - } - - setSelectedRowKeys(unique); - updateSelectionOrder(unique, interactedKeys); - - const nextFocusedDocumentId = (() => { - if (focusedDocumentId) { - const focusKey = resolveDocumentRowKey(focusedDocumentId); - if (focusKey && unique.includes(focusKey)) { - return focusedDocumentId; - } - } - - if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) { - return getRowId(resolvedAnchor) || null; - } - - const lastDocKey = [...unique].reverse().find(isDocumentRowKey); - return lastDocKey ? getRowId(lastDocKey) || null : null; - })(); - - setFocusedDocumentId(nextFocusedDocumentId); - - if (resolvedAnchor) { - selectionAnchorRef.current = resolvedAnchor; - } else if (!unique.length) { - selectionAnchorRef.current = null; - } else if (!selectionAnchorRef.current || !unique.includes(selectionAnchorRef.current)) { - selectionAnchorRef.current = unique[unique.length - 1]; - } - - return { selection: unique, focusKey: selectionAnchorRef.current }; - }, - [visibleRowKeySet, focusedDocumentId, updateSelectionOrder], - ); - - const promoteSelectionOrder = useCallback( - (docId) => { - if (!docId) return; - const rowKey = resolveDocumentRowKey(docId); - if (!rowKey) return; - if (!selectedRowKeys.includes(rowKey)) return; - updateSelectionOrder(selectedRowKeys, [rowKey]); - selectionAnchorRef.current = rowKey; - setFocusedDocumentId(docId); - setActivePreviewId(docId); - }, - [selectedRowKeys, updateSelectionOrder], - ); - const folderOptions = useMemo(() => { const cache = new Map(); @@ -967,85 +894,38 @@ const AppLayout = () => { [navigableRows], ); + useEffect(() => { + configureSelectionEnvironment({ + visibleRowKeySet, + navigableRowKeys, + }); + }, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]); + const handleRowSelection = useCallback( (rowKey, event) => { - if (!rowKey || !visibleRowKeySet.has(rowKey)) { - return; - } - - setFocusedRowKey(rowKey); - - const shiftKey = Boolean(event?.shiftKey); - const metaKey = Boolean(event?.metaKey); - const ctrlKey = Boolean(event?.ctrlKey); - const additive = metaKey || ctrlKey; - - if (shiftKey) { - event?.preventDefault?.(); - } - - let anchorKey = selectionAnchorRef.current; - if (!anchorKey && shiftKey && selectedRowKeys.length) { - anchorKey = selectedRowKeys[selectedRowKeys.length - 1]; - } - if (!anchorKey) { - anchorKey = rowKey; - } - - let nextKeys = []; - let interactedKeys = []; - - if (shiftKey && anchorKey) { - const anchorIndex = navigableRowKeys.indexOf(anchorKey); - const targetIndex = navigableRowKeys.indexOf(rowKey); - if (anchorIndex !== -1 && targetIndex !== -1) { - const [start, end] = - anchorIndex <= targetIndex - ? [anchorIndex, targetIndex] - : [targetIndex, anchorIndex]; - const range = navigableRowKeys.slice(start, end + 1); - nextKeys = range; - - const previousSet = new Set(selectedRowKeys); - interactedKeys = range.filter( - (key) => key === rowKey || !previousSet.has(key), - ); - if (!interactedKeys.includes(rowKey)) { - interactedKeys.push(rowKey); - } - } else { - nextKeys = [rowKey]; - interactedKeys = [rowKey]; - } - } else if (additive) { - if (selectedRowKeys.includes(rowKey)) { - nextKeys = selectedRowKeys.filter((key) => key !== rowKey); - interactedKeys = []; - } else { - nextKeys = [...selectedRowKeys, rowKey]; - interactedKeys = [rowKey]; - } - anchorKey = rowKey; - } else { - nextKeys = [rowKey]; - interactedKeys = [rowKey]; - anchorKey = rowKey; - } - - applySelection(nextKeys, { anchor: anchorKey, interactedKeys }); + handleRowSelectionInternal(rowKey, event); }, - [ - applySelection, - navigableRowKeys, - selectedRowKeys, - setFocusedRowKey, - visibleRowKeySet, - ], + [handleRowSelectionInternal], ); - const openDetailPanel = useCallback(() => { - setDetailPanelOpen(true); - }, []); + const promoteSelectionOrder = useCallback( + (docId) => { + if (!docId) return; + promoteSelectionOrderInternal(docId); + const rowKey = resolveDocumentRowKey(docId); + if (rowKey) { + selectionAnchorRef.current = rowKey; + } + setFocusedDocumentId(docId); + setActivePreviewId(docId); + }, + [ + promoteSelectionOrderInternal, + selectionAnchorRef, + setFocusedDocumentId, + setActivePreviewId, + ], + ); const handleDocumentRowClick = useCallback( (documentId, event) => { @@ -1054,16 +934,15 @@ const AppLayout = () => { const hadSelection = selectionCount > 0; handleRowSelection(rowKey, event); if (!hadSelection) { - openDetailPanel(); + detailPanelControlRef.current.open(); } }, - [handleRowSelection, openDetailPanel, selectionCount], + [handleRowSelection, selectionCount], ); const clearDocumentSelection = useCallback(() => { - setFocusedRowKey(null); - applySelection([], { anchor: null, interactedKeys: [] }); - }, [applySelection]); + clearSelectionInternal(); + }, [clearSelectionInternal]); const handleFolderRowClick = useCallback( (folderId, event) => { @@ -1086,7 +965,7 @@ const AppLayout = () => { } else { setFocusedRowKey((current) => (isFolderRowKey(current) ? current : null)); } - }, [focusedDocumentId]); + }, [focusedDocumentId, setFocusedRowKey]); useEffect(() => { if (!focusedRowKey) { @@ -1105,7 +984,7 @@ const AppLayout = () => { } else { setFocusedRowKey(null); } - }, [focusedRowKey, navigableRowKeys, focusedDocumentId]); + }, [focusedRowKey, navigableRowKeys, focusedDocumentId, setFocusedRowKey]); const ensureFolderData = useCallback( @@ -3203,6 +3082,8 @@ const AppLayout = () => { setSelectionOrder, setSelectedRowKeys, setStatusMessage, + selectionAnchorRef, + selectionOrderRef, ], ); @@ -3281,7 +3162,7 @@ const AppLayout = () => { const openDocumentPreview = useCallback( (documentId, { replace = false } = {}) => { if (!documentId) return; - setDetailPanelOpen(false); + detailPanelControlRef.current.close(); previewReturnPathRef.current = `${location.pathname}${location.search}`; navigate(`/documents/${documentId}`, { replace }); }, @@ -3338,6 +3219,7 @@ const AppLayout = () => { selectedRowKeys, navigableRows, applySelection, + setFocusedRowKey, ]); const handleDocumentListKeyDown = useCallback( @@ -3386,7 +3268,7 @@ const AppLayout = () => { selectFolder(row.id); } else if (row.type === 'document') { if (selectionCount === 0) { - openDetailPanel(); + detailPanelControlRef.current.open(); } openDocumentPreview(row.id); } @@ -3426,7 +3308,7 @@ const AppLayout = () => { preventDefault: () => {}, }); if (targetRow.type === 'document' && !hadSelection) { - openDetailPanel(); + detailPanelControlRef.current.open(); } }, [ @@ -3435,10 +3317,10 @@ const AppLayout = () => { focusedRowKey, selectedRowKeys, handleRowSelection, - openDetailPanel, selectFolder, openDocumentPreview, selectionCount, + setFocusedRowKey, ], ); @@ -4036,6 +3918,11 @@ const AppLayout = () => { selectedFolder, notifyApiError, assetManager, + selectionOrderRef, + selectionAnchorRef, + setSelectedRowKeys, + setSelectionOrder, + setFocusedDocumentId, ]); useEffect(() => { @@ -4486,94 +4373,25 @@ const AppLayout = () => { return ordered; }, [selectionOrder, documentLookup, selectedDocumentIds]); - useEffect(() => { - if (!orderedSelectedDocuments.length) { - return; - } + const { + detailPanelOpen, + detailPanelSelectedDocuments, + openDetailPanel, + closeDetailPanel, + } = useDetailPanel({ + selectedDocumentIds, + documentLookup, + orderedSelectedDocuments, + selectionOrder, + documentsViewMode, + getRowId, + isDocumentRowKey, + }); - const nextIds = orderedSelectedDocuments - .map((doc) => (doc?.id ? doc.id : null)) - .filter(Boolean); - - setDetailPanelDocIds((prev) => { - if (nextIds.length === prev.length && nextIds.every((id, index) => id === prev[index])) { - return prev; - } - return nextIds; - }); - }, [orderedSelectedDocuments]); - - useEffect(() => { - if (!detailPanelDocIds.length) { - setDetailPanelDocs([]); - return; - } - - setDetailPanelDocs((prevDocs) => { - const prevMap = new Map((prevDocs || []).map((doc) => [doc.id, doc])); - const next = detailPanelDocIds - .map((id) => documentLookup.get(id) || prevMap.get(id) || null) - .filter(Boolean); - - if (next.length === prevDocs.length && next.every((doc, index) => doc === prevDocs[index])) { - return prevDocs; - } - return next; - }); - }, [detailPanelDocIds, documentLookup]); - - const detailPanelSelectedDocuments = detailPanelDocs; - - const lastScrolledDetailDocRef = useRef(null); - - useEffect(() => { - if (!detailPanelOpen) { - lastScrolledDetailDocRef.current = null; - return; - } - - if (!orderedSelectedDocuments.length) { - lastScrolledDetailDocRef.current = null; - return; - } - - let lastSelectedId = null; - for (let index = selectionOrder.length - 1; index >= 0; index -= 1) { - const key = selectionOrder[index]; - if (isDocumentRowKey(key)) { - lastSelectedId = getRowId(key); - if (lastSelectedId) { - break; - } - } - } - - if (!lastSelectedId && orderedSelectedDocuments.length) { - const fallbackDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1]; - lastSelectedId = fallbackDoc?.id || null; - } - - if (!lastSelectedId || lastScrolledDetailDocRef.current === lastSelectedId) { - return; - } - - if (typeof document === 'undefined') { - return; - } - - const targetElement = - document.getElementById(`document-row-${lastSelectedId}`) || - document.getElementById(`document-card-${lastSelectedId}`); - - if (!targetElement) { - return; - } - - lastScrolledDetailDocRef.current = lastSelectedId; - requestAnimationFrame(() => { - targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - }); - }, [orderedSelectedDocuments, selectionOrder, documentsViewMode, detailPanelOpen]); + detailPanelControlRef.current = { + open: openDetailPanel, + close: closeDetailPanel, + }; const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => { const chain = []; @@ -5035,8 +4853,8 @@ const AppLayout = () => { ); const handleDetailPanelClose = useCallback(() => { - setDetailPanelOpen(false); - }, []); + closeDetailPanel(); + }, [closeDetailPanel]); const detailPanelProps = useMemo( () => ({ diff --git a/frontend/src/app/useDetailPanel.js b/frontend/src/app/useDetailPanel.js new file mode 100644 index 0000000..b63dc1a --- /dev/null +++ b/frontend/src/app/useDetailPanel.js @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export const useDetailPanel = ({ + documentLookup, + orderedSelectedDocuments, + selectionOrder, + documentsViewMode, + getRowId, + isDocumentRowKey, +}) => { + const [detailPanelOpen, setDetailPanelOpen] = useState(false); + const [detailPanelDocIds, setDetailPanelDocIds] = useState([]); + const [detailPanelDocs, setDetailPanelDocs] = useState([]); + const lastScrolledDetailDocRef = useRef(null); + + useEffect(() => { + if (!orderedSelectedDocuments.length) { + setDetailPanelDocIds((prev) => (prev.length ? [] : prev)); + return; + } + + const nextIds = orderedSelectedDocuments + .map((doc) => doc?.id) + .filter((id) => typeof id === 'string' || typeof id === 'number'); + + setDetailPanelDocIds((prev) => { + if (prev.length === nextIds.length && prev.every((id, index) => id === nextIds[index])) { + return prev; + } + return nextIds; + }); + }, [orderedSelectedDocuments]); + + useEffect(() => { + if (!detailPanelDocIds.length) { + setDetailPanelDocs((prev) => (prev.length ? [] : prev)); + return; + } + + setDetailPanelDocs((prevDocs) => { + const prevMap = new Map((prevDocs || []).map((doc) => [doc.id, doc])); + const next = detailPanelDocIds + .map((id) => documentLookup.get(id) || prevMap.get(id) || null) + .filter(Boolean); + + if (next.length === prevDocs.length && next.every((doc, index) => doc === prevDocs[index])) { + return prevDocs; + } + return next; + }); + }, [detailPanelDocIds, documentLookup]); + + useEffect(() => { + if (!detailPanelOpen) { + lastScrolledDetailDocRef.current = null; + return; + } + + if (!orderedSelectedDocuments.length) { + lastScrolledDetailDocRef.current = null; + return; + } + + let lastSelectedId = null; + for (let index = selectionOrder.length - 1; index >= 0; index -= 1) { + const key = selectionOrder[index]; + if (isDocumentRowKey(key)) { + lastSelectedId = getRowId(key); + if (lastSelectedId) { + break; + } + } + } + + if (!lastSelectedId && orderedSelectedDocuments.length) { + const fallbackDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1]; + lastSelectedId = fallbackDoc?.id || null; + } + + if (!lastSelectedId || lastScrolledDetailDocRef.current === lastSelectedId) { + return; + } + + if (typeof document === 'undefined') { + return; + } + + const targetElement = + document.getElementById(`document-row-${lastSelectedId}`) + || document.getElementById(`document-card-${lastSelectedId}`); + + if (!targetElement) { + return; + } + + lastScrolledDetailDocRef.current = lastSelectedId; + requestAnimationFrame(() => { + targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }); + }, [ + detailPanelOpen, + orderedSelectedDocuments, + selectionOrder, + documentsViewMode, + getRowId, + isDocumentRowKey, + ]); + + const detailPanelSelectedDocuments = detailPanelDocs; + + const openDetailPanel = useCallback(() => { + setDetailPanelOpen(true); + }, []); + + const closeDetailPanel = useCallback(() => { + setDetailPanelOpen(false); + }, []); + + return { + detailPanelOpen, + detailPanelSelectedDocuments, + openDetailPanel, + closeDetailPanel, + setDetailPanelOpen, + }; +}; + +export default useDetailPanel; diff --git a/frontend/src/app/useDocumentSelection.js b/frontend/src/app/useDocumentSelection.js new file mode 100644 index 0000000..c07833c --- /dev/null +++ b/frontend/src/app/useDocumentSelection.js @@ -0,0 +1,250 @@ +import { useCallback, useRef, useState } from 'react'; + +const DEFAULT_INITIAL_SELECTION = []; + +export const useDocumentSelection = ({ + resolveDocumentRowKey, + resolveFolderRowKey, + isDocumentRowKey, + isFolderRowKey, + getRowId, + initialSelection = DEFAULT_INITIAL_SELECTION, +}) => { + const [selectedRowKeys, setSelectedRowKeys] = useState(initialSelection); + const [selectionOrder, setSelectionOrder] = useState(initialSelection); + const selectionOrderRef = useRef(initialSelection); + const selectionAnchorRef = useRef(null); + const selectionInitializedRef = useRef(false); + const [focusedDocumentId, setFocusedDocumentId] = useState(null); + const [focusedRowKey, setFocusedRowKey] = useState(null); + + const visibleRowKeySetRef = useRef(new Set()); + const navigableRowKeysRef = useRef([]); + + const configureSelectionEnvironment = useCallback(({ + visibleRowKeySet, + navigableRowKeys, + }) => { + if (visibleRowKeySet) { + visibleRowKeySetRef.current = visibleRowKeySet; + } + if (Array.isArray(navigableRowKeys)) { + navigableRowKeysRef.current = navigableRowKeys; + } + }, []); + + const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => { + const nextSet = new Set(nextSelection); + const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id)); + const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index); + + const base = previousOrder.filter((id) => !interacted.includes(id)); + const result = [...base]; + + interacted.forEach((id) => { + if (nextSet.has(id) && !result.includes(id)) { + result.push(id); + } + }); + + nextSelection.forEach((id) => { + if (!result.includes(id)) { + result.push(id); + } + }); + + if ( + result.length !== selectionOrderRef.current.length + || result.some((id, index) => selectionOrderRef.current[index] !== id) + ) { + selectionOrderRef.current = result; + setSelectionOrder(result); + } else { + selectionOrderRef.current = result; + } + }, []); + + const applySelection = useCallback( + (rowKeys, { anchor, interactedKeys = [] } = {}) => { + const visibleRowKeySet = visibleRowKeySetRef.current; + const unique = []; + + (rowKeys || []).forEach((key) => { + if (!key) return; + let canonicalKey = null; + if (visibleRowKeySet.has(key)) { + canonicalKey = key; + } else if (isDocumentRowKey(key)) { + const id = getRowId(key); + canonicalKey = id ? resolveDocumentRowKey(id) : null; + } else if (isFolderRowKey(key)) { + const id = getRowId(key); + canonicalKey = id ? resolveFolderRowKey(id) : null; + } + + if (!canonicalKey || !visibleRowKeySet.has(canonicalKey)) { + return; + } + + if (!unique.includes(canonicalKey)) { + unique.push(canonicalKey); + } + }); + + let resolvedAnchor = anchor; + if (resolvedAnchor && !unique.includes(resolvedAnchor)) { + resolvedAnchor = null; + } + + setSelectedRowKeys(unique); + updateSelectionOrder(unique, interactedKeys); + + const nextFocusedDocumentId = (() => { + if (focusedDocumentId) { + const focusKey = resolveDocumentRowKey(focusedDocumentId); + if (focusKey && unique.includes(focusKey)) { + return focusedDocumentId; + } + } + + if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) { + return getRowId(resolvedAnchor) || null; + } + + const lastDocKey = [...unique].reverse().find(isDocumentRowKey); + return lastDocKey ? getRowId(lastDocKey) || null : null; + })(); + + setFocusedDocumentId(nextFocusedDocumentId); + + if (resolvedAnchor) { + selectionAnchorRef.current = resolvedAnchor; + } else if (!unique.length) { + selectionAnchorRef.current = null; + } else if (!selectionAnchorRef.current || !unique.includes(selectionAnchorRef.current)) { + selectionAnchorRef.current = unique[unique.length - 1]; + } + + return { selection: unique, focusKey: selectionAnchorRef.current }; + }, + [ + focusedDocumentId, + getRowId, + isDocumentRowKey, + isFolderRowKey, + resolveDocumentRowKey, + resolveFolderRowKey, + updateSelectionOrder, + ], + ); + + const clearSelection = useCallback(() => { + setFocusedRowKey(null); + applySelection([], { anchor: null, interactedKeys: [] }); + }, [applySelection]); + + const handleRowSelection = useCallback( + (rowKey, event) => { + const visibleRowKeySet = visibleRowKeySetRef.current; + const navigableRowKeys = navigableRowKeysRef.current; + if (!rowKey || !visibleRowKeySet.has(rowKey)) { + return; + } + + setFocusedRowKey(rowKey); + + const shiftKey = Boolean(event?.shiftKey); + const metaKey = Boolean(event?.metaKey); + const ctrlKey = Boolean(event?.ctrlKey); + const additive = metaKey || ctrlKey; + + if (shiftKey) { + event?.preventDefault?.(); + } + + let anchorKey = selectionAnchorRef.current; + if (!anchorKey && shiftKey && selectedRowKeys.length) { + anchorKey = selectedRowKeys[selectedRowKeys.length - 1]; + } + if (!anchorKey) { + anchorKey = rowKey; + } + + let nextKeys = []; + let interactedKeys = []; + + if (shiftKey && anchorKey) { + const anchorIndex = navigableRowKeys.indexOf(anchorKey); + const targetIndex = navigableRowKeys.indexOf(rowKey); + if (anchorIndex !== -1 && targetIndex !== -1) { + const [start, end] = anchorIndex <= targetIndex + ? [anchorIndex, targetIndex] + : [targetIndex, anchorIndex]; + const range = navigableRowKeys.slice(start, end + 1); + nextKeys = range; + + const previousSet = new Set(selectedRowKeys); + interactedKeys = range.filter((key) => key === rowKey || !previousSet.has(key)); + if (!interactedKeys.includes(rowKey)) { + interactedKeys.push(rowKey); + } + } else { + nextKeys = [rowKey]; + interactedKeys = [rowKey]; + } + } else if (additive) { + if (selectedRowKeys.includes(rowKey)) { + nextKeys = selectedRowKeys.filter((key) => key !== rowKey); + interactedKeys = []; + } else { + nextKeys = [...selectedRowKeys, rowKey]; + interactedKeys = [rowKey]; + } + anchorKey = rowKey; + } else { + nextKeys = [rowKey]; + interactedKeys = [rowKey]; + anchorKey = rowKey; + } + + applySelection(nextKeys, { anchor: anchorKey, interactedKeys }); + }, + [applySelection, selectedRowKeys], + ); + + const promoteSelectionOrder = useCallback( + (docId) => { + if (!docId) return; + const rowKey = resolveDocumentRowKey(docId); + if (!rowKey) return; + + if (!selectedRowKeys.includes(rowKey)) { + return; + } + + updateSelectionOrder(selectedRowKeys, [rowKey]); + }, + [resolveDocumentRowKey, selectedRowKeys, updateSelectionOrder], + ); + + return { + selectedRowKeys, + setSelectedRowKeys, + selectionOrder, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + selectionInitializedRef, + focusedDocumentId, + setFocusedDocumentId, + focusedRowKey, + setFocusedRowKey, + applySelection, + clearSelection, + handleRowSelection, + promoteSelectionOrder, + configureSelectionEnvironment, + }; +}; + +export default useDocumentSelection;