Files
ObsiGate/frontend/js/dragdrop.js
T
bruno cfb3825dbe
CI / lint (push) Failing after 4s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 13s
feat: drag & drop de fichiers dans l'arborescence (portion 5)
- Backend: nouvel endpoint POST /api/move/{vault_name} pour déplacer
  fichiers et dossiers vers un autre répertoire parent
- Frontend: module dragdrop.js avec délégation d'événements sur le
  vault-tree (dragstart, dragover, dragleave, drop)
- CSS: styles pour drag-source, drag-valid-target, drag-over
- Protections: pas de drop sur soi-même, pas dans un enfant,
  pas de cross-vault
- Mise à jour de l'index et broadcast SSE après move
- ROADMAP: portion 5 marquée comme complétée
2026-06-03 13:56:27 -04:00

330 lines
10 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 { showToast } from './ui.js';
import { refreshSidebarTreePreservingState } from './sidebar.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) {
// Only directories are valid targets
const icon = dirItem.querySelector('[data-lucide]');
const isDir = icon && (icon.getAttribute('data-lucide') === 'folder'
|| icon.getAttribute('data-lucide') === 'chevron-right'
|| icon.getAttribute('data-lucide') === 'chevron-down');
// Heuristic: if the tree item has a child container sibling, it's a directory
const hasChildren = dirItem.nextElementSibling
&& dirItem.nextElementSibling.classList.contains('tree-children');
if (isDir || 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 hasChildren = treeItem.nextElementSibling
&& treeItem.nextElementSibling.classList.contains('tree-children');
const type = hasChildren ? 'directory' : 'file';
dragState = { vault, path, type, element: treeItem };
// Set drag data and visual feedback
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', path);
// 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]');
const hasChildContainer = item.nextElementSibling
&& item.nextElementSibling.classList.contains('tree-children');
if (icon && (icon.getAttribute('data-lucide') === 'folder' || hasChildContainer)) {
item.classList.add(DRAG_VALID_TARGET_CLASS);
}
}
});
}, 0);
}
function onDragOver(e) {
if (!dragState) return;
e.preventDefault(); // Required to allow drop
e.dataTransfer.dropEffect = 'move';
clearAllHighlights();
const target = getDropTarget(e.target);
if (!target || 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
const container = document.getElementById('vault-tree');
if (!container) return;
// Use a small delay to avoid flickering when moving between children
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();
if (!dragState) return;
const target = getDropTarget(e.target);
if (!target || isInvalidDrop(dragState, target)) {
onDragEnd();
return;
}
const { vault, path, type } = dragState;
const { vault: targetVault, targetDir } = target;
// Don't move if source and target are the same vault
if (vault !== targetVault) {
showToast('Cross-vault moves are not supported', '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('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 });
}
/**
* 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');
}
});
}