Files
papercrate/frontend/src/hooks/documents/useDocumentDragHandlers.ts
T
2025-11-15 04:11:22 +01:00

396 lines
13 KiB
TypeScript

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<Identifier, DocumentLike>;
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<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 instanceof HTMLElement
? rowEl.querySelector<HTMLElement>('.document-thumbnail-wrapper')
: null;
const thumbnailEl = rowEl instanceof HTMLElement
? rowEl.querySelector<HTMLImageElement>('.document-thumbnail')
: null;
const placeholderEl = rowEl instanceof HTMLElement
? rowEl.querySelector<HTMLElement>('.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<HTMLElement>, 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<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;