refactor(documents): encapsulate panel logic and state into dedicated React contexts

This commit is contained in:
2025-12-08 12:31:30 +01:00
parent 44cf4603fe
commit 73468bbb02
13 changed files with 524 additions and 338 deletions
@@ -0,0 +1,260 @@
import { useMemo, useCallback, useRef } from 'react';
import type { DocumentsPanelInnerProps } from './DocumentsPanel';
import { isTagTransferEvent } from '../features/tagging/tagTransfer';
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../features/selection/useEntryPointer';
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
import type { Identifier } from '../../types/identifiers';
const EntryType = {
folder: 'folder',
document: 'document',
};
export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
const {
ensureAssetUrl,
getDocumentAsset,
isSearchLoading,
searchQuery = '',
activeTagFilters = [],
activeCorrespondentFilters = [],
selectedFolder = null,
onDocumentDragStart,
onDocumentDragEnd,
onEntryPointer,
activeCorrespondentIds = [],
draggingDocumentIds = [],
tagLookupById,
} = props;
const {
setFocusedEntryKey,
} = useWorkspaceSelectionContext();
const {
toggleTag: toggleTagFilter,
toggleCorrespondent: toggleCorrespondentFilter,
} = useDocumentsFilter();
// Derived State
const draggingDocumentIdsSet = useMemo(
() => new Set(draggingDocumentIds || []),
[draggingDocumentIds],
);
const activeCorrespondentIdSet = useMemo(
() => new Set(activeCorrespondentIds || []),
[activeCorrespondentIds],
);
const showingSearchResults = Array.isArray(props.searchResultIds);
const hasDocumentEntries = (props.documents || []).length > 0 || (showingSearchResults && (props.searchResultIds || []).length > 0);
const viewId = useMemo(() => {
if (showingSearchResults) {
const trimmedQuery = searchQuery.trim();
const tagsKey = [...activeTagFilters].sort().join(',');
const correspondentsKey = [...activeCorrespondentFilters].sort().join(',');
return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`;
}
const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root';
return `folder:${folderKey}`;
}, [
showingSearchResults,
searchQuery,
activeTagFilters,
activeCorrespondentFilters,
selectedFolder,
]);
// Refs
const scrollRef = useRef<HTMLElement | null>(null);
const suppressDocumentClickRef = useRef(false);
const draggingTagRef = useRef<{ docId: Identifier; tagId: Identifier } | null>(null);
// Handlers
const isTagDragEvent = useCallback((event: any) => isTagTransferEvent(event), []);
const handleDocumentTagDragStart = useCallback(
(_event: any, docId: Identifier, tagId: Identifier) => {
draggingTagRef.current = { docId, tagId };
},
[],
);
const handleDocumentTagDragEnd = useCallback(
(_event: any) => {
draggingTagRef.current = null;
},
[],
);
const handleDocumentTagDragOver = useCallback(
(event: any, docId: Identifier) => {
if (!isTagDragEvent(event)) {
return;
}
event.preventDefault();
const isSource = draggingTagRef.current?.docId === docId;
event.dataTransfer.dropEffect = isSource ? 'copy' : 'move';
event.currentTarget.classList.add('tag-drop-target');
},
[isTagDragEvent],
);
const handleDocumentTagDragLeave = useCallback(
(event: any) => {
if (!isTagDragEvent(event)) {
return;
}
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
return;
}
event.currentTarget.classList.remove('tag-drop-target');
},
[isTagDragEvent],
);
const handleFolderClick = useCallback(
(folder: any, event: any) => {
if (!folder) {
return;
}
if (onEntryPointer) {
onEntryPointer(
{ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder },
event,
);
}
if (
!isPointerModifierEvent(event)
&& isPrimaryPointerEvent(event)
&& scrollRef.current
) {
scrollRef.current.focus({ preventScroll: true });
setFocusedEntryKey(`folder:${folder.id}`);
}
},
[onEntryPointer, setFocusedEntryKey],
);
const handleDocumentDragStartLocal = useCallback(
(event: any, doc: any) => {
suppressDocumentClickRef.current = true;
onDocumentDragStart?.(event, doc);
},
[onDocumentDragStart],
);
const handleDocumentDragEndLocal = useCallback(
(event: any) => {
onDocumentDragEnd?.(event);
requestAnimationFrame(() => {
suppressDocumentClickRef.current = false;
});
},
[onDocumentDragEnd],
);
// Context Values Construction
const assetContextValue = useMemo(() => ({
ensureAssetUrl,
getDocumentAsset,
}), [ensureAssetUrl, getDocumentAsset]);
const viewStateContextValue = useMemo(() => ({
viewId,
scrollRef,
tagLookupById,
activeCorrespondentIdSet,
draggingDocumentIdsSet,
draggedFolderId: props.draggedFolderId,
}), [
viewId,
scrollRef,
tagLookupById,
activeCorrespondentIdSet,
draggingDocumentIdsSet,
props.draggedFolderId,
]);
// Use refs to stabilize handlers and avoid massive dependency arrays
const latestPropsRef = useRef(props);
const latestHandlersRef = useRef({
handleFolderClick,
handleDocumentDragStartLocal,
handleDocumentDragEndLocal,
handleDocumentTagDragStart,
handleDocumentTagDragEnd,
handleDocumentTagDragOver,
handleDocumentTagDragLeave,
toggleTagFilter,
toggleCorrespondentFilter,
});
// Update refs on every render
latestPropsRef.current = props;
latestHandlersRef.current = {
handleFolderClick,
handleDocumentDragStartLocal,
handleDocumentDragEndLocal,
handleDocumentTagDragStart,
handleDocumentTagDragEnd,
handleDocumentTagDragOver,
handleDocumentTagDragLeave,
toggleTagFilter,
toggleCorrespondentFilter,
};
const commandContextValue = useMemo(() => ({
folder: {
onClick: (f: any, e: any) => latestHandlersRef.current.handleFolderClick(f, e),
onSelect: (id: Identifier) => latestPropsRef.current.onFolderSelect?.(id),
onRename: (id: Identifier, name: string) => latestPropsRef.current.onFolderRename?.(id, name),
onDrag: {
start: (e: any, f: any) => latestPropsRef.current.onFolderDragStart?.(e, f),
end: (e: any) => latestPropsRef.current.onFolderDragEnd?.(e),
over: (e: any, f: any) => latestPropsRef.current.onFolderDragOver?.(e, f),
leave: (e: any) => latestPropsRef.current.onFolderDragLeave?.(e),
drop: (e: any, f: any) => latestPropsRef.current.onFolderDrop?.(e, f),
},
},
document: {
onRename: (id: Identifier, name: string) => latestPropsRef.current.onDocumentRename?.(id, name),
onDrag: {
start: (e: any, d: any) => latestHandlersRef.current.handleDocumentDragStartLocal(e, d),
end: (e: any) => latestHandlersRef.current.handleDocumentDragEndLocal(e),
},
},
tags: {
onDrag: {
start: (e: any, d: any, t: any) => latestHandlersRef.current.handleDocumentTagDragStart(e, d, t),
end: (e: any) => latestHandlersRef.current.handleDocumentTagDragEnd(e),
over: (e: any, d: any) => latestHandlersRef.current.handleDocumentTagDragOver(e, d),
leave: (e: any) => latestHandlersRef.current.handleDocumentTagDragLeave(e),
},
onAttach: (d: any, t: any) => latestPropsRef.current.onDocumentTagAttach?.(d, t),
onDetach: (d: any, t: any) => latestPropsRef.current.onDocumentTagDetach?.(d, t),
onClick: (t: any) => latestHandlersRef.current.toggleTagFilter(t),
},
correspondents: {
onClick: (c: any) => latestHandlersRef.current.toggleCorrespondentFilter(c),
},
onEntryPointer: (entry: any, e: any) => latestPropsRef.current.onEntryPointer?.(entry, e),
}), []); // Stable forever!
return {
assetContextValue,
viewStateContextValue,
commandContextValue,
scrollRef,
hasDocumentEntries,
isSearchLoading,
showingSearchResults,
activeTagFilters,
activeCorrespondentFilters,
};
};