feat: Add TagRemovalZone component and refactor drag-drop for tag management

This commit is contained in:
2025-12-09 01:30:31 +01:00
parent aaa4a9b577
commit 1d127e2122
6 changed files with 118 additions and 84 deletions
@@ -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;
}
@@ -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 (
<div
className={`tag-removal-zone ${isDragOver ? 'tag-removal-zone--drag-over' : ''}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
style={{ pointerEvents: isInteractive ? 'all' : 'none' }}
>
<TrashIcon className="tag-removal-zone__icon" />
</div>
);
};
export default TagRemovalZone;
@@ -51,7 +51,6 @@ 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 useWorkspaceViewData from './useWorkspaceViewData';
import useWorkspaceDragDrop from '../interactions/useWorkspaceDragDrop';
import { useManagementModals } from '../../app/useManagementModals'; import { useManagementModals } from '../../app/useManagementModals';
import { useAppDispatch, useAppState } from '../../lib/store/appState'; import { useAppDispatch, useAppState } from '../../lib/store/appState';
import { listFolderContents } from '../../lib/api/apiClient'; import { listFolderContents } from '../../lib/api/apiClient';
@@ -926,9 +925,7 @@ const useDocumentsWorkspace = ({
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [settingsOpen]); }, [settingsOpen]);
useWorkspaceDragDrop({
shellRef,
});
const { const {
detailPanelProps, detailPanelProps,
@@ -44,11 +44,24 @@ interface ActiveDragState {
} }
let activeDragState: ActiveDragState = { tagId: null, sourceDocId: null }; let activeDragState: ActiveDragState = { tagId: null, sourceDocId: null };
const listeners = new Set<(state: ActiveDragState) => void>();
export const getActiveDragState = (): ActiveDragState => activeDragState; 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 => { export const clearTagTransferData = (): void => {
activeDragState = { tagId: null, sourceDocId: null }; activeDragState = { tagId: null, sourceDocId: null };
notifyListeners();
}; };
export const writeTagTransferData = ( export const writeTagTransferData = (
@@ -61,6 +74,7 @@ export const writeTagTransferData = (
tagId: tag.id || null, tagId: tag.id || null,
sourceDocId: sourceDocId || null, sourceDocId: sourceDocId || null,
}; };
notifyListeners();
if (!dataTransfer) { if (!dataTransfer) {
return; return;
@@ -1,80 +0,0 @@
import { useEffect } from 'react';
import type { MutableRefObject } from 'react';
import {
isTagTransferEvent,
getActiveDragState,
} 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 (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;
@@ -5,6 +5,7 @@ import React, {
useRef, useRef,
useEffect, useEffect,
} from 'react'; } from 'react';
import TagRemovalZone from '../components/TagRemovalZone';
import { DocumentsList, DocumentsGrid } from '../DocumentsView'; import { DocumentsList, DocumentsGrid } from '../DocumentsView';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { import type {
@@ -307,6 +308,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = (props) => {
onBreadcrumbClick={onBreadcrumbNavigate} onBreadcrumbClick={onBreadcrumbNavigate}
/> />
<div className="documents-panel-wrapper"> <div className="documents-panel-wrapper">
<TagRemovalZone />
<section <section
ref={scrollRef} ref={scrollRef}
className={`documents-panel documents-panel--view-${panelVariant}`} className={`documents-panel documents-panel--view-${panelVariant}`}