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)
411 lines
14 KiB
JavaScript
411 lines
14 KiB
JavaScript
/**
|
||
* 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;
|