feat: Implement new drag-and-drop system for documents and tags, including workspace-level handling and improved visual feedback.

This commit is contained in:
2025-12-08 23:30:18 +01:00
parent a850f70e24
commit 757913cf00
15 changed files with 293 additions and 355 deletions
@@ -28,20 +28,20 @@ interface DesktopDocumentCardProps {
selection: string[]; selection: string[];
requestCanvasFocus?: () => void; requestCanvasFocus?: () => void;
onTagDragEnter?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void; onTagDragEnter?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void; onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void; onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
onTagDrop?: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void; onTagDrop?: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
onDocTagDragStart?: (event: React.DragEvent<HTMLElement>, doc: Document, tag: any) => void; onTagDragStart?: (event: React.DragEvent<HTMLElement>, doc: Document, tag: any) => void;
onDocTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void; onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
layoutCard: LayoutCard; layoutCard: LayoutCard;
} }
const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
doc, doc,
style, style,
shouldLoad, shouldLoad = false,
matchesFilter, matchesFilter = true,
selected, selected = false,
docTagTokens, docTagTokens,
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
@@ -54,8 +54,8 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
onTagDragOver, onTagDragOver,
onTagDragLeave, onTagDragLeave,
onTagDrop, onTagDrop,
onDocTagDragStart, onTagDragStart,
onDocTagDragEnd, onTagDragEnd,
layoutCard, layoutCard,
}) => { }) => {
const cardPointerHandlers = useCardPointer( const cardPointerHandlers = useCardPointer(
@@ -89,27 +89,10 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
aria-hidden={ariaHidden} aria-hidden={ariaHidden}
ref={(node) => layoutCard?.setRef(node)} ref={(node) => layoutCard?.setRef(node)}
{...cardPointerHandlers} {...cardPointerHandlers}
onDragEnter={(event) => { onDragEnter={(event) => onTagDragEnter?.(event, doc.id!)}
if (doc?.id == null) { onDragOver={(event) => onTagDragOver?.(event, doc)}
return; onDragLeave={(event) => onTagDragLeave?.(event, doc.id!)}
} onDrop={(event) => onTagDrop?.(event, doc)}
onTagDragEnter?.(event, doc.id);
}}
onDragOver={(event) => {
if (doc?.id == null) {
return;
}
onTagDragOver?.(event, doc.id);
}}
onDragLeave={(event) => {
if (doc?.id == null) {
return;
}
onTagDragLeave?.(event, doc.id);
}}
onDrop={(event) => {
onTagDrop?.(event, doc);
}}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
preventAll(event); preventAll(event);
@@ -151,8 +134,8 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
title={tag.label} title={tag.label}
draggable draggable
data-desk-tag-chip="true" data-desk-tag-chip="true"
onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)} onDragStart={(event) => onTagDragStart?.(event, doc, tag)}
onDragEnd={(event) => onDocTagDragEnd?.(event)} onDragEnd={(event) => onTagDragEnd?.(event)}
> >
<span className="tag-chip__label">{tag.label}</span> <span className="tag-chip__label">{tag.label}</span>
</span> </span>
@@ -8,7 +8,10 @@ import React, {
import { LayoutStore, LayoutCard } from '../logic/LayoutSystem'; import { LayoutStore, LayoutCard } from '../logic/LayoutSystem';
import DesktopDocumentCard from './DesktopDocumentCard'; import DesktopDocumentCard from './DesktopDocumentCard';
import usePreviewMetadata from '../hooks/usePreviewMetadata'; import usePreviewMetadata from '../hooks/usePreviewMetadata';
import { useDeskTagInteractions } from '../tags/useDeskTagInteractions'; import {
TagDragHandlers,
useTagInteractions,
} from '../../documents/interactions/useTagInteractions';
import './workspace-layout.css'; import './workspace-layout.css';
import './workspace-items.css'; import './workspace-items.css';
import './workspace-cards.css'; import './workspace-cards.css';
@@ -180,7 +183,7 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
}, []); }, []);
// Tag Interactions // Tag Interactions
const tagInteractions = useDeskTagInteractions({ const tagDragHandlers: TagDragHandlers = useTagInteractions({
onAssignTagToDocument: (docId: string, tagId: string) => { onAssignTagToDocument: (docId: string, tagId: string) => {
tags.onAttach?.(docId, tagId); tags.onAttach?.(docId, tagId);
}, },
@@ -349,8 +352,6 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
(e.target as Element).releasePointerCapture(e.pointerId); (e.target as Element).releasePointerCapture(e.pointerId);
} }
}} }}
onDrop={tagInteractions.handleCanvasDrop}
onDragOver={tagInteractions.handleCanvasDragOver}
> >
{isLayoutReady && items.map((doc, index) => { {isLayoutReady && items.map((doc, index) => {
const docId = doc.id ? String(doc.id) : `temp-${index}`; const docId = doc.id ? String(doc.id) : `temp-${index}`;
@@ -391,12 +392,7 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
openDocument(doc, isPreview ? 'preview' : 'sidepanel'); openDocument(doc, isPreview ? 'preview' : 'sidepanel');
}} }}
layoutCard={layoutCard} layoutCard={layoutCard}
onTagDragEnter={tagInteractions.handleTagDragEnterDoc} {...tagDragHandlers}
onTagDragOver={tagInteractions.handleTagDragOverDoc}
onTagDragLeave={tagInteractions.handleTagDragLeaveDoc}
onTagDrop={tagInteractions.handleTagDropOnDoc}
onDocTagDragStart={tagInteractions.handleDocTagDragStart}
onDocTagDragEnd={tagInteractions.handleDocTagDragEnd}
onSelect={(ids, extend = false) => { onSelect={(ids, extend = false) => {
if (!extend) { if (!extend) {
handleSelectionChange(ids); handleSelectionChange(ids);
@@ -30,11 +30,10 @@
outline-offset: 4px; outline-offset: 4px;
} }
.desk-item.is-tag-target .desk-item__card { .desk-item.is-tag-target .desk-item__card {
outline: 0.35rem dashed var(--accent); outline: 0.35rem dashed var(--accent);
outline-offset: 0.35rem; outline-offset: 0.35rem;
cursor: copy;
} }
.desk-item.is-tag-pending .desk-item__card { .desk-item.is-tag-pending .desk-item__card {
@@ -113,14 +112,9 @@
.tag-chip--draggable { .tag-chip--draggable {
user-select: none; user-select: none;
pointer-events: auto; pointer-events: auto;
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;
} }
.tag-chip--draggable:active {
cursor: grabbing;
}
.tag-chip--draggable.is-drag-hidden { .tag-chip--draggable.is-drag-hidden {
opacity: 0.4; opacity: 0.4;
} }
@@ -1,6 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { getTagColorStyle } from '../../utils/colors'; import { getTagColorStyle } from '../../utils/colors';
import { writeTagTransferData } from '../features/tagging/tagTransfer';
import type { DocumentTag } from '../../types/documents'; import type { DocumentTag } from '../../types/documents';
import type { Identifier } from '../../types/identifiers'; import type { Identifier } from '../../types/identifiers';
@@ -9,8 +8,7 @@ interface DocumentTagsProps {
tagLookupById?: Map<Identifier, DocumentTag> | null; tagLookupById?: Map<Identifier, DocumentTag> | null;
onTagClick?: (tagId: Identifier) => void; onTagClick?: (tagId: Identifier) => void;
docId: Identifier; docId: Identifier;
onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void; onTagDragStart?: (event: React.DragEvent<HTMLElement>, tag: DocumentTag) => void;
onTagDragStart?: (event: React.DragEvent<HTMLElement>, tagId: Identifier) => void;
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void; onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
} }
@@ -19,7 +17,6 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
tagLookupById, tagLookupById,
onTagClick, onTagClick,
docId, docId,
onDocumentTagDetach,
onTagDragStart, onTagDragStart,
onTagDragEnd, onTagDragEnd,
}) => { }) => {
@@ -58,24 +55,13 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
} : undefined} } : undefined}
draggable draggable
onDragStart={(event) => { onDragStart={(event) => {
event.stopPropagation(); // Let the hook handle the data transfer and UI
try { if (tagId && onTagDragStart) {
if (event.dataTransfer) { onTagDragStart(event, tag);
event.dataTransfer.effectAllowed = 'copyMove';
}
} catch (error) {
console.warn('[documents] Failed to configure drag effect', error);
}
writeTagTransferData(event.dataTransfer, tag, docId);
if (tagId) {
onTagDragStart?.(event, tagId);
} }
}} }}
onDragEnd={(event) => { onDragEnd={(event) => {
event.stopPropagation(); // Let the hook handle the cleanup and logic
if (event.dataTransfer.dropEffect === 'move' && tagId && onDocumentTagDetach) {
onDocumentTagDetach(docId, tagId);
}
onTagDragEnd?.(event); onTagDragEnd?.(event);
}} }}
onKeyDown={clickable ? (event) => { onKeyDown={clickable ? (event) => {
@@ -9,7 +9,7 @@ interface EntryTagsProps {
onTagClick?: (tagId: Identifier) => void; onTagClick?: (tagId: Identifier) => void;
docId: Identifier; docId: Identifier;
onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void; onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
onTagDragStart?: (event: React.DragEvent<HTMLElement>, tagId: Identifier) => void; onTagDragStart?: (event: React.DragEvent<HTMLElement>, tag: DocumentTag) => void;
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void; onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
} }
@@ -24,10 +24,11 @@ interface DocumentsCommandContextValue {
}; };
tags: { tags: {
onDrag: { onDrag: {
start?: (event: DragEvent<HTMLElement>, docId: Identifier, tagId: Identifier) => void; start?: (event: DragEvent<HTMLElement>, doc: Document, tag: any) => void;
end?: (event: DragEvent<HTMLElement>) => void; end?: (event: DragEvent<HTMLElement>) => void;
over?: (event: DragEvent<HTMLElement>, docId: Identifier) => void; over?: (event: DragEvent<HTMLElement>, doc: Document) => void;
leave?: (event: DragEvent<HTMLElement>) => void; drop?: (event: DragEvent<HTMLElement>, doc: Document) => void;
leave?: (event: DragEvent<HTMLElement>, doc: Document) => void;
}; };
onAttach?: (documentId: Identifier, tagId: Identifier) => void; onAttach?: (documentId: Identifier, tagId: Identifier) => void;
onDetach?: (documentId: Identifier, tagId: Identifier) => void; onDetach?: (documentId: Identifier, tagId: Identifier) => void;
@@ -15,20 +15,13 @@ import {
import AssetManager, { getAssetFromVersion } from '../../lib/assets/AssetManager'; import AssetManager, { getAssetFromVersion } from '../../lib/assets/AssetManager';
import useNotifyApiError from '../../hooks/useNotifyApiError'; import useNotifyApiError from '../../hooks/useNotifyApiError';
import TagManager from '../../lib/assets/TagManager'; import TagManager from '../../lib/assets/TagManager';
import { useManagementModals } from '../../app/useManagementModals'; import { fetchAsset } from '../../lib/api/apiClient';
import { useAppDispatch, useAppState } from '../../lib/store/appState';
import { fetchAsset, listFolderContents } from '../../lib/api/apiClient';
import { useApi } from '../../lib/context/ApiContext';
import { useWorkspaceSelection } from '../../app/useWorkspaceSelection';
import { useEntryPointer as useEntryPointerCore } from '../features/selection/useEntryPointer'; import { useEntryPointer as useEntryPointerCore } from '../features/selection/useEntryPointer';
import { isTagTransferEvent } from '../features/tagging/tagTransfer';
import useDocumentsSelection from '../features/selection/useDocumentsSelection'; import useDocumentsSelection from '../features/selection/useDocumentsSelection';
import useBulkDocumentActions from './useBulkDocumentActions'; import useBulkDocumentActions from './useBulkDocumentActions';
import useDocumentPreview from '../../app/useDocumentPreview';
import { import {
DEFAULT_SORT_DIRECTION, DEFAULT_SORT_DIRECTION,
DEFAULT_SORT_FIELD, DEFAULT_SORT_FIELD,
createRootNode,
mergeAssetIntoDocument, mergeAssetIntoDocument,
} from '../../app/workspaceUtils'; } from '../../app/workspaceUtils';
import { import {
@@ -57,6 +50,15 @@ import useDocumentCorrespondentActions from '../features/correspondents/useDocum
import usePasskeys from '../../settings/usePasskeys'; import usePasskeys from '../../settings/usePasskeys';
import { resolveBreadcrumbs } from '../logic/breadcrumbs'; import { resolveBreadcrumbs } from '../logic/breadcrumbs';
import useWorkspaceSelectionSync from '../features/selection/useWorkspaceSelectionSync'; import useWorkspaceSelectionSync from '../features/selection/useWorkspaceSelectionSync';
import useWorkspaceViewData from './useWorkspaceViewData';
import useWorkspaceDragDrop from '../interactions/useWorkspaceDragDrop';
import { useManagementModals } from '../../app/useManagementModals';
import { useAppDispatch, useAppState } from '../../lib/store/appState';
import { listFolderContents } from '../../lib/api/apiClient';
import { useApi } from '../../lib/context/ApiContext';
import { useWorkspaceSelection } from '../../app/useWorkspaceSelection';
import useDocumentPreview from '../../app/useDocumentPreview';
import { createRootNode } from '../../app/workspaceUtils';
import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers'; import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers';
const EntryType = Object.freeze({ const EntryType = Object.freeze({
@@ -148,24 +150,8 @@ const useDocumentsWorkspace = ({
const [creatingFolder, setCreatingFolder] = useState(false); const [creatingFolder, setCreatingFolder] = useState(false);
const { handleLogout } = useAuthManager({}); const { handleLogout } = useAuthManager({});
const tagRemovalCursorActiveRef = useRef(false);
const tenantIdRef = useRef(currentTenantId); const tenantIdRef = useRef(currentTenantId);
const detailPanelControlRef = useRef({ open: () => { }, close: () => { } }); const detailPanelControlRef = useRef({ open: () => { }, close: () => { } });
const setTagRemovalCursor = useCallback((active) => {
if (tagRemovalCursorActiveRef.current === active) {
return;
}
const body = document.body;
if (!body) {
return;
}
tagRemovalCursorActiveRef.current = active;
if (active) {
body.classList.add('desk-cursor-remove');
} else {
body.classList.remove('desk-cursor-remove');
}
}, []);
const documentsRouteMatch = useMatch('/documents'); const documentsRouteMatch = useMatch('/documents');
const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId'); const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId');
const documentsDetailRouteMatch = useMatch('/documents/:documentId'); const documentsDetailRouteMatch = useMatch('/documents/:documentId');
@@ -393,59 +379,18 @@ const useDocumentsWorkspace = ({
}); });
const documentsFilter = documentsFilterValue; const documentsFilter = documentsFilterValue;
const [visibleDocumentIds, setVisibleDocumentIds] = useState<DocumentId[]>([]);
const showingSearchResults = searchResultIds !== null; const showingSearchResults = searchResultIds !== null;
useEffect(() => { const {
const arraysEqual = (a: DocumentId[], b: DocumentId[]) => viewDocuments,
a.length === b.length && a.every((value, index) => value === b[index]); visibleEntryKeySet,
} = useWorkspaceViewData({
if (showingSearchResults && Array.isArray(searchResultIds)) { documents,
const ids = searchResultIds.filter((id): id is DocumentId => id != null); documentLookup,
setVisibleDocumentIds((prev) => (arraysEqual(prev, ids) ? prev : ids)); searchResultIds,
return; showingSearchResults,
} currentSubfolders,
});
const folderIds = documents
.map((doc) => (doc?.id ?? null) as DocumentId | null)
.filter((id): id is DocumentId => id != null);
setVisibleDocumentIds((prev) => (arraysEqual(prev, folderIds) ? prev : folderIds));
}, [showingSearchResults, searchResultIds, documents]);
const viewDocuments = useMemo(
() =>
visibleDocumentIds
.map((id) => documentLookup.get(id) || null)
.filter((doc): doc is Document => Boolean(doc)),
[visibleDocumentIds, documentLookup],
);
const visibleDocumentKeys = useMemo(
() => visibleDocumentIds.map((id) => createDocumentEntryKey(id)).filter(Boolean),
[visibleDocumentIds],
);
const visibleFolderKeys = useMemo(
() =>
showingSearchResults
? []
: (currentSubfolders || [])
.map((folder) => createFolderEntryKey(folder.id))
.filter(Boolean),
[showingSearchResults, currentSubfolders],
);
const visibleEntryKeys = useMemo(
() => [...visibleFolderKeys, ...visibleDocumentKeys],
[visibleFolderKeys, visibleDocumentKeys],
);
const visibleEntryKeySet = useMemo(
() => new Set(visibleEntryKeys),
[visibleEntryKeys],
);
const { const {
openDocumentPreview, openDocumentPreview,
@@ -981,67 +926,9 @@ const useDocumentsWorkspace = ({
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [settingsOpen]); }, [settingsOpen]);
useEffect( useWorkspaceDragDrop({
() => () => { shellRef,
setTagRemovalCursor(false); });
},
[setTagRemovalCursor],
);
useEffect(() => {
const host = shellRef.current;
if (!host) {
return undefined;
}
const isTagTransfer = (event) => isTagTransferEvent(event);
const isDocumentDropTarget = (target) =>
target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false;
const handleTagDragOver = (event) => {
if (!isTagTransfer(event)) {
return;
}
if (isDocumentDropTarget(event.target)) {
setTagRemovalCursor(false);
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setTagRemovalCursor(true);
};
const handleTagDragLeave = (event) => {
if (!isTagTransfer(event)) {
return;
}
const related = event.relatedTarget;
if (related instanceof Element && host.contains(related)) {
if (isDocumentDropTarget(related)) {
setTagRemovalCursor(false);
}
return;
}
setTagRemovalCursor(false);
};
const handleTagDragEnd = () => {
setTagRemovalCursor(false);
};
host.addEventListener('dragover', handleTagDragOver, true);
host.addEventListener('dragleave', handleTagDragLeave, true);
window.addEventListener('dragend', handleTagDragEnd, true);
return () => {
host.removeEventListener('dragover', handleTagDragOver, true);
host.removeEventListener('dragleave', handleTagDragLeave, true);
window.removeEventListener('dragend', handleTagDragEnd, true);
setTagRemovalCursor(false);
};
}, [handleDocumentTagDetach, setTagRemovalCursor]);
const { const {
detailPanelProps, detailPanelProps,
@@ -0,0 +1,80 @@
import { useEffect, useMemo, useState } from 'react';
import type { DocumentId } from '../../types/identifiers';
import type { Document } from '../../types/documents';
import { createDocumentEntryKey, createFolderEntryKey } from '../../app/entryKey';
interface UseWorkspaceViewDataArgs {
documents: Document[];
documentLookup: Map<DocumentId, Document>;
searchResultIds: DocumentId[] | null;
showingSearchResults: boolean;
currentSubfolders: any[];
}
const useWorkspaceViewData = ({
documents,
documentLookup,
searchResultIds,
showingSearchResults,
currentSubfolders,
}: UseWorkspaceViewDataArgs) => {
const [visibleDocumentIds, setVisibleDocumentIds] = useState<DocumentId[]>([]);
useEffect(() => {
const arraysEqual = (a: DocumentId[], b: DocumentId[]) =>
a.length === b.length && a.every((value, index) => value === b[index]);
if (showingSearchResults && Array.isArray(searchResultIds)) {
const ids = searchResultIds.filter((id): id is DocumentId => id != null);
setVisibleDocumentIds((prev) => (arraysEqual(prev, ids) ? prev : ids));
return;
}
const folderIds = documents
.map((doc) => (doc?.id ?? null) as DocumentId | null)
.filter((id): id is DocumentId => id != null);
setVisibleDocumentIds((prev) => (arraysEqual(prev, folderIds) ? prev : folderIds));
}, [showingSearchResults, searchResultIds, documents]);
const viewDocuments = useMemo(
() =>
visibleDocumentIds
.map((id) => documentLookup.get(id) || null)
.filter((doc): doc is Document => Boolean(doc)),
[visibleDocumentIds, documentLookup],
);
const visibleDocumentKeys = useMemo(
() => visibleDocumentIds.map((id) => createDocumentEntryKey(id)).filter(Boolean),
[visibleDocumentIds],
);
const visibleFolderKeys = useMemo(
() =>
showingSearchResults
? []
: (currentSubfolders || [])
.map((folder: any) => createFolderEntryKey(folder.id))
.filter(Boolean),
[showingSearchResults, currentSubfolders],
);
const visibleEntryKeys = useMemo(
() => [...visibleFolderKeys, ...visibleDocumentKeys],
[visibleFolderKeys, visibleDocumentKeys],
);
const visibleEntryKeySet = useMemo(
() => new Set(visibleEntryKeys),
[visibleEntryKeys],
);
return {
viewDocuments,
visibleDocumentIds,
visibleEntryKeys,
visibleEntryKeySet,
};
};
export default useWorkspaceViewData;
@@ -36,11 +36,32 @@ const createTagTransferPayload = (
}; };
}; };
// Shared state to track dragged tag ID across components (Sidebar <-> Workspace)
// This is necessary because dataTransfer payload is inaccessible during dragOver.
interface ActiveDragState {
tagId: TagId | null;
sourceDocId: DocumentId | null;
}
let activeDragState: ActiveDragState = { tagId: null, sourceDocId: null };
export const getActiveDragState = (): ActiveDragState => activeDragState;
export const clearTagTransferData = (): void => {
activeDragState = { tagId: null, sourceDocId: null };
};
export const writeTagTransferData = ( export const writeTagTransferData = (
dataTransfer: DataTransfer | null, dataTransfer: DataTransfer | null,
tag: TagLike, tag: TagLike,
sourceDocId: DocumentId | null = null, sourceDocId: DocumentId | null = null,
): void => { ): void => {
// Track globally for cursor logic
activeDragState = {
tagId: tag.id || null,
sourceDocId: sourceDocId || null,
};
if (!dataTransfer) { if (!dataTransfer) {
return; return;
} }
@@ -56,7 +77,9 @@ export const writeTagTransferData = (
} }
try { try {
TAG_MIME_TYPES.forEach((type) => dataTransfer.setData(type, serialized)); TAG_MIME_TYPES.forEach((mime) => {
dataTransfer.setData(mime, serialized);
});
if (payload.label) { if (payload.label) {
dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label); dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label);
} }
@@ -8,6 +8,8 @@ import {
isTagTransferEvent, isTagTransferEvent,
parseTagTransferPayload, parseTagTransferPayload,
writeTagTransferData, writeTagTransferData,
getActiveDragState,
clearTagTransferData,
} from '../../documents/features/tagging/tagTransfer'; } from '../../documents/features/tagging/tagTransfer';
import type { Identifier } from '../../types/identifiers'; import type { Identifier } from '../../types/identifiers';
import type { Document, DocumentTag } from '../../types/documents'; import type { Document, DocumentTag } from '../../types/documents';
@@ -42,7 +44,7 @@ const cleanupPreview = (previewNode: HTMLElement | null) => {
} }
}; };
interface UseDeskTagInteractionsArgs { interface UseTagInteractionsArgs {
onAssignTagToDocument?: (docId: Identifier, tagId: Identifier) => void; onAssignTagToDocument?: (docId: Identifier, tagId: Identifier) => void;
onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier) => void; onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier) => void;
requestCanvasFocus?: () => void; requestCanvasFocus?: () => void;
@@ -50,24 +52,28 @@ interface UseDeskTagInteractionsArgs {
interface DraggingTagState { interface DraggingTagState {
element: HTMLElement | null; element: HTMLElement | null;
previewClone: HTMLElement | null; previewClone?: HTMLElement;
sourceDocId: Identifier;
tagId: Identifier;
initialX: number;
initialY: number;
distance: number;
} }
export const useDeskTagInteractions = ({ export interface TagDragHandlers {
onTagDragEnter: (event: React.DragEvent<HTMLDivElement>, docId: Identifier) => void;
onTagDragOver: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
onTagDragLeave: (event: React.DragEvent<HTMLDivElement>, docId: Identifier) => void;
onTagDrop: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
onTagDragStart: (event: React.DragEvent<HTMLElement>, doc: Document, tag: DocumentTag) => void;
onTagDragEnd: (event: React.DragEvent<HTMLElement>) => void;
}
export const useTagInteractions = ({
onAssignTagToDocument, onAssignTagToDocument,
onRemoveTagFromDocument, onRemoveTagFromDocument,
requestCanvasFocus, requestCanvasFocus,
}: UseDeskTagInteractionsArgs) => { }: UseTagInteractionsArgs): TagDragHandlers => {
const draggingTagRef = useRef<DraggingTagState | null>(null); const draggingTagRef = useRef<DraggingTagState | null>(null);
const isTagTransfer = useCallback((event: React.DragEvent) => isTagTransferEvent(event), []); const isTagTransfer = useCallback((event: React.DragEvent) => isTagTransferEvent(event), []);
const handleTagDragEnterDoc = useCallback( const onTagDragEnter = useCallback(
(event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => { (event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => {
if (!isTagTransfer(event)) return; if (!isTagTransfer(event)) return;
preventAll(event); preventAll(event);
@@ -76,22 +82,43 @@ export const useDeskTagInteractions = ({
[isTagTransfer], [isTagTransfer],
); );
const handleTagDragOverDoc = useCallback( const onTagDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>, docId: Identifier) => { (event: React.DragEvent<HTMLDivElement>, doc: Document) => {
if (!doc || !doc.id) return;
if (!isTagTransfer(event)) return; if (!isTagTransfer(event)) return;
preventAll(event); preventAll(event);
event.currentTarget.classList.add('is-tag-target');
// Use shared state for all logic (Single Source of Truth)
const { tagId: draggedTagId, sourceDocId: draggedSourceId } = getActiveDragState();
const isAssigned = doc.tags?.some((t) => t.id === draggedTagId);
if (event.dataTransfer) { if (event.dataTransfer) {
// If dragging over source, copy (no removal). Else move (removal). const isSource = draggedSourceId === doc.id;
const isSource = draggingTagRef.current?.sourceDocId === docId;
event.dataTransfer.dropEffect = isSource ? 'copy' : 'move'; // Otherwise: separate document.
if (isSource || isAssigned) {
event.dataTransfer.dropEffect = 'none';
event.currentTarget.classList.remove('is-tag-target');
return;
}
const isFromDocument = !!draggedSourceId;
if (isFromDocument) {
// Default to Move (transfer), allow Copy with Alt key
event.dataTransfer.dropEffect = event.altKey ? 'copy' : 'move';
} else {
// Sidebar or external source: Copy only
event.dataTransfer.dropEffect = 'copy';
}
event.currentTarget.classList.add('is-tag-target');
} }
}, },
[isTagTransfer], [isTagTransfer],
); );
const handleTagDragLeaveDoc = useCallback( const onTagDragLeave = useCallback(
(event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => { (event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => {
if (!isTagTransfer(event)) return; if (!isTagTransfer(event)) return;
// Ignore if leaving to a child element // Ignore if leaving to a child element
@@ -103,27 +130,7 @@ export const useDeskTagInteractions = ({
[isTagTransfer], [isTagTransfer],
); );
const handleCanvasDragOver = useCallback( const onTagDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
},
[],
);
const handleCanvasDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
// Implicit removal via dragend (dropEffect='move')
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
preventAll(event);
},
[],
);
const handleTagDropOnDoc = useCallback(
(event: React.DragEvent<HTMLDivElement>, doc: Document) => { (event: React.DragEvent<HTMLDivElement>, doc: Document) => {
if (!doc || !doc.id) { if (!doc || !doc.id) {
return; return;
@@ -134,20 +141,23 @@ export const useDeskTagInteractions = ({
preventAll(event); preventAll(event);
event.currentTarget.classList.remove('is-tag-target'); event.currentTarget.classList.remove('is-tag-target');
// Add to target
const payload = parseTagTransferPayload(event); const payload = parseTagTransferPayload(event);
if (payload && payload.sourceDocId === doc.id) {
if (event.dataTransfer) event.dataTransfer.dropEffect = 'none';
return;
}
setTimeout(() => { setTimeout(() => {
if (!payload || !payload.id) { if (!payload || !payload.id) {
return; return;
} }
if (payload.sourceDocId === doc.id) {
return;
}
requestCanvasFocus?.(); requestCanvasFocus?.();
// Double-check assignment (even though cursor logic tries to prevent it)
const isAssigned = doc.tags?.some((t) => t.id === payload.id);
if (isAssigned) return;
if (onAssignTagToDocument && doc.id) { if (onAssignTagToDocument && doc.id) {
onAssignTagToDocument(doc.id, payload.id); onAssignTagToDocument(doc.id, payload.id);
} }
@@ -156,12 +166,12 @@ export const useDeskTagInteractions = ({
[isTagTransfer, onAssignTagToDocument, requestCanvasFocus], [isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
); );
const handleDocTagDragStart = useCallback( const onTagDragStart = useCallback(
(event: React.DragEvent<HTMLElement>, doc: Document, tag: DocumentTag) => { (event: React.DragEvent<HTMLElement>, doc: Document, tag: DocumentTag) => {
console.log('[Tag] handleDocTagDragStart', { docId: doc?.id, tagId: tag?.id });
if (!event?.dataTransfer || !doc?.id || !tag?.id) { if (!event?.dataTransfer || !doc?.id || !tag?.id) {
return; return;
} }
event.stopPropagation();
event.dataTransfer.effectAllowed = 'copyMove'; event.dataTransfer.effectAllowed = 'copyMove';
writeTagTransferData(event.dataTransfer, tag, doc.id); writeTagTransferData(event.dataTransfer, tag, doc.id);
@@ -179,20 +189,18 @@ export const useDeskTagInteractions = ({
draggingTagRef.current = { draggingTagRef.current = {
element, element,
previewClone: clone || null, previewClone: clone,
sourceDocId: doc.id,
tagId: tag.id,
initialX: pointerX,
initialY: pointerY,
distance: 0,
}; };
}, },
[], [],
); );
const handleDocTagDragEnd = useCallback( const onTagDragEnd = useCallback(
(event: React.DragEvent<HTMLElement>) => { (event: React.DragEvent<HTMLElement>) => {
console.log('[Tag] handleDocTagDragEnd', { dropEffect: event?.dataTransfer?.dropEffect }); event.stopPropagation();
const { sourceDocId, tagId } = getActiveDragState();
clearTagTransferData();
const dropEffect = event?.dataTransfer?.dropEffect; const dropEffect = event?.dataTransfer?.dropEffect;
setTimeout(() => { setTimeout(() => {
@@ -206,8 +214,8 @@ export const useDeskTagInteractions = ({
// Remove if move operation completed // Remove if move operation completed
if (dropEffect === 'move') { if (dropEffect === 'move') {
if (onRemoveTagFromDocument && state.sourceDocId && state.tagId) { if (onRemoveTagFromDocument && sourceDocId && tagId) {
onRemoveTagFromDocument(state.sourceDocId, state.tagId); onRemoveTagFromDocument(sourceDocId, tagId);
} }
} }
} }
@@ -227,13 +235,11 @@ export const useDeskTagInteractions = ({
}, []); }, []);
return { return {
handleTagDragEnterDoc, onTagDragEnter,
handleTagDragOverDoc, onTagDragOver,
handleTagDragLeaveDoc, onTagDragLeave,
handleTagDropOnDoc, onTagDrop,
handleDocTagDragStart, onTagDragStart,
handleDocTagDragEnd, onTagDragEnd,
handleCanvasDragOver,
handleCanvasDrop,
}; };
}; };
@@ -0,0 +1,40 @@
import { useEffect } from 'react';
import type { MutableRefObject } from 'react';
import { isTagTransferEvent } from '../features/tagging/tagTransfer';
interface UseWorkspaceDragDropArgs {
shellRef: MutableRefObject<HTMLElement | null>;
}
const useWorkspaceDragDrop = ({
shellRef,
}: UseWorkspaceDragDropArgs) => {
useEffect(() => {
const host = shellRef.current;
if (!host) {
return;
}
// Allow drop on workspace
const handleTagDragOver = (event: DragEvent) => {
if (!isTagTransferEvent(event)) {
return;
}
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
};
// Use bubbling (false) so children can stopPropagation
host.addEventListener('dragover', handleTagDragOver, false);
return () => {
host.removeEventListener('dragover', handleTagDragOver, false);
};
}, [shellRef]);
return {};
};
export default useWorkspaceDragDrop;
@@ -1,12 +1,10 @@
import React, { type DragEvent } from 'react'; import React, { type DragEvent } from 'react';
import { parseTagTransferPayload } from '../features/tagging/tagTransfer';
import { createDocumentEntryKey } from '../../app/entryKey'; import { createDocumentEntryKey } from '../../app/entryKey';
import { useDocumentOpen } from '../../lib/context/DocumentOpenContext'; import { useDocumentOpen } from '../../lib/context/DocumentOpenContext';
import type { Document } from '../../types/documents'; import type { Document, DocumentTag } from '../../types/documents';
import { useDocumentsCommandContext } from '../context/DocumentsCommandContext'; import { useDocumentsCommandContext } from '../context/DocumentsCommandContext';
import { useDocumentsViewStateContext } from '../context/DocumentsViewStateContext'; import { useDocumentsViewStateContext } from '../context/DocumentsViewStateContext';
import type { DocumentViewLogic } from './useDocumentViewLogic'; import type { DocumentViewLogic } from './useDocumentViewLogic';
import type { Identifier } from '../../types/identifiers';
interface UseDocumentItemLogicProps { interface UseDocumentItemLogicProps {
doc: Document; doc: Document;
@@ -30,8 +28,8 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
end: onDocumentTagDragEnd, end: onDocumentTagDragEnd,
over: onDocumentTagDragOver, over: onDocumentTagDragOver,
leave: onDocumentTagDragLeave, leave: onDocumentTagDragLeave,
drop: onDocumentTagDrop,
}, },
onAttach: onDocumentTagAttach
}, },
onEntryPointer onEntryPointer
} = useDocumentsCommandContext(); } = useDocumentsCommandContext();
@@ -79,18 +77,11 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
}, },
onDragStart: (event: DragEvent<HTMLElement>) => onDocumentDragStart?.(event, doc), onDragStart: (event: DragEvent<HTMLElement>) => onDocumentDragStart?.(event, doc),
onDragEnd: (event: DragEvent<HTMLElement>) => onDocumentDragEnd?.(event), onDragEnd: (event: DragEvent<HTMLElement>) => onDocumentDragEnd?.(event),
onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event, doc.id), onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event, doc),
onDragLeave: onDocumentTagDragLeave, onDragLeave: (event: DragEvent<HTMLElement>) => onDocumentTagDragLeave?.(event, doc),
onTagDragStart: (event: DragEvent<HTMLElement>, tagId: Identifier) => onDocumentTagDragStart?.(event, doc.id, tagId), onTagDragStart: (event: DragEvent<HTMLElement>, tag: DocumentTag) => onDocumentTagDragStart?.(event, doc, tag),
onTagDragEnd: (event: DragEvent<HTMLElement>) => onDocumentTagDragEnd?.(event), onTagDragEnd: (event: DragEvent<HTMLElement>) => onDocumentTagDragEnd?.(event),
onDrop: (event: DragEvent<HTMLElement>) => { onDrop: (event: DragEvent<HTMLElement>) => { onDocumentTagDrop?.(event, doc); },
event.preventDefault();
event.stopPropagation();
const payload = parseTagTransferPayload(event);
if (payload && payload.id && onDocumentTagAttach) {
onDocumentTagAttach(doc.id, payload.id);
}
},
onRenameChange: setDocumentDraft, onRenameChange: setDocumentDraft,
onRenameSubmit: () => submitDocumentEditing(doc), onRenameSubmit: () => submitDocumentEditing(doc),
onRenameCancel: (event?: React.SyntheticEvent) => cancelDocumentEditing(event), onRenameCancel: (event?: React.SyntheticEvent) => cancelDocumentEditing(event),
@@ -1,10 +1,10 @@
import { useMemo, useCallback, useRef } from 'react'; import { useMemo, useCallback, useRef } from 'react';
import type { DocumentsPanelInnerProps } from './DocumentsPanel'; import type { DocumentsPanelInnerProps } from './DocumentsPanel';
import { isTagTransferEvent } from '../features/tagging/tagTransfer';
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../features/selection/useEntryPointer'; import { isPointerModifierEvent, isPrimaryPointerEvent } from '../features/selection/useEntryPointer';
import { useDocumentsFilter } from '../context/DocumentsFilterContext'; import { useDocumentsFilter } from '../context/DocumentsFilterContext';
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext'; import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
import type { Identifier } from '../../types/identifiers'; import type { Identifier } from '../../types/identifiers';
import { useTagInteractions } from '../interactions/useTagInteractions';
const EntryType = { const EntryType = {
folder: 'folder', folder: 'folder',
@@ -37,6 +37,12 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
toggleCorrespondent: toggleCorrespondentFilter, toggleCorrespondent: toggleCorrespondentFilter,
} = useDocumentsFilter(); } = useDocumentsFilter();
// Handlers
const tagDragHandlers = useTagInteractions({
onAssignTagToDocument: props.onDocumentTagAttach,
onRemoveTagFromDocument: props.onDocumentTagDetach,
});
// Derived State // Derived State
const draggingDocumentIdsSet = useMemo( const draggingDocumentIdsSet = useMemo(
() => new Set(draggingDocumentIds || []), () => new Set(draggingDocumentIds || []),
@@ -69,52 +75,6 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
// Refs // Refs
const scrollRef = useRef<HTMLElement | null>(null); const scrollRef = useRef<HTMLElement | null>(null);
const suppressDocumentClickRef = useRef(false); 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( const handleFolderClick = useCallback(
(folder: any, event: any) => { (folder: any, event: any) => {
@@ -187,10 +147,7 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
handleFolderClick, handleFolderClick,
handleDocumentDragStartLocal, handleDocumentDragStartLocal,
handleDocumentDragEndLocal, handleDocumentDragEndLocal,
handleDocumentTagDragStart, tagDragHandlers,
handleDocumentTagDragEnd,
handleDocumentTagDragOver,
handleDocumentTagDragLeave,
toggleTagFilter, toggleTagFilter,
toggleCorrespondentFilter, toggleCorrespondentFilter,
}); });
@@ -201,10 +158,7 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
handleFolderClick, handleFolderClick,
handleDocumentDragStartLocal, handleDocumentDragStartLocal,
handleDocumentDragEndLocal, handleDocumentDragEndLocal,
handleDocumentTagDragStart, tagDragHandlers,
handleDocumentTagDragEnd,
handleDocumentTagDragOver,
handleDocumentTagDragLeave,
toggleTagFilter, toggleTagFilter,
toggleCorrespondentFilter, toggleCorrespondentFilter,
}; };
@@ -231,10 +185,11 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
}, },
tags: { tags: {
onDrag: { onDrag: {
start: (e: any, d: any, t: any) => latestHandlersRef.current.handleDocumentTagDragStart(e, d, t), start: (e: any, d: any, t: any) => latestHandlersRef.current.tagDragHandlers.onTagDragStart(e, d, t),
end: (e: any) => latestHandlersRef.current.handleDocumentTagDragEnd(e), end: (e: any) => latestHandlersRef.current.tagDragHandlers.onTagDragEnd(e),
over: (e: any, d: any) => latestHandlersRef.current.handleDocumentTagDragOver(e, d), over: (e: any, d: any) => latestHandlersRef.current.tagDragHandlers.onTagDragOver(e, d),
leave: (e: any) => latestHandlersRef.current.handleDocumentTagDragLeave(e), 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), onAttach: (d: any, t: any) => latestPropsRef.current.onDocumentTagAttach?.(d, t),
onDetach: (d: any, t: any) => latestPropsRef.current.onDocumentTagDetach?.(d, t), onDetach: (d: any, t: any) => latestPropsRef.current.onDocumentTagDetach?.(d, t),
+3 -3
View File
@@ -698,17 +698,17 @@
opacity: 0.4; opacity: 0.4;
} }
.documents-panel tbody tr.document.tag-drop-target { .documents-panel tbody tr.document.is-tag-target {
background: var(--accent-soft); background: var(--accent-soft);
box-shadow: inset 0 0 0 2px var(--accent); box-shadow: inset 0 0 0 2px var(--accent);
} }
.document-card.tag-drop-target { .document-card.is-tag-target {
box-shadow: 0 0 0 2px var(--accent); box-shadow: 0 0 0 2px var(--accent);
border-color: var(--accent); border-color: var(--accent);
} }
.document-card.tag-drop-target .document-card__title { .document-card.is-tag-target .document-card__title {
color: var(--accent); color: var(--accent);
} }
@@ -1,6 +1,7 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { PlusIcon, SettingsIcon } from '../../components/icons'; import { PlusIcon, SettingsIcon } from '../../components/icons';
import { getTagColorStyle } from '../../utils/colors'; import { getTagColorStyle } from '../../utils/colors';
import { writeTagTransferData, clearTagTransferData } from '../../documents/features/tagging/tagTransfer';
import type { Identifier } from '../../types/identifiers'; import type { Identifier } from '../../types/identifiers';
import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext'; import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext';
@@ -120,18 +121,13 @@ const SidebarTagList: React.FC<SidebarTagListProps> = ({
draggable draggable
onDragStart={(event) => { onDragStart={(event) => {
try { try {
const payload = JSON.stringify({
id: tag.id,
label: tag.label,
color: tag.color || null,
});
event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('application/x-papercrate-tag', payload); writeTagTransferData(event.dataTransfer, tag);
event.dataTransfer.setData('text/papercrate-tag', payload);
} catch (error) { } catch (error) {
console.warn('[sidebar] Failed to set tag drag payload', error); console.warn('[sidebar] Failed to set tag drag payload', error);
} }
}} }}
onDragEnd={clearTagTransferData}
> >
{tag.label} {tag.label}
</button> </button>