Files
ObsiGate/frontend/js/pane-manager.js
T
bruno b27b38d32b
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
feat: split view multi-panneaux (#75) — PaneManager intégré avec split right/down, menu contextuel, raccourcis clavier, CSS responsive et tests
- 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)
2026-07-27 21:06:47 -04:00

411 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
const PaneManager = {
panes: [],
activePaneId: 0,
_paneCounter: 1,
layout: 'horizontal',
// ------------------------------------------------------------------
// Init / Restore
// ------------------------------------------------------------------
init() {
const saved = localStorage.getItem('obsigate-panes');
if (saved) {
try {
const data = JSON.parse(saved);
if (data.panes && data.panes.length >= 2) {
this.layout = data.layout || 'horizontal';
this._paneCounter = data.panes.length;
this._buildGrid(data.panes.length);
data.panes.forEach((p, i) => {
if (this.panes[i]) {
this.panes[i].activeTab = p.activeTab;
this.panes[i].width = p.width;
}
});
this.activePaneId = data.panes.length > 0 ? this.panes[0].id : 0;
this._applyResize();
this._highlightActive();
return;
}
} 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 (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) {
this._addResizeHandle(grid, i, i + 1);
}
}
this.activePaneId = 0;
this._highlightActive();
},
_createPane(idx, grid, reuseTabBar, reuseContent) {
const container = document.createElement('div');
container.className = 'pane-container';
container.dataset.pane = idx;
if (reuseTabBar && reuseContent) {
// Pane 0 reuses existing DOM
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';
tabBar.id = 'tab-bar-' + idx;
tabBar.innerHTML = '<div class="tab-list" id="tab-list-' + idx + '"></div>';
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', () => {
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: null, activeTab: null };
},
_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) {
this._buildGrid(2);
} else {
this._addPane();
}
this._persist();
},
splitDown() {
if (this.panes.length >= PANE_MAX) return;
this.layout = 'vertical';
if (this.panes.length === 0) {
this._buildGrid(2);
} else {
this._addPane();
}
this._persist();
},
_addPane() {
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();
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;
this.panes[idx].element.remove();
const handles = document.querySelectorAll('.pane-resize-handle');
if (handles[idx]) handles[idx].remove();
else if (handles[idx - 1]) handles[idx - 1].remove();
this.panes.splice(idx, 1);
if (this.panes.length <= 1) {
this._collapseToSingle();
} else {
this.activePaneId = Math.min(this.activePaneId, this.panes.length - 1);
this._highlightActive();
}
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 && 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');
const startX = e.clientX;
const startY = e.clientY;
const totalSize = this.layout === 'horizontal' ? grid.offsetWidth : grid.offsetHeight;
const onMove = (ev) => {
const delta = this.layout === 'horizontal' ? ev.clientX - startX : ev.clientY - startY;
const pct = (delta / totalSize) * 100;
const leftEl = this.panes[leftIdx].element;
const rightEl = this.panes[rightIdx].element;
const curLeft = parseFloat(leftEl.style.flexBasis || leftEl.style.width || '50');
const curRight = parseFloat(rightEl.style.flexBasis || rightEl.style.width || '50');
const newLeft = Math.max(20, Math.min(80, curLeft + pct));
const newRight = 100 - newLeft;
leftEl.style.flexBasis = newLeft + '%';
rightEl.style.flexBasis = newRight + '%';
this.panes[leftIdx].width = newLeft + '%';
this.panes[rightIdx].width = newRight + '%';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
this._persist();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
_resetResize(leftIdx, rightIdx) {
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) 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');
return;
}
const data = {
layout: this.layout,
panes: this.panes.map(p => ({ id: p.id, activeTab: p.activeTab, width: p.width })),
};
localStorage.setItem('obsigate-panes', JSON.stringify(data));
},
// ------------------------------------------------------------------
// Queries
// ------------------------------------------------------------------
isSplit() {
return this.panes.length >= 2;
},
getPaneCount() {
return Math.max(this.panes.length, 1);
},
};
// ------------------------------------------------------------------
// Export + auto-init
// ------------------------------------------------------------------
if (typeof window !== 'undefined') {
window.PaneManager = PaneManager;
document.addEventListener('DOMContentLoaded', () => {
// 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();
PaneManager.splitRight();
}
if (e.ctrlKey && e.altKey && e.shiftKey && e.key === '\\') {
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();
}
});
// Init if saved state exists
setTimeout(() => PaneManager.init(), 100);
});
}
export default PaneManager;