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}
/>
+