feat: Implement new drag-and-drop system for documents and tags, including workspace-level handling and improved visual feedback.

This commit is contained in:
2025-12-08 23:30:18 +01:00
parent a850f70e24
commit 757913cf00
15 changed files with 293 additions and 355 deletions
@@ -28,20 +28,20 @@ interface DesktopDocumentCardProps {
selection: string[];
requestCanvasFocus?: () => void;
onTagDragEnter?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
onTagDrop?: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
onDocTagDragStart?: (event: React.DragEvent<HTMLElement>, doc: Document, tag: any) => void;
onDocTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
onTagDragStart?: (event: React.DragEvent<HTMLElement>, doc: Document, tag: any) => void;
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
layoutCard: LayoutCard;
}
const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
doc,
style,
shouldLoad,
matchesFilter,
selected,
shouldLoad = false,
matchesFilter = true,
selected = false,
docTagTokens,
ensureAssetUrl,
getDocumentAsset,
@@ -54,8 +54,8 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
onTagDragOver,
onTagDragLeave,
onTagDrop,
onDocTagDragStart,
onDocTagDragEnd,
onTagDragStart,
onTagDragEnd,
layoutCard,
}) => {
const cardPointerHandlers = useCardPointer(
@@ -89,27 +89,10 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
aria-hidden={ariaHidden}
ref={(node) => layoutCard?.setRef(node)}
{...cardPointerHandlers}
onDragEnter={(event) => {
if (doc?.id == null) {
return;
}
onTagDragEnter?.(event, doc.id);
}}
onDragOver={(event) => {
if (doc?.id == null) {
return;
}
onTagDragOver?.(event, doc.id);
}}
onDragLeave={(event) => {
if (doc?.id == null) {
return;
}
onTagDragLeave?.(event, doc.id);
}}
onDrop={(event) => {
onTagDrop?.(event, doc);
}}
onDragEnter={(event) => onTagDragEnter?.(event, doc.id!)}
onDragOver={(event) => onTagDragOver?.(event, doc)}
onDragLeave={(event) => onTagDragLeave?.(event, doc.id!)}
onDrop={(event) => onTagDrop?.(event, doc)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
preventAll(event);
@@ -151,8 +134,8 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
title={tag.label}
draggable
data-desk-tag-chip="true"
onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)}
onDragEnd={(event) => onDocTagDragEnd?.(event)}
onDragStart={(event) => onTagDragStart?.(event, doc, tag)}
onDragEnd={(event) => onTagDragEnd?.(event)}
>
<span className="tag-chip__label">{tag.label}</span>
</span>
@@ -8,7 +8,10 @@ import React, {
import { LayoutStore, LayoutCard } from '../logic/LayoutSystem';
import DesktopDocumentCard from './DesktopDocumentCard';
import usePreviewMetadata from '../hooks/usePreviewMetadata';
import { useDeskTagInteractions } from '../tags/useDeskTagInteractions';
import {
TagDragHandlers,
useTagInteractions,
} from '../../documents/interactions/useTagInteractions';
import './workspace-layout.css';
import './workspace-items.css';
import './workspace-cards.css';
@@ -180,7 +183,7 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
}, []);
// Tag Interactions
const tagInteractions = useDeskTagInteractions({
const tagDragHandlers: TagDragHandlers = useTagInteractions({
onAssignTagToDocument: (docId: string, tagId: string) => {
tags.onAttach?.(docId, tagId);
},
@@ -349,8 +352,6 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
(e.target as Element).releasePointerCapture(e.pointerId);
}
}}
onDrop={tagInteractions.handleCanvasDrop}
onDragOver={tagInteractions.handleCanvasDragOver}
>
{isLayoutReady && items.map((doc, index) => {
const docId = doc.id ? String(doc.id) : `temp-${index}`;
@@ -391,12 +392,7 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
openDocument(doc, isPreview ? 'preview' : 'sidepanel');
}}
layoutCard={layoutCard}
onTagDragEnter={tagInteractions.handleTagDragEnterDoc}
onTagDragOver={tagInteractions.handleTagDragOverDoc}
onTagDragLeave={tagInteractions.handleTagDragLeaveDoc}
onTagDrop={tagInteractions.handleTagDropOnDoc}
onDocTagDragStart={tagInteractions.handleDocTagDragStart}
onDocTagDragEnd={tagInteractions.handleDocTagDragEnd}
{...tagDragHandlers}
onSelect={(ids, extend = false) => {
if (!extend) {
handleSelectionChange(ids);
@@ -30,11 +30,10 @@
outline-offset: 4px;
}
.desk-item.is-tag-target .desk-item__card {
outline: 0.35rem dashed var(--accent);
outline-offset: 0.35rem;
cursor: copy;
}
.desk-item.is-tag-pending .desk-item__card {
@@ -113,14 +112,9 @@
.tag-chip--draggable {
user-select: none;
pointer-events: auto;
cursor: grab;
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
}
.tag-chip--draggable:active {
cursor: grabbing;
}
.tag-chip--draggable.is-drag-hidden {
opacity: 0.4;
}
@@ -1,6 +1,5 @@
import React, { useMemo } from 'react';
import { getTagColorStyle } from '../../utils/colors';
import { writeTagTransferData } from '../features/tagging/tagTransfer';
import type { DocumentTag } from '../../types/documents';
import type { Identifier } from '../../types/identifiers';
@@ -9,8 +8,7 @@ interface DocumentTagsProps {
tagLookupById?: Map<Identifier, DocumentTag> | null;
onTagClick?: (tagId: Identifier) => void;
docId: Identifier;
onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
onTagDragStart?: (event: React.DragEvent<HTMLElement>, tagId: Identifier) => void;
onTagDragStart?: (event: React.DragEvent<HTMLElement>, tag: DocumentTag) => void;
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
}
@@ -19,7 +17,6 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
tagLookupById,
onTagClick,
docId,
onDocumentTagDetach,
onTagDragStart,
onTagDragEnd,
}) => {
@@ -58,24 +55,13 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
} : undefined}
draggable
onDragStart={(event) => {
event.stopPropagation();
try {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copyMove';
}
} catch (error) {
console.warn('[documents] Failed to configure drag effect', error);
}
writeTagTransferData(event.dataTransfer, tag, docId);
if (tagId) {
onTagDragStart?.(event, tagId);
// Let the hook handle the data transfer and UI
if (tagId && onTagDragStart) {
onTagDragStart(event, tag);
}
}}
onDragEnd={(event) => {
event.stopPropagation();
if (event.dataTransfer.dropEffect === 'move' && tagId && onDocumentTagDetach) {
onDocumentTagDetach(docId, tagId);
}
// Let the hook handle the cleanup and logic
onTagDragEnd?.(event);
}}
onKeyDown={clickable ? (event) => {
@@ -9,7 +9,7 @@ interface EntryTagsProps {
onTagClick?: (tagId: Identifier) => void;
docId: Identifier;
onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
onTagDragStart?: (event: React.DragEvent<HTMLElement>, tagId: Identifier) => void;
onTagDragStart?: (event: React.DragEvent<HTMLElement>, tag: DocumentTag) => void;
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
}
@@ -24,10 +24,11 @@ interface DocumentsCommandContextValue {
};
tags: {
onDrag: {
start?: (event: DragEvent<HTMLElement>, docId: Identifier, tagId: Identifier) => void;
start?: (event: DragEvent<HTMLElement>, doc: Document, tag: any) => void;
end?: (event: DragEvent<HTMLElement>) => void;
over?: (event: DragEvent<HTMLElement>, docId: Identifier) => void;
leave?: (event: DragEvent<HTMLElement>) => void;
over?: (event: DragEvent<HTMLElement>, doc: Document) => void;
drop?: (event: DragEvent<HTMLElement>, doc: Document) => void;
leave?: (event: DragEvent<HTMLElement>, doc: Document) => void;
};
onAttach?: (documentId: Identifier, tagId: Identifier) => void;
onDetach?: (documentId: Identifier, tagId: Identifier) => void;
@@ -15,20 +15,13 @@ import {
import AssetManager, { getAssetFromVersion } from '../../lib/assets/AssetManager';
import useNotifyApiError from '../../hooks/useNotifyApiError';
import TagManager from '../../lib/assets/TagManager';
import { useManagementModals } from '../../app/useManagementModals';
import { useAppDispatch, useAppState } from '../../lib/store/appState';
import { fetchAsset, listFolderContents } from '../../lib/api/apiClient';
import { useApi } from '../../lib/context/ApiContext';
import { useWorkspaceSelection } from '../../app/useWorkspaceSelection';
import { fetchAsset } from '../../lib/api/apiClient';
import { useEntryPointer as useEntryPointerCore } from '../features/selection/useEntryPointer';
import { isTagTransferEvent } from '../features/tagging/tagTransfer';
import useDocumentsSelection from '../features/selection/useDocumentsSelection';
import useBulkDocumentActions from './useBulkDocumentActions';
import useDocumentPreview from '../../app/useDocumentPreview';
import {
DEFAULT_SORT_DIRECTION,
DEFAULT_SORT_FIELD,
createRootNode,
mergeAssetIntoDocument,
} from '../../app/workspaceUtils';
import {
@@ -57,6 +50,15 @@ import useDocumentCorrespondentActions from '../features/correspondents/useDocum
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';
import { useApi } from '../../lib/context/ApiContext';
import { useWorkspaceSelection } from '../../app/useWorkspaceSelection';
import useDocumentPreview from '../../app/useDocumentPreview';
import { createRootNode } from '../../app/workspaceUtils';
import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers';
const EntryType = Object.freeze({
@@ -148,24 +150,8 @@ const useDocumentsWorkspace = ({
const [creatingFolder, setCreatingFolder] = useState(false);
const { handleLogout } = useAuthManager({});
const tagRemovalCursorActiveRef = useRef(false);
const tenantIdRef = useRef(currentTenantId);
const detailPanelControlRef = useRef({ open: () => { }, close: () => { } });
const setTagRemovalCursor = useCallback((active) => {
if (tagRemovalCursorActiveRef.current === active) {
return;
}
const body = document.body;
if (!body) {
return;
}
tagRemovalCursorActiveRef.current = active;
if (active) {
body.classList.add('desk-cursor-remove');
} else {
body.classList.remove('desk-cursor-remove');
}
}, []);
const documentsRouteMatch = useMatch('/documents');
const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId');
const documentsDetailRouteMatch = useMatch('/documents/:documentId');
@@ -393,59 +379,18 @@ const useDocumentsWorkspace = ({
});
const documentsFilter = documentsFilterValue;
const [visibleDocumentIds, setVisibleDocumentIds] = useState<DocumentId[]>([]);
const showingSearchResults = searchResultIds !== null;
useEffect(() => {
const arraysEqual = (a: DocumentId[], b: DocumentId[]) =>
a.length === b.length && a.every((value, index) => value === b[index]);
if (showingSearchResults && Array.isArray(searchResultIds)) {
const ids = searchResultIds.filter((id): id is DocumentId => id != null);
setVisibleDocumentIds((prev) => (arraysEqual(prev, ids) ? prev : ids));
return;
}
const folderIds = documents
.map((doc) => (doc?.id ?? null) as DocumentId | null)
.filter((id): id is DocumentId => id != null);
setVisibleDocumentIds((prev) => (arraysEqual(prev, folderIds) ? prev : folderIds));
}, [showingSearchResults, searchResultIds, documents]);
const viewDocuments = useMemo(
() =>
visibleDocumentIds
.map((id) => documentLookup.get(id) || null)
.filter((doc): doc is Document => Boolean(doc)),
[visibleDocumentIds, documentLookup],
);
const visibleDocumentKeys = useMemo(
() => visibleDocumentIds.map((id) => createDocumentEntryKey(id)).filter(Boolean),
[visibleDocumentIds],
);
const visibleFolderKeys = useMemo(
() =>
showingSearchResults
? []
: (currentSubfolders || [])
.map((folder) => createFolderEntryKey(folder.id))
.filter(Boolean),
[showingSearchResults, currentSubfolders],
);
const visibleEntryKeys = useMemo(
() => [...visibleFolderKeys, ...visibleDocumentKeys],
[visibleFolderKeys, visibleDocumentKeys],
);
const visibleEntryKeySet = useMemo(
() => new Set(visibleEntryKeys),
[visibleEntryKeys],
);
const {
viewDocuments,
visibleEntryKeySet,
} = useWorkspaceViewData({
documents,
documentLookup,
searchResultIds,
showingSearchResults,
currentSubfolders,
});
const {
openDocumentPreview,
@@ -981,67 +926,9 @@ const useDocumentsWorkspace = ({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [settingsOpen]);
useEffect(
() => () => {
setTagRemovalCursor(false);
},
[setTagRemovalCursor],
);
useEffect(() => {
const host = shellRef.current;
if (!host) {
return undefined;
}
const isTagTransfer = (event) => isTagTransferEvent(event);
const isDocumentDropTarget = (target) =>
target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false;
const handleTagDragOver = (event) => {
if (!isTagTransfer(event)) {
return;
}
if (isDocumentDropTarget(event.target)) {
setTagRemovalCursor(false);
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setTagRemovalCursor(true);
};
const handleTagDragLeave = (event) => {
if (!isTagTransfer(event)) {
return;
}
const related = event.relatedTarget;
if (related instanceof Element && host.contains(related)) {
if (isDocumentDropTarget(related)) {
setTagRemovalCursor(false);
}
return;
}
setTagRemovalCursor(false);
};
const handleTagDragEnd = () => {
setTagRemovalCursor(false);
};
host.addEventListener('dragover', handleTagDragOver, true);
host.addEventListener('dragleave', handleTagDragLeave, true);
window.addEventListener('dragend', handleTagDragEnd, true);
return () => {
host.removeEventListener('dragover', handleTagDragOver, true);
host.removeEventListener('dragleave', handleTagDragLeave, true);
window.removeEventListener('dragend', handleTagDragEnd, true);
setTagRemovalCursor(false);
};
}, [handleDocumentTagDetach, setTagRemovalCursor]);
useWorkspaceDragDrop({
shellRef,
});
const {
detailPanelProps,
@@ -0,0 +1,80 @@
import { useEffect, useMemo, useState } from 'react';
import type { DocumentId } from '../../types/identifiers';
import type { Document } from '../../types/documents';
import { createDocumentEntryKey, createFolderEntryKey } from '../../app/entryKey';
interface UseWorkspaceViewDataArgs {
documents: Document[];
documentLookup: Map<DocumentId, Document>;
searchResultIds: DocumentId[] | null;
showingSearchResults: boolean;
currentSubfolders: any[];
}
const useWorkspaceViewData = ({
documents,
documentLookup,
searchResultIds,
showingSearchResults,
currentSubfolders,
}: UseWorkspaceViewDataArgs) => {
const [visibleDocumentIds, setVisibleDocumentIds] = useState<DocumentId[]>([]);
useEffect(() => {
const arraysEqual = (a: DocumentId[], b: DocumentId[]) =>
a.length === b.length && a.every((value, index) => value === b[index]);
if (showingSearchResults && Array.isArray(searchResultIds)) {
const ids = searchResultIds.filter((id): id is DocumentId => id != null);
setVisibleDocumentIds((prev) => (arraysEqual(prev, ids) ? prev : ids));
return;
}
const folderIds = documents
.map((doc) => (doc?.id ?? null) as DocumentId | null)
.filter((id): id is DocumentId => id != null);
setVisibleDocumentIds((prev) => (arraysEqual(prev, folderIds) ? prev : folderIds));
}, [showingSearchResults, searchResultIds, documents]);
const viewDocuments = useMemo(
() =>
visibleDocumentIds
.map((id) => documentLookup.get(id) || null)
.filter((doc): doc is Document => Boolean(doc)),
[visibleDocumentIds, documentLookup],
);
const visibleDocumentKeys = useMemo(
() => visibleDocumentIds.map((id) => createDocumentEntryKey(id)).filter(Boolean),
[visibleDocumentIds],
);
const visibleFolderKeys = useMemo(
() =>
showingSearchResults
? []
: (currentSubfolders || [])
.map((folder: any) => createFolderEntryKey(folder.id))
.filter(Boolean),
[showingSearchResults, currentSubfolders],
);
const visibleEntryKeys = useMemo(
() => [...visibleFolderKeys, ...visibleDocumentKeys],
[visibleFolderKeys, visibleDocumentKeys],
);
const visibleEntryKeySet = useMemo(
() => new Set(visibleEntryKeys),
[visibleEntryKeys],
);
return {
viewDocuments,
visibleDocumentIds,
visibleEntryKeys,
visibleEntryKeySet,
};
};
export default useWorkspaceViewData;
@@ -36,11 +36,32 @@ const createTagTransferPayload = (
};
};
// Shared state to track dragged tag ID across components (Sidebar <-> Workspace)
// This is necessary because dataTransfer payload is inaccessible during dragOver.
interface ActiveDragState {
tagId: TagId | null;
sourceDocId: DocumentId | null;
}
let activeDragState: ActiveDragState = { tagId: null, sourceDocId: null };
export const getActiveDragState = (): ActiveDragState => activeDragState;
export const clearTagTransferData = (): void => {
activeDragState = { tagId: null, sourceDocId: null };
};
export const writeTagTransferData = (
dataTransfer: DataTransfer | null,
tag: TagLike,
sourceDocId: DocumentId | null = null,
): void => {
// Track globally for cursor logic
activeDragState = {
tagId: tag.id || null,
sourceDocId: sourceDocId || null,
};
if (!dataTransfer) {
return;
}
@@ -56,7 +77,9 @@ export const writeTagTransferData = (
}
try {
TAG_MIME_TYPES.forEach((type) => dataTransfer.setData(type, serialized));
TAG_MIME_TYPES.forEach((mime) => {
dataTransfer.setData(mime, serialized);
});
if (payload.label) {
dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label);
}
@@ -8,6 +8,8 @@ import {
isTagTransferEvent,
parseTagTransferPayload,
writeTagTransferData,
getActiveDragState,
clearTagTransferData,
} from '../../documents/features/tagging/tagTransfer';
import type { Identifier } from '../../types/identifiers';
import type { Document, DocumentTag } from '../../types/documents';
@@ -42,7 +44,7 @@ const cleanupPreview = (previewNode: HTMLElement | null) => {
}
};
interface UseDeskTagInteractionsArgs {
interface UseTagInteractionsArgs {
onAssignTagToDocument?: (docId: Identifier, tagId: Identifier) => void;
onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier) => void;
requestCanvasFocus?: () => void;
@@ -50,24 +52,28 @@ interface UseDeskTagInteractionsArgs {
interface DraggingTagState {
element: HTMLElement | null;
previewClone: HTMLElement | null;
sourceDocId: Identifier;
tagId: Identifier;
initialX: number;
initialY: number;
distance: number;
previewClone?: HTMLElement;
}
export const useDeskTagInteractions = ({
export interface TagDragHandlers {
onTagDragEnter: (event: React.DragEvent<HTMLDivElement>, docId: Identifier) => void;
onTagDragOver: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
onTagDragLeave: (event: React.DragEvent<HTMLDivElement>, docId: Identifier) => void;
onTagDrop: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
onTagDragStart: (event: React.DragEvent<HTMLElement>, doc: Document, tag: DocumentTag) => void;
onTagDragEnd: (event: React.DragEvent<HTMLElement>) => void;
}
export const useTagInteractions = ({
onAssignTagToDocument,
onRemoveTagFromDocument,
requestCanvasFocus,
}: UseDeskTagInteractionsArgs) => {
}: UseTagInteractionsArgs): TagDragHandlers => {
const draggingTagRef = useRef<DraggingTagState | null>(null);
const isTagTransfer = useCallback((event: React.DragEvent) => isTagTransferEvent(event), []);
const handleTagDragEnterDoc = useCallback(
const onTagDragEnter = useCallback(
(event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => {
if (!isTagTransfer(event)) return;
preventAll(event);
@@ -76,22 +82,43 @@ export const useDeskTagInteractions = ({
[isTagTransfer],
);
const handleTagDragOverDoc = useCallback(
(event: React.DragEvent<HTMLDivElement>, docId: Identifier) => {
const onTagDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>, doc: Document) => {
if (!doc || !doc.id) return;
if (!isTagTransfer(event)) return;
preventAll(event);
event.currentTarget.classList.add('is-tag-target');
// Use shared state for all logic (Single Source of Truth)
const { tagId: draggedTagId, sourceDocId: draggedSourceId } = getActiveDragState();
const isAssigned = doc.tags?.some((t) => t.id === draggedTagId);
if (event.dataTransfer) {
// If dragging over source, copy (no removal). Else move (removal).
const isSource = draggingTagRef.current?.sourceDocId === docId;
event.dataTransfer.dropEffect = isSource ? 'copy' : 'move';
const isSource = draggedSourceId === doc.id;
// Otherwise: separate document.
if (isSource || isAssigned) {
event.dataTransfer.dropEffect = 'none';
event.currentTarget.classList.remove('is-tag-target');
return;
}
const isFromDocument = !!draggedSourceId;
if (isFromDocument) {
// Default to Move (transfer), allow Copy with Alt key
event.dataTransfer.dropEffect = event.altKey ? 'copy' : 'move';
} else {
// Sidebar or external source: Copy only
event.dataTransfer.dropEffect = 'copy';
}
event.currentTarget.classList.add('is-tag-target');
}
},
[isTagTransfer],
);
const handleTagDragLeaveDoc = useCallback(
const onTagDragLeave = useCallback(
(event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => {
if (!isTagTransfer(event)) return;
// Ignore if leaving to a child element
@@ -103,27 +130,7 @@ export const useDeskTagInteractions = ({
[isTagTransfer],
);
const handleCanvasDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
},
[],
);
const handleCanvasDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
// Implicit removal via dragend (dropEffect='move')
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
preventAll(event);
},
[],
);
const handleTagDropOnDoc = useCallback(
const onTagDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>, doc: Document) => {
if (!doc || !doc.id) {
return;
@@ -134,20 +141,23 @@ export const useDeskTagInteractions = ({
preventAll(event);
event.currentTarget.classList.remove('is-tag-target');
// Add to target
const payload = parseTagTransferPayload(event);
if (payload && payload.sourceDocId === doc.id) {
if (event.dataTransfer) event.dataTransfer.dropEffect = 'none';
return;
}
setTimeout(() => {
if (!payload || !payload.id) {
return;
}
if (payload.sourceDocId === doc.id) {
return;
}
requestCanvasFocus?.();
// Double-check assignment (even though cursor logic tries to prevent it)
const isAssigned = doc.tags?.some((t) => t.id === payload.id);
if (isAssigned) return;
if (onAssignTagToDocument && doc.id) {
onAssignTagToDocument(doc.id, payload.id);
}
@@ -156,12 +166,12 @@ export const useDeskTagInteractions = ({
[isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
);
const handleDocTagDragStart = useCallback(
const onTagDragStart = useCallback(
(event: React.DragEvent<HTMLElement>, doc: Document, tag: DocumentTag) => {
console.log('[Tag] handleDocTagDragStart', { docId: doc?.id, tagId: tag?.id });
if (!event?.dataTransfer || !doc?.id || !tag?.id) {
return;
}
event.stopPropagation();
event.dataTransfer.effectAllowed = 'copyMove';
writeTagTransferData(event.dataTransfer, tag, doc.id);
@@ -179,20 +189,18 @@ export const useDeskTagInteractions = ({
draggingTagRef.current = {
element,
previewClone: clone || null,
sourceDocId: doc.id,
tagId: tag.id,
initialX: pointerX,
initialY: pointerY,
distance: 0,
previewClone: clone,
};
},
[],
);
const handleDocTagDragEnd = useCallback(
const onTagDragEnd = useCallback(
(event: React.DragEvent<HTMLElement>) => {
console.log('[Tag] handleDocTagDragEnd', { dropEffect: event?.dataTransfer?.dropEffect });
event.stopPropagation();
const { sourceDocId, tagId } = getActiveDragState();
clearTagTransferData();
const dropEffect = event?.dataTransfer?.dropEffect;
setTimeout(() => {
@@ -206,8 +214,8 @@ export const useDeskTagInteractions = ({
// Remove if move operation completed
if (dropEffect === 'move') {
if (onRemoveTagFromDocument && state.sourceDocId && state.tagId) {
onRemoveTagFromDocument(state.sourceDocId, state.tagId);
if (onRemoveTagFromDocument && sourceDocId && tagId) {
onRemoveTagFromDocument(sourceDocId, tagId);
}
}
}
@@ -227,13 +235,11 @@ export const useDeskTagInteractions = ({
}, []);
return {
handleTagDragEnterDoc,
handleTagDragOverDoc,
handleTagDragLeaveDoc,
handleTagDropOnDoc,
handleDocTagDragStart,
handleDocTagDragEnd,
handleCanvasDragOver,
handleCanvasDrop,
onTagDragEnter,
onTagDragOver,
onTagDragLeave,
onTagDrop,
onTagDragStart,
onTagDragEnd,
};
};
@@ -0,0 +1,40 @@
import { useEffect } from 'react';
import type { MutableRefObject } from 'react';
import { isTagTransferEvent } 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
const handleTagDragOver = (event: DragEvent) => {
if (!isTagTransferEvent(event)) {
return;
}
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
};
// Use bubbling (false) so children can stopPropagation
host.addEventListener('dragover', handleTagDragOver, false);
return () => {
host.removeEventListener('dragover', handleTagDragOver, false);
};
}, [shellRef]);
return {};
};
export default useWorkspaceDragDrop;
@@ -1,12 +1,10 @@
import React, { type DragEvent } from 'react';
import { parseTagTransferPayload } from '../features/tagging/tagTransfer';
import { createDocumentEntryKey } from '../../app/entryKey';
import { useDocumentOpen } from '../../lib/context/DocumentOpenContext';
import type { Document } from '../../types/documents';
import type { Document, DocumentTag } from '../../types/documents';
import { useDocumentsCommandContext } from '../context/DocumentsCommandContext';
import { useDocumentsViewStateContext } from '../context/DocumentsViewStateContext';
import type { DocumentViewLogic } from './useDocumentViewLogic';
import type { Identifier } from '../../types/identifiers';
interface UseDocumentItemLogicProps {
doc: Document;
@@ -30,8 +28,8 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
end: onDocumentTagDragEnd,
over: onDocumentTagDragOver,
leave: onDocumentTagDragLeave,
drop: onDocumentTagDrop,
},
onAttach: onDocumentTagAttach
},
onEntryPointer
} = useDocumentsCommandContext();
@@ -79,18 +77,11 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
},
onDragStart: (event: DragEvent<HTMLElement>) => onDocumentDragStart?.(event, doc),
onDragEnd: (event: DragEvent<HTMLElement>) => onDocumentDragEnd?.(event),
onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event, doc.id),
onDragLeave: onDocumentTagDragLeave,
onTagDragStart: (event: DragEvent<HTMLElement>, tagId: Identifier) => onDocumentTagDragStart?.(event, doc.id, tagId),
onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event, doc),
onDragLeave: (event: DragEvent<HTMLElement>) => onDocumentTagDragLeave?.(event, doc),
onTagDragStart: (event: DragEvent<HTMLElement>, tag: DocumentTag) => onDocumentTagDragStart?.(event, doc, tag),
onTagDragEnd: (event: DragEvent<HTMLElement>) => onDocumentTagDragEnd?.(event),
onDrop: (event: DragEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
const payload = parseTagTransferPayload(event);
if (payload && payload.id && onDocumentTagAttach) {
onDocumentTagAttach(doc.id, payload.id);
}
},
onDrop: (event: DragEvent<HTMLElement>) => { onDocumentTagDrop?.(event, doc); },
onRenameChange: setDocumentDraft,
onRenameSubmit: () => submitDocumentEditing(doc),
onRenameCancel: (event?: React.SyntheticEvent) => cancelDocumentEditing(event),
@@ -1,10 +1,10 @@
import { useMemo, useCallback, useRef } from 'react';
import type { DocumentsPanelInnerProps } from './DocumentsPanel';
import { isTagTransferEvent } from '../features/tagging/tagTransfer';
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../features/selection/useEntryPointer';
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
import type { Identifier } from '../../types/identifiers';
import { useTagInteractions } from '../interactions/useTagInteractions';
const EntryType = {
folder: 'folder',
@@ -37,6 +37,12 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
toggleCorrespondent: toggleCorrespondentFilter,
} = useDocumentsFilter();
// Handlers
const tagDragHandlers = useTagInteractions({
onAssignTagToDocument: props.onDocumentTagAttach,
onRemoveTagFromDocument: props.onDocumentTagDetach,
});
// Derived State
const draggingDocumentIdsSet = useMemo(
() => new Set(draggingDocumentIds || []),
@@ -69,52 +75,6 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
// Refs
const scrollRef = useRef<HTMLElement | null>(null);
const suppressDocumentClickRef = useRef(false);
const draggingTagRef = useRef<{ docId: Identifier; tagId: Identifier } | null>(null);
// Handlers
const isTagDragEvent = useCallback((event: any) => isTagTransferEvent(event), []);
const handleDocumentTagDragStart = useCallback(
(_event: any, docId: Identifier, tagId: Identifier) => {
draggingTagRef.current = { docId, tagId };
},
[],
);
const handleDocumentTagDragEnd = useCallback(
(_event: any) => {
draggingTagRef.current = null;
},
[],
);
const handleDocumentTagDragOver = useCallback(
(event: any, docId: Identifier) => {
if (!isTagDragEvent(event)) {
return;
}
event.preventDefault();
const isSource = draggingTagRef.current?.docId === docId;
event.dataTransfer.dropEffect = isSource ? 'copy' : 'move';
event.currentTarget.classList.add('tag-drop-target');
},
[isTagDragEvent],
);
const handleDocumentTagDragLeave = useCallback(
(event: any) => {
if (!isTagDragEvent(event)) {
return;
}
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
return;
}
event.currentTarget.classList.remove('tag-drop-target');
},
[isTagDragEvent],
);
const handleFolderClick = useCallback(
(folder: any, event: any) => {
@@ -187,10 +147,7 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
handleFolderClick,
handleDocumentDragStartLocal,
handleDocumentDragEndLocal,
handleDocumentTagDragStart,
handleDocumentTagDragEnd,
handleDocumentTagDragOver,
handleDocumentTagDragLeave,
tagDragHandlers,
toggleTagFilter,
toggleCorrespondentFilter,
});
@@ -201,10 +158,7 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
handleFolderClick,
handleDocumentDragStartLocal,
handleDocumentDragEndLocal,
handleDocumentTagDragStart,
handleDocumentTagDragEnd,
handleDocumentTagDragOver,
handleDocumentTagDragLeave,
tagDragHandlers,
toggleTagFilter,
toggleCorrespondentFilter,
};
@@ -231,10 +185,11 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
},
tags: {
onDrag: {
start: (e: any, d: any, t: any) => latestHandlersRef.current.handleDocumentTagDragStart(e, d, t),
end: (e: any) => latestHandlersRef.current.handleDocumentTagDragEnd(e),
over: (e: any, d: any) => latestHandlersRef.current.handleDocumentTagDragOver(e, d),
leave: (e: any) => latestHandlersRef.current.handleDocumentTagDragLeave(e),
start: (e: any, d: any, t: any) => latestHandlersRef.current.tagDragHandlers.onTagDragStart(e, d, t),
end: (e: any) => latestHandlersRef.current.tagDragHandlers.onTagDragEnd(e),
over: (e: any, d: any) => latestHandlersRef.current.tagDragHandlers.onTagDragOver(e, d),
leave: (e: any, d: any) => latestHandlersRef.current.tagDragHandlers.onTagDragLeave(e, d.id),
drop: (e: any, d: any) => latestHandlersRef.current.tagDragHandlers.onTagDrop(e, d),
},
onAttach: (d: any, t: any) => latestPropsRef.current.onDocumentTagAttach?.(d, t),
onDetach: (d: any, t: any) => latestPropsRef.current.onDocumentTagDetach?.(d, t),
+3 -3
View File
@@ -698,17 +698,17 @@
opacity: 0.4;
}
.documents-panel tbody tr.document.tag-drop-target {
.documents-panel tbody tr.document.is-tag-target {
background: var(--accent-soft);
box-shadow: inset 0 0 0 2px var(--accent);
}
.document-card.tag-drop-target {
.document-card.is-tag-target {
box-shadow: 0 0 0 2px var(--accent);
border-color: var(--accent);
}
.document-card.tag-drop-target .document-card__title {
.document-card.is-tag-target .document-card__title {
color: var(--accent);
}
@@ -1,6 +1,7 @@
import React, { useCallback, useMemo } from 'react';
import { PlusIcon, SettingsIcon } from '../../components/icons';
import { getTagColorStyle } from '../../utils/colors';
import { writeTagTransferData, clearTagTransferData } from '../../documents/features/tagging/tagTransfer';
import type { Identifier } from '../../types/identifiers';
import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext';
@@ -120,18 +121,13 @@ const SidebarTagList: React.FC<SidebarTagListProps> = ({
draggable
onDragStart={(event) => {
try {
const payload = JSON.stringify({
id: tag.id,
label: tag.label,
color: tag.color || null,
});
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('application/x-papercrate-tag', payload);
event.dataTransfer.setData('text/papercrate-tag', payload);
writeTagTransferData(event.dataTransfer, tag);
} catch (error) {
console.warn('[sidebar] Failed to set tag drag payload', error);
}
}}
onDragEnd={clearTagTransferData}
>
{tag.label}
</button>