import { debug, debugError } from './debug.js'; /** * FileTree - Gère l'arborescence hiérarchique avec drag & drop * Utilise la délégation d'événements pour éviter les problèmes de listeners perdus */ class FileTree { constructor() { this.draggedPath = null; this.draggedType = null; this.init(); } init() { this.setupEventListeners(); // Restaurer l'état des dossiers au démarrage setTimeout(() => this.restoreFolderStates(), 500); debug('FileTree initialized with event delegation'); } setupEventListeners() { // Utiliser la délégation d'événements sur le conteneur de la sidebar // Cela évite de perdre les listeners après les swaps htmx const sidebar = document.getElementById('sidebar'); if (!sidebar) { console.error('FileTree: sidebar not found'); return; } // Supprimer les anciens listeners s'ils existent if (this.clickHandler) { sidebar.removeEventListener('click', this.clickHandler); } // Créer et stocker le handler pour pouvoir le supprimer plus tard this.clickHandler = (e) => { // Ignorer les clics sur les checkboxes if (e.target.classList.contains('selection-checkbox')) { return; } // Vérifier d'abord si c'est un folder-header ou un de ses enfants const folderHeader = e.target.closest('.folder-header'); if (folderHeader && !e.target.closest('.file-item')) { e.preventDefault(); e.stopPropagation(); this.toggleFolder(folderHeader); return; } // Event listener délégué pour les clics sur les fichiers const fileItem = e.target.closest('.file-item'); if (fileItem && !folderHeader) { // Laisser HTMX gérer le chargement via l'attribut hx-get // Ne pas bloquer la propagation pour les fichiers return; } }; // Attacher le handler sidebar.addEventListener('click', this.clickHandler); // Event listeners délégués pour le drag & drop this.setupDelegatedDragAndDrop(sidebar); } toggleFolder(header) { const folderItem = header.parentElement; const children = folderItem.querySelector('.folder-children'); const toggle = header.querySelector('.folder-toggle'); const icon = header.querySelector('.folder-icon'); const folderPath = folderItem.getAttribute('data-path'); if (children.style.display === 'none') { // Ouvrir le dossier children.style.display = 'block'; toggle.classList.add('expanded'); icon.innerHTML = ''; this.saveFolderState(folderPath, true); } else { // Fermer le dossier children.style.display = 'none'; toggle.classList.remove('expanded'); icon.innerHTML = ''; this.saveFolderState(folderPath, false); } } saveFolderState(folderPath, isExpanded) { if (!folderPath) return; const expandedFolders = this.getExpandedFolders(); if (isExpanded) { expandedFolders.add(folderPath); } else { expandedFolders.delete(folderPath); } localStorage.setItem('expanded-folders', JSON.stringify([...expandedFolders])); } getExpandedFolders() { const saved = localStorage.getItem('expanded-folders'); return saved ? new Set(JSON.parse(saved)) : new Set(); } restoreFolderStates() { const expandedFolders = this.getExpandedFolders(); document.querySelectorAll('.folder-item').forEach(folderItem => { const folderPath = folderItem.getAttribute('data-path'); if (folderPath && expandedFolders.has(folderPath)) { const header = folderItem.querySelector('.folder-header'); const children = folderItem.querySelector('.folder-children'); const toggle = header?.querySelector('.folder-toggle'); const icon = header?.querySelector('.folder-icon'); if (children && toggle && icon) { children.style.display = 'block'; toggle.classList.add('expanded'); icon.innerHTML = ''; } } }); // Réinitialiser les icônes Lucide if (typeof lucide !== 'undefined') { lucide.createIcons(); } } setupDelegatedDragAndDrop(sidebar) { // Supprimer les anciens handlers s'ils existent if (this.dragStartHandler) { sidebar.removeEventListener('dragstart', this.dragStartHandler); sidebar.removeEventListener('dragend', this.dragEndHandler); sidebar.removeEventListener('dragover', this.dragOverHandler); sidebar.removeEventListener('dragleave', this.dragLeaveHandler); sidebar.removeEventListener('drop', this.dropHandler); } // Drag start - délégué pour fichiers et dossiers this.dragStartHandler = (e) => { const fileItem = e.target.closest('.file-item'); const folderHeader = e.target.closest('.folder-header'); if (fileItem && fileItem.draggable) { this.handleDragStart(e, 'file', fileItem); } else if (folderHeader && folderHeader.draggable) { this.handleDragStart(e, 'folder', folderHeader); } }; // Drag end - délégué this.dragEndHandler = (e) => { const fileItem = e.target.closest('.file-item'); const folderHeader = e.target.closest('.folder-header'); if (fileItem || folderHeader) { this.handleDragEnd(e); } }; // Drag over - délégué sur les folder-headers et la racine this.dragOverHandler = (e) => { const folderHeader = e.target.closest('.folder-header'); const rootHeader = e.target.closest('.sidebar-section-header[data-section="notes"]'); const target = folderHeader || rootHeader; if (target) { this.handleDragOver(e, target); } }; // Drag leave - délégué this.dragLeaveHandler = (e) => { const folderHeader = e.target.closest('.folder-header'); const rootHeader = e.target.closest('.sidebar-section-header[data-section="notes"]'); const target = folderHeader || rootHeader; if (target) { this.handleDragLeave(e, target); } }; // Drop - délégué this.dropHandler = (e) => { const folderHeader = e.target.closest('.folder-header'); const rootHeader = e.target.closest('.sidebar-section-header[data-section="notes"]'); const target = folderHeader || rootHeader; if (target) { this.handleDrop(e, target); } }; // Attacher les handlers sidebar.addEventListener('dragstart', this.dragStartHandler); sidebar.addEventListener('dragend', this.dragEndHandler); sidebar.addEventListener('dragover', this.dragOverHandler); sidebar.addEventListener('dragleave', this.dragLeaveHandler); sidebar.addEventListener('drop', this.dropHandler); // Rendre les dossiers draggables (sauf racine) this.updateDraggableAttributes(); // Écouter les événements HTMX pour mettre à jour les attributs après les swaps // Plus performant et plus cohérent qu'un MutationObserver document.body.addEventListener('htmx:afterSwap', (event) => { // Vérifier si le swap concerne le file-tree const target = event.detail?.target; if (target && (target.id === 'file-tree' || target.closest('#file-tree'))) { debug('FileTree: afterSwap detected, updating attributes and restoring folder states...'); this.updateDraggableAttributes(); setTimeout(() => this.restoreFolderStates(), 50); } }); // Écouter aussi les swaps out-of-band (oob) qui mettent à jour le file-tree document.body.addEventListener('htmx:oobAfterSwap', (event) => { const target = event.detail?.target; // Ignorer les swaps de statut (auto-save-status, save-status) if (target && target.id === 'file-tree') { debug('FileTree: oobAfterSwap detected, updating attributes and restoring folder states...'); this.updateDraggableAttributes(); setTimeout(() => this.restoreFolderStates(), 50); } }); // Écouter les restaurations d'historique (bouton retour du navigateur) document.body.addEventListener('htmx:historyRestore', () => { debug('FileTree: History restored, re-initializing event listeners...'); // Réinitialiser complètement les event listeners après restauration de l'historique setTimeout(() => { this.setupEventListeners(); this.updateDraggableAttributes(); this.restoreFolderStates(); }, 50); }); } updateDraggableAttributes() { // Mettre à jour l'attribut draggable pour les dossiers non-racine const folderItems = document.querySelectorAll('.folder-item'); folderItems.forEach(folder => { const header = folder.querySelector('.folder-header'); const isRoot = folder.dataset.isRoot === 'true'; if (header && !isRoot) { header.setAttribute('draggable', 'true'); } }); } handleDragStart(e, type, item) { item.classList.add('dragging'); let path, name; if (type === 'file') { path = item.dataset.path; name = path.split('/').pop(); } else if (type === 'folder') { const folderItem = item.closest('.folder-item'); path = folderItem.dataset.path; name = folderItem.querySelector('.folder-name').textContent.trim(); } e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', path); e.dataTransfer.setData('application/note-path', path); e.dataTransfer.setData('application/note-type', type); e.dataTransfer.setData('application/note-name', name); // Stocker le chemin source pour validation this.draggedPath = path; this.draggedType = type; debug('Drag start:', { type, path, name }); } handleDragEnd(e) { // Trouver l'élément draggé (fichier ou folder-header) const fileItem = e.target.closest('.file-item'); const folderHeader = e.target.closest('.folder-header'); const item = fileItem || folderHeader; if (item) { item.classList.remove('dragging'); } // Supprimer les highlights de tous les dossiers document.querySelectorAll('.folder-item.drag-over').forEach(f => { f.classList.remove('drag-over'); }); // Supprimer l'indicateur de destination const indicator = document.getElementById('drag-destination-indicator'); if (indicator) { indicator.remove(); } this.draggedPath = null; this.draggedType = null; } handleDragOver(e, target) { e.preventDefault(); e.stopPropagation(); // Gérer soit un folder-header dans un folder-item, soit la racine (sidebar-section-header) const isRoot = target.classList.contains('sidebar-section-header'); const targetElement = isRoot ? target : target.closest('.folder-item'); if (!targetElement) return; const targetPath = targetElement.dataset.path; // Empêcher de déplacer un dossier dans lui-même ou dans ses enfants if (this.draggedType === 'folder' && this.draggedPath) { if (targetPath === this.draggedPath || targetPath.startsWith(this.draggedPath + '/')) { e.dataTransfer.dropEffect = 'none'; targetElement.classList.remove('drag-over'); this.removeDestinationIndicator(); return; } } e.dataTransfer.dropEffect = 'move'; if (targetElement && !targetElement.classList.contains('drag-over')) { // Retirer la classe des autres dossiers et de la racine document.querySelectorAll('.folder-item.drag-over, .sidebar-section-header.drag-over').forEach(f => { if (f !== targetElement) { f.classList.remove('drag-over'); } }); targetElement.classList.add('drag-over'); // Afficher l'indicateur de destination this.showDestinationIndicator(targetElement, targetPath, isRoot); } } handleDragLeave(e, target) { // Gérer soit un folder-header dans un folder-item, soit la racine (sidebar-section-header) const isRoot = target.classList.contains('sidebar-section-header'); const targetElement = isRoot ? target : target.closest('.folder-item'); if (!targetElement) return; // Vérifier que la souris a vraiment quitté l'élément const rect = target.getBoundingClientRect(); if (e.clientX < rect.left || e.clientX >= rect.right || e.clientY < rect.top || e.clientY >= rect.bottom) { targetElement.classList.remove('drag-over'); this.removeDestinationIndicator(); } } showDestinationIndicator(targetElement, targetPath, isRoot) { let indicator = document.getElementById('drag-destination-indicator'); if (!indicator) { indicator = document.createElement('div'); indicator.id = 'drag-destination-indicator'; indicator.className = 'drag-destination-indicator'; document.body.appendChild(indicator); } const folderName = targetElement.querySelector('.folder-name').textContent.trim(); const displayPath = isRoot ? 'notes/' : targetPath; indicator.innerHTML = ` 📥 Déplacer vers: ${folderName} ${displayPath} `; indicator.style.display = 'flex'; } removeDestinationIndicator() { const indicator = document.getElementById('drag-destination-indicator'); if (indicator) { indicator.style.display = 'none'; } } handleDrop(e, target) { e.preventDefault(); e.stopPropagation(); // Gérer soit un folder-header dans un folder-item, soit la racine (sidebar-section-header) const isRoot = target.classList.contains('sidebar-section-header'); const targetElement = isRoot ? target : target.closest('.folder-item'); if (!targetElement) return; targetElement.classList.remove('drag-over'); // Supprimer l'indicateur de destination this.removeDestinationIndicator(); const sourcePath = e.dataTransfer.getData('application/note-path') || e.dataTransfer.getData('text/plain'); const sourceType = e.dataTransfer.getData('application/note-type'); const targetFolderPath = targetElement.dataset.path; debug('Drop event:', { sourcePath, sourceType, targetFolderPath, dataTransfer: e.dataTransfer.types }); // Validation : sourcePath doit exister, targetFolderPath peut être vide (racine) if (!sourcePath || targetFolderPath === undefined || targetFolderPath === null) { console.error('Chemins invalides pour le drag & drop', { sourcePath, targetFolderPath }); alert(`Erreur: source='${sourcePath}', destination='${targetFolderPath}'`); return; } // Empêcher de déplacer un dossier dans lui-même ou dans ses enfants if (sourceType === 'folder') { if (targetFolderPath === sourcePath || targetFolderPath.startsWith(sourcePath + '/')) { alert('Impossible de déplacer un dossier dans lui-même ou dans un de ses sous-dossiers'); return; } } // Ne pas déplacer si c'est déjà dans le même dossier parent const sourceDir = sourcePath.includes('/') ? sourcePath.substring(0, sourcePath.lastIndexOf('/')) : ''; if (sourceDir === targetFolderPath) { debug('Déjà dans le même dossier parent, rien à faire'); return; } // Extraire le nom du fichier/dossier const itemName = sourcePath.includes('/') ? sourcePath.substring(sourcePath.lastIndexOf('/') + 1) : sourcePath; // Construire le chemin de destination // Si targetFolderPath est vide (racine), ne pas ajouter de slash const destinationPath = targetFolderPath === '' ? itemName : targetFolderPath + '/' + itemName; debug(`Déplacement: ${sourcePath} → ${destinationPath}`); this.moveFile(sourcePath, destinationPath); } async moveFile(sourcePath, destinationPath) { debug('moveFile called:', { sourcePath, destinationPath }); try { // Utiliser htmx.ajax() au lieu de fetch() manuel // HTMX gère automatiquement les swaps oob et le traitement du HTML // Les attributs draggables seront mis à jour automatiquement via htmx:oobAfterSwap htmx.ajax('POST', '/api/files/move', { values: { source: sourcePath, destination: destinationPath }, swap: 'none' // On ne swap rien directement, le serveur utilise hx-swap-oob }).then(() => { debug(`Fichier déplacé: ${sourcePath} -> ${destinationPath}`); }).catch((error) => { console.error('Erreur lors du déplacement:', error); alert('Erreur lors du déplacement du fichier'); }); } catch (error) { console.error('Erreur lors du déplacement:', error); alert('Erreur lors du déplacement du fichier: ' + error.message); } } } /** * Gestion de la création de notes */ window.showNewNoteModal = function() { const modal = document.getElementById('new-note-modal'); modal.style.display = 'flex'; setTimeout(() => { document.getElementById('note-name').focus(); }, 100); } window.hideNewNoteModal = function() { const modal = document.getElementById('new-note-modal'); modal.style.display = 'none'; document.getElementById('note-name').value = ''; } window.handleNewNote = function(event) { event.preventDefault(); let noteName = document.getElementById('note-name').value.trim(); if (!noteName) { alert('Veuillez entrer un nom de note'); return; } // Ajouter .md si pas déjà présent if (!noteName.endsWith('.md')) { noteName += '.md'; } // Valider le nom (pas de caractères dangereux) if (noteName.includes('..') || noteName.includes('\\')) { alert('Nom de note invalide. Évitez les caractères \\ et ..'); return; } // Fermer la modale window.hideNewNoteModal(); // Utiliser HTMX pour charger la note (au lieu de fetch manuel) const editorContainer = document.getElementById('editor-container'); // Supprimer temporairement le trigger "load" pour éviter qu'il se redéclenche editorContainer.removeAttribute('hx-trigger'); // Utiliser l'API HTMX pour charger la note if (typeof htmx !== 'undefined') { htmx.ajax('GET', `/api/notes/${encodeURIComponent(noteName)}`, { target: '#editor-container', swap: 'innerHTML', pushUrl: true }); } } /** * Gestion de la création de dossiers */ window.showNewFolderModal = function() { const modal = document.getElementById('new-folder-modal'); modal.style.display = 'flex'; setTimeout(() => { document.getElementById('folder-name').focus(); }, 100); } window.hideNewFolderModal = function() { const modal = document.getElementById('new-folder-modal'); modal.style.display = 'none'; document.getElementById('folder-name').value = ''; } window.handleNewFolder = async function(event) { event.preventDefault(); let folderName = document.getElementById('folder-name').value.trim(); if (!folderName) { alert('Veuillez entrer un nom de dossier'); return; } // Valider le nom (pas de caractères dangereux) if (folderName.includes('..') || folderName.includes('\\')) { alert('Nom de dossier invalide. Évitez les caractères \\ et ..'); return; } try { // Utiliser htmx.ajax() au lieu de fetch() manuel // HTMX gère automatiquement les swaps oob et le traitement du HTML // Les attributs draggables seront mis à jour automatiquement via htmx:oobAfterSwap htmx.ajax('POST', '/api/folders/create', { values: { path: folderName }, swap: 'none' // On ne swap rien directement, le serveur utilise hx-swap-oob }).then(() => { window.hideNewFolderModal(); debug(`Dossier créé: ${folderName}`); }).catch((error) => { console.error('Erreur lors de la création du dossier:', error); alert('Erreur lors de la création du dossier'); }); } catch (error) { console.error('Erreur lors de la création du dossier:', error); alert('Erreur lors de la création du dossier: ' + error.message); } } // Fermer les modales avec Escape document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { const folderModal = document.getElementById('new-folder-modal'); const noteModal = document.getElementById('new-note-modal'); if (folderModal && folderModal.style.display === 'flex') { window.hideNewFolderModal(); } if (noteModal && noteModal.style.display === 'flex') { window.hideNewNoteModal(); } } }); /** * Initialisation automatique */ document.addEventListener('DOMContentLoaded', () => { window.fileTree = new FileTree(); window.selectionManager = new SelectionManager(); }); /** * SelectionManager - Gère le mode sélection et la suppression en masse */ class SelectionManager { constructor() { this.isSelectionMode = false; this.selectedPaths = new Set(); this.init(); } init() { // Écouter les événements HTMX pour réinitialiser les listeners après les swaps document.body.addEventListener('htmx:afterSwap', (event) => { if (event.detail.target.id === 'file-tree' || event.detail.target.closest('#file-tree')) { this.attachCheckboxListeners(); if (this.isSelectionMode) { this.showCheckboxes(); } } }); document.body.addEventListener('htmx:oobAfterSwap', (event) => { if (event.detail.target.id === 'file-tree') { this.attachCheckboxListeners(); if (this.isSelectionMode) { this.showCheckboxes(); } } }); // Attacher les listeners initiaux setTimeout(() => this.attachCheckboxListeners(), 500); } attachCheckboxListeners() { const checkboxes = document.querySelectorAll('.selection-checkbox'); checkboxes.forEach(checkbox => { // Retirer l'ancien listener s'il existe checkbox.removeEventListener('change', this.handleCheckboxChange); // Ajouter le nouveau listener checkbox.addEventListener('change', (e) => this.handleCheckboxChange(e)); }); } handleCheckboxChange(e) { const checkbox = e.target; const path = checkbox.dataset.path; if (checkbox.checked) { window.selectionManager.selectedPaths.add(path); } else { window.selectionManager.selectedPaths.delete(path); } window.selectionManager.updateToolbar(); } toggleSelectionMode() { this.isSelectionMode = !this.isSelectionMode; if (this.isSelectionMode) { this.showCheckboxes(); document.getElementById('toggle-selection-mode')?.classList.add('active'); } else { this.hideCheckboxes(); this.clearSelection(); document.getElementById('toggle-selection-mode')?.classList.remove('active'); } } showCheckboxes() { const checkboxes = document.querySelectorAll('.selection-checkbox'); checkboxes.forEach(checkbox => { checkbox.style.display = 'inline-block'; }); } hideCheckboxes() { const checkboxes = document.querySelectorAll('.selection-checkbox'); checkboxes.forEach(checkbox => { checkbox.style.display = 'none'; checkbox.checked = false; }); } clearSelection() { this.selectedPaths.clear(); this.updateToolbar(); } updateToolbar() { const toolbar = document.getElementById('selection-toolbar'); const countSpan = document.getElementById('selection-count'); if (this.selectedPaths.size > 0) { toolbar.style.display = 'flex'; countSpan.textContent = `${this.selectedPaths.size} élément(s) sélectionné(s)`; } else { toolbar.style.display = 'none'; } } showDeleteConfirmationModal() { const modal = document.getElementById('delete-confirmation-modal'); const countSpan = document.getElementById('delete-count'); const itemsList = document.getElementById('delete-items-list'); countSpan.textContent = this.selectedPaths.size; // Générer la liste des éléments à supprimer itemsList.innerHTML = ''; const ul = document.createElement('ul'); ul.style.margin = '0'; ul.style.padding = '0 0 0 1.5rem'; ul.style.color = 'var(--text-primary)'; this.selectedPaths.forEach(path => { const li = document.createElement('li'); li.style.marginBottom = '0.5rem'; // Déterminer si c'est un dossier const checkbox = document.querySelector(`.selection-checkbox[data-path="${path}"]`); const isDir = checkbox?.dataset.isDir === 'true'; li.innerHTML = `${isDir ? '' : ''} ${path}`; ul.appendChild(li); }); itemsList.appendChild(ul); modal.style.display = 'flex'; } hideDeleteConfirmationModal() { const modal = document.getElementById('delete-confirmation-modal'); modal.style.display = 'none'; } async deleteSelectedItems() { const paths = Array.from(this.selectedPaths); if (paths.length === 0) { alert('Aucun élément sélectionné'); return; } try { // Construire le corps de la requête au format query string // Le backend attend: paths[]=path1&paths[]=path2 const params = new URLSearchParams(); paths.forEach(path => { params.append('paths[]', path); }); // Utiliser fetch() avec le corps en query string const response = await fetch('/api/files/delete-multiple', { method: 'DELETE', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const html = await response.text(); // Parser le HTML pour trouver les éléments avec hx-swap-oob const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Traiter les swaps out-of-band manuellement doc.querySelectorAll('[hx-swap-oob]').forEach(element => { const targetId = element.id; const target = document.getElementById(targetId); if (target) { target.innerHTML = element.innerHTML; // Déclencher l'événement htmx pour que les listeners se réattachent htmx.process(target); } }); debug(`${paths.length} élément(s) supprimé(s)`); // Fermer la modale this.hideDeleteConfirmationModal(); // Réinitialiser la sélection et garder le mode sélection actif this.clearSelection(); // Réattacher les listeners sur les nouvelles checkboxes setTimeout(() => { this.attachCheckboxListeners(); if (this.isSelectionMode) { this.showCheckboxes(); } }, 100); } catch (error) { console.error('Erreur lors de la suppression:', error); alert('Erreur lors de la suppression des éléments: ' + error.message); } } } /** * Fonctions globales pour les boutons */ window.toggleSelectionMode = function() { window.selectionManager.toggleSelectionMode(); }; window.deleteSelected = function() { window.selectionManager.showDeleteConfirmationModal(); }; window.cancelSelection = function() { window.selectionManager.toggleSelectionMode(); }; window.hideDeleteConfirmationModal = function() { window.selectionManager.hideDeleteConfirmationModal(); }; window.confirmDelete = function() { window.selectionManager.deleteSelectedItems(); };