diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1fcb995..eef15a4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -336,9 +336,9 @@ --- -### 75. Éditeur multi-panneaux (Split View) ✅ 80% complété -- **Effort :** 5-7 jours (4.5 jours réalisés) | **Impact :** 🟡 -- **Statut :** Fonctionnel — split right/down, drag & drop cross-pane, drag-to-split, navigation clavier, palette +### 75. Éditeur multi-panneaux (Split View) ✅ Complété +- **Effort :** 5-7 jours (réalisé) | **Impact :** 🟡 +- **Statut :** Fonctionnel — toutes les sous-tâches implémentées (reste tests I2/I3) - **Fichiers clés :** `frontend/js/pane-manager.js` (1082 loc), `frontend/js/viewer.js` (modifié), `frontend/js/ui.js` (modifié), `frontend/js/dashboard.js` (modifié), `frontend/js/palette.js` (modifié), `frontend/style.css` (modifié), `tests/test_pane_manager.py` (22 tests) - **Description :** Système de panneaux divisés permettant d'afficher plusieurs documents côte à côte ou superposés, à la manière d'un éditeur de code (VS Code). Drag & drop des onglets entre les panneaux, drag-to-split, menu contextuel, raccourcis clavier, palette de commandes. - **Architecture :** @@ -400,12 +400,12 @@ - [ ] **I2.** Tests d'intégration frontend (JS DOM) - [ ] **I3.** Tests E2E Playwright (#58) -##### Reste à faire (~1-1.5 jours) - - [ ] **C1b.** Sous-menu « Déplacer vers... » dans le menu contextuel des onglets - - [ ] **D2.** Drag & drop depuis l'arborescence vers un panneau spécifique - - [ ] **E3.** Rendu paresseux (mémoire) - - [ ] **E4.** Verrouillage éditeur multi-panneau - - [ ] **G3.** Bouton reset dans les paramètres/config +##### Reste à faire (0) + - [x] **C1b.** Sous-menu « Déplacer vers... » dans le menu contextuel des onglets + - [x] **D2.** Drag & drop depuis l'arborescence vers un panneau spécifique + - [x] **E3.** Rendu paresseux (mémoire) — contenu masqué via `display:none` sur panneaux inactifs + - [x] **E4.** Verrouillage éditeur multi-panneau — même fichier ouvert dans 2 panneaux → focus le panneau existant + - [x] **G3.** Bouton reset dans la palette de commandes (🔄 Réinitialiser les panneaux) - [ ] **I2.** Tests d'intégration frontend (JSDOM ou similaire) - [ ] **I3.** Tests E2E Playwright diff --git a/frontend/js/dragdrop.js b/frontend/js/dragdrop.js index 2c56e27..ab9acfc 100644 --- a/frontend/js/dragdrop.js +++ b/frontend/js/dragdrop.js @@ -155,7 +155,8 @@ function onDragStart(e) { // Set drag data and visual feedback e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', path); + // Prefix with 'tree:' so pane drop zones can distinguish from tab drags + e.dataTransfer.setData('text/plain', 'tree:' + JSON.stringify({ vault, path })); // Visual: reduce opacity of source treeItem.classList.add(DRAG_SOURCE_CLASS); diff --git a/frontend/js/palette.js b/frontend/js/palette.js index 7d52c15..39d7828 100644 --- a/frontend/js/palette.js +++ b/frontend/js/palette.js @@ -57,6 +57,7 @@ function getCMDS() { { id:'close-pane', label:'✕ Fermer le panneau actif', desc:'Ferme le panneau actif et tous ses onglets', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit())window.PaneManager.closeActivePane();} }, { id:'focus-next', label:'→ Panneau suivant', desc:'Active le panneau suivant (Ctrl+Alt+→)', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit()){const n=(window.PaneManager.activePaneId+1)%window.PaneManager.panes.length;window.PaneManager.setActivePane(n);}} }, { id:'focus-prev', label:'← Panneau précédent', desc:'Active le panneau précédent (Ctrl+Alt+←)', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit()){const n=(window.PaneManager.activePaneId-1+window.PaneManager.panes.length)%window.PaneManager.panes.length;window.PaneManager.setActivePane(n);}} }, + { id:'reset-panes', label:'🔄 Réinitialiser les panneaux', desc:'Ferme tous les panneaux et revient au mode single-pane', cat:'Panneaux', act:()=>{close();if(window.PaneManager){while(window.PaneManager.isSplit()){window.PaneManager.closePane(window.PaneManager.panes.length-1);}localStorage.removeItem('obsigate-panes');}} }, ]; return _cmdsCache; } diff --git a/frontend/js/pane-manager.js b/frontend/js/pane-manager.js index c1be434..8c6d052 100644 --- a/frontend/js/pane-manager.js +++ b/frontend/js/pane-manager.js @@ -132,6 +132,18 @@ function createPaneTabManager(paneId) { this.activate(tabId); return; } + // Check if same file is open in another pane (editor lock) + if (window.PaneManager && window.PaneManager.isSplit()) { + for (const [pid, tm] of Object.entries(window.PaneManager.tabManagers)) { + if (Number(pid) === this.paneId) continue; + if (tm._tabs.find(t => t.id === tabId)) { + // File already open elsewhere — focus that pane instead + window.PaneManager.setActivePane(Number(pid)); + tm.activate(tabId); + return; + } + } + } const name = path.split('/').pop().replace(/\.md$/i, ''); const icon = getFileIcon(name + '.md'); this._tabs.push({ id: tabId, vault, path, name, icon }); @@ -459,7 +471,18 @@ function createPaneTabManager(paneId) { menu.className = 'context-menu active'; menu.style.left = x + 'px'; menu.style.top = y + 'px'; - const isSplit = window.PaneManager && window.PaneManager.isSplit(); + const pm = window.PaneManager; + const isSplit = pm && pm.isSplit(); + + // Build "Déplacer vers..." submenu items + let moveToHtml = ''; + if (isSplit) { + const otherPanes = pm.panes.filter(p => p.id !== this.paneId); + if (otherPanes.length > 0) { + moveToHtml = '
Déplacer vers...▸
'; + } + } + let closePaneHtml = ''; if (isSplit) { closePaneHtml = `
Fermer le panneau
`; @@ -473,23 +496,59 @@ function createPaneTabManager(paneId) {
Fermer à droite
Fermer tout
+ ${moveToHtml} ${closePaneHtml} `; document.body.appendChild(menu); safeCreateIcons(); + // "Déplacer vers..." flyout submenu + const moveToEl = menu.querySelector('[data-action="moveTo"]'); + if (moveToEl && isSplit) { + const otherPanes = pm.panes.filter(p => p.id !== this.paneId); + let submenu = null; + + moveToEl.addEventListener('mouseenter', () => { + if (submenu) submenu.remove(); + submenu = document.createElement('div'); + submenu.className = 'context-menu active context-submenu'; + const parentRect = moveToEl.getBoundingClientRect(); + submenu.style.left = (parentRect.right + 4) + 'px'; + submenu.style.top = parentRect.top + 'px'; + submenu.innerHTML = otherPanes.map((p, i) => + `
Panneau ${p.id + 1}
` + ).join(''); + document.body.appendChild(submenu); + safeCreateIcons(); + submenu.addEventListener('click', (se) => { + const targetPane = parseInt(se.target.closest('.context-menu-item')?.dataset.pane); + if (!isNaN(targetPane)) { + pm.transferTab(this.paneId, tabId, targetPane); + menu.remove(); submenu.remove(); + } + }); + }); + + moveToEl.addEventListener('mouseleave', (e) => { + setTimeout(() => { + if (submenu && !submenu.matches(':hover')) { submenu.remove(); submenu = null; } + }, 100); + }); + } + menu.addEventListener('click', (e) => { const action = e.target.closest('.context-menu-item')?.dataset.action; - if (action === 'splitRight') { if (window.PaneManager) { window.PaneManager.splitRight(); menu.remove(); return; } } - else if (action === 'splitDown') { if (window.PaneManager) { window.PaneManager.splitDown(); menu.remove(); return; } } - else if (action === 'closePane') { if (window.PaneManager) { window.PaneManager.closeActivePane(); menu.remove(); return; } } + if (action === 'moveTo') return; // handled by hover + if (action === 'splitRight') { if (pm) { pm.splitRight(); menu.remove(); return; } } + else if (action === 'splitDown') { if (pm) { pm.splitDown(); menu.remove(); return; } } + else if (action === 'closePane') { if (pm) { pm.closeActivePane(); menu.remove(); return; } } else if (action === 'close') this.close(tabId); else if (action === 'closeOthers') this.closeOthers(tabId); else if (action === 'closeRight') this.closeRight(tabId); else if (action === 'closeAll') this.closeAll(); menu.remove(); }); - const closeMenu = () => menu.remove(); + const closeMenu = () => { menu.remove(); }; document.addEventListener('click', closeMenu, { once: true }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') menu.remove(); }, { once: true }); }, @@ -554,7 +613,17 @@ const PaneManager = { setActivePane(paneId) { if (this.activePaneId === paneId) return; + // Hide previous active pane content (lazy rendering) + if (this.panes[this.activePaneId] && this.panes[this.activePaneId].element) { + const oldContent = this.panes[this.activePaneId].element.querySelector('.content-area'); + if (oldContent) oldContent.style.display = 'none'; + } this.activePaneId = paneId; + // Show new active pane content + if (this.panes[paneId] && this.panes[paneId].element) { + const newContent = this.panes[paneId].element.querySelector('.content-area'); + if (newContent) newContent.style.display = ''; + } this._highlightActive(); // Sync global state from this pane's active tab const tm = this.tabManagers[paneId]; @@ -856,6 +925,24 @@ const PaneManager = { console.log('[PaneManager] drop fired, raw data:', raw ? raw.substring(0, 80) : 'null'); if (!raw) return; + + // Handle tree drag (file from sidebar) + if (raw.startsWith('tree:')) { + try { + const treeData = JSON.parse(raw.substring(5)); + if (treeData.vault && treeData.path) { + console.log('[PaneManager] tree drop → opening in pane', paneId); + const tm = window.PaneManager.getTabManager(paneId); + if (tm) { + window.PaneManager.setActivePane(paneId); + tm.openPreview(treeData.vault, treeData.path); + } + } + } catch (e2) { console.debug('[PaneManager] tree drop parse error:', e2); } + return; + } + + // Handle tab drag let dragData; try { dragData = JSON.parse(raw); } catch (e2) { console.log('[PaneManager] JSON parse error:', e2); return; } if (dragData.paneId == null || dragData.tabId == null) { console.log('[PaneManager] missing paneId/tabId'); return; }