diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index 04df63a..2597847 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -73,6 +73,8 @@ const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, al src={url} alt={alt || ''} className="document-thumbnail" + draggable={false} + onDragStart={(event) => event.preventDefault()} /> ); } diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index abdf395..829dec4 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -1873,6 +1873,23 @@ const AppLayout = () => { [visibleDocuments], ); + const documentLookup = useMemo(() => { + const map = new Map(); + const push = (items) => { + (items || []).forEach((doc) => { + if (doc?.id) { + map.set(doc.id, doc); + } + }); + }; + + push(documents); + if (Array.isArray(searchResults)) { + push(searchResults); + } + return map; + }, [documents, searchResults]); + const applySelection = useCallback( (ids, { anchor, interactedIds = [] } = {}) => { const visibleSet = new Set(visibleDocumentIds); @@ -2101,44 +2118,6 @@ const AppLayout = () => { }, [focusedRowKey, navigableRowKeys, focusedDocumentId]); - const handleDocumentDragStart = useCallback( - (event, documentOrId) => { - const documentId = typeof documentOrId === 'string' ? documentOrId : documentOrId?.id; - if (!documentId) { - return; - } - - const isAlreadySelected = selectedDocumentIds.includes(documentId); - const selection = isAlreadySelected ? [...selectedDocumentIds] : [documentId]; - - if (!isAlreadySelected) { - applySelection([documentId], { - anchor: documentId, - interactedIds: [documentId], - }); - } - - setDraggedDocumentIds(selection); - event.dataTransfer.effectAllowed = 'move'; - try { - event.dataTransfer.setData( - 'application/x-papercrate-doc-list', - JSON.stringify(selection), - ); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} - event.currentTarget.classList.add('dragging'); - }, - [selectedDocumentIds, applySelection], - ); - - const handleDocumentDragEnd = useCallback((event) => { - setDraggedDocumentIds([]); - event.currentTarget.classList.remove('dragging'); - }, []); - const ensureFolderData = useCallback( async (folderId, { force = false } = {}) => { if (!force && folderContents.has(folderId)) { @@ -3264,6 +3243,131 @@ const AppLayout = () => { ], ); + 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( + (documentsForPreview) => { + destroyDragPreview(); + const docs = (documentsForPreview || []).filter(Boolean); + if (!docs.length) { + return null; + } + + const maxVisible = 4; + const size = 64; + const canvasSize = Math.round(size * 1.6); + const visibleDocs = docs.slice(0, maxVisible); + + const wrapper = document.createElement('div'); + wrapper.className = 'document-drag-preview'; + wrapper.style.setProperty('--drag-preview-size', `${canvasSize}px`); + + visibleDocs.forEach((doc, index) => { + const layer = document.createElement('div'); + layer.className = 'document-drag-preview__thumb'; + const rotationMagnitude = Math.random() * 8 + 2; // 2..10 degrees + const rotation = (index % 2 === 0 ? 1 : -1) * rotationMagnitude; + layer.style.setProperty('--rotation-deg', `${rotation}deg`); + + const rowEl = doc?.id ? document.getElementById(`document-row-${doc.id}`) : null; + const thumbnailEl = rowEl?.querySelector('.document-thumbnail'); + const placeholderEl = rowEl?.querySelector('.thumb-placeholder'); + + let content = null; + if (thumbnailEl instanceof HTMLImageElement) { + content = thumbnailEl.cloneNode(true); + content.draggable = false; + content.style.pointerEvents = 'none'; + } else if (placeholderEl instanceof HTMLElement) { + content = placeholderEl.cloneNode(true); + content.style.pointerEvents = 'none'; + } + + if (content) { + layer.appendChild(content); + } else { + layer.textContent = 'DOC'; + } + + wrapper.appendChild(layer); + }); + + if (docs.length > 1) { + const badge = document.createElement('div'); + badge.className = 'document-drag-preview__count'; + badge.textContent = docs.length > maxVisible ? `+${docs.length - maxVisible}` : `${docs.length}`; + wrapper.appendChild(badge); + } + + document.body.appendChild(wrapper); + dragPreviewRef.current = wrapper; + return wrapper; + }, + [destroyDragPreview], + ); + + const handleDocumentDragStart = useCallback( + (event, documentOrId) => { + const documentId = typeof documentOrId === 'string' ? documentOrId : documentOrId?.id; + if (!documentId) { + return; + } + + const isAlreadySelected = selectedDocumentIds.includes(documentId); + const selection = isAlreadySelected ? [...selectedDocumentIds] : [documentId]; + + if (!isAlreadySelected) { + applySelection([documentId], { + anchor: documentId, + interactedIds: [documentId], + }); + } + + const previewDocs = selection + .map((id) => documentLookup.get(id) || null) + .filter(Boolean); + const previewNode = createDragPreview(previewDocs); + + setDraggedDocumentIds(selection); + event.dataTransfer.effectAllowed = 'move'; + try { + event.dataTransfer.setData( + 'application/x-papercrate-doc-list', + JSON.stringify(selection), + ); + } catch ( + // eslint-disable-next-line no-empty + 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], + ); + + const handleDocumentDragEnd = useCallback( + (event) => { + setDraggedDocumentIds([]); + event.currentTarget.classList.remove('dragging'); + destroyDragPreview(); + }, + [destroyDragPreview], + ); + const ensurePreviewUrl = useCallback( async (documentId, { force = false } = {}) => { if (!documentId) return null; @@ -4598,23 +4702,6 @@ const AppLayout = () => { return list.find((doc) => doc.id === focusedDocumentId) || null; }, [searchResults, documents, focusedDocumentId]); - const documentLookup = useMemo(() => { - const map = new Map(); - documents.forEach((doc) => { - if (doc?.id) { - map.set(doc.id, doc); - } - }); - if (Array.isArray(searchResults)) { - searchResults.forEach((doc) => { - if (doc?.id) { - map.set(doc.id, doc); - } - }); - } - return map; - }, [documents, searchResults]); - const handleDocumentDelete = useCallback( async (documentId) => { if (!documentId) return; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 49c8846..529ebda 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -313,6 +313,69 @@ button.icon-button.ghost:hover:not([disabled]) { color: var(--muted); } +.document-drag-preview { + position: fixed; + pointer-events: none; + top: -9999px; + left: -9999px; + width: var(--drag-preview-size, 96px); + height: var(--drag-preview-size, 96px); + z-index: 9999; +} + +.document-drag-preview__thumb { + position: absolute; + top: 50%; + left: 50%; + width: 64px; + height: 64px; + border-radius: 6px; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25); + overflow: hidden; + background-color: rgba(32, 34, 40, 0.2); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + transform: translate(-50%, -50%) rotate(var(--rotation-deg, 0deg)); + transform-origin: center; +} + +.document-drag-preview__thumb .document-thumbnail, +.document-drag-preview__thumb img { + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; +} + +.document-drag-preview__thumb .thumb-placeholder, +.document-drag-preview__thumb .thumb-placeholder * { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.document-drag-preview__count { + position: absolute; + bottom: 4px; + right: 4px; + background-color: var(--accent); + color: #fff; + border-radius: 999px; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 600; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25); + pointer-events: none; +} + .preview-workspace__actions { display: flex; align-items: center;