From 757913cf00b1bb6bd62da232addad916a40ad9fe Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Mon, 8 Dec 2025 23:30:18 +0100 Subject: [PATCH] feat: Implement new drag-and-drop system for documents and tags, including workspace-level handling and improved visual feedback. --- .../components/DesktopDocumentCard.tsx | 45 ++--- .../desktop/components/DesktopWorkspace.tsx | 16 +- .../desktop/components/workspace-items.css | 8 +- .../src/documents/components/DocumentTags.tsx | 24 +-- .../src/documents/components/EntryTags.tsx | 2 +- .../context/DocumentsCommandContext.tsx | 7 +- .../documents/data/useDocumentsWorkspace.ts | 159 +++--------------- .../documents/data/useWorkspaceViewData.ts | 80 +++++++++ .../documents/features/tagging/tagTransfer.ts | 25 ++- .../interactions/useTagInteractions.ts} | 132 ++++++++------- .../interactions/useWorkspaceDragDrop.ts | 40 +++++ .../documents/logic/useDocumentItemLogic.ts | 21 +-- .../panel/useDocumentsContextValues.ts | 73 ++------ frontend/src/documents/styles/listing.css | 6 +- .../src/sidebar/components/SidebarTagList.tsx | 10 +- 15 files changed, 293 insertions(+), 355 deletions(-) create mode 100644 frontend/src/documents/data/useWorkspaceViewData.ts rename frontend/src/{desktop/tags/useDeskTagInteractions.ts => documents/interactions/useTagInteractions.ts} (65%) create mode 100644 frontend/src/documents/interactions/useWorkspaceDragDrop.ts diff --git a/frontend/src/desktop/components/DesktopDocumentCard.tsx b/frontend/src/desktop/components/DesktopDocumentCard.tsx index 526b33c..d0d030c 100644 --- a/frontend/src/desktop/components/DesktopDocumentCard.tsx +++ b/frontend/src/desktop/components/DesktopDocumentCard.tsx @@ -28,20 +28,20 @@ interface DesktopDocumentCardProps { selection: string[]; requestCanvasFocus?: () => void; onTagDragEnter?: (event: React.DragEvent, docId: DocumentId) => void; - onTagDragOver?: (event: React.DragEvent, docId: DocumentId) => void; + onTagDragOver?: (event: React.DragEvent, doc: Document) => void; onTagDragLeave?: (event: React.DragEvent, docId: DocumentId) => void; onTagDrop?: (event: React.DragEvent, doc: Document) => void; - onDocTagDragStart?: (event: React.DragEvent, doc: Document, tag: any) => void; - onDocTagDragEnd?: (event: React.DragEvent) => void; + onTagDragStart?: (event: React.DragEvent, doc: Document, tag: any) => void; + onTagDragEnd?: (event: React.DragEvent) => void; layoutCard: LayoutCard; } const DesktopDocumentCard: React.FC = ({ doc, style, - shouldLoad, - matchesFilter, - selected, + shouldLoad = false, + matchesFilter = true, + selected = false, docTagTokens, ensureAssetUrl, getDocumentAsset, @@ -54,8 +54,8 @@ const DesktopDocumentCard: React.FC = ({ onTagDragOver, onTagDragLeave, onTagDrop, - onDocTagDragStart, - onDocTagDragEnd, + onTagDragStart, + onTagDragEnd, layoutCard, }) => { const cardPointerHandlers = useCardPointer( @@ -89,27 +89,10 @@ const DesktopDocumentCard: React.FC = ({ aria-hidden={ariaHidden} ref={(node) => layoutCard?.setRef(node)} {...cardPointerHandlers} - onDragEnter={(event) => { - if (doc?.id == null) { - return; - } - 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); - }} + onDragEnter={(event) => onTagDragEnter?.(event, doc.id!)} + onDragOver={(event) => onTagDragOver?.(event, doc)} + onDragLeave={(event) => onTagDragLeave?.(event, doc.id!)} + onDrop={(event) => onTagDrop?.(event, doc)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { preventAll(event); @@ -151,8 +134,8 @@ const DesktopDocumentCard: React.FC = ({ title={tag.label} draggable data-desk-tag-chip="true" - onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)} - onDragEnd={(event) => onDocTagDragEnd?.(event)} + onDragStart={(event) => onTagDragStart?.(event, doc, tag)} + onDragEnd={(event) => onTagDragEnd?.(event)} > {tag.label} diff --git a/frontend/src/desktop/components/DesktopWorkspace.tsx b/frontend/src/desktop/components/DesktopWorkspace.tsx index b369c7e..beb81ae 100644 --- a/frontend/src/desktop/components/DesktopWorkspace.tsx +++ b/frontend/src/desktop/components/DesktopWorkspace.tsx @@ -8,7 +8,10 @@ import React, { import { LayoutStore, LayoutCard } from '../logic/LayoutSystem'; import DesktopDocumentCard from './DesktopDocumentCard'; import usePreviewMetadata from '../hooks/usePreviewMetadata'; -import { useDeskTagInteractions } from '../tags/useDeskTagInteractions'; +import { + TagDragHandlers, + useTagInteractions, +} from '../../documents/interactions/useTagInteractions'; import './workspace-layout.css'; import './workspace-items.css'; import './workspace-cards.css'; @@ -180,7 +183,7 @@ const DesktopWorkspaceContent: React.FC = ({ }, []); // Tag Interactions - const tagInteractions = useDeskTagInteractions({ + const tagDragHandlers: TagDragHandlers = useTagInteractions({ onAssignTagToDocument: (docId: string, tagId: string) => { tags.onAttach?.(docId, tagId); }, @@ -349,8 +352,6 @@ const DesktopWorkspaceContent: React.FC = ({ (e.target as Element).releasePointerCapture(e.pointerId); } }} - onDrop={tagInteractions.handleCanvasDrop} - onDragOver={tagInteractions.handleCanvasDragOver} > {isLayoutReady && items.map((doc, index) => { const docId = doc.id ? String(doc.id) : `temp-${index}`; @@ -391,12 +392,7 @@ const DesktopWorkspaceContent: React.FC = ({ openDocument(doc, isPreview ? 'preview' : 'sidepanel'); }} layoutCard={layoutCard} - onTagDragEnter={tagInteractions.handleTagDragEnterDoc} - onTagDragOver={tagInteractions.handleTagDragOverDoc} - onTagDragLeave={tagInteractions.handleTagDragLeaveDoc} - onTagDrop={tagInteractions.handleTagDropOnDoc} - onDocTagDragStart={tagInteractions.handleDocTagDragStart} - onDocTagDragEnd={tagInteractions.handleDocTagDragEnd} + {...tagDragHandlers} onSelect={(ids, extend = false) => { if (!extend) { handleSelectionChange(ids); diff --git a/frontend/src/desktop/components/workspace-items.css b/frontend/src/desktop/components/workspace-items.css index 9476696..70337de 100644 --- a/frontend/src/desktop/components/workspace-items.css +++ b/frontend/src/desktop/components/workspace-items.css @@ -30,11 +30,10 @@ outline-offset: 4px; } - - .desk-item.is-tag-target .desk-item__card { outline: 0.35rem dashed var(--accent); outline-offset: 0.35rem; + cursor: copy; } .desk-item.is-tag-pending .desk-item__card { @@ -113,14 +112,9 @@ .tag-chip--draggable { user-select: none; pointer-events: auto; - cursor: grab; 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 { opacity: 0.4; } diff --git a/frontend/src/documents/components/DocumentTags.tsx b/frontend/src/documents/components/DocumentTags.tsx index 2b8fbea..a625195 100644 --- a/frontend/src/documents/components/DocumentTags.tsx +++ b/frontend/src/documents/components/DocumentTags.tsx @@ -1,6 +1,5 @@ import React, { useMemo } from 'react'; import { getTagColorStyle } from '../../utils/colors'; -import { writeTagTransferData } from '../features/tagging/tagTransfer'; import type { DocumentTag } from '../../types/documents'; import type { Identifier } from '../../types/identifiers'; @@ -9,8 +8,7 @@ interface DocumentTagsProps { tagLookupById?: Map | null; onTagClick?: (tagId: Identifier) => void; docId: Identifier; - onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void; - onTagDragStart?: (event: React.DragEvent, tagId: Identifier) => void; + onTagDragStart?: (event: React.DragEvent, tag: DocumentTag) => void; onTagDragEnd?: (event: React.DragEvent) => void; } @@ -19,7 +17,6 @@ const DocumentTags: React.FC = ({ tagLookupById, onTagClick, docId, - onDocumentTagDetach, onTagDragStart, onTagDragEnd, }) => { @@ -58,24 +55,13 @@ const DocumentTags: React.FC = ({ } : undefined} draggable onDragStart={(event) => { - event.stopPropagation(); - try { - if (event.dataTransfer) { - 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); + // Let the hook handle the data transfer and UI + if (tagId && onTagDragStart) { + onTagDragStart(event, tag); } }} onDragEnd={(event) => { - event.stopPropagation(); - if (event.dataTransfer.dropEffect === 'move' && tagId && onDocumentTagDetach) { - onDocumentTagDetach(docId, tagId); - } + // Let the hook handle the cleanup and logic onTagDragEnd?.(event); }} onKeyDown={clickable ? (event) => { diff --git a/frontend/src/documents/components/EntryTags.tsx b/frontend/src/documents/components/EntryTags.tsx index 9db4804..146008a 100644 --- a/frontend/src/documents/components/EntryTags.tsx +++ b/frontend/src/documents/components/EntryTags.tsx @@ -9,7 +9,7 @@ interface EntryTagsProps { onTagClick?: (tagId: Identifier) => void; docId: Identifier; onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void; - onTagDragStart?: (event: React.DragEvent, tagId: Identifier) => void; + onTagDragStart?: (event: React.DragEvent, tag: DocumentTag) => void; onTagDragEnd?: (event: React.DragEvent) => void; } diff --git a/frontend/src/documents/context/DocumentsCommandContext.tsx b/frontend/src/documents/context/DocumentsCommandContext.tsx index 6c3269e..96d8028 100644 --- a/frontend/src/documents/context/DocumentsCommandContext.tsx +++ b/frontend/src/documents/context/DocumentsCommandContext.tsx @@ -24,10 +24,11 @@ interface DocumentsCommandContextValue { }; tags: { onDrag: { - start?: (event: DragEvent, docId: Identifier, tagId: Identifier) => void; + start?: (event: DragEvent, doc: Document, tag: any) => void; end?: (event: DragEvent) => void; - over?: (event: DragEvent, docId: Identifier) => void; - leave?: (event: DragEvent) => void; + over?: (event: DragEvent, doc: Document) => void; + drop?: (event: DragEvent, doc: Document) => void; + leave?: (event: DragEvent, doc: Document) => void; }; onAttach?: (documentId: Identifier, tagId: Identifier) => void; onDetach?: (documentId: Identifier, tagId: Identifier) => void; diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts index eb01017..99c7cf7 100644 --- a/frontend/src/documents/data/useDocumentsWorkspace.ts +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -15,20 +15,13 @@ import { import AssetManager, { getAssetFromVersion } from '../../lib/assets/AssetManager'; import useNotifyApiError from '../../hooks/useNotifyApiError'; import TagManager from '../../lib/assets/TagManager'; -import { useManagementModals } from '../../app/useManagementModals'; -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 { fetchAsset } from '../../lib/api/apiClient'; import { useEntryPointer as useEntryPointerCore } from '../features/selection/useEntryPointer'; -import { isTagTransferEvent } from '../features/tagging/tagTransfer'; import useDocumentsSelection from '../features/selection/useDocumentsSelection'; import useBulkDocumentActions from './useBulkDocumentActions'; -import useDocumentPreview from '../../app/useDocumentPreview'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD, - createRootNode, mergeAssetIntoDocument, } from '../../app/workspaceUtils'; import { @@ -57,6 +50,15 @@ import useDocumentCorrespondentActions from '../features/correspondents/useDocum import usePasskeys from '../../settings/usePasskeys'; import { resolveBreadcrumbs } from '../logic/breadcrumbs'; 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'; const EntryType = Object.freeze({ @@ -148,24 +150,8 @@ const useDocumentsWorkspace = ({ const [creatingFolder, setCreatingFolder] = useState(false); const { handleLogout } = useAuthManager({}); - const tagRemovalCursorActiveRef = useRef(false); const tenantIdRef = useRef(currentTenantId); 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 documentsFolderRouteMatch = useMatch('/documents/folder/:folderId'); const documentsDetailRouteMatch = useMatch('/documents/:documentId'); @@ -393,59 +379,18 @@ const useDocumentsWorkspace = ({ }); const documentsFilter = documentsFilterValue; - - const [visibleDocumentIds, setVisibleDocumentIds] = useState([]); - const showingSearchResults = searchResultIds !== null; - 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) => createFolderEntryKey(folder.id)) - .filter(Boolean), - [showingSearchResults, currentSubfolders], - ); - - const visibleEntryKeys = useMemo( - () => [...visibleFolderKeys, ...visibleDocumentKeys], - [visibleFolderKeys, visibleDocumentKeys], - ); - - const visibleEntryKeySet = useMemo( - () => new Set(visibleEntryKeys), - [visibleEntryKeys], - ); + const { + viewDocuments, + visibleEntryKeySet, + } = useWorkspaceViewData({ + documents, + documentLookup, + searchResultIds, + showingSearchResults, + currentSubfolders, + }); const { openDocumentPreview, @@ -981,67 +926,9 @@ const useDocumentsWorkspace = ({ return () => window.removeEventListener('keydown', handleKeyDown); }, [settingsOpen]); - useEffect( - () => () => { - 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]); - + useWorkspaceDragDrop({ + shellRef, + }); const { detailPanelProps, diff --git a/frontend/src/documents/data/useWorkspaceViewData.ts b/frontend/src/documents/data/useWorkspaceViewData.ts new file mode 100644 index 0000000..2af721c --- /dev/null +++ b/frontend/src/documents/data/useWorkspaceViewData.ts @@ -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; + searchResultIds: DocumentId[] | null; + showingSearchResults: boolean; + currentSubfolders: any[]; +} + +const useWorkspaceViewData = ({ + documents, + documentLookup, + searchResultIds, + showingSearchResults, + currentSubfolders, +}: UseWorkspaceViewDataArgs) => { + const [visibleDocumentIds, setVisibleDocumentIds] = useState([]); + + 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; diff --git a/frontend/src/documents/features/tagging/tagTransfer.ts b/frontend/src/documents/features/tagging/tagTransfer.ts index 9f43a56..0580ded 100644 --- a/frontend/src/documents/features/tagging/tagTransfer.ts +++ b/frontend/src/documents/features/tagging/tagTransfer.ts @@ -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 = ( dataTransfer: DataTransfer | null, tag: TagLike, sourceDocId: DocumentId | null = null, ): void => { + // Track globally for cursor logic + activeDragState = { + tagId: tag.id || null, + sourceDocId: sourceDocId || null, + }; + if (!dataTransfer) { return; } @@ -56,7 +77,9 @@ export const writeTagTransferData = ( } try { - TAG_MIME_TYPES.forEach((type) => dataTransfer.setData(type, serialized)); + TAG_MIME_TYPES.forEach((mime) => { + dataTransfer.setData(mime, serialized); + }); if (payload.label) { dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label); } diff --git a/frontend/src/desktop/tags/useDeskTagInteractions.ts b/frontend/src/documents/interactions/useTagInteractions.ts similarity index 65% rename from frontend/src/desktop/tags/useDeskTagInteractions.ts rename to frontend/src/documents/interactions/useTagInteractions.ts index 197bf32..1cbf8cb 100644 --- a/frontend/src/desktop/tags/useDeskTagInteractions.ts +++ b/frontend/src/documents/interactions/useTagInteractions.ts @@ -8,6 +8,8 @@ import { isTagTransferEvent, parseTagTransferPayload, writeTagTransferData, + getActiveDragState, + clearTagTransferData, } from '../../documents/features/tagging/tagTransfer'; import type { Identifier } from '../../types/identifiers'; 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; onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier) => void; requestCanvasFocus?: () => void; @@ -50,24 +52,28 @@ interface UseDeskTagInteractionsArgs { interface DraggingTagState { element: HTMLElement | null; - previewClone: HTMLElement | null; - sourceDocId: Identifier; - tagId: Identifier; - initialX: number; - initialY: number; - distance: number; + previewClone?: HTMLElement; } -export const useDeskTagInteractions = ({ +export interface TagDragHandlers { + onTagDragEnter: (event: React.DragEvent, docId: Identifier) => void; + onTagDragOver: (event: React.DragEvent, doc: Document) => void; + onTagDragLeave: (event: React.DragEvent, docId: Identifier) => void; + onTagDrop: (event: React.DragEvent, doc: Document) => void; + onTagDragStart: (event: React.DragEvent, doc: Document, tag: DocumentTag) => void; + onTagDragEnd: (event: React.DragEvent) => void; +} + +export const useTagInteractions = ({ onAssignTagToDocument, onRemoveTagFromDocument, requestCanvasFocus, -}: UseDeskTagInteractionsArgs) => { +}: UseTagInteractionsArgs): TagDragHandlers => { const draggingTagRef = useRef(null); const isTagTransfer = useCallback((event: React.DragEvent) => isTagTransferEvent(event), []); - const handleTagDragEnterDoc = useCallback( + const onTagDragEnter = useCallback( (event: React.DragEvent, _docId: Identifier) => { if (!isTagTransfer(event)) return; preventAll(event); @@ -76,22 +82,43 @@ export const useDeskTagInteractions = ({ [isTagTransfer], ); - const handleTagDragOverDoc = useCallback( - (event: React.DragEvent, docId: Identifier) => { + const onTagDragOver = useCallback( + (event: React.DragEvent, doc: Document) => { + if (!doc || !doc.id) return; if (!isTagTransfer(event)) return; 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 dragging over source, copy (no removal). Else move (removal). - const isSource = draggingTagRef.current?.sourceDocId === docId; - event.dataTransfer.dropEffect = isSource ? 'copy' : 'move'; + const isSource = draggedSourceId === doc.id; + + // 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], ); - const handleTagDragLeaveDoc = useCallback( + const onTagDragLeave = useCallback( (event: React.DragEvent, _docId: Identifier) => { if (!isTagTransfer(event)) return; // Ignore if leaving to a child element @@ -103,27 +130,7 @@ export const useDeskTagInteractions = ({ [isTagTransfer], ); - const handleCanvasDragOver = useCallback( - (event: React.DragEvent) => { - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'move'; - } - }, - [], - ); - - const handleCanvasDrop = useCallback( - (event: React.DragEvent) => { - // Implicit removal via dragend (dropEffect='move') - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'move'; - } - preventAll(event); - }, - [], - ); - - const handleTagDropOnDoc = useCallback( + const onTagDrop = useCallback( (event: React.DragEvent, doc: Document) => { if (!doc || !doc.id) { return; @@ -134,20 +141,23 @@ export const useDeskTagInteractions = ({ preventAll(event); event.currentTarget.classList.remove('is-tag-target'); - // Add to target const payload = parseTagTransferPayload(event); + if (payload && payload.sourceDocId === doc.id) { + if (event.dataTransfer) event.dataTransfer.dropEffect = 'none'; + return; + } setTimeout(() => { if (!payload || !payload.id) { return; } - if (payload.sourceDocId === doc.id) { - return; - } - 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) { onAssignTagToDocument(doc.id, payload.id); } @@ -156,12 +166,12 @@ export const useDeskTagInteractions = ({ [isTagTransfer, onAssignTagToDocument, requestCanvasFocus], ); - const handleDocTagDragStart = useCallback( + const onTagDragStart = useCallback( (event: React.DragEvent, doc: Document, tag: DocumentTag) => { - console.log('[Tag] handleDocTagDragStart', { docId: doc?.id, tagId: tag?.id }); if (!event?.dataTransfer || !doc?.id || !tag?.id) { return; } + event.stopPropagation(); event.dataTransfer.effectAllowed = 'copyMove'; writeTagTransferData(event.dataTransfer, tag, doc.id); @@ -179,20 +189,18 @@ export const useDeskTagInteractions = ({ draggingTagRef.current = { element, - previewClone: clone || null, - sourceDocId: doc.id, - tagId: tag.id, - initialX: pointerX, - initialY: pointerY, - distance: 0, + previewClone: clone, }; }, [], ); - const handleDocTagDragEnd = useCallback( + const onTagDragEnd = useCallback( (event: React.DragEvent) => { - console.log('[Tag] handleDocTagDragEnd', { dropEffect: event?.dataTransfer?.dropEffect }); + event.stopPropagation(); + const { sourceDocId, tagId } = getActiveDragState(); + clearTagTransferData(); + const dropEffect = event?.dataTransfer?.dropEffect; setTimeout(() => { @@ -206,8 +214,8 @@ export const useDeskTagInteractions = ({ // Remove if move operation completed if (dropEffect === 'move') { - if (onRemoveTagFromDocument && state.sourceDocId && state.tagId) { - onRemoveTagFromDocument(state.sourceDocId, state.tagId); + if (onRemoveTagFromDocument && sourceDocId && tagId) { + onRemoveTagFromDocument(sourceDocId, tagId); } } } @@ -227,13 +235,11 @@ export const useDeskTagInteractions = ({ }, []); return { - handleTagDragEnterDoc, - handleTagDragOverDoc, - handleTagDragLeaveDoc, - handleTagDropOnDoc, - handleDocTagDragStart, - handleDocTagDragEnd, - handleCanvasDragOver, - handleCanvasDrop, + onTagDragEnter, + onTagDragOver, + onTagDragLeave, + onTagDrop, + onTagDragStart, + onTagDragEnd, }; }; diff --git a/frontend/src/documents/interactions/useWorkspaceDragDrop.ts b/frontend/src/documents/interactions/useWorkspaceDragDrop.ts new file mode 100644 index 0000000..02c3881 --- /dev/null +++ b/frontend/src/documents/interactions/useWorkspaceDragDrop.ts @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import type { MutableRefObject } from 'react'; +import { isTagTransferEvent } from '../features/tagging/tagTransfer'; + +interface UseWorkspaceDragDropArgs { + shellRef: MutableRefObject; +} + +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; diff --git a/frontend/src/documents/logic/useDocumentItemLogic.ts b/frontend/src/documents/logic/useDocumentItemLogic.ts index 71602b9..08e7118 100644 --- a/frontend/src/documents/logic/useDocumentItemLogic.ts +++ b/frontend/src/documents/logic/useDocumentItemLogic.ts @@ -1,12 +1,10 @@ import React, { type DragEvent } from 'react'; -import { parseTagTransferPayload } from '../features/tagging/tagTransfer'; import { createDocumentEntryKey } from '../../app/entryKey'; 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 { useDocumentsViewStateContext } from '../context/DocumentsViewStateContext'; import type { DocumentViewLogic } from './useDocumentViewLogic'; -import type { Identifier } from '../../types/identifiers'; interface UseDocumentItemLogicProps { doc: Document; @@ -30,8 +28,8 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => { end: onDocumentTagDragEnd, over: onDocumentTagDragOver, leave: onDocumentTagDragLeave, + drop: onDocumentTagDrop, }, - onAttach: onDocumentTagAttach }, onEntryPointer } = useDocumentsCommandContext(); @@ -79,18 +77,11 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => { }, onDragStart: (event: DragEvent) => onDocumentDragStart?.(event, doc), onDragEnd: (event: DragEvent) => onDocumentDragEnd?.(event), - onDragOver: (event: DragEvent) => onDocumentTagDragOver?.(event, doc.id), - onDragLeave: onDocumentTagDragLeave, - onTagDragStart: (event: DragEvent, tagId: Identifier) => onDocumentTagDragStart?.(event, doc.id, tagId), + onDragOver: (event: DragEvent) => onDocumentTagDragOver?.(event, doc), + onDragLeave: (event: DragEvent) => onDocumentTagDragLeave?.(event, doc), + onTagDragStart: (event: DragEvent, tag: DocumentTag) => onDocumentTagDragStart?.(event, doc, tag), onTagDragEnd: (event: DragEvent) => onDocumentTagDragEnd?.(event), - onDrop: (event: DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - const payload = parseTagTransferPayload(event); - if (payload && payload.id && onDocumentTagAttach) { - onDocumentTagAttach(doc.id, payload.id); - } - }, + onDrop: (event: DragEvent) => { onDocumentTagDrop?.(event, doc); }, onRenameChange: setDocumentDraft, onRenameSubmit: () => submitDocumentEditing(doc), onRenameCancel: (event?: React.SyntheticEvent) => cancelDocumentEditing(event), diff --git a/frontend/src/documents/panel/useDocumentsContextValues.ts b/frontend/src/documents/panel/useDocumentsContextValues.ts index 9a6b4a2..d8499e4 100644 --- a/frontend/src/documents/panel/useDocumentsContextValues.ts +++ b/frontend/src/documents/panel/useDocumentsContextValues.ts @@ -1,10 +1,10 @@ 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'; +import { useTagInteractions } from '../interactions/useTagInteractions'; const EntryType = { folder: 'folder', @@ -37,6 +37,12 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => { toggleCorrespondent: toggleCorrespondentFilter, } = useDocumentsFilter(); + // Handlers + const tagDragHandlers = useTagInteractions({ + onAssignTagToDocument: props.onDocumentTagAttach, + onRemoveTagFromDocument: props.onDocumentTagDetach, + }); + // Derived State const draggingDocumentIdsSet = useMemo( () => new Set(draggingDocumentIds || []), @@ -69,52 +75,6 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => { // Refs const scrollRef = useRef(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) => { @@ -187,10 +147,7 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => { handleFolderClick, handleDocumentDragStartLocal, handleDocumentDragEndLocal, - handleDocumentTagDragStart, - handleDocumentTagDragEnd, - handleDocumentTagDragOver, - handleDocumentTagDragLeave, + tagDragHandlers, toggleTagFilter, toggleCorrespondentFilter, }); @@ -201,10 +158,7 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => { handleFolderClick, handleDocumentDragStartLocal, handleDocumentDragEndLocal, - handleDocumentTagDragStart, - handleDocumentTagDragEnd, - handleDocumentTagDragOver, - handleDocumentTagDragLeave, + tagDragHandlers, toggleTagFilter, toggleCorrespondentFilter, }; @@ -231,10 +185,11 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => { }, 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), + 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), diff --git a/frontend/src/documents/styles/listing.css b/frontend/src/documents/styles/listing.css index d572536..cab7372 100644 --- a/frontend/src/documents/styles/listing.css +++ b/frontend/src/documents/styles/listing.css @@ -698,17 +698,17 @@ opacity: 0.4; } -.documents-panel tbody tr.document.tag-drop-target { +.documents-panel tbody tr.document.is-tag-target { background: var(--accent-soft); 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); border-color: var(--accent); } -.document-card.tag-drop-target .document-card__title { +.document-card.is-tag-target .document-card__title { color: var(--accent); } diff --git a/frontend/src/sidebar/components/SidebarTagList.tsx b/frontend/src/sidebar/components/SidebarTagList.tsx index 776bace..0a3c8c2 100644 --- a/frontend/src/sidebar/components/SidebarTagList.tsx +++ b/frontend/src/sidebar/components/SidebarTagList.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from 'react'; import { PlusIcon, SettingsIcon } from '../../components/icons'; import { getTagColorStyle } from '../../utils/colors'; +import { writeTagTransferData, clearTagTransferData } from '../../documents/features/tagging/tagTransfer'; import type { Identifier } from '../../types/identifiers'; import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext'; @@ -120,18 +121,13 @@ const SidebarTagList: React.FC = ({ draggable onDragStart={(event) => { try { - const payload = JSON.stringify({ - id: tag.id, - label: tag.label, - color: tag.color || null, - }); event.dataTransfer.effectAllowed = 'copy'; - event.dataTransfer.setData('application/x-papercrate-tag', payload); - event.dataTransfer.setData('text/papercrate-tag', payload); + writeTagTransferData(event.dataTransfer, tag); } catch (error) { console.warn('[sidebar] Failed to set tag drag payload', error); } }} + onDragEnd={clearTagTransferData} > {tag.label}