feat: Add TagRemovalZone component and refactor drag-drop for tag management
This commit is contained in:
@@ -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 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
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<DocumentsPanelInnerProps> = (props) => {
|
||||
onBreadcrumbClick={onBreadcrumbNavigate}
|
||||
/>
|
||||
<div className="documents-panel-wrapper">
|
||||
<TagRemovalZone />
|
||||
<section
|
||||
ref={scrollRef}
|
||||
className={`documents-panel documents-panel--view-${panelVariant}`}
|
||||
|
||||
Reference in New Issue
Block a user