From 1d127e2122ecb3add9043f6ab515dd806ed3a059 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 9 Dec 2025 01:30:31 +0100 Subject: [PATCH] feat: Add TagRemovalZone component and refactor drag-drop for tag management --- .../documents/components/TagRemovalZone.css | 31 +++++++ .../documents/components/TagRemovalZone.tsx | 70 ++++++++++++++++ .../documents/data/useDocumentsWorkspace.ts | 5 +- .../documents/features/tagging/tagTransfer.ts | 14 ++++ .../interactions/useWorkspaceDragDrop.ts | 80 ------------------- .../src/documents/panel/DocumentsPanel.tsx | 2 + 6 files changed, 118 insertions(+), 84 deletions(-) create mode 100644 frontend/src/documents/components/TagRemovalZone.css create mode 100644 frontend/src/documents/components/TagRemovalZone.tsx delete mode 100644 frontend/src/documents/interactions/useWorkspaceDragDrop.ts diff --git a/frontend/src/documents/components/TagRemovalZone.css b/frontend/src/documents/components/TagRemovalZone.css new file mode 100644 index 0000000..e1b5cc7 --- /dev/null +++ b/frontend/src/documents/components/TagRemovalZone.css @@ -0,0 +1,31 @@ +.tag-removal-zone { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 4rem; + background-color: var(--surface-subtle); + border-top: 2px dashed var(--border); + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + z-index: 6000002; + transition: all 0.2s ease; + pointer-events: all; + gap: 0.5rem; +} + +.tag-removal-zone--drag-over { + height: 6rem; + background: linear-gradient(var(--surface-danger-subtle), var(--surface-danger-subtle)), var(--surface); + border-color: var(--danger); + color: var(--danger); +} + +/* Ensure icon inherits color and overrides .icon class size */ +.tag-removal-zone__icon { + width: 60%; + height: 60%; + stroke: currentColor; +} \ No newline at end of file diff --git a/frontend/src/documents/components/TagRemovalZone.tsx b/frontend/src/documents/components/TagRemovalZone.tsx new file mode 100644 index 0000000..578f20e --- /dev/null +++ b/frontend/src/documents/components/TagRemovalZone.tsx @@ -0,0 +1,70 @@ +import React, { useEffect, useState } from 'react'; +import { subscribeToTagDrag } from '../features/tagging/tagTransfer'; +import { TrashIcon } from '../../components/icons'; +import './TagRemovalZone.css'; + +const TagRemovalZone: React.FC = () => { + const [isVisible, setIsVisible] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const [isInteractive, setIsInteractive] = useState(false); + + useEffect(() => { + // Subscribe to global tag drag state. + // This avoids issues with event bubbling (stopPropagation) preventing window listeners. + return subscribeToTagDrag((state) => { + if (state.sourceDocId) { + setIsVisible(true); + // Delay interactivity to allow drag to start without immediate capture + // and to allow dropping on documents 'behind' the zone if done quickly + setTimeout(() => setIsInteractive(true), 200); + } else { + setIsVisible(false); + setIsDragOver(false); + setIsInteractive(false); + } + }); + }, []); + + const onDragOver = (event: React.DragEvent) => { + if (!isVisible || !isInteractive) return; + event.preventDefault(); + event.stopPropagation(); // Exclusive zone + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + setIsDragOver(true); + }; + + const onDragLeave = () => { + setIsDragOver(false); + }; + + const onDrop = (event: React.DragEvent) => { + if (!isInteractive) return; + event.preventDefault(); + event.stopPropagation(); + // Drop accepted. Browser sets dropEffect='move'. + // Source component's onDragEnd will handle the data removal. + setIsVisible(false); + setIsDragOver(false); + setIsInteractive(false); + }; + + if (!isVisible) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default TagRemovalZone; diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts index 99c7cf7..fb76c3b 100644 --- a/frontend/src/documents/data/useDocumentsWorkspace.ts +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -51,7 +51,6 @@ 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'; @@ -926,9 +925,7 @@ const useDocumentsWorkspace = ({ return () => window.removeEventListener('keydown', handleKeyDown); }, [settingsOpen]); - useWorkspaceDragDrop({ - shellRef, - }); + const { detailPanelProps, diff --git a/frontend/src/documents/features/tagging/tagTransfer.ts b/frontend/src/documents/features/tagging/tagTransfer.ts index 0580ded..1777c35 100644 --- a/frontend/src/documents/features/tagging/tagTransfer.ts +++ b/frontend/src/documents/features/tagging/tagTransfer.ts @@ -44,11 +44,24 @@ interface ActiveDragState { } let activeDragState: ActiveDragState = { tagId: null, sourceDocId: null }; +const listeners = new Set<(state: ActiveDragState) => void>(); export const getActiveDragState = (): ActiveDragState => activeDragState; +export const subscribeToTagDrag = (callback: (state: ActiveDragState) => void): () => void => { + listeners.add(callback); + return () => { + listeners.delete(callback); + }; +}; + +const notifyListeners = () => { + listeners.forEach((cb) => cb(activeDragState)); +}; + export const clearTagTransferData = (): void => { activeDragState = { tagId: null, sourceDocId: null }; + notifyListeners(); }; export const writeTagTransferData = ( @@ -61,6 +74,7 @@ export const writeTagTransferData = ( tagId: tag.id || null, sourceDocId: sourceDocId || null, }; + notifyListeners(); if (!dataTransfer) { return; diff --git a/frontend/src/documents/interactions/useWorkspaceDragDrop.ts b/frontend/src/documents/interactions/useWorkspaceDragDrop.ts deleted file mode 100644 index 47152dc..0000000 --- a/frontend/src/documents/interactions/useWorkspaceDragDrop.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { useEffect } from 'react'; -import type { MutableRefObject } from 'react'; -import { - isTagTransferEvent, - getActiveDragState, -} from '../features/tagging/tagTransfer'; - -interface UseWorkspaceDragDropArgs { - shellRef: MutableRefObject; -} - -const useWorkspaceDragDrop = ({ - shellRef, -}: UseWorkspaceDragDropArgs) => { - - useEffect(() => { - const host = shellRef.current; - if (!host) { - return; - } - - // Allow drop on workspace (global app shell) - const handleTagDragOver = (event: DragEvent) => { - if (!isTagTransferEvent(event)) { - return; - } - - const { sourceDocId } = getActiveDragState(); - - // Only allow "Move" (removal) if the tag came from a document - if (sourceDocId) { - event.preventDefault(); // Necessary to allow dropping - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'move'; - } - } - }; - - const handleTagDragEnter = (event: DragEvent) => { - if (!isTagTransferEvent(event)) { - return; - } - const { sourceDocId } = getActiveDragState(); - if (sourceDocId) { - event.preventDefault(); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'move'; - } - } - }; - - const handleTagDrop = (event: DragEvent) => { - if (!isTagTransferEvent(event)) { - return; - } - - const { sourceDocId } = getActiveDragState(); - - if (sourceDocId) { - // Consume the event so the browser reports 'move' back to the source - event.preventDefault(); - } - }; - - // Use bubbling (false) so children can stopPropagation - host.addEventListener('dragenter', handleTagDragEnter, false); - host.addEventListener('dragover', handleTagDragOver, false); - host.addEventListener('drop', handleTagDrop, false); - - return () => { - host.removeEventListener('dragenter', handleTagDragEnter, false); - host.removeEventListener('dragover', handleTagDragOver, false); - host.removeEventListener('drop', handleTagDrop, false); - }; - }, [shellRef]); - - return {}; -}; - -export default useWorkspaceDragDrop; diff --git a/frontend/src/documents/panel/DocumentsPanel.tsx b/frontend/src/documents/panel/DocumentsPanel.tsx index 4d8a4ee..1c35069 100644 --- a/frontend/src/documents/panel/DocumentsPanel.tsx +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -5,6 +5,7 @@ import React, { useRef, useEffect, } from 'react'; +import TagRemovalZone from '../components/TagRemovalZone'; import { DocumentsList, DocumentsGrid } from '../DocumentsView'; import type { ReactNode } from 'react'; import type { @@ -307,6 +308,7 @@ const DocumentsPanelInner: React.FC = (props) => { onBreadcrumbClick={onBreadcrumbNavigate} />
+