typescript
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
type Identifier = string | number;
|
||||
type FolderIdentifier = Identifier | 'root';
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier | null;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type ApplySelectionFn = (
|
||||
keys: string[],
|
||||
options?: { anchor?: string | null; interactedKeys?: string[] },
|
||||
) => void;
|
||||
|
||||
type HandleEntrySelectionFn = (
|
||||
key: string,
|
||||
event: { preventDefault?: () => void },
|
||||
) => void;
|
||||
|
||||
interface UseDocumentDragHandlersOptions {
|
||||
selectedEntries: string[];
|
||||
selectedDocumentIds: Identifier[];
|
||||
selectedFolderIds: FolderIdentifier[];
|
||||
applySelection: ApplySelectionFn;
|
||||
handleEntrySelection: HandleEntrySelectionFn;
|
||||
documentLookup: Map<Identifier, DocumentLike>;
|
||||
setDraggedDocumentIds: (ids: Identifier[] | []) => void;
|
||||
setDraggedFolderId: (id: FolderIdentifier | null) => void;
|
||||
resolveDocumentRowKey: (id: Identifier) => string | null | undefined;
|
||||
resolveFolderRowKey: (id: FolderIdentifier) => string | null | undefined;
|
||||
documentsViewMode: string;
|
||||
}
|
||||
|
||||
const useDocumentDragHandlers = ({
|
||||
selectedEntries,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
applySelection,
|
||||
handleEntrySelection,
|
||||
documentLookup,
|
||||
setDraggedDocumentIds,
|
||||
setDraggedFolderId,
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
documentsViewMode,
|
||||
}: UseDocumentDragHandlersOptions) => {
|
||||
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const destroyDragPreview = useCallback(() => {
|
||||
const node = dragPreviewRef.current;
|
||||
if (node && node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
dragPreviewRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => destroyDragPreview, [destroyDragPreview]);
|
||||
|
||||
const createDragPreview = useCallback(
|
||||
({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: Array<FolderIdentifier | Identifier> } = {}) => {
|
||||
destroyDragPreview();
|
||||
|
||||
const docEntries = (documents || []).filter(Boolean);
|
||||
const folderEntries = (folders || []).filter(Boolean);
|
||||
const totalCount = docEntries.length + folderEntries.length;
|
||||
if (!totalCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxVisible = 4;
|
||||
const size = 64;
|
||||
const canvasSize = Math.round(size * 1.6);
|
||||
|
||||
const visibleItems = [];
|
||||
docEntries.slice(0, maxVisible).forEach((doc) => {
|
||||
visibleItems.push({ type: 'document', payload: doc });
|
||||
});
|
||||
|
||||
if (visibleItems.length < maxVisible) {
|
||||
folderEntries
|
||||
.slice(0, maxVisible - visibleItems.length)
|
||||
.forEach((folderId) => visibleItems.push({ type: 'folder', payload: folderId }));
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'document-drag-preview';
|
||||
wrapper.style.setProperty('--drag-preview-size', `${canvasSize}px`);
|
||||
wrapper.style.width = `${canvasSize}px`;
|
||||
wrapper.style.height = `${canvasSize}px`;
|
||||
|
||||
visibleItems.forEach((item, index) => {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'document-drag-preview__item';
|
||||
layer.style.setProperty('--index', String(index));
|
||||
const rotationMagnitude = Math.random() * 8 + 2;
|
||||
const rotation = (index % 2 === 0 ? 1 : -1) * rotationMagnitude;
|
||||
layer.style.setProperty('--rotation-deg', `${rotation}deg`);
|
||||
|
||||
if (item.type === 'document') {
|
||||
const doc = item.payload;
|
||||
const rowEl = doc?.id
|
||||
? document.getElementById(`document-row-${doc.id}`)
|
||||
|| document.getElementById(`document-card-${doc.id}`)
|
||||
: null;
|
||||
const wrapperEl = rowEl?.querySelector('.document-thumbnail-wrapper');
|
||||
const thumbnailEl = rowEl?.querySelector('.document-thumbnail');
|
||||
const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
|
||||
const aspectAttr = wrapperEl?.dataset?.thumbnailAspect;
|
||||
const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null;
|
||||
|
||||
let thumbWidth = size;
|
||||
let thumbHeight = size;
|
||||
if (Number.isFinite(aspectRatio) && aspectRatio > 0) {
|
||||
if (aspectRatio >= 1) {
|
||||
thumbWidth = size;
|
||||
thumbHeight = Math.max(size / aspectRatio, size * 0.5);
|
||||
} else {
|
||||
thumbHeight = size;
|
||||
thumbWidth = Math.max(size * aspectRatio, size * 0.5);
|
||||
}
|
||||
}
|
||||
layer.style.width = `${Math.round(thumbWidth)}px`;
|
||||
layer.style.height = `${Math.round(thumbHeight)}px`;
|
||||
|
||||
const thumbSrc = thumbnailEl?.currentSrc || thumbnailEl?.src || null;
|
||||
if (thumbSrc) {
|
||||
layer.classList.add('document-drag-preview__item--image');
|
||||
layer.style.backgroundImage = `url("${thumbSrc}")`;
|
||||
} else if (placeholderEl instanceof HTMLElement) {
|
||||
const clone = placeholderEl.cloneNode(true);
|
||||
clone.style.pointerEvents = 'none';
|
||||
layer.appendChild(clone);
|
||||
} else {
|
||||
layer.textContent = doc?.title || 'Document';
|
||||
}
|
||||
} else {
|
||||
const payload = item.payload;
|
||||
const folderId = payload?.id ?? (typeof payload?.trim === 'function' ? payload : null);
|
||||
const rowEl = folderId
|
||||
? document.getElementById(`folder-row-${folderId}`)
|
||||
|| document.getElementById(`folder-card-${folderId}`)
|
||||
: null;
|
||||
const iconEl = rowEl?.querySelector('.thumb-icon, .folder-card__icon');
|
||||
layer.style.width = `${size}px`;
|
||||
layer.style.height = `${size}px`;
|
||||
layer.classList.add('document-drag-preview__item--folder');
|
||||
|
||||
let content = null;
|
||||
if (iconEl instanceof HTMLElement) {
|
||||
const cloneSource = iconEl.classList.contains('folder-card__icon')
|
||||
? iconEl.querySelector('svg') || iconEl
|
||||
: iconEl;
|
||||
content = cloneSource.cloneNode(true);
|
||||
content.classList.add('document-drag-preview__folder-thumb');
|
||||
const svg = content.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.setAttribute('width', '48');
|
||||
svg.setAttribute('height', '48');
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
content = document.createElement('div');
|
||||
content.className = 'document-drag-preview__folder-placeholder';
|
||||
content.textContent = 'Folder';
|
||||
}
|
||||
|
||||
layer.appendChild(content);
|
||||
}
|
||||
|
||||
wrapper.appendChild(layer);
|
||||
});
|
||||
|
||||
if (totalCount > 1) {
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'document-drag-preview__count';
|
||||
badge.textContent = `${totalCount}`;
|
||||
wrapper.appendChild(badge);
|
||||
}
|
||||
|
||||
document.body.appendChild(wrapper);
|
||||
dragPreviewRef.current = wrapper;
|
||||
return wrapper;
|
||||
},
|
||||
[destroyDragPreview],
|
||||
);
|
||||
|
||||
const handleDocumentDragStart = useCallback(
|
||||
(event: DragEvent<HTMLElement>, documentOrId: DocumentLike | Identifier | null | undefined) => {
|
||||
const documentId =
|
||||
(documentOrId as DocumentLike)?.id ?? (typeof documentOrId === 'string' || typeof documentOrId === 'number'
|
||||
? documentOrId
|
||||
: null);
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentKey = resolveDocumentRowKey(documentId);
|
||||
if (!documentKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isGridView = documentsViewMode === 'grid';
|
||||
const isAlreadySelected = selectedDocumentIds.includes(documentId);
|
||||
const selection: Identifier[] = isAlreadySelected
|
||||
? [...selectedDocumentIds]
|
||||
: isGridView
|
||||
? [...selectedDocumentIds, documentId]
|
||||
: [documentId];
|
||||
const folderSelection: FolderIdentifier[] = [];
|
||||
|
||||
if (!isAlreadySelected && !isGridView) {
|
||||
applySelection([documentKey], {
|
||||
anchor: documentKey,
|
||||
interactedKeys: [documentKey],
|
||||
});
|
||||
}
|
||||
|
||||
const previewDocs = selection
|
||||
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
|
||||
.filter(Boolean);
|
||||
const previewNode = createDragPreview({
|
||||
documents: previewDocs,
|
||||
folders: folderSelection,
|
||||
});
|
||||
|
||||
setDraggedDocumentIds(selection);
|
||||
if (folderSelection.length) {
|
||||
setDraggedFolderId(folderSelection[0] || null);
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
try {
|
||||
event.dataTransfer.setData(
|
||||
'application/x-papercrate-doc-list',
|
||||
JSON.stringify(selection),
|
||||
);
|
||||
if (folderSelection.length) {
|
||||
event.dataTransfer.setData(
|
||||
'application/x-papercrate-folder-list',
|
||||
JSON.stringify(folderSelection),
|
||||
);
|
||||
if (folderSelection.length === 1) {
|
||||
event.dataTransfer.setData('application/x-papercrate-folder', folderSelection[0]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[documents] Failed to populate drag payload', error);
|
||||
}
|
||||
if (previewNode) {
|
||||
const width = previewNode.offsetWidth || 96;
|
||||
const height = previewNode.offsetHeight || 96;
|
||||
event.dataTransfer.setDragImage(previewNode, width / 2, height / 2);
|
||||
}
|
||||
event.currentTarget.classList.add('dragging');
|
||||
},
|
||||
[
|
||||
selectedDocumentIds,
|
||||
applySelection,
|
||||
documentLookup,
|
||||
createDragPreview,
|
||||
setDraggedFolderId,
|
||||
setDraggedDocumentIds,
|
||||
documentsViewMode,
|
||||
resolveDocumentRowKey,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDocumentDragEnd = useCallback(
|
||||
(event: DragEvent<HTMLElement>) => {
|
||||
setDraggedDocumentIds([]);
|
||||
event.currentTarget.classList.remove('dragging');
|
||||
destroyDragPreview();
|
||||
setDraggedFolderId(null);
|
||||
},
|
||||
[destroyDragPreview, setDraggedFolderId, setDraggedDocumentIds],
|
||||
);
|
||||
|
||||
const handleFolderDragStart = useCallback(
|
||||
(event: DragEvent<HTMLElement>, folderId: FolderIdentifier) => {
|
||||
if (folderId === 'root') {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
const folderKey = resolveFolderRowKey(folderId);
|
||||
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
|
||||
|
||||
let effectiveFolderSelection: FolderIdentifier[] = selectedFolderIds;
|
||||
let effectiveDocumentSelection: Identifier[] = selectedDocumentIds;
|
||||
|
||||
if (!isAlreadySelected && folderKey) {
|
||||
effectiveFolderSelection = [folderId];
|
||||
effectiveDocumentSelection = [];
|
||||
handleEntrySelection(folderKey, { preventDefault: () => {} });
|
||||
}
|
||||
|
||||
const uniqueFolders = effectiveFolderSelection.length
|
||||
? Array.from(new Set(effectiveFolderSelection.filter(Boolean)))
|
||||
: [folderId];
|
||||
|
||||
setDraggedFolderId(folderId);
|
||||
if (effectiveDocumentSelection.length) {
|
||||
setDraggedDocumentIds(effectiveDocumentSelection);
|
||||
}
|
||||
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
try {
|
||||
event.dataTransfer.setData(
|
||||
'application/x-papercrate-folder-list',
|
||||
JSON.stringify(uniqueFolders),
|
||||
);
|
||||
if (uniqueFolders.length === 1) {
|
||||
event.dataTransfer.setData('application/x-papercrate-folder', uniqueFolders[0]);
|
||||
}
|
||||
if (effectiveDocumentSelection.length) {
|
||||
event.dataTransfer.setData(
|
||||
'application/x-papercrate-doc-list',
|
||||
JSON.stringify(effectiveDocumentSelection),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[documents] Failed to populate folder drag payload', error);
|
||||
}
|
||||
|
||||
const previewNode = createDragPreview({
|
||||
documents: effectiveDocumentSelection
|
||||
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
|
||||
.filter(Boolean),
|
||||
folders: uniqueFolders,
|
||||
});
|
||||
event.currentTarget.classList.add('dragging');
|
||||
|
||||
if (previewNode) {
|
||||
const width = previewNode.offsetWidth || 96;
|
||||
const height = previewNode.offsetHeight || 96;
|
||||
event.dataTransfer.setDragImage(previewNode, width / 2, height / 2);
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedFolderIds,
|
||||
selectedEntries,
|
||||
selectedDocumentIds,
|
||||
handleEntrySelection,
|
||||
setDraggedFolderId,
|
||||
setDraggedDocumentIds,
|
||||
documentLookup,
|
||||
createDragPreview,
|
||||
resolveFolderRowKey,
|
||||
],
|
||||
);
|
||||
|
||||
const handleFolderDragEnd = useCallback(
|
||||
(event?: DragEvent<HTMLElement>) => {
|
||||
if (event?.currentTarget) {
|
||||
event.currentTarget.classList.remove('dragging');
|
||||
}
|
||||
setDraggedFolderId(null);
|
||||
setDraggedDocumentIds([]);
|
||||
destroyDragPreview();
|
||||
},
|
||||
[setDraggedFolderId, setDraggedDocumentIds, destroyDragPreview],
|
||||
);
|
||||
|
||||
return {
|
||||
handleDocumentDragStart,
|
||||
handleDocumentDragEnd,
|
||||
handleFolderDragStart,
|
||||
handleFolderDragEnd,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentDragHandlers;
|
||||
Reference in New Issue
Block a user