/** * Drag & Drop module for ObsiGate sidebar tree. * * Allows drag-and-drop of tree items (files and directories) onto * target directories or vault roots to reorganize the vault. * * Usage: import { initDragDrop } from './dragdrop.js'; * initDragDrop() after the vault tree is ready. */ import { api } from './auth.js'; import { t } from './i18n.js'; import { showToast } from './ui.js'; import { refreshSidebarTreePreservingState } from './sidebar.js'; import { readDataTransferEntries, handleVaultDropUpload } from './upload.js'; import booksLM from './bookslm.js'; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- /** Currently dragged item: { vault, path, type, element } */ let dragState = null; // --------------------------------------------------------------------------- // CSS classes applied during drag operations // --------------------------------------------------------------------------- const DRAG_OVER_CLASS = 'drag-over'; const DRAG_SOURCE_CLASS = 'drag-source'; const DRAG_VALID_TARGET_CLASS = 'drag-valid-target'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Walk up the DOM to find the closest ancestor matching the selector. */ function closest(selector, el) { while (el && el !== document) { if (el.matches && el.matches(selector)) return el; el = el.parentNode; } return null; } /** * Determine if an element is a valid drop target (directory or vault root). * Returns { vault, targetDir } or null. */ function getDropTarget(el) { // Directory tree item const dirItem = closest('.tree-item[data-path]', el); if (dirItem) { const isExplicitDir = dirItem.dataset.type === 'directory'; const icon = dirItem.querySelector('[data-lucide], i'); const iconName = icon?.getAttribute('data-lucide'); const isFolderIcon = iconName === 'folder' || iconName === 'chevron-right' || iconName === 'chevron-down'; const hasChildren = dirItem.nextElementSibling && dirItem.nextElementSibling.classList.contains('tree-children'); if (isExplicitDir || isFolderIcon || hasChildren) { return { vault: dirItem.dataset.vault, targetDir: dirItem.dataset.path, }; } return null; } // Vault item (root of a vault) const vaultItem = closest('.tree-item.vault-item[data-vault]', el); if (vaultItem) { return { vault: vaultItem.dataset.vault, targetDir: '', // empty = vault root }; } return null; } /** * Highlight drop target and its parent chain. */ function highlightDropTarget(el) { const target = getDropTarget(el); if (!target) return; if (target.targetDir) { // Highlight the directory const dirEl = document.querySelector( `.tree-item[data-vault="${CSS.escape(target.vault)}"][data-path="${CSS.escape(target.targetDir)}"]` ); if (dirEl) dirEl.classList.add(DRAG_OVER_CLASS); } else { // Highlight the vault const vaultEl = document.querySelector(`.tree-item.vault-item[data-vault="${CSS.escape(target.vault)}"]`); if (vaultEl) vaultEl.classList.add(DRAG_OVER_CLASS); } } /** * Remove all drop target highlights. */ function clearAllHighlights() { document.querySelectorAll(`.${DRAG_OVER_CLASS}`).forEach(el => el.classList.remove(DRAG_OVER_CLASS)); } /** * Check if the source item is being dropped on itself or inside itself. */ function isInvalidDrop(source, target) { if (source.vault !== target.vault) return true; // cross-vault not supported if (!target.targetDir) { // Target is vault root — check if source is already at root const sourceParent = source.path.includes('/') ? source.path.substring(0, source.path.lastIndexOf('/')) : ''; return sourceParent === ''; } // Can't drop a directory into itself or its children if (source.type === 'directory') { if (source.path === target.targetDir) return true; // dropping on itself if (target.targetDir.startsWith(source.path + '/')) return true; // dropping into a child } // Check if source is already in this directory const sourceParent = source.path.includes('/') ? source.path.substring(0, source.path.lastIndexOf('/')) : ''; if (sourceParent === target.targetDir) return true; // same directory return false; } // --------------------------------------------------------------------------- // Event handlers // --------------------------------------------------------------------------- function onDragStart(e) { const treeItem = closest('.tree-item', e.target); if (!treeItem) return; // Don't allow dragging vault items themselves if (treeItem.classList.contains('vault-item')) return; const vault = treeItem.dataset.vault; const path = treeItem.dataset.path; if (!vault || path === undefined) return; // Determine type: directory or file const isExplicitDir = treeItem.dataset.type === 'directory'; const hasChildren = treeItem.nextElementSibling && treeItem.nextElementSibling.classList.contains('tree-children'); const type = (isExplicitDir || hasChildren) ? 'directory' : 'file'; dragState = { vault, path, type, element: treeItem }; // Set drag data and visual feedback e.dataTransfer.effectAllowed = 'move'; // Prefix with 'tree:' so pane drop zones can distinguish from tab drags e.dataTransfer.setData('text/plain', 'tree:' + JSON.stringify({ vault, path })); e.dataTransfer.setData('tree:vault', vault); e.dataTransfer.setData('tree:path', path); e.dataTransfer.setData('tree:type', type); // Visual: reduce opacity of source treeItem.classList.add(DRAG_SOURCE_CLASS); // Add valid-target indicator to all directories and vaults setTimeout(() => { document.querySelectorAll('.tree-item').forEach(item => { if (item === treeItem) return; if (item.classList.contains('vault-item')) { item.classList.add(DRAG_VALID_TARGET_CLASS); } else { const icon = item.querySelector('[data-lucide], i'); const iconName = icon?.getAttribute('data-lucide'); const isFolder = item.dataset.type === 'directory' || iconName === 'folder' || iconName === 'chevron-right' || iconName === 'chevron-down'; const hasChildContainer = item.nextElementSibling && item.nextElementSibling.classList.contains('tree-children'); if (isFolder || hasChildContainer) { item.classList.add(DRAG_VALID_TARGET_CLASS); } } }); }, 0); } function onDragOver(e) { const isExternalFiles = e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'); if (!dragState && !isExternalFiles) return; e.preventDefault(); // Required to allow drop if (isExternalFiles) { e.dataTransfer.dropEffect = 'copy'; } else { e.dataTransfer.dropEffect = 'move'; } clearAllHighlights(); const target = getDropTarget(e.target); if (!target || (!isExternalFiles && isInvalidDrop(dragState, target))) { e.dataTransfer.dropEffect = 'none'; return; } highlightDropTarget(e.target); } function onDragLeave(e) { // Only clear when actually leaving the container, not entering a child. // Use relatedTarget: if it's still inside #vault-tree, keep highlights. const container = document.getElementById('vault-tree'); if (!container) return; if (e.relatedTarget && container.contains(e.relatedTarget)) return; clearAllHighlights(); } function onDragEnd() { clearAllHighlights(); // Remove drag source visual if (dragState && dragState.element) { dragState.element.classList.remove(DRAG_SOURCE_CLASS); } // Remove valid-target indicators document.querySelectorAll(`.${DRAG_VALID_TARGET_CLASS}`).forEach(el => { el.classList.remove(DRAG_VALID_TARGET_CLASS); }); dragState = null; } async function onDrop(e) { e.preventDefault(); e.stopPropagation(); const isExternalFiles = e.dataTransfer && ( (e.dataTransfer.files && e.dataTransfer.files.length > 0) || (e.dataTransfer.items && e.dataTransfer.items.length > 0) || Array.from(e.dataTransfer.types || []).includes('Files') ); if (!dragState && !isExternalFiles) return; const target = getDropTarget(e.target); if (!target || (!isExternalFiles && isInvalidDrop(dragState, target))) { onDragEnd(); return; } const { vault: targetVault, targetDir } = target; if (isExternalFiles) { onDragEnd(); const items = await readDataTransferEntries(e.dataTransfer.items || e.dataTransfer.files); if (items.length > 0) { await handleVaultDropUpload(targetVault, targetDir, items); } return; } const { vault, path, type } = dragState; // Don't move if source and target are the same vault if (vault !== targetVault) { showToast(t('dragdrop.cross_vault_unsupported'), 'error'); onDragEnd(); return; } // Save drag state before cleanup const savedState = { ...dragState }; // Clean up visual state immediately onDragEnd(); // Call API try { const response = await api(`/api/move/${encodeURIComponent(vault)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source_path: path, destination_dir: targetDir, }), }); if (response && response.success) { showToast(`Moved ${type} to ${targetDir || 'vault root'}`, 'success'); // Refresh the tree preserving expanded state await refreshSidebarTreePreservingState(); } else { showToast(t('dragdrop.move_failed'), 'error'); } } catch (err) { console.error('Drag & drop move failed:', err); const detail = err?.detail || err?.message || 'Unknown error'; showToast(`Move failed: ${detail}`, 'error'); // Refresh anyway in case the filesystem state changed try { await refreshSidebarTreePreservingState(); } catch (_) { /* ignore */ } } } // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- /** * Attach drag-and-drop event listeners to the vault tree container. * Uses event delegation on the #vault-tree element. */ export function initDragDrop() { const container = document.getElementById('vault-tree'); if (!container) { console.warn('DragDrop: vault-tree container not found'); return; } // Remove previous listeners if any (idempotent) container.removeEventListener('dragstart', onDragStart); container.removeEventListener('dragover', onDragOver); container.removeEventListener('dragleave', onDragLeave); container.removeEventListener('dragend', onDragEnd); container.removeEventListener('drop', onDrop); // Attach listeners with delegation container.addEventListener('dragstart', onDragStart); container.addEventListener('dragover', onDragOver); container.addEventListener('dragleave', onDragLeave); container.addEventListener('dragend', onDragEnd); container.addEventListener('drop', onDrop); // Make all existing tree items draggable makeAllDraggable(); // Re-apply draggable after any DOM mutation in the tree const observer = new MutationObserver(() => { makeAllDraggable(); }); observer.observe(container, { childList: true, subtree: true }); // Initialize global drop overlay initGlobalDropOverlay(); } /** * Initialize a global dropzone overlay across the entire window * when dragging files/folders from the OS. */ export function initGlobalDropOverlay() { if (document.getElementById('obsigate-global-drop-overlay')) return; const overlay = document.createElement('div'); overlay.id = 'obsigate-global-drop-overlay'; overlay.className = 'obsigate-drop-overlay'; overlay.innerHTML = `