feat: Implement new drag-and-drop system for documents and tags, including workspace-level handling and improved visual feedback.
This commit is contained in:
@@ -28,20 +28,20 @@ interface DesktopDocumentCardProps {
|
|||||||
selection: string[];
|
selection: string[];
|
||||||
requestCanvasFocus?: () => void;
|
requestCanvasFocus?: () => void;
|
||||||
onTagDragEnter?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => 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;
|
onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
|
||||||
onTagDrop?: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
|
onTagDrop?: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
|
||||||
onDocTagDragStart?: (event: React.DragEvent<HTMLElement>, doc: Document, tag: any) => void;
|
onTagDragStart?: (event: React.DragEvent<HTMLElement>, doc: Document, tag: any) => void;
|
||||||
onDocTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
||||||
layoutCard: LayoutCard;
|
layoutCard: LayoutCard;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
||||||
doc,
|
doc,
|
||||||
style,
|
style,
|
||||||
shouldLoad,
|
shouldLoad = false,
|
||||||
matchesFilter,
|
matchesFilter = true,
|
||||||
selected,
|
selected = false,
|
||||||
docTagTokens,
|
docTagTokens,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
@@ -54,8 +54,8 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
onTagDragOver,
|
onTagDragOver,
|
||||||
onTagDragLeave,
|
onTagDragLeave,
|
||||||
onTagDrop,
|
onTagDrop,
|
||||||
onDocTagDragStart,
|
onTagDragStart,
|
||||||
onDocTagDragEnd,
|
onTagDragEnd,
|
||||||
layoutCard,
|
layoutCard,
|
||||||
}) => {
|
}) => {
|
||||||
const cardPointerHandlers = useCardPointer(
|
const cardPointerHandlers = useCardPointer(
|
||||||
@@ -89,27 +89,10 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
aria-hidden={ariaHidden}
|
aria-hidden={ariaHidden}
|
||||||
ref={(node) => layoutCard?.setRef(node)}
|
ref={(node) => layoutCard?.setRef(node)}
|
||||||
{...cardPointerHandlers}
|
{...cardPointerHandlers}
|
||||||
onDragEnter={(event) => {
|
onDragEnter={(event) => onTagDragEnter?.(event, doc.id!)}
|
||||||
if (doc?.id == null) {
|
onDragOver={(event) => onTagDragOver?.(event, doc)}
|
||||||
return;
|
onDragLeave={(event) => onTagDragLeave?.(event, doc.id!)}
|
||||||
}
|
onDrop={(event) => onTagDrop?.(event, doc)}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
preventAll(event);
|
preventAll(event);
|
||||||
@@ -151,8 +134,8 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
title={tag.label}
|
title={tag.label}
|
||||||
draggable
|
draggable
|
||||||
data-desk-tag-chip="true"
|
data-desk-tag-chip="true"
|
||||||
onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)}
|
onDragStart={(event) => onTagDragStart?.(event, doc, tag)}
|
||||||
onDragEnd={(event) => onDocTagDragEnd?.(event)}
|
onDragEnd={(event) => onTagDragEnd?.(event)}
|
||||||
>
|
>
|
||||||
<span className="tag-chip__label">{tag.label}</span>
|
<span className="tag-chip__label">{tag.label}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import React, {
|
|||||||
import { LayoutStore, LayoutCard } from '../logic/LayoutSystem';
|
import { LayoutStore, LayoutCard } from '../logic/LayoutSystem';
|
||||||
import DesktopDocumentCard from './DesktopDocumentCard';
|
import DesktopDocumentCard from './DesktopDocumentCard';
|
||||||
import usePreviewMetadata from '../hooks/usePreviewMetadata';
|
import usePreviewMetadata from '../hooks/usePreviewMetadata';
|
||||||
import { useDeskTagInteractions } from '../tags/useDeskTagInteractions';
|
import {
|
||||||
|
TagDragHandlers,
|
||||||
|
useTagInteractions,
|
||||||
|
} from '../../documents/interactions/useTagInteractions';
|
||||||
import './workspace-layout.css';
|
import './workspace-layout.css';
|
||||||
import './workspace-items.css';
|
import './workspace-items.css';
|
||||||
import './workspace-cards.css';
|
import './workspace-cards.css';
|
||||||
@@ -180,7 +183,7 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Tag Interactions
|
// Tag Interactions
|
||||||
const tagInteractions = useDeskTagInteractions({
|
const tagDragHandlers: TagDragHandlers = useTagInteractions({
|
||||||
onAssignTagToDocument: (docId: string, tagId: string) => {
|
onAssignTagToDocument: (docId: string, tagId: string) => {
|
||||||
tags.onAttach?.(docId, tagId);
|
tags.onAttach?.(docId, tagId);
|
||||||
},
|
},
|
||||||
@@ -349,8 +352,6 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDrop={tagInteractions.handleCanvasDrop}
|
|
||||||
onDragOver={tagInteractions.handleCanvasDragOver}
|
|
||||||
>
|
>
|
||||||
{isLayoutReady && items.map((doc, index) => {
|
{isLayoutReady && items.map((doc, index) => {
|
||||||
const docId = doc.id ? String(doc.id) : `temp-${index}`;
|
const docId = doc.id ? String(doc.id) : `temp-${index}`;
|
||||||
@@ -391,12 +392,7 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
openDocument(doc, isPreview ? 'preview' : 'sidepanel');
|
openDocument(doc, isPreview ? 'preview' : 'sidepanel');
|
||||||
}}
|
}}
|
||||||
layoutCard={layoutCard}
|
layoutCard={layoutCard}
|
||||||
onTagDragEnter={tagInteractions.handleTagDragEnterDoc}
|
{...tagDragHandlers}
|
||||||
onTagDragOver={tagInteractions.handleTagDragOverDoc}
|
|
||||||
onTagDragLeave={tagInteractions.handleTagDragLeaveDoc}
|
|
||||||
onTagDrop={tagInteractions.handleTagDropOnDoc}
|
|
||||||
onDocTagDragStart={tagInteractions.handleDocTagDragStart}
|
|
||||||
onDocTagDragEnd={tagInteractions.handleDocTagDragEnd}
|
|
||||||
onSelect={(ids, extend = false) => {
|
onSelect={(ids, extend = false) => {
|
||||||
if (!extend) {
|
if (!extend) {
|
||||||
handleSelectionChange(ids);
|
handleSelectionChange(ids);
|
||||||
|
|||||||
@@ -30,11 +30,10 @@
|
|||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.desk-item.is-tag-target .desk-item__card {
|
.desk-item.is-tag-target .desk-item__card {
|
||||||
outline: 0.35rem dashed var(--accent);
|
outline: 0.35rem dashed var(--accent);
|
||||||
outline-offset: 0.35rem;
|
outline-offset: 0.35rem;
|
||||||
|
cursor: copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desk-item.is-tag-pending .desk-item__card {
|
.desk-item.is-tag-pending .desk-item__card {
|
||||||
@@ -113,14 +112,9 @@
|
|||||||
.tag-chip--draggable {
|
.tag-chip--draggable {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
cursor: grab;
|
|
||||||
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
|
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 {
|
.tag-chip--draggable.is-drag-hidden {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { getTagColorStyle } from '../../utils/colors';
|
import { getTagColorStyle } from '../../utils/colors';
|
||||||
import { writeTagTransferData } from '../features/tagging/tagTransfer';
|
|
||||||
import type { DocumentTag } from '../../types/documents';
|
import type { DocumentTag } from '../../types/documents';
|
||||||
import type { Identifier } from '../../types/identifiers';
|
import type { Identifier } from '../../types/identifiers';
|
||||||
|
|
||||||
@@ -9,8 +8,7 @@ interface DocumentTagsProps {
|
|||||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||||
onTagClick?: (tagId: Identifier) => void;
|
onTagClick?: (tagId: Identifier) => void;
|
||||||
docId: Identifier;
|
docId: Identifier;
|
||||||
onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
|
onTagDragStart?: (event: React.DragEvent<HTMLElement>, tag: DocumentTag) => void;
|
||||||
onTagDragStart?: (event: React.DragEvent<HTMLElement>, tagId: Identifier) => void;
|
|
||||||
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +17,6 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
|
|||||||
tagLookupById,
|
tagLookupById,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
docId,
|
docId,
|
||||||
onDocumentTagDetach,
|
|
||||||
onTagDragStart,
|
onTagDragStart,
|
||||||
onTagDragEnd,
|
onTagDragEnd,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -58,24 +55,13 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
|
|||||||
} : undefined}
|
} : undefined}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
event.stopPropagation();
|
// Let the hook handle the data transfer and UI
|
||||||
try {
|
if (tagId && onTagDragStart) {
|
||||||
if (event.dataTransfer) {
|
onTagDragStart(event, tag);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
event.stopPropagation();
|
// Let the hook handle the cleanup and logic
|
||||||
if (event.dataTransfer.dropEffect === 'move' && tagId && onDocumentTagDetach) {
|
|
||||||
onDocumentTagDetach(docId, tagId);
|
|
||||||
}
|
|
||||||
onTagDragEnd?.(event);
|
onTagDragEnd?.(event);
|
||||||
}}
|
}}
|
||||||
onKeyDown={clickable ? (event) => {
|
onKeyDown={clickable ? (event) => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface EntryTagsProps {
|
|||||||
onTagClick?: (tagId: Identifier) => void;
|
onTagClick?: (tagId: Identifier) => void;
|
||||||
docId: Identifier;
|
docId: Identifier;
|
||||||
onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
|
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;
|
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ interface DocumentsCommandContextValue {
|
|||||||
};
|
};
|
||||||
tags: {
|
tags: {
|
||||||
onDrag: {
|
onDrag: {
|
||||||
start?: (event: DragEvent<HTMLElement>, docId: Identifier, tagId: Identifier) => void;
|
start?: (event: DragEvent<HTMLElement>, doc: Document, tag: any) => void;
|
||||||
end?: (event: DragEvent<HTMLElement>) => void;
|
end?: (event: DragEvent<HTMLElement>) => void;
|
||||||
over?: (event: DragEvent<HTMLElement>, docId: Identifier) => void;
|
over?: (event: DragEvent<HTMLElement>, doc: Document) => void;
|
||||||
leave?: (event: DragEvent<HTMLElement>) => void;
|
drop?: (event: DragEvent<HTMLElement>, doc: Document) => void;
|
||||||
|
leave?: (event: DragEvent<HTMLElement>, doc: Document) => void;
|
||||||
};
|
};
|
||||||
onAttach?: (documentId: Identifier, tagId: Identifier) => void;
|
onAttach?: (documentId: Identifier, tagId: Identifier) => void;
|
||||||
onDetach?: (documentId: Identifier, tagId: Identifier) => void;
|
onDetach?: (documentId: Identifier, tagId: Identifier) => void;
|
||||||
|
|||||||
@@ -15,20 +15,13 @@ import {
|
|||||||
import AssetManager, { getAssetFromVersion } from '../../lib/assets/AssetManager';
|
import AssetManager, { getAssetFromVersion } from '../../lib/assets/AssetManager';
|
||||||
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
||||||
import TagManager from '../../lib/assets/TagManager';
|
import TagManager from '../../lib/assets/TagManager';
|
||||||
import { useManagementModals } from '../../app/useManagementModals';
|
import { fetchAsset } from '../../lib/api/apiClient';
|
||||||
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 { useEntryPointer as useEntryPointerCore } from '../features/selection/useEntryPointer';
|
import { useEntryPointer as useEntryPointerCore } from '../features/selection/useEntryPointer';
|
||||||
import { isTagTransferEvent } from '../features/tagging/tagTransfer';
|
|
||||||
import useDocumentsSelection from '../features/selection/useDocumentsSelection';
|
import useDocumentsSelection from '../features/selection/useDocumentsSelection';
|
||||||
import useBulkDocumentActions from './useBulkDocumentActions';
|
import useBulkDocumentActions from './useBulkDocumentActions';
|
||||||
import useDocumentPreview from '../../app/useDocumentPreview';
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_SORT_DIRECTION,
|
DEFAULT_SORT_DIRECTION,
|
||||||
DEFAULT_SORT_FIELD,
|
DEFAULT_SORT_FIELD,
|
||||||
createRootNode,
|
|
||||||
mergeAssetIntoDocument,
|
mergeAssetIntoDocument,
|
||||||
} from '../../app/workspaceUtils';
|
} from '../../app/workspaceUtils';
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +50,15 @@ import useDocumentCorrespondentActions from '../features/correspondents/useDocum
|
|||||||
import usePasskeys from '../../settings/usePasskeys';
|
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 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';
|
import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers';
|
||||||
|
|
||||||
const EntryType = Object.freeze({
|
const EntryType = Object.freeze({
|
||||||
@@ -148,24 +150,8 @@ const useDocumentsWorkspace = ({
|
|||||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||||
const { handleLogout } = useAuthManager({});
|
const { handleLogout } = useAuthManager({});
|
||||||
|
|
||||||
const tagRemovalCursorActiveRef = useRef(false);
|
|
||||||
const tenantIdRef = useRef(currentTenantId);
|
const tenantIdRef = useRef(currentTenantId);
|
||||||
const detailPanelControlRef = useRef({ open: () => { }, close: () => { } });
|
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 documentsRouteMatch = useMatch('/documents');
|
||||||
const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId');
|
const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId');
|
||||||
const documentsDetailRouteMatch = useMatch('/documents/:documentId');
|
const documentsDetailRouteMatch = useMatch('/documents/:documentId');
|
||||||
@@ -393,59 +379,18 @@ const useDocumentsWorkspace = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const documentsFilter = documentsFilterValue;
|
const documentsFilter = documentsFilterValue;
|
||||||
|
|
||||||
const [visibleDocumentIds, setVisibleDocumentIds] = useState<DocumentId[]>([]);
|
|
||||||
|
|
||||||
const showingSearchResults = searchResultIds !== null;
|
const showingSearchResults = searchResultIds !== null;
|
||||||
|
|
||||||
useEffect(() => {
|
const {
|
||||||
const arraysEqual = (a: DocumentId[], b: DocumentId[]) =>
|
viewDocuments,
|
||||||
a.length === b.length && a.every((value, index) => value === b[index]);
|
visibleEntryKeySet,
|
||||||
|
} = useWorkspaceViewData({
|
||||||
if (showingSearchResults && Array.isArray(searchResultIds)) {
|
documents,
|
||||||
const ids = searchResultIds.filter((id): id is DocumentId => id != null);
|
documentLookup,
|
||||||
setVisibleDocumentIds((prev) => (arraysEqual(prev, ids) ? prev : ids));
|
searchResultIds,
|
||||||
return;
|
showingSearchResults,
|
||||||
}
|
currentSubfolders,
|
||||||
|
});
|
||||||
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 {
|
const {
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
@@ -981,67 +926,9 @@ const useDocumentsWorkspace = ({
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [settingsOpen]);
|
}, [settingsOpen]);
|
||||||
|
|
||||||
useEffect(
|
useWorkspaceDragDrop({
|
||||||
() => () => {
|
shellRef,
|
||||||
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]);
|
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
detailPanelProps,
|
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 = (
|
export const writeTagTransferData = (
|
||||||
dataTransfer: DataTransfer | null,
|
dataTransfer: DataTransfer | null,
|
||||||
tag: TagLike,
|
tag: TagLike,
|
||||||
sourceDocId: DocumentId | null = null,
|
sourceDocId: DocumentId | null = null,
|
||||||
): void => {
|
): void => {
|
||||||
|
// Track globally for cursor logic
|
||||||
|
activeDragState = {
|
||||||
|
tagId: tag.id || null,
|
||||||
|
sourceDocId: sourceDocId || null,
|
||||||
|
};
|
||||||
|
|
||||||
if (!dataTransfer) {
|
if (!dataTransfer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -56,7 +77,9 @@ export const writeTagTransferData = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
TAG_MIME_TYPES.forEach((type) => dataTransfer.setData(type, serialized));
|
TAG_MIME_TYPES.forEach((mime) => {
|
||||||
|
dataTransfer.setData(mime, serialized);
|
||||||
|
});
|
||||||
if (payload.label) {
|
if (payload.label) {
|
||||||
dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label);
|
dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label);
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-63
@@ -8,6 +8,8 @@ import {
|
|||||||
isTagTransferEvent,
|
isTagTransferEvent,
|
||||||
parseTagTransferPayload,
|
parseTagTransferPayload,
|
||||||
writeTagTransferData,
|
writeTagTransferData,
|
||||||
|
getActiveDragState,
|
||||||
|
clearTagTransferData,
|
||||||
} from '../../documents/features/tagging/tagTransfer';
|
} from '../../documents/features/tagging/tagTransfer';
|
||||||
import type { Identifier } from '../../types/identifiers';
|
import type { Identifier } from '../../types/identifiers';
|
||||||
import type { Document, DocumentTag } from '../../types/documents';
|
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;
|
onAssignTagToDocument?: (docId: Identifier, tagId: Identifier) => void;
|
||||||
onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier) => void;
|
onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier) => void;
|
||||||
requestCanvasFocus?: () => void;
|
requestCanvasFocus?: () => void;
|
||||||
@@ -50,24 +52,28 @@ interface UseDeskTagInteractionsArgs {
|
|||||||
|
|
||||||
interface DraggingTagState {
|
interface DraggingTagState {
|
||||||
element: HTMLElement | null;
|
element: HTMLElement | null;
|
||||||
previewClone: HTMLElement | null;
|
previewClone?: HTMLElement;
|
||||||
sourceDocId: Identifier;
|
|
||||||
tagId: Identifier;
|
|
||||||
initialX: number;
|
|
||||||
initialY: number;
|
|
||||||
distance: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
onAssignTagToDocument,
|
||||||
onRemoveTagFromDocument,
|
onRemoveTagFromDocument,
|
||||||
requestCanvasFocus,
|
requestCanvasFocus,
|
||||||
}: UseDeskTagInteractionsArgs) => {
|
}: UseTagInteractionsArgs): TagDragHandlers => {
|
||||||
const draggingTagRef = useRef<DraggingTagState | null>(null);
|
const draggingTagRef = useRef<DraggingTagState | null>(null);
|
||||||
|
|
||||||
const isTagTransfer = useCallback((event: React.DragEvent) => isTagTransferEvent(event), []);
|
const isTagTransfer = useCallback((event: React.DragEvent) => isTagTransferEvent(event), []);
|
||||||
|
|
||||||
const handleTagDragEnterDoc = useCallback(
|
const onTagDragEnter = useCallback(
|
||||||
(event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => {
|
(event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => {
|
||||||
if (!isTagTransfer(event)) return;
|
if (!isTagTransfer(event)) return;
|
||||||
preventAll(event);
|
preventAll(event);
|
||||||
@@ -76,22 +82,43 @@ export const useDeskTagInteractions = ({
|
|||||||
[isTagTransfer],
|
[isTagTransfer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTagDragOverDoc = useCallback(
|
const onTagDragOver = useCallback(
|
||||||
(event: React.DragEvent<HTMLDivElement>, docId: Identifier) => {
|
(event: React.DragEvent<HTMLDivElement>, doc: Document) => {
|
||||||
|
if (!doc || !doc.id) return;
|
||||||
if (!isTagTransfer(event)) return;
|
if (!isTagTransfer(event)) return;
|
||||||
preventAll(event);
|
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 (event.dataTransfer) {
|
||||||
// If dragging over source, copy (no removal). Else move (removal).
|
const isSource = draggedSourceId === doc.id;
|
||||||
const isSource = draggingTagRef.current?.sourceDocId === docId;
|
|
||||||
event.dataTransfer.dropEffect = isSource ? 'copy' : 'move';
|
// 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],
|
[isTagTransfer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTagDragLeaveDoc = useCallback(
|
const onTagDragLeave = useCallback(
|
||||||
(event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => {
|
(event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => {
|
||||||
if (!isTagTransfer(event)) return;
|
if (!isTagTransfer(event)) return;
|
||||||
// Ignore if leaving to a child element
|
// Ignore if leaving to a child element
|
||||||
@@ -103,27 +130,7 @@ export const useDeskTagInteractions = ({
|
|||||||
[isTagTransfer],
|
[isTagTransfer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCanvasDragOver = useCallback(
|
const onTagDrop = 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(
|
|
||||||
(event: React.DragEvent<HTMLDivElement>, doc: Document) => {
|
(event: React.DragEvent<HTMLDivElement>, doc: Document) => {
|
||||||
if (!doc || !doc.id) {
|
if (!doc || !doc.id) {
|
||||||
return;
|
return;
|
||||||
@@ -134,20 +141,23 @@ export const useDeskTagInteractions = ({
|
|||||||
preventAll(event);
|
preventAll(event);
|
||||||
event.currentTarget.classList.remove('is-tag-target');
|
event.currentTarget.classList.remove('is-tag-target');
|
||||||
|
|
||||||
// Add to target
|
|
||||||
const payload = parseTagTransferPayload(event);
|
const payload = parseTagTransferPayload(event);
|
||||||
|
if (payload && payload.sourceDocId === doc.id) {
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!payload || !payload.id) {
|
if (!payload || !payload.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.sourceDocId === doc.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestCanvasFocus?.();
|
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) {
|
if (onAssignTagToDocument && doc.id) {
|
||||||
onAssignTagToDocument(doc.id, payload.id);
|
onAssignTagToDocument(doc.id, payload.id);
|
||||||
}
|
}
|
||||||
@@ -156,12 +166,12 @@ export const useDeskTagInteractions = ({
|
|||||||
[isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
|
[isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocTagDragStart = useCallback(
|
const onTagDragStart = useCallback(
|
||||||
(event: React.DragEvent<HTMLElement>, doc: Document, tag: DocumentTag) => {
|
(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) {
|
if (!event?.dataTransfer || !doc?.id || !tag?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
event.stopPropagation();
|
||||||
event.dataTransfer.effectAllowed = 'copyMove';
|
event.dataTransfer.effectAllowed = 'copyMove';
|
||||||
writeTagTransferData(event.dataTransfer, tag, doc.id);
|
writeTagTransferData(event.dataTransfer, tag, doc.id);
|
||||||
|
|
||||||
@@ -179,20 +189,18 @@ export const useDeskTagInteractions = ({
|
|||||||
|
|
||||||
draggingTagRef.current = {
|
draggingTagRef.current = {
|
||||||
element,
|
element,
|
||||||
previewClone: clone || null,
|
previewClone: clone,
|
||||||
sourceDocId: doc.id,
|
|
||||||
tagId: tag.id,
|
|
||||||
initialX: pointerX,
|
|
||||||
initialY: pointerY,
|
|
||||||
distance: 0,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocTagDragEnd = useCallback(
|
const onTagDragEnd = useCallback(
|
||||||
(event: React.DragEvent<HTMLElement>) => {
|
(event: React.DragEvent<HTMLElement>) => {
|
||||||
console.log('[Tag] handleDocTagDragEnd', { dropEffect: event?.dataTransfer?.dropEffect });
|
event.stopPropagation();
|
||||||
|
const { sourceDocId, tagId } = getActiveDragState();
|
||||||
|
clearTagTransferData();
|
||||||
|
|
||||||
const dropEffect = event?.dataTransfer?.dropEffect;
|
const dropEffect = event?.dataTransfer?.dropEffect;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -206,8 +214,8 @@ export const useDeskTagInteractions = ({
|
|||||||
|
|
||||||
// Remove if move operation completed
|
// Remove if move operation completed
|
||||||
if (dropEffect === 'move') {
|
if (dropEffect === 'move') {
|
||||||
if (onRemoveTagFromDocument && state.sourceDocId && state.tagId) {
|
if (onRemoveTagFromDocument && sourceDocId && tagId) {
|
||||||
onRemoveTagFromDocument(state.sourceDocId, state.tagId);
|
onRemoveTagFromDocument(sourceDocId, tagId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,13 +235,11 @@ export const useDeskTagInteractions = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleTagDragEnterDoc,
|
onTagDragEnter,
|
||||||
handleTagDragOverDoc,
|
onTagDragOver,
|
||||||
handleTagDragLeaveDoc,
|
onTagDragLeave,
|
||||||
handleTagDropOnDoc,
|
onTagDrop,
|
||||||
handleDocTagDragStart,
|
onTagDragStart,
|
||||||
handleDocTagDragEnd,
|
onTagDragEnd,
|
||||||
handleCanvasDragOver,
|
|
||||||
handleCanvasDrop,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -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 React, { type DragEvent } from 'react';
|
||||||
import { parseTagTransferPayload } from '../features/tagging/tagTransfer';
|
|
||||||
import { createDocumentEntryKey } from '../../app/entryKey';
|
import { createDocumentEntryKey } from '../../app/entryKey';
|
||||||
import { useDocumentOpen } from '../../lib/context/DocumentOpenContext';
|
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 { useDocumentsCommandContext } from '../context/DocumentsCommandContext';
|
||||||
import { useDocumentsViewStateContext } from '../context/DocumentsViewStateContext';
|
import { useDocumentsViewStateContext } from '../context/DocumentsViewStateContext';
|
||||||
import type { DocumentViewLogic } from './useDocumentViewLogic';
|
import type { DocumentViewLogic } from './useDocumentViewLogic';
|
||||||
import type { Identifier } from '../../types/identifiers';
|
|
||||||
|
|
||||||
interface UseDocumentItemLogicProps {
|
interface UseDocumentItemLogicProps {
|
||||||
doc: Document;
|
doc: Document;
|
||||||
@@ -30,8 +28,8 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
|
|||||||
end: onDocumentTagDragEnd,
|
end: onDocumentTagDragEnd,
|
||||||
over: onDocumentTagDragOver,
|
over: onDocumentTagDragOver,
|
||||||
leave: onDocumentTagDragLeave,
|
leave: onDocumentTagDragLeave,
|
||||||
|
drop: onDocumentTagDrop,
|
||||||
},
|
},
|
||||||
onAttach: onDocumentTagAttach
|
|
||||||
},
|
},
|
||||||
onEntryPointer
|
onEntryPointer
|
||||||
} = useDocumentsCommandContext();
|
} = useDocumentsCommandContext();
|
||||||
@@ -79,18 +77,11 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
|
|||||||
},
|
},
|
||||||
onDragStart: (event: DragEvent<HTMLElement>) => onDocumentDragStart?.(event, doc),
|
onDragStart: (event: DragEvent<HTMLElement>) => onDocumentDragStart?.(event, doc),
|
||||||
onDragEnd: (event: DragEvent<HTMLElement>) => onDocumentDragEnd?.(event),
|
onDragEnd: (event: DragEvent<HTMLElement>) => onDocumentDragEnd?.(event),
|
||||||
onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event, doc.id),
|
onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event, doc),
|
||||||
onDragLeave: onDocumentTagDragLeave,
|
onDragLeave: (event: DragEvent<HTMLElement>) => onDocumentTagDragLeave?.(event, doc),
|
||||||
onTagDragStart: (event: DragEvent<HTMLElement>, tagId: Identifier) => onDocumentTagDragStart?.(event, doc.id, tagId),
|
onTagDragStart: (event: DragEvent<HTMLElement>, tag: DocumentTag) => onDocumentTagDragStart?.(event, doc, tag),
|
||||||
onTagDragEnd: (event: DragEvent<HTMLElement>) => onDocumentTagDragEnd?.(event),
|
onTagDragEnd: (event: DragEvent<HTMLElement>) => onDocumentTagDragEnd?.(event),
|
||||||
onDrop: (event: DragEvent<HTMLElement>) => {
|
onDrop: (event: DragEvent<HTMLElement>) => { onDocumentTagDrop?.(event, doc); },
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
const payload = parseTagTransferPayload(event);
|
|
||||||
if (payload && payload.id && onDocumentTagAttach) {
|
|
||||||
onDocumentTagAttach(doc.id, payload.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRenameChange: setDocumentDraft,
|
onRenameChange: setDocumentDraft,
|
||||||
onRenameSubmit: () => submitDocumentEditing(doc),
|
onRenameSubmit: () => submitDocumentEditing(doc),
|
||||||
onRenameCancel: (event?: React.SyntheticEvent) => cancelDocumentEditing(event),
|
onRenameCancel: (event?: React.SyntheticEvent) => cancelDocumentEditing(event),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useMemo, useCallback, useRef } from 'react';
|
import { useMemo, useCallback, useRef } from 'react';
|
||||||
import type { DocumentsPanelInnerProps } from './DocumentsPanel';
|
import type { DocumentsPanelInnerProps } from './DocumentsPanel';
|
||||||
import { isTagTransferEvent } from '../features/tagging/tagTransfer';
|
|
||||||
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../features/selection/useEntryPointer';
|
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../features/selection/useEntryPointer';
|
||||||
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
|
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
|
||||||
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
|
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
|
||||||
import type { Identifier } from '../../types/identifiers';
|
import type { Identifier } from '../../types/identifiers';
|
||||||
|
import { useTagInteractions } from '../interactions/useTagInteractions';
|
||||||
|
|
||||||
const EntryType = {
|
const EntryType = {
|
||||||
folder: 'folder',
|
folder: 'folder',
|
||||||
@@ -37,6 +37,12 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
|
|||||||
toggleCorrespondent: toggleCorrespondentFilter,
|
toggleCorrespondent: toggleCorrespondentFilter,
|
||||||
} = useDocumentsFilter();
|
} = useDocumentsFilter();
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const tagDragHandlers = useTagInteractions({
|
||||||
|
onAssignTagToDocument: props.onDocumentTagAttach,
|
||||||
|
onRemoveTagFromDocument: props.onDocumentTagDetach,
|
||||||
|
});
|
||||||
|
|
||||||
// Derived State
|
// Derived State
|
||||||
const draggingDocumentIdsSet = useMemo(
|
const draggingDocumentIdsSet = useMemo(
|
||||||
() => new Set(draggingDocumentIds || []),
|
() => new Set(draggingDocumentIds || []),
|
||||||
@@ -69,52 +75,6 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
|
|||||||
// Refs
|
// Refs
|
||||||
const scrollRef = useRef<HTMLElement | null>(null);
|
const scrollRef = useRef<HTMLElement | null>(null);
|
||||||
const suppressDocumentClickRef = useRef(false);
|
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(
|
const handleFolderClick = useCallback(
|
||||||
(folder: any, event: any) => {
|
(folder: any, event: any) => {
|
||||||
@@ -187,10 +147,7 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
|
|||||||
handleFolderClick,
|
handleFolderClick,
|
||||||
handleDocumentDragStartLocal,
|
handleDocumentDragStartLocal,
|
||||||
handleDocumentDragEndLocal,
|
handleDocumentDragEndLocal,
|
||||||
handleDocumentTagDragStart,
|
tagDragHandlers,
|
||||||
handleDocumentTagDragEnd,
|
|
||||||
handleDocumentTagDragOver,
|
|
||||||
handleDocumentTagDragLeave,
|
|
||||||
toggleTagFilter,
|
toggleTagFilter,
|
||||||
toggleCorrespondentFilter,
|
toggleCorrespondentFilter,
|
||||||
});
|
});
|
||||||
@@ -201,10 +158,7 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
|
|||||||
handleFolderClick,
|
handleFolderClick,
|
||||||
handleDocumentDragStartLocal,
|
handleDocumentDragStartLocal,
|
||||||
handleDocumentDragEndLocal,
|
handleDocumentDragEndLocal,
|
||||||
handleDocumentTagDragStart,
|
tagDragHandlers,
|
||||||
handleDocumentTagDragEnd,
|
|
||||||
handleDocumentTagDragOver,
|
|
||||||
handleDocumentTagDragLeave,
|
|
||||||
toggleTagFilter,
|
toggleTagFilter,
|
||||||
toggleCorrespondentFilter,
|
toggleCorrespondentFilter,
|
||||||
};
|
};
|
||||||
@@ -231,10 +185,11 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
|
|||||||
},
|
},
|
||||||
tags: {
|
tags: {
|
||||||
onDrag: {
|
onDrag: {
|
||||||
start: (e: any, d: any, t: any) => latestHandlersRef.current.handleDocumentTagDragStart(e, d, t),
|
start: (e: any, d: any, t: any) => latestHandlersRef.current.tagDragHandlers.onTagDragStart(e, d, t),
|
||||||
end: (e: any) => latestHandlersRef.current.handleDocumentTagDragEnd(e),
|
end: (e: any) => latestHandlersRef.current.tagDragHandlers.onTagDragEnd(e),
|
||||||
over: (e: any, d: any) => latestHandlersRef.current.handleDocumentTagDragOver(e, d),
|
over: (e: any, d: any) => latestHandlersRef.current.tagDragHandlers.onTagDragOver(e, d),
|
||||||
leave: (e: any) => latestHandlersRef.current.handleDocumentTagDragLeave(e),
|
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),
|
onAttach: (d: any, t: any) => latestPropsRef.current.onDocumentTagAttach?.(d, t),
|
||||||
onDetach: (d: any, t: any) => latestPropsRef.current.onDocumentTagDetach?.(d, t),
|
onDetach: (d: any, t: any) => latestPropsRef.current.onDocumentTagDetach?.(d, t),
|
||||||
|
|||||||
@@ -698,17 +698,17 @@
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.documents-panel tbody tr.document.tag-drop-target {
|
.documents-panel tbody tr.document.is-tag-target {
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
box-shadow: inset 0 0 0 2px var(--accent);
|
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);
|
box-shadow: 0 0 0 2px var(--accent);
|
||||||
border-color: 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);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { PlusIcon, SettingsIcon } from '../../components/icons';
|
import { PlusIcon, SettingsIcon } from '../../components/icons';
|
||||||
import { getTagColorStyle } from '../../utils/colors';
|
import { getTagColorStyle } from '../../utils/colors';
|
||||||
|
import { writeTagTransferData, clearTagTransferData } from '../../documents/features/tagging/tagTransfer';
|
||||||
import type { Identifier } from '../../types/identifiers';
|
import type { Identifier } from '../../types/identifiers';
|
||||||
import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext';
|
import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext';
|
||||||
|
|
||||||
@@ -120,18 +121,13 @@ const SidebarTagList: React.FC<SidebarTagListProps> = ({
|
|||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.stringify({
|
|
||||||
id: tag.id,
|
|
||||||
label: tag.label,
|
|
||||||
color: tag.color || null,
|
|
||||||
});
|
|
||||||
event.dataTransfer.effectAllowed = 'copy';
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
event.dataTransfer.setData('application/x-papercrate-tag', payload);
|
writeTagTransferData(event.dataTransfer, tag);
|
||||||
event.dataTransfer.setData('text/papercrate-tag', payload);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[sidebar] Failed to set tag drag payload', error);
|
console.warn('[sidebar] Failed to set tag drag payload', error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDragEnd={clearTagTransferData}
|
||||||
>
|
>
|
||||||
{tag.label}
|
{tag.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user