feat: #75 complété — 5 dernières sous-tâches implémentées
CI / lint (push) Failing after 9s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Successful in 12s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled

C1b: Sous-menu « Déplacer vers... » (flyout hover) — liste les autres panneaux
D2: Drag depuis l'arborescence vers panneau — format 'tree:{vault,path}'
E3: Rendu paresseux — display:none sur contenu des panneaux inactifs
E4: Verrouillage éditeur — même fichier déjà ouvert → focus le panneau existant
G3: Bouton reset (🔄 Réinitialiser les panneaux) dans la palette

ROADMAP: section 75 marquée ✅ Complété (reste I2/I3 tests)
This commit is contained in:
2026-07-28 10:15:50 -04:00
parent 804e6313b3
commit 03e115b65b
4 changed files with 104 additions and 15 deletions
+9 -9
View File
@@ -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
+2 -1
View File
@@ -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);
+1
View File
@@ -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;
}
+92 -5
View File
@@ -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 = '<div class="context-menu-separator"></div><div class="context-menu-item context-menu-sub" data-action="moveTo"><i data-lucide="arrow-right-left" class="icon"></i><span>Déplacer vers...</span><span class="context-menu-arrow">▸</span></div>';
}
}
let closePaneHtml = '';
if (isSplit) {
closePaneHtml = `<div class="context-menu-separator"></div><div class="context-menu-item" data-action="closePane"><i data-lucide="panel-right-close" class="icon"></i><span>Fermer le panneau</span></div>`;
@@ -473,23 +496,59 @@ function createPaneTabManager(paneId) {
<div class="context-menu-item" data-action="closeRight"><i data-lucide="arrow-right-circle" class="icon"></i><span>Fermer à droite</span></div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="closeAll"><i data-lucide="trash-2" class="icon"></i><span>Fermer tout</span></div>
${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) =>
`<div class="context-menu-item" data-pane="${p.id}"><i data-lucide="panel-right" class="icon"></i><span>Panneau ${p.id + 1}</span></div>`
).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; }