feat: split view multi-panneaux (#75) — PaneManager intégré avec split right/down, menu contextuel, raccourcis clavier, CSS responsive et tests
CI / lint (push) Failing after 35s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Successful in 15s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled

- PaneManager: splitRight/splitDown avec 1-4 panneaux, persistance localStorage, resize
- Viewer/TabManager pane-aware: fonctions getContentArea/getTabBar/getTabList
- Menu contextuel onglets: actions Diviser à droite / Diviser en bas / Fermer le panneau
- Raccourcis clavier: Ctrl+Alt+\ (split right), Ctrl+Alt+Shift+\ (split down), Ctrl+Alt+W (fermer panneau)
- CSS pane-grid: grille, poignées resize, bordure active pane, responsive (désactivé <768px)
- Tests: 22 nouveaux tests unitaires + régression (307 total, 100% green)
- Déployé sur Docker test (localhost:2021)
This commit is contained in:
2026-07-27 21:06:47 -04:00
parent 0cea78b3b1
commit b27b38d32b
5 changed files with 555 additions and 57 deletions
+188 -41
View File
@@ -2,7 +2,15 @@
* PaneManager — Multi-pane split view for ObsiGate (v2.0.0-dev)
* Supports 1–4 panes in CSS grid, split right/down, resize, drag tabs between panes.
* Integrates with existing TabManager singleton.
*
* Architecture:
* Single-pane (default): uses existing #tab-bar, #content-area — zero DOM changes.
* Multi-pane: builds a .pane-grid inside .content-wrapper, each pane gets its
* own tab-bar-N and content-area-N DOM elements.
* TabManager remains the singleton source of truth for open tabs.
* PaneManager tracks per-pane active tab and delegates rendering.
*/
import { state } from './state.js';
const PANE_MAX = 4;
@@ -12,6 +20,9 @@ const PaneManager = {
_paneCounter: 1,
layout: 'horizontal',
// ------------------------------------------------------------------
// Init / Restore
// ------------------------------------------------------------------
init() {
const saved = localStorage.getItem('obsigate-panes');
if (saved) {
@@ -22,44 +33,91 @@ const PaneManager = {
this._paneCounter = data.panes.length;
this._buildGrid(data.panes.length);
data.panes.forEach((p, i) => {
this.panes[i].activeTab = p.activeTab;
this.panes[i].width = p.width;
if (this.panes[i]) {
this.panes[i].activeTab = p.activeTab;
this.panes[i].width = p.width;
}
});
this.activePaneId = data.panes[0].id;
this.activePaneId = data.panes.length > 0 ? this.panes[0].id : 0;
this._applyResize();
this._highlightActive();
return;
}
} catch (e) { /* ignore */ }
} catch (e) { /* ignore corrupt state */ }
}
},
// ------------------------------------------------------------------
// DOM Helpers — return the active pane's DOM elements
// ------------------------------------------------------------------
getActiveContentArea() {
if (this.panes.length < 2) return document.getElementById('content-area');
return document.getElementById('content-area-' + this.activePaneId)
|| document.getElementById('content-area-0');
},
getActiveTabBar() {
if (this.panes.length < 2) return document.getElementById('tab-bar');
return document.getElementById('tab-bar-' + this.activePaneId)
|| document.getElementById('tab-bar-0');
},
getActiveTabList() {
if (this.panes.length < 2) return document.getElementById('tab-list');
return document.getElementById('tab-list-' + this.activePaneId)
|| document.getElementById('tab-list-0');
},
// ------------------------------------------------------------------
// Build / Destroy pane grid
// ------------------------------------------------------------------
_buildGrid(n) {
const wrapper = document.querySelector('.content-wrapper');
const origTabBar = document.getElementById('tab-bar');
const origContent = document.getElementById('content-area');
if (!wrapper || !origContent || n < 2) return;
// Save dashboard before clearing
const dashboard = document.getElementById('dashboard-home');
// Create grid container
const grid = document.createElement('div');
grid.className = 'pane-grid';
if (n === 2) grid.style.gridTemplateColumns = this.layout === 'horizontal' ? '1fr 4px 1fr' : '1fr';
if (n >= 3) grid.style.gridTemplateColumns = '1fr 4px 1fr';
if (n === 4) grid.style.gridTemplateRows = '1fr 4px 1fr';
if (n === 2) {
grid.style.gridTemplateColumns = this.layout === 'horizontal'
? '1fr 4px 1fr' : '1fr';
if (this.layout === 'vertical') {
grid.style.gridTemplateRows = '1fr 4px 1fr';
}
} else if (n === 3) {
grid.style.gridTemplateColumns = '1fr 4px 1fr';
grid.style.gridTemplateRows = '1fr 4px 1fr';
// 3 panes: first two in top row, third spans bottom row
}
if (n === 4) {
grid.style.gridTemplateColumns = '1fr 4px 1fr';
grid.style.gridTemplateRows = '1fr 4px 1fr';
}
wrapper.innerHTML = '';
wrapper.appendChild(grid);
this.panes = [];
for (let i = 0; i < Math.min(n, PANE_MAX); i++) {
const pane = this._createPane(i, grid, i === 0 ? origTabBar : null, i === 0 ? origContent : null);
if (dashboard && i === 0 && pane) {
// Re-attach dashboard to first pane's content area
const contentArea = document.getElementById('content-area-0') || origContent;
if (contentArea && !contentArea.querySelector('#dashboard-home')) {
contentArea.innerHTML = '';
contentArea.appendChild(dashboard);
dashboard.style.display = '';
}
}
this.panes.push(pane);
if (i < n - 1) {
const handle = document.createElement('div');
handle.className = 'pane-resize-handle';
if (this.layout === 'horizontal') handle.style.cursor = 'col-resize';
else handle.style.cursor = 'row-resize';
handle.addEventListener('mousedown', (e) => this._onResizeStart(e, i, i + 1));
handle.addEventListener('dblclick', () => this._resetResize(i, i + 1));
grid.appendChild(handle);
this._addResizeHandle(grid, i, i + 1);
}
}
this.activePaneId = 0;
@@ -76,6 +134,10 @@ const PaneManager = {
container.appendChild(reuseTabBar);
container.appendChild(reuseContent);
reuseContent.id = 'content-area-0';
reuseTabBar.id = 'tab-bar-0';
// Rename tab-list too
const tl = reuseTabBar.querySelector('.tab-list') || reuseTabBar.querySelector('#tab-list');
if (tl) tl.id = 'tab-list-0';
} else {
const tabBar = document.createElement('div');
tabBar.className = 'tab-bar pane-tab-bar';
@@ -84,29 +146,52 @@ const PaneManager = {
const content = document.createElement('main');
content.className = 'content-area pane-content';
content.id = 'content-area-' + idx;
content.setAttribute('aria-label', 'Contenu panneau ' + (idx + 1));
container.appendChild(tabBar);
container.appendChild(content);
}
container.addEventListener('click', () => {
this.activePaneId = idx;
this._highlightActive();
if (this.activePaneId !== idx) {
this.activePaneId = idx;
this._highlightActive();
// Sync global state with this pane's active tab
const pane = this.panes[idx];
if (pane && pane.activeTab) {
const parts = pane.activeTab.split('::');
if (parts.length === 2) {
state.currentVault = parts[0];
state.currentPath = parts[1];
}
}
// Re-render if needed — delegate to TabManager
if (typeof window.TabManager !== 'undefined' && window.TabManager.activatePaneTab) {
window.TabManager.activatePaneTab(idx);
}
}
});
grid.appendChild(container);
return { id: idx, element: container, width: 100 / this.panes.length + '%' };
return { id: idx, element: container, width: null, activeTab: null };
},
_highlightActive() {
this.panes.forEach((p, i) => {
p.element.classList.toggle('pane-active', i === this.activePaneId);
});
_addResizeHandle(grid, leftIdx, rightIdx) {
const handle = document.createElement('div');
handle.className = 'pane-resize-handle';
if (this.layout === 'horizontal') handle.style.cursor = 'col-resize';
else handle.style.cursor = 'row-resize';
handle.addEventListener('mousedown', (e) => this._onResizeStart(e, leftIdx, rightIdx));
handle.addEventListener('dblclick', () => this._resetResize(leftIdx, rightIdx));
grid.appendChild(handle);
},
// ------------------------------------------------------------------
// Split actions
// ------------------------------------------------------------------
splitRight() {
if (this.panes.length >= PANE_MAX) return;
this.layout = 'horizontal';
if (this.panes.length === 0) {
// First split: promote single pane to grid
this._buildGrid(2);
} else {
this._addPane();
@@ -129,23 +214,35 @@ const PaneManager = {
const grid = document.querySelector('.pane-grid');
if (!grid) return;
const n = this.panes.length + 1;
if (n === 3) {
grid.style.gridTemplateColumns = '1fr 4px 1fr';
grid.style.gridTemplateRows = '1fr 4px 1fr';
}
if (n === 4) {
grid.style.gridTemplateColumns = '1fr 4px 1fr';
grid.style.gridTemplateRows = '1fr 4px 1fr';
}
const handle = document.createElement('div');
handle.className = 'pane-resize-handle';
handle.style.cursor = this.layout === 'horizontal' ? 'col-resize' : 'row-resize';
handle.addEventListener('mousedown', (e) => this._onResizeStart(e, n - 2, n - 1));
handle.addEventListener('dblclick', () => this._resetResize(n - 2, n - 1));
grid.appendChild(handle);
const pane = this._createPane(n - 1, grid, null, null);
pane.activeTab = this.panes.length > 0 ? this.panes[this.activePaneId].activeTab : null;
this.panes.push(pane);
this.activePaneId = n - 1;
this._highlightActive();
if (n <= 2) grid.style.gridTemplateColumns = '1fr 4px 1fr';
else if (n === 4) grid.style.gridTemplateRows = '1fr 4px 1fr';
this._persist();
},
// ------------------------------------------------------------------
// Close / collapse
// ------------------------------------------------------------------
closePane(paneId) {
if (this.panes.length <= 1) return;
const idx = this.panes.findIndex(p => p.id === paneId);
if (idx < 0) return;
// Remove pane and its resize handles
this.panes[idx].element.remove();
const handles = document.querySelectorAll('.pane-resize-handle');
if (handles[idx]) handles[idx].remove();
@@ -160,20 +257,49 @@ const PaneManager = {
this._persist();
},
closeActivePane() {
if (this.panes.length <= 1) return;
this.closePane(this.activePaneId);
},
closeOtherPanes() {
if (this.panes.length <= 1) return;
const keepId = this.activePaneId;
const toClose = this.panes.filter(p => p.id !== keepId);
for (const p of toClose.reverse()) {
this.closePane(p.id);
}
},
_collapseToSingle() {
const grid = document.querySelector('.pane-grid');
if (!grid) return;
const wrapper = grid.parentElement;
const pane0 = this.panes[0];
wrapper.innerHTML = '';
const tabBar = pane0.element.querySelector('.tab-bar') || document.getElementById('tab-bar-0');
const content = pane0.element.querySelector('.content-area') || document.getElementById('content-area-0');
if (tabBar) wrapper.appendChild(tabBar);
if (content) wrapper.appendChild(content);
const tabBar = (pane0 && pane0.element.querySelector('.tab-bar'))
|| document.getElementById('tab-bar-0');
const content = (pane0 && pane0.element.querySelector('.content-area'))
|| document.getElementById('content-area-0');
if (tabBar) {
tabBar.id = 'tab-bar';
wrapper.appendChild(tabBar);
}
if (content) {
content.id = 'content-area';
wrapper.appendChild(content);
}
// Restore tab-list ID
const tl = wrapper.querySelector('.tab-list');
if (tl) tl.id = 'tab-list';
this.panes = [];
this.activePaneId = 0;
this._persist();
},
// ------------------------------------------------------------------
// Resize
// ------------------------------------------------------------------
_onResizeStart(e, leftIdx, rightIdx) {
e.preventDefault();
const grid = document.querySelector('.pane-grid');
@@ -207,17 +333,29 @@ const PaneManager = {
},
_resetResize(leftIdx, rightIdx) {
this.panes[leftIdx].element.style.flexBasis = '50%';
this.panes[rightIdx].element.style.flexBasis = '50%';
if (this.panes[leftIdx]) this.panes[leftIdx].element.style.flexBasis = '50%';
if (this.panes[rightIdx]) this.panes[rightIdx].element.style.flexBasis = '50%';
this._persist();
},
_applyResize() {
this.panes.forEach(p => {
if (p.width) p.element.style.flexBasis = p.width;
if (p.width && p.element) p.element.style.flexBasis = p.width;
});
},
// ------------------------------------------------------------------
// Highlight
// ------------------------------------------------------------------
_highlightActive() {
this.panes.forEach((p, i) => {
if (p.element) p.element.classList.toggle('pane-active', i === this.activePaneId);
});
},
// ------------------------------------------------------------------
// Persistence
// ------------------------------------------------------------------
_persist() {
if (this.panes.length <= 1) {
localStorage.removeItem('obsigate-panes');
@@ -230,21 +368,25 @@ const PaneManager = {
localStorage.setItem('obsigate-panes', JSON.stringify(data));
},
getActiveContentArea() {
if (this.panes.length < 2) return document.getElementById('content-area');
return document.getElementById('content-area-' + this.activePaneId) || document.getElementById('content-area-0');
},
// ------------------------------------------------------------------
// Queries
// ------------------------------------------------------------------
isSplit() {
return this.panes.length >= 2;
},
getPaneCount() {
return Math.max(this.panes.length, 1);
},
};
// Export and auto-init
// ------------------------------------------------------------------
// Export + auto-init
// ------------------------------------------------------------------
if (typeof window !== 'undefined') {
window.PaneManager = PaneManager;
document.addEventListener('DOMContentLoaded', () => {
// Register keyboard shortcuts
// Keyboard shortcuts — Ctrl+Alt+\ for split right, Ctrl+Alt+Shift+\ for split down
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.altKey && !e.shiftKey && e.key === '\\') {
e.preventDefault();
@@ -254,9 +396,14 @@ if (typeof window !== 'undefined') {
e.preventDefault();
PaneManager.splitDown();
}
// Ctrl+Alt+W — close active pane
if (e.ctrlKey && e.altKey && !e.shiftKey && (e.key === 'w' || e.key === 'W') && PaneManager.isSplit()) {
e.preventDefault();
PaneManager.closeActivePane();
}
});
// Initialize if saved state exists
setTimeout(() => PaneManager.init(), 500);
// Init if saved state exists
setTimeout(() => PaneManager.init(), 100);
});
}