feat: Implement tag drag-and-drop functionality

This commit is contained in:
2025-12-08 23:49:50 +01:00
parent 757913cf00
commit c38d88c209
9 changed files with 54 additions and 77 deletions
@@ -2,9 +2,11 @@ import React from 'react';
import type { DocumentViewLogic } from '../logic/useDocumentViewLogic';
import { useDocumentItemLogic } from '../logic/useDocumentItemLogic';
import EntryShell from './EntryShell';
import type { TagDragHandlers } from '../interactions/useTagInteractions';
interface DocumentEntryProps {
doc: any;
tagDragHandlers?: TagDragHandlers;
viewLogic: DocumentViewLogic;
component: React.ElementType;
className?: string;
@@ -13,8 +15,8 @@ interface DocumentEntryProps {
}
const DocumentEntry: React.FC<DocumentEntryProps> = (props) => {
const { doc, component, className, role, children, viewLogic } = props;
const logic = useDocumentItemLogic({ doc, viewLogic });
const { doc, tagDragHandlers, component, className, role, children, viewLogic } = props;
const logic = useDocumentItemLogic({ doc, tagDragHandlers, viewLogic });
return (
<EntryShell
@@ -1,24 +1,23 @@
import React, { useMemo } from 'react';
import { getTagColorStyle } from '../../utils/colors';
import type { DocumentTag } from '../../types/documents';
import type { Document, DocumentTag } from '../../types/documents';
import type { Identifier } from '../../types/identifiers';
import type { TagDragHandlers } from '../interactions/useTagInteractions';
interface DocumentTagsProps {
tags: DocumentTag[];
tagLookupById?: Map<Identifier, DocumentTag> | null;
onTagClick?: (tagId: Identifier) => void;
docId: Identifier;
onTagDragStart?: (event: React.DragEvent<HTMLElement>, tag: DocumentTag) => void;
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
doc: Document;
tagDragHandlers?: TagDragHandlers;
}
const DocumentTags: React.FC<DocumentTagsProps> = ({
tags,
tagLookupById,
onTagClick,
docId,
onTagDragStart,
onTagDragEnd,
doc,
tagDragHandlers,
}) => {
const sortedTags = useMemo(() => {
return [...tags].sort((a, b) => {
@@ -39,7 +38,7 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
const style = getTagColorStyle(colorSource);
const tagId = tag?.id ?? null;
const clickable = tagId != null && typeof onTagClick === 'function';
const key = tagId ?? `${docId}-tag-${index}`;
const key = tagId ?? `${doc.id}-tag-${index}`;
return (
<span
@@ -53,17 +52,9 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
if (tagId == null) return;
onTagClick?.(tagId);
} : undefined}
draggable
onDragStart={(event) => {
// Let the hook handle the data transfer and UI
if (tagId && onTagDragStart) {
onTagDragStart(event, tag);
}
}}
onDragEnd={(event) => {
// Let the hook handle the cleanup and logic
onTagDragEnd?.(event);
}}
draggable={!!tagId}
onDragStart={(event) => tagId && tagDragHandlers?.onTagDragStart(event, doc, tag)}
onDragEnd={tagDragHandlers?.onTagDragEnd}
onKeyDown={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
@@ -12,20 +12,22 @@ import EntryCorrespondents from './EntryCorrespondents';
import EntryTags from './EntryTags';
import FolderEntry from './FolderEntry';
import DocumentEntry from './DocumentEntry';
import { TagDragHandlers } from '../interactions/useTagInteractions';
interface DocumentsGridCardProps {
entry: DocumentsListEntry;
viewLogic: DocumentViewLogic;
iconSize?: number;
tagDragHandlers?: TagDragHandlers;
}
const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (props) => {
const { entry, iconSize } = props;
const { entry, iconSize, tagDragHandlers } = props;
const { ensureAssetUrl, getDocumentAsset } = useDocumentsAssetContext();
const { scrollRef, activeCorrespondentIdSet, tagLookupById } = useDocumentsViewStateContext();
const {
correspondents: { onClick: onCorrespondentClick },
tags: { onClick: onTagClick, onDetach: onDocumentTagDetach }
tags: { onClick: onTagClick }
} = useDocumentsCommandContext();
if (entry.type === 'folder') {
@@ -78,6 +80,7 @@ const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (props) => {
return (
<DocumentEntry
doc={doc}
tagDragHandlers={tagDragHandlers}
viewLogic={props.viewLogic}
component="div"
className="document-card document"
@@ -123,10 +126,8 @@ const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (props) => {
tags={doc.tags || []}
tagLookupById={tagLookupById}
onTagClick={onTagClick}
docId={doc.id}
onDocumentTagDetach={onDocumentTagDetach}
onTagDragStart={logic.handlers.onTagDragStart}
onTagDragEnd={logic.handlers.onTagDragEnd}
doc={doc}
tagDragHandlers={logic.handlers.tagDragHandlers}
/>
</div>
</div>
@@ -14,19 +14,22 @@ import EntryTags from './EntryTags';
import FolderEntry from './FolderEntry';
import DocumentEntry from './DocumentEntry';
import { TagDragHandlers } from '../interactions/useTagInteractions';
interface DocumentsListRowProps {
entry: DocumentsListEntry;
viewLogic: DocumentViewLogic;
iconSize?: number;
tagDragHandlers?: TagDragHandlers;
}
const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => {
const { entry, iconSize } = props;
const { entry, iconSize, tagDragHandlers } = props;
const { ensureAssetUrl, getDocumentAsset } = useDocumentsAssetContext();
const { scrollRef, activeCorrespondentIdSet, tagLookupById } = useDocumentsViewStateContext();
const {
correspondents: { onClick: onCorrespondentClick },
tags: { onClick: onTagClick, onDetach: onDocumentTagDetach }
tags: { onClick: onTagClick }
} = useDocumentsCommandContext();
if (entry.type === 'folder') {
@@ -88,6 +91,7 @@ const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => {
return (
<DocumentEntry
doc={doc}
tagDragHandlers={tagDragHandlers}
viewLogic={props.viewLogic}
component="tr"
className="document"
@@ -137,10 +141,8 @@ const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => {
tags={doc.tags || []}
tagLookupById={tagLookupById}
onTagClick={onTagClick}
docId={doc.id}
onDocumentTagDetach={onDocumentTagDetach}
onTagDragStart={logic.handlers.onTagDragStart}
onTagDragEnd={logic.handlers.onTagDragEnd}
doc={doc}
tagDragHandlers={logic.handlers.tagDragHandlers}
/>
</div>
</div>
@@ -1,16 +1,15 @@
import React from 'react';
import DocumentTags from './DocumentTags';
import type { Document, DocumentTag } from '../../types/documents';
import type { TagDragHandlers } from '../interactions/useTagInteractions';
import type { Identifier } from '../../types/identifiers';
import type { DocumentTag } from '../../types/documents';
interface EntryTagsProps {
tags: DocumentTag[];
tagLookupById?: Map<Identifier, DocumentTag> | null;
onTagClick?: (tagId: Identifier) => void;
docId: Identifier;
onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
onTagDragStart?: (event: React.DragEvent<HTMLElement>, tag: DocumentTag) => void;
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
doc: Document;
tagDragHandlers?: TagDragHandlers;
}
const EntryTags: React.FC<EntryTagsProps> = (props) => {
@@ -23,10 +22,8 @@ const EntryTags: React.FC<EntryTagsProps> = (props) => {
tags={props.tags}
tagLookupById={props.tagLookupById}
onTagClick={props.onTagClick}
docId={props.docId}
onDocumentTagDetach={props.onDocumentTagDetach}
onTagDragStart={props.onTagDragStart}
onTagDragEnd={props.onTagDragEnd}
doc={props.doc}
tagDragHandlers={props.tagDragHandlers}
/>
);
};
@@ -23,13 +23,6 @@ interface DocumentsCommandContextValue {
};
};
tags: {
onDrag: {
start?: (event: DragEvent<HTMLElement>, doc: Document, tag: any) => void;
end?: (event: DragEvent<HTMLElement>) => void;
over?: (event: DragEvent<HTMLElement>, doc: Document) => void;
drop?: (event: DragEvent<HTMLElement>, doc: Document) => void;
leave?: (event: DragEvent<HTMLElement>, doc: Document) => void;
};
onAttach?: (documentId: Identifier, tagId: Identifier) => void;
onDetach?: (documentId: Identifier, tagId: Identifier) => void;
onClick?: (tagId: Identifier) => void;
@@ -44,7 +37,7 @@ interface DocumentsCommandContextValue {
export const DocumentsCommandContext = createContext<DocumentsCommandContextValue>({
folder: { onDrag: {} },
document: { onDrag: {} },
tags: { onDrag: {} },
tags: {},
correspondents: {},
});
@@ -1,18 +1,20 @@
import React, { type DragEvent } from 'react';
import { createDocumentEntryKey } from '../../app/entryKey';
import { useDocumentOpen } from '../../lib/context/DocumentOpenContext';
import type { Document, DocumentTag } from '../../types/documents';
import type { Document } from '../../types/documents';
import { useDocumentsCommandContext } from '../context/DocumentsCommandContext';
import { useDocumentsViewStateContext } from '../context/DocumentsViewStateContext';
import type { DocumentViewLogic } from './useDocumentViewLogic';
interface UseDocumentItemLogicProps {
import { TagDragHandlers } from '../interactions/useTagInteractions';
interface UseDocumentItemLogicArgs {
doc: Document;
viewLogic: DocumentViewLogic;
tagDragHandlers?: TagDragHandlers;
viewLogic: DocumentViewLogic; // Retained from original props
}
export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
const { doc, viewLogic } = props;
export const useDocumentItemLogic = ({ doc, tagDragHandlers, viewLogic }: UseDocumentItemLogicArgs) => {
const {
draggingDocumentIdsSet
} = useDocumentsViewStateContext();
@@ -22,15 +24,6 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
onDrag: { start: onDocumentDragStart, end: onDocumentDragEnd },
onRename: onDocumentRename
},
tags: {
onDrag: {
start: onDocumentTagDragStart,
end: onDocumentTagDragEnd,
over: onDocumentTagDragOver,
leave: onDocumentTagDragLeave,
drop: onDocumentTagDrop,
},
},
onEntryPointer
} = useDocumentsCommandContext();
@@ -77,11 +70,10 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
},
onDragStart: (event: DragEvent<HTMLElement>) => onDocumentDragStart?.(event, doc),
onDragEnd: (event: DragEvent<HTMLElement>) => onDocumentDragEnd?.(event),
onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event, doc),
onDragLeave: (event: DragEvent<HTMLElement>) => onDocumentTagDragLeave?.(event, doc),
onTagDragStart: (event: DragEvent<HTMLElement>, tag: DocumentTag) => onDocumentTagDragStart?.(event, doc, tag),
onTagDragEnd: (event: DragEvent<HTMLElement>) => onDocumentTagDragEnd?.(event),
onDrop: (event: DragEvent<HTMLElement>) => { onDocumentTagDrop?.(event, doc); },
onDragOver: (event: DragEvent<HTMLElement>) => tagDragHandlers?.onTagDragOver(event, doc),
onDragLeave: (event: DragEvent<HTMLElement>) => tagDragHandlers?.onTagDragLeave(event, doc.id),
onDrop: (event: DragEvent<HTMLElement>) => { tagDragHandlers?.onTagDrop(event, doc); },
tagDragHandlers,
onRenameChange: setDocumentDraft,
onRenameSubmit: () => submitDocumentEditing(doc),
onRenameCancel: (event?: React.SyntheticEvent) => cancelDocumentEditing(event),
@@ -48,8 +48,11 @@ interface DocumentsPanelProps extends DocumentsPanelInnerProps {
selectionValue: WorkspaceSelectionValue;
}
import type { TagDragHandlers } from '../interactions/useTagInteractions';
export interface DocumentsViewProps {
entries: DocumentsListEntry[];
tagDragHandlers?: TagDragHandlers;
}
const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = (props) => {
@@ -85,6 +88,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = (props) => {
assetContextValue,
viewStateContextValue,
commandContextValue,
tagDragHandlers,
scrollRef,
hasDocumentEntries,
} = useDocumentsContextValues(props);
@@ -242,6 +246,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = (props) => {
const viewProps = {
entries,
viewId: currentFolderId,
tagDragHandlers,
};
const [iconSizes] = useState({
@@ -184,13 +184,6 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
},
},
tags: {
onDrag: {
start: (e: any, d: any, t: any) => latestHandlersRef.current.tagDragHandlers.onTagDragStart(e, d, t),
end: (e: any) => latestHandlersRef.current.tagDragHandlers.onTagDragEnd(e),
over: (e: any, d: any) => latestHandlersRef.current.tagDragHandlers.onTagDragOver(e, d),
leave: (e: any, d: any) => latestHandlersRef.current.tagDragHandlers.onTagDragLeave(e, d.id),
drop: (e: any, d: any) => latestHandlersRef.current.tagDragHandlers.onTagDrop(e, d),
},
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),
@@ -199,12 +192,13 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
onClick: (c: any) => latestHandlersRef.current.toggleCorrespondentFilter(c),
},
onEntryPointer: (entry: any, e: any) => latestPropsRef.current.onEntryPointer?.(entry, e),
}), []); // Stable forever!
}), []);
return {
assetContextValue,
viewStateContextValue,
commandContextValue,
tagDragHandlers,
scrollRef,
hasDocumentEntries,
isSearchLoading,