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 { 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}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user