485 lines
16 KiB
JavaScript
485 lines
16 KiB
JavaScript
/**
|
|
* 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 = `
|
|
<div class="obsigate-drop-backdrop"></div>
|
|
<div class="obsigate-drop-modal">
|
|
<h2 class="obsigate-drop-title">${t('dragdrop.overlay_title') || 'Déposer des fichiers ou dossiers'}</h2>
|
|
<div class="obsigate-drop-zones">
|
|
<div class="obsigate-drop-zone obsigate-drop-vault" data-zone="vault">
|
|
<div class="obsigate-drop-zone-icon">📁</div>
|
|
<div class="obsigate-drop-zone-title">${t('dragdrop.overlay_vault_zone') || 'Importer dans le Vault'}</div>
|
|
<div class="obsigate-drop-zone-desc" id="obsigate-drop-vault-target"></div>
|
|
</div>
|
|
<div class="obsigate-drop-zone obsigate-drop-ai" data-zone="ai">
|
|
<div class="obsigate-drop-zone-icon">🧠</div>
|
|
<div class="obsigate-drop-zone-title">${t('dragdrop.overlay_ai_zone') || "Analyser avec l'Assistant IA"}</div>
|
|
<div class="obsigate-drop-zone-desc">Envoyer au chat BooksLM</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(overlay);
|
|
|
|
let dragCounter = 0;
|
|
|
|
window.addEventListener('dragenter', (e) => {
|
|
if (dragState) return; // Don't show global overlay for internal tree reordering
|
|
const types = Array.from(e.dataTransfer?.types || []);
|
|
if (!types.includes('Files')) return;
|
|
|
|
dragCounter++;
|
|
if (dragCounter === 1) {
|
|
// Update target vault description
|
|
const activeVault = window.state?.activeVault || (document.querySelector('.tree-item.vault-item')?.dataset?.vault) || '';
|
|
const vaultDesc = overlay.querySelector('#obsigate-drop-vault-target');
|
|
if (vaultDesc) {
|
|
vaultDesc.textContent = activeVault ? `Vault : ${activeVault}` : 'Vault par défaut';
|
|
}
|
|
overlay.classList.add('active');
|
|
}
|
|
});
|
|
|
|
window.addEventListener('dragleave', (e) => {
|
|
if (dragState) return;
|
|
const types = Array.from(e.dataTransfer?.types || []);
|
|
if (!types.includes('Files')) return;
|
|
|
|
dragCounter--;
|
|
if (dragCounter <= 0) {
|
|
dragCounter = 0;
|
|
overlay.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
window.addEventListener('dragover', (e) => {
|
|
const types = Array.from(e.dataTransfer?.types || []);
|
|
if (!types.includes('Files')) return;
|
|
e.preventDefault();
|
|
});
|
|
|
|
const zones = overlay.querySelectorAll('.obsigate-drop-zone');
|
|
zones.forEach((zone) => {
|
|
zone.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
zone.classList.add('hover');
|
|
});
|
|
|
|
zone.addEventListener('dragleave', () => {
|
|
zone.classList.remove('hover');
|
|
});
|
|
|
|
zone.addEventListener('drop', async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dragCounter = 0;
|
|
overlay.classList.remove('active');
|
|
zone.classList.remove('hover');
|
|
|
|
const zoneType = zone.dataset.zone;
|
|
const items = await readDataTransferEntries(e.dataTransfer.items || e.dataTransfer.files);
|
|
if (!items.length) return;
|
|
|
|
if (zoneType === 'vault') {
|
|
const activeVault = window.state?.activeVault || (document.querySelector('.tree-item.vault-item')?.dataset?.vault);
|
|
if (activeVault) {
|
|
// Pass null for targetDir so user is prompted to choose destination folder
|
|
await handleVaultDropUpload(activeVault, null, items);
|
|
} else {
|
|
showToast('Aucun vault sélectionné', 'error');
|
|
}
|
|
} else if (zoneType === 'ai') {
|
|
// Automatically open assistant if not already open and pass items
|
|
if (booksLM) {
|
|
if (!booksLM._isOpen) {
|
|
await booksLM.openForCurrentContext();
|
|
}
|
|
await booksLM.handleExternalDroppedItems(items);
|
|
} else if (window.BooksLMInstance) {
|
|
if (!window.BooksLMInstance._isOpen) {
|
|
await window.BooksLMInstance.openForCurrentContext();
|
|
}
|
|
await window.BooksLMInstance.handleExternalDroppedItems(items);
|
|
} else {
|
|
showToast("Assistant IA non disponible", 'warning');
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Make all tree items (files and directories) draggable.
|
|
* Vault items themselves are NOT draggable.
|
|
*/
|
|
function makeAllDraggable() {
|
|
const container = document.getElementById('vault-tree');
|
|
if (!container) return;
|
|
|
|
container.querySelectorAll('.tree-item').forEach(item => {
|
|
if (item.classList.contains('vault-item')) return;
|
|
if (!item.hasAttribute('draggable')) {
|
|
item.setAttribute('draggable', 'true');
|
|
}
|
|
});
|
|
}
|