import { useCallback, useEffect, useRef } from 'react'; import type { DragEvent } from 'react'; import { isPlainObject, isFunctionValue } from '../../utils/typeGuards'; 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; setDraggedDocumentIds: (ids: Identifier[] | []) => void; setDraggedFolderId: (id: FolderIdentifier | null) => void; resolveDocumentRowKey: (id: Identifier) => string | null; resolveFolderRowKey: (id: FolderIdentifier) => string | null; documentsViewMode: string; } const useDocumentDragHandlers = ({ selectedEntries, selectedDocumentIds, selectedFolderIds, applySelection, handleEntrySelection, documentLookup, setDraggedDocumentIds, setDraggedFolderId, resolveDocumentRowKey, resolveFolderRowKey, documentsViewMode, }: UseDocumentDragHandlersOptions) => { const dragPreviewRef = useRef(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 } = {}) => { 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 instanceof HTMLElement ? rowEl.querySelector('.document-thumbnail-wrapper') : null; const thumbnailEl = rowEl instanceof HTMLElement ? rowEl.querySelector('.document-thumbnail') : null; const placeholderEl = rowEl instanceof HTMLElement ? rowEl.querySelector('.thumb-placeholder') : null; 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) as HTMLElement; clone.style.pointerEvents = 'none'; layer.appendChild(clone); } else { layer.textContent = doc?.title || 'Document'; } } else { const payload = item.payload; const folderId = (() => { if (isPlainObject(payload) && 'id' in payload) { return (payload as { id?: FolderIdentifier }).id ?? null; } const maybeTrim = (payload as { trim?: () => string })?.trim; if (isFunctionValue(maybeTrim)) { const nextValue = maybeTrim.call(payload); return nextValue || null; } return null; })(); const rowEl = folderId ? (document.getElementById(`folder-row-${folderId}`) || document.getElementById(`folder-card-${folderId}`)) : null; const iconEl = rowEl instanceof HTMLElement ? rowEl.querySelector('.thumb-icon, .folder-card__icon') : null; layer.style.width = `${size}px`; layer.style.height = `${size}px`; layer.classList.add('document-drag-preview__item--folder'); let content: HTMLElement | null = null; if (iconEl instanceof HTMLElement) { const cloneSource = iconEl.classList.contains('folder-card__icon') ? iconEl.querySelector('svg') || iconEl : iconEl; const clone = cloneSource.cloneNode(true); if (clone instanceof HTMLElement) { content = clone; 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, documentOrId: DocumentLike | Identifier | null) => { const documentId: Identifier | null = Object(documentOrId) === documentOrId ? (documentOrId as DocumentLike)?.id ?? null : (documentOrId as Identifier | 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) => { setDraggedDocumentIds([]); event.currentTarget.classList.remove('dragging'); destroyDragPreview(); setDraggedFolderId(null); }, [destroyDragPreview, setDraggedFolderId, setDraggedDocumentIds], ); const handleFolderDragStart = useCallback( (event: DragEvent, 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) => { if (event?.currentTarget) { event.currentTarget.classList.remove('dragging'); } setDraggedFolderId(null); setDraggedDocumentIds([]); destroyDragPreview(); }, [setDraggedFolderId, setDraggedDocumentIds, destroyDragPreview], ); return { handleDocumentDragStart, handleDocumentDragEnd, handleFolderDragStart, handleFolderDragEnd, }; }; export default useDocumentDragHandlers;