Files
ObsiGate/frontend/js/pane-manager.js
T
bruno ed5cf597f9
CI / lint (push) Failing after 13s
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
fix: migration des onglets singleton → PaneTabManager au split
_buildGrid: quand on passe de single-pane à multi-pane, migre les onglets
du TabManager singleton vers le PaneTabManager du pane 0.
Sans ça, transferTab() ne trouvait pas l'onglet (il était resté dans le singleton).

_collapseToSingle: migration inverse (PaneTabManager → singleton)
quand on ferme le dernier split.
2026-07-27 23:34:06 -04:00

1083 lines
42 KiB
JavaScript

/**
* PaneManager + PaneTabManager — Multi-pane split view for ObsiGate (v2.0.0-dev)
*
* Architecture:
* Single-pane (default): uses existing singleton TabManager + #content-area / #tab-bar.
* Multi-pane: builds .pane-grid inside .content-wrapper. Each pane gets its OWN
* independent PaneTabManager instance with its own tabs, tab bar, and content area.
* Drag & drop tabs between panes.
* Persisted to localStorage.
*/
import { state } from './state.js';
import { api } from './auth.js';
import { escapeHtml, getFileIcon, safeCreateIcons } from './utils.js';
const PANE_MAX = 4;
// =============================================================================
// PaneTabManager factory — one per pane
// =============================================================================
function createPaneTabManager(paneId) {
const DEFAULT_BAR = document.getElementById('tab-bar');
const DEFAULT_LIST = document.getElementById('tab-list');
const DEFAULT_AREA = document.getElementById('content-area');
const tm = {
paneId,
_tabs: [],
_activeTabId: null,
_previewTabId: null,
_tabCache: {},
_dirtyTabs: new Set(),
_tabBar: paneId === 0 ? DEFAULT_BAR : null,
_tabList: paneId === 0 ? DEFAULT_LIST : null,
_contentArea: paneId === 0 ? DEFAULT_AREA : null,
// ------------------------------------------------------------------
// Init — bind to DOM
// ------------------------------------------------------------------
init(tabBar, tabList, contentArea) {
if (tabBar) this._tabBar = tabBar;
if (tabList) this._tabList = tabList;
if (contentArea) this._contentArea = contentArea;
if (!this._tabBar)
this._tabBar = document.getElementById('tab-bar-' + paneId);
if (!this._tabList)
this._tabList = document.getElementById('tab-list-' + paneId);
if (!this._contentArea)
this._contentArea = document.getElementById('content-area-' + paneId);
},
getContentArea() {
return this._contentArea || DEFAULT_AREA;
},
getTabBar() { return this._tabBar; },
getTabList() { return this._tabList; },
// ------------------------------------------------------------------
// Open / Activate / Close
// ------------------------------------------------------------------
async openPreview(vault, path) {
const tabId = `${vault}::${path}`;
const existing = this._tabs.find(t => t.id === tabId && !t.preview);
if (existing) { this.activate(tabId); return; }
if (this._previewTabId && this._previewTabId !== tabId)
this.close(this._previewTabId);
const previewExisting = this._tabs.find(t => t.id === tabId && t.preview);
if (previewExisting) { this.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, preview: true });
this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon };
this._previewTabId = tabId;
this._renderTabs();
this.activate(tabId);
},
async openPersistent(vault, path) {
const tabId = `${vault}::${path}`;
const previewTab = this._tabs.find(t => t.id === tabId && t.preview);
if (previewTab) {
previewTab.preview = false;
if (this._previewTabId === tabId) this._previewTabId = null;
this._renderTabs();
this.activate(tabId);
return;
}
const existing = this._tabs.find(t => t.id === tabId && !t.preview);
if (existing) { this.activate(tabId); return; }
this.open(vault, path);
},
async open(vault, path) {
const tabId = `${vault}::${path}`;
const existing = this._tabs.find(t => t.id === tabId);
if (existing) {
if (existing.preview) {
existing.preview = false;
if (this._previewTabId === tabId) this._previewTabId = null;
this._renderTabs();
}
this.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 });
this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon };
this._renderTabs();
this.activate(tabId);
},
async activate(tabId) {
if (this._activeTabId === tabId && this._tabs.length > 0) return;
if (this._activeTabId && this._tabCache[this._activeTabId])
this._saveCurrentTabState();
this._activeTabId = tabId;
this._renderTabs();
const cache = this._tabCache[tabId];
if (!cache) return;
state.currentVault = cache.vault;
state.currentPath = cache.path;
// syncActiveFileTreeItem is imported below to avoid circular deps
if (typeof syncActiveFileTreeItem !== 'undefined')
syncActiveFileTreeItem(cache.vault, cache.path);
const area = this._contentArea || DEFAULT_AREA;
if (cache.data) {
this._restoreTabContent(cache, area);
} else {
area.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Chargement...</div>';
try {
const data = await api(`/api/file/${encodeURIComponent(cache.vault)}?path=${encodeURIComponent(cache.path)}`);
cache.data = data;
cache.title = data.title;
this._renderFileInPane(cache.data, area);
if (cache.sourceView) await this._toggleSourceView(cache, area);
if (cache.scrollTop) area.scrollTop = cache.scrollTop;
} catch (err) {
area.innerHTML = `<div style="padding:40px;text-align:center;color:var(--text-error)">Erreur: ${escapeHtml(err.message)}</div>`;
}
}
if (history.pushState)
history.pushState(null, '', `#/file/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`);
const db = document.getElementById('dashboard-home');
if (db) db.style.display = 'none';
},
close(tabId) {
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx === -1) return;
this._tabs.splice(idx, 1);
delete this._tabCache[tabId];
this._dirtyTabs.delete(tabId);
if (this._tabs.length === 0) {
this._activeTabId = null;
// If this pane has no more tabs and isn't the last pane, close it
if (window.PaneManager && window.PaneManager.isSplit() && window.PaneManager.panes.length > 1) {
this._renderTabs();
window.PaneManager.closePane(this.paneId);
return;
}
this._showDashboard();
if (this._tabBar) this._tabBar.hidden = true;
} else if (this._activeTabId === tabId) {
const newIdx = Math.min(idx, this._tabs.length - 1);
this.activate(this._tabs[newIdx].id);
}
this._renderTabs();
},
closeAll() {
this._tabs = [];
this._tabCache = {};
this._dirtyTabs.clear();
this._activeTabId = null;
this._showDashboard();
if (this._tabBar) this._tabBar.hidden = true;
},
closeRight(tabId) {
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx === -1) return;
const toClose = this._tabs.slice(idx + 1);
for (const tab of toClose) { delete this._tabCache[tab.id]; this._dirtyTabs.delete(tab.id); }
this._tabs = this._tabs.slice(0, idx + 1);
if (!this._tabs.find(t => t.id === this._activeTabId)) this.activate(tabId);
this._renderTabs();
},
closeOthers(tabId) {
const tab = this._tabs.find(t => t.id === tabId);
if (!tab) return;
for (const t of this._tabs) { if (t.id !== tabId) { delete this._tabCache[t.id]; this._dirtyTabs.delete(t.id); } }
this._tabs = [tab];
this.activate(tabId);
this._renderTabs();
},
moveTab(fromIdx, toIdx) {
if (fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return;
const tab = this._tabs.splice(fromIdx, 1)[0];
this._tabs.splice(toIdx, 0, tab);
this._renderTabs();
},
// ------------------------------------------------------------------
// Transfer a tab to another pane's TabManager
// ------------------------------------------------------------------
transferTabTo(tabId, targetTm) {
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx === -1) return false;
const tab = this._tabs[idx];
const cache = this._tabCache[tabId];
// Remove from source
this._tabs.splice(idx, 1);
if (this._previewTabId === tabId) this._previewTabId = null;
delete this._tabCache[tabId];
this._dirtyTabs.delete(tabId);
// Add to target
targetTm._tabs.push({ ...tab, preview: false });
targetTm._tabCache[tabId] = cache || { vault: tab.vault, path: tab.path, title: tab.name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: tab.icon };
// If source is now empty and not the last pane, close it
if (this._tabs.length === 0) {
this._activeTabId = null;
if (window.PaneManager && window.PaneManager.isSplit() && window.PaneManager.panes.length > 1) {
this._renderTabs();
window.PaneManager.closePane(this.paneId);
return true;
}
this._showDashboard();
if (this._tabBar) this._tabBar.hidden = true;
} else if (this._activeTabId === tabId) {
const newIdx = Math.min(idx, this._tabs.length - 1);
this.activate(this._tabs[newIdx].id);
}
this._renderTabs();
return true;
},
// ------------------------------------------------------------------
// Internal rendering
// ------------------------------------------------------------------
_renderFileInPane(data, area) {
// Set temporary override so getContentArea() returns this pane's area
const prev = window._activePaneContentArea;
window._activePaneContentArea = area;
// Use dynamic import to call viewer.js renderFile in the right context
import('./viewer.js').then(m => {
m.renderFile(data);
window._activePaneContentArea = prev;
}).catch(() => {
window._activePaneContentArea = prev;
});
},
_saveCurrentTabState() {
const cache = this._tabCache[this._activeTabId];
if (!cache) return;
const area = this._contentArea || DEFAULT_AREA;
const rendered = document.getElementById('file-rendered-content');
cache.scrollTop = area ? area.scrollTop : 0;
cache.sourceView = rendered ? rendered.style.display === 'none' : false;
},
_restoreTabContent(cache, area) {
this._renderFileInPane(cache.data, area);
if (cache.sourceView) this._restoreSourceView(cache, area);
if (cache.scrollTop && area) area.scrollTop = cache.scrollTop;
},
async _toggleSourceView(cache, area) {
const rendered = document.getElementById('file-rendered-content');
const raw = document.getElementById('file-raw-content');
if (!rendered || !raw) return;
if (!cache.rawSource) {
const rawData = await api(`/api/file/${encodeURIComponent(cache.vault)}/raw?path=${encodeURIComponent(cache.path)}`);
cache.rawSource = rawData.raw;
}
raw.textContent = cache.rawSource;
rendered.style.display = 'none';
raw.style.display = 'block';
},
_restoreSourceView(cache, area) {
requestAnimationFrame(() => {
const rendered = document.getElementById('file-rendered-content');
const raw = document.getElementById('file-raw-content');
if (rendered && raw && cache.rawSource) {
raw.textContent = cache.rawSource;
rendered.style.display = 'none';
raw.style.display = 'block';
}
});
},
_showDashboard() {
const area = this._contentArea || DEFAULT_AREA;
let dashboard = document.getElementById('dashboard-home');
if (!dashboard) {
// Dashboard was destroyed — rebuild
area.innerHTML = '';
if (typeof showWelcome !== 'undefined') showWelcome();
return;
}
area.innerHTML = '';
dashboard.style.display = '';
area.appendChild(dashboard);
if (typeof DashboardStatsWidget !== 'undefined') DashboardStatsWidget.load();
if (typeof DashboardConflictsWidget !== 'undefined') DashboardConflictsWidget.load();
if (history.pushState) history.pushState(null, '', '#');
},
// ------------------------------------------------------------------
// Render tab bar
// ------------------------------------------------------------------
_renderTabs() {
if (!this._tabList) return;
this._tabList.innerHTML = '';
if (this._tabs.length === 0) {
if (this._tabBar) this._tabBar.hidden = true;
return;
}
if (this._tabBar) this._tabBar.hidden = false;
this._tabs.forEach((tab, idx) => {
const el = document.createElement('div');
el.className = 'tab-item' + (tab.id === this._activeTabId ? ' active' : '') + (tab.preview ? ' preview' : '');
el.draggable = true;
el.dataset.tabId = tab.id;
el.dataset.index = idx;
el.dataset.paneId = String(this.paneId);
const iconEl = document.createElement('i');
iconEl.setAttribute('data-lucide', tab.icon);
iconEl.className = 'tab-icon';
iconEl.style.width = '14px';
iconEl.style.height = '14px';
el.appendChild(iconEl);
const nameEl = document.createElement('span');
nameEl.className = 'tab-name';
nameEl.textContent = tab.name;
nameEl.title = `${tab.vault}/${tab.path}`;
el.appendChild(nameEl);
const closeEl = document.createElement('span');
closeEl.className = 'tab-close';
closeEl.innerHTML = '<i data-lucide="x" style="width:12px;height:12px"></i>';
closeEl.addEventListener('click', (e) => { e.stopPropagation(); this.close(tab.id); });
el.appendChild(closeEl);
el.addEventListener('click', () => {
// Set this pane as active in PaneManager
if (window.PaneManager) window.PaneManager.setActivePane(this.paneId);
this.activate(tab.id);
});
el.addEventListener('dblclick', (e) => { e.preventDefault(); this.close(tab.id); });
el.addEventListener('mousedown', (e) => {
if (e.button === 1) { e.preventDefault(); this.close(tab.id); }
});
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
this._showTabContextMenu(e.clientX, e.clientY, tab.id);
});
// Drag within same pane (+ cross-pane via container drop zone)
el.addEventListener('dragstart', (e) => {
const payload = JSON.stringify({
paneId: this.paneId, tabId: tab.id, index: idx
});
// text/plain is universally supported across all browsers for drag-drop
e.dataTransfer.setData('text/plain', payload);
e.dataTransfer.effectAllowed = 'move';
el.classList.add('dragging');
});
el.addEventListener('dragend', () => {
el.classList.remove('dragging');
document.querySelectorAll('.tab-drop-indicator').forEach(d => d.remove());
});
el.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const mid = rect.left + rect.width / 2;
document.querySelectorAll('.tab-drop-indicator').forEach(d => d.remove());
const indicator = document.createElement('div');
indicator.className = 'tab-drop-indicator';
if (e.clientX < mid) el.before(indicator);
else el.after(indicator);
});
el.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation(); // Prevent container handler from double-firing
document.querySelectorAll('.tab-drop-indicator').forEach(d => d.remove());
const raw = e.dataTransfer.getData('text/plain');
if (!raw) return;
try {
const dragData = JSON.parse(raw);
// Same pane — reorder
if (dragData.paneId === this.paneId) {
const rect = el.getBoundingClientRect();
const mid = rect.left + rect.width / 2;
const toIdx = e.clientX < mid ? idx : idx + 1;
if (dragData.index !== toIdx && dragData.index !== toIdx - 1)
this.moveTab(dragData.index, toIdx);
} else {
// Cross-pane transfer
if (window.PaneManager) {
window.PaneManager.transferTab(dragData.paneId, dragData.tabId, this.paneId);
}
}
} catch (e2) { /* ignore */ }
});
this._tabList.appendChild(el);
});
safeCreateIcons();
},
_showTabContextMenu(x, y, tabId) {
const existing = document.getElementById('tab-context-menu');
if (existing) existing.remove();
const menu = document.createElement('div');
menu.id = 'tab-context-menu';
menu.className = 'context-menu active';
menu.style.left = x + 'px';
menu.style.top = y + 'px';
const isSplit = window.PaneManager && window.PaneManager.isSplit();
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>`;
}
menu.innerHTML = `
<div class="context-menu-item" data-action="splitRight"><i data-lucide="layout-panel-right" class="icon"></i><span>Diviser à droite</span></div>
<div class="context-menu-item" data-action="splitDown"><i data-lucide="layout-panel-bottom" class="icon"></i><span>Diviser en bas</span></div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="close"><i data-lucide="x" class="icon"></i><span>Fermer</span></div>
<div class="context-menu-item" data-action="closeOthers"><i data-lucide="x-circle" class="icon"></i><span>Fermer les autres</span></div>
<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>
${closePaneHtml}
`;
document.body.appendChild(menu);
safeCreateIcons();
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; } }
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();
document.addEventListener('click', closeMenu, { once: true });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') menu.remove(); }, { once: true });
},
};
return tm;
}
// =============================================================================
// PaneManager — orchestrates panes and their TabManagers
// =============================================================================
const PaneManager = {
panes: [],
tabManagers: {}, // { paneId: PaneTabManager instance }
activePaneId: 0,
layout: 'horizontal',
_paneCounter: 1,
init() {
// Create default pane-0 TabManager (will bind to existing DOM)
this.tabManagers[0] = createPaneTabManager(0);
this.tabManagers[0].init();
// Setup drop zone on content-wrapper for single-pane drag-to-split
const wrapper = document.querySelector('.content-wrapper');
if (wrapper && !wrapper.dataset.dropZoneSetup) {
wrapper.dataset.dropZoneSetup = '1';
this._setupPaneDropZone(wrapper, 0);
}
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);
this.activePaneId = 0;
this._highlightActive();
this._applyResize();
// Restore per-pane state
data.panes.forEach((p, i) => {
if (this.tabManagers[i] && p.tabs) {
const tm = this.tabManagers[i];
tm._tabs = p.tabs.map(t => ({ ...t }));
tm._activeTabId = p.activeTab;
tm._previewTabId = p.previewTab || null;
// Rebuild tab cache from tabs
p.tabs.forEach(t => {
if (!tm._tabCache[t.id]) {
tm._tabCache[t.id] = { vault: t.vault, path: t.path, title: t.name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: t.icon };
}
});
tm._renderTabs();
if (tm._activeTabId) tm.activate(tm._activeTabId);
}
});
}
} catch (e) { /* ignore corrupt state */ }
}
},
setActivePane(paneId) {
if (this.activePaneId === paneId) return;
this.activePaneId = paneId;
this._highlightActive();
// Sync global state from this pane's active tab
const tm = this.tabManagers[paneId];
if (tm && tm._activeTabId) {
const cache = tm._tabCache[tm._activeTabId];
if (cache) {
state.currentVault = cache.vault;
state.currentPath = cache.path;
}
}
this._persist();
},
getActiveTabManager() {
return this.tabManagers[this.activePaneId] || this.tabManagers[0];
},
getTabManager(paneId) {
return this.tabManagers[paneId] || this.tabManagers[0];
},
getActiveContentArea() {
const tm = this.getActiveTabManager();
return tm ? tm.getContentArea() : document.getElementById('content-area');
},
transferTab(fromPaneId, tabId, toPaneId) {
const fromTm = this.tabManagers[fromPaneId];
const toTm = this.tabManagers[toPaneId];
if (!fromTm || !toTm) return false;
const ok = fromTm.transferTabTo(tabId, toTm);
if (ok) {
toTm._renderTabs();
toTm.activate(tabId);
this._persist();
}
return ok;
},
isSplit() { return this.panes.length >= 2; },
// ------------------------------------------------------------------
// 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();
},
// ------------------------------------------------------------------
// Close pane
// ------------------------------------------------------------------
closePane(paneId) {
if (this.panes.length <= 1) return;
const idx = this.panes.findIndex(p => p.id === paneId);
if (idx < 0) return;
// Clean up tab manager
const tm = this.tabManagers[paneId];
if (tm) {
tm._tabs = [];
tm._tabCache = {};
tm._activeTabId = null;
}
delete this.tabManagers[paneId];
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);
},
// ------------------------------------------------------------------
// Build / Destroy pane grid
// ------------------------------------------------------------------
_buildGrid(n, savedPanes) {
const wrapper = document.querySelector('.content-wrapper');
const origTabBar = document.getElementById('tab-bar');
const origContent = document.getElementById('content-area');
if (!wrapper || !origContent || n < 2) return;
const dashboard = document.getElementById('dashboard-home');
const grid = document.createElement('div');
grid.className = 'pane-grid';
if (n === 2) {
if (this.layout === 'horizontal')
grid.style.gridTemplateColumns = '1fr 4px 1fr';
else
grid.style.gridTemplateRows = '1fr 4px 1fr';
} else if (n === 3) {
grid.style.gridTemplateColumns = '1fr 4px 1fr';
grid.style.gridTemplateRows = '1fr 4px 1fr';
} else if (n === 4) {
grid.style.gridTemplateColumns = '1fr 4px 1fr';
grid.style.gridTemplateRows = '1fr 4px 1fr';
}
wrapper.innerHTML = '';
wrapper.appendChild(grid);
this.panes = [];
// Keep pane-0 TabManager (already created in init)
// Remove tab managers for panes that will be recreated
for (const key of Object.keys(this.tabManagers)) {
if (Number(key) >= n) delete this.tabManagers[key];
}
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) {
const ca = document.getElementById('content-area-0') || origContent;
if (ca && !ca.querySelector('#dashboard-home')) {
ca.innerHTML = '';
ca.appendChild(dashboard);
dashboard.style.display = '';
}
}
this.panes.push(pane);
// Create or update TabManager for this pane
if (!this.tabManagers[i]) {
this.tabManagers[i] = createPaneTabManager(i);
}
const tabBar = document.getElementById('tab-bar-' + i) || origTabBar;
const tabList = document.getElementById('tab-list-' + i) || tabBar.querySelector('.tab-list');
const contentArea = document.getElementById('content-area-' + i) || origContent;
this.tabManagers[i].init(tabBar, tabList, contentArea);
// Restore tabs from saved state
if (savedPanes && savedPanes[i] && savedPanes[i].tabs) {
const tm = this.tabManagers[i];
tm._tabs = savedPanes[i].tabs.map(t => ({ ...t }));
tm._activeTabId = savedPanes[i].activeTab;
tm._previewTabId = savedPanes[i].previewTab || null;
savedPanes[i].tabs.forEach(t => {
if (!tm._tabCache[t.id])
tm._tabCache[t.id] = { vault: t.vault, path: t.path, title: t.name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: t.icon };
});
setTimeout(() => {
tm._renderTabs();
if (tm._activeTabId) tm.activate(tm._activeTabId);
}, 200);
}
if (i < n - 1) this._addResizeHandle(grid, i, i + 1);
}
this.activePaneId = 0;
this._highlightActive();
// Migrate tabs from singleton TabManager to pane-0 PaneTabManager
// (the singleton managed all tabs in single-pane mode)
if (!savedPanes && window._singletonTabManager && window._singletonTabManager._tabs.length > 0) {
const stm = window._singletonTabManager;
const ptm = this.tabManagers[0];
if (ptm && ptm._tabs.length === 0) {
console.log('[PaneManager] migrating', stm._tabs.length, 'tabs from singleton to pane-0');
ptm._tabs = stm._tabs.map(t => ({ ...t }));
ptm._tabCache = { ...stm._tabCache };
ptm._activeTabId = stm._activeTabId;
ptm._previewTabId = stm._previewTabId;
// Clear singleton — it now delegates to PaneManager
stm._tabs = [];
stm._tabCache = {};
stm._activeTabId = null;
stm._previewTabId = null;
// Render the migrated tabs
setTimeout(() => {
ptm._renderTabs();
if (ptm._activeTabId) ptm.activate(ptm._activeTabId);
}, 100);
}
}
},
_createPane(idx, grid, reuseTabBar, reuseContent) {
const container = document.createElement('div');
container.className = 'pane-container';
container.dataset.pane = idx;
if (reuseTabBar && reuseContent) {
container.appendChild(reuseTabBar);
container.appendChild(reuseContent);
reuseContent.id = 'content-area-0';
reuseTabBar.id = 'tab-bar-0';
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;
const tabList = document.createElement('div');
tabList.className = 'tab-list';
tabList.id = 'tab-list-' + idx;
tabBar.appendChild(tabList);
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', (e) => {
if (e.target.closest('.tab-item')) return;
this.setActivePane(idx);
});
// --- Drop zone handlers on the pane container ---
this._setupPaneDropZone(container, idx);
grid.appendChild(container);
return { id: idx, element: container, width: null };
},
/** Add dragover/drop on a pane container for cross-pane transfers + drag-to-split */
_setupPaneDropZone(container, paneId) {
let _dragCounter = 0;
container.addEventListener('dragenter', (e) => {
_dragCounter++;
e.preventDefault(); // Accept the drop unconditionally
});
container.addEventListener('dragover', (e) => {
e.preventDefault(); // Required for drop to work
e.dataTransfer.dropEffect = 'move';
// Detect edge zones for drag-to-split (only in single-pane mode)
const rect = container.getBoundingClientRect();
const relX = e.clientX - rect.left;
const relY = e.clientY - rect.top;
const isSingle = !this.isSplit();
// Clean up previous indicators
container.querySelectorAll('.pane-drop-zone-indicator').forEach(d => d.remove());
const tabBar = container.querySelector('.tab-bar');
if (tabBar) tabBar.classList.remove('drop-target');
if (isSingle && relX > rect.width * 0.72) {
const indicator = document.createElement('div');
indicator.className = 'pane-drop-zone-indicator pane-drop-zone-right';
container.appendChild(indicator);
} else if (isSingle && relY > rect.height * 0.72) {
const indicator = document.createElement('div');
indicator.className = 'pane-drop-zone-indicator pane-drop-zone-bottom';
container.appendChild(indicator);
} else {
// Show tab-bar highlight for cross-pane transfer
if (tabBar) tabBar.classList.add('drop-target');
}
});
container.addEventListener('dragleave', (e) => {
_dragCounter--;
if (_dragCounter > 0) return;
container.querySelectorAll('.pane-drop-zone-indicator').forEach(d => d.remove());
const tabBar = container.querySelector('.tab-bar');
if (tabBar) tabBar.classList.remove('drop-target');
});
container.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
_dragCounter = 0;
container.querySelectorAll('.pane-drop-zone-indicator').forEach(d => d.remove());
const tabBar = container.querySelector('.tab-bar');
if (tabBar) tabBar.classList.remove('drop-target');
// Get drag data (text/plain is universally supported)
const raw = e.dataTransfer.getData('text/plain');
console.log('[PaneManager] drop fired, raw data:', raw ? raw.substring(0, 80) : 'null');
if (!raw) return;
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; }
// Recalculate edge zone from drop coordinates
const rect = container.getBoundingClientRect();
const relX = e.clientX - rect.left;
const relY = e.clientY - rect.top;
const isSingle = !this.isSplit();
console.log('[PaneManager] drop at', relX.toFixed(0), relY.toFixed(0),
'rect', rect.width.toFixed(0), 'x', rect.height.toFixed(0),
'isSingle', isSingle, 'paneId', paneId, 'srcPane', dragData.paneId);
if (isSingle && relX > rect.width * 0.72) {
console.log('[PaneManager] → splitRight');
this.splitRight();
// Transfer after DOM settles
const transfer = () => {
const newPaneId = this.panes.length - 1;
console.log('[PaneManager] transferring tab', dragData.tabId, 'to pane', newPaneId);
this.transferTab(dragData.paneId, dragData.tabId, newPaneId);
};
setTimeout(transfer, 200);
} else if (isSingle && relY > rect.height * 0.72) {
console.log('[PaneManager] → splitDown');
this.splitDown();
const transfer = () => {
const newPaneId = this.panes.length - 1;
this.transferTab(dragData.paneId, dragData.tabId, newPaneId);
};
setTimeout(transfer, 200);
} else if (dragData.paneId !== paneId) {
console.log('[PaneManager] → cross-pane transfer', dragData.paneId, '→', paneId);
this.transferTab(dragData.paneId, dragData.tabId, paneId);
} else {
console.log('[PaneManager] → same pane, no edge — letting tab handler deal');
}
});
},
_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'; }
this._addResizeHandle(grid, n - 2, n - 1);
const pane = this._createPane(n - 1, grid, null, null);
this.panes.push(pane);
// Create TabManager for new pane
const tm = createPaneTabManager(n - 1);
const tabBar = document.getElementById('tab-bar-' + (n - 1));
const tabList = document.getElementById('tab-list-' + (n - 1));
const contentArea = document.getElementById('content-area-' + (n - 1));
tm.init(tabBar, tabList, contentArea);
this.tabManagers[n - 1] = tm;
// Copy active tab from previous pane to new pane
const prevTm = this.tabManagers[this.activePaneId];
if (prevTm && prevTm._activeTabId) {
const prevTabId = prevTm._activeTabId;
const prevCache = prevTm._tabCache[prevTabId];
if (prevCache) {
tm.open(prevCache.vault, prevCache.path);
}
}
this.activePaneId = n - 1;
this._highlightActive();
this._persist();
},
_addResizeHandle(grid, leftIdx, rightIdx) {
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, leftIdx, rightIdx));
handle.addEventListener('dblclick', () => this._resetResize(leftIdx, rightIdx));
grid.appendChild(handle);
},
_collapseToSingle() {
const grid = document.querySelector('.pane-grid');
if (!grid) return;
const wrapper = grid.parentElement;
const pane0 = this.panes[0];
wrapper.innerHTML = '';
let tabBar = null, content = null;
if (pane0 && pane0.element) {
tabBar = pane0.element.querySelector('.tab-bar');
content = pane0.element.querySelector('.content-area');
}
if (!tabBar) tabBar = document.getElementById('tab-bar-0');
if (!content) content = document.getElementById('content-area-0');
if (tabBar) { tabBar.id = 'tab-bar'; wrapper.appendChild(tabBar); }
if (content) { content.id = 'content-area'; wrapper.appendChild(content); }
const tl = wrapper.querySelector('.tab-list');
if (tl) tl.id = 'tab-list';
// Migrate tabs back to singleton TabManager
const ptm = this.tabManagers[0];
if (ptm && window._singletonTabManager) {
const stm = window._singletonTabManager;
stm._tabs = ptm._tabs.map(t => ({ ...t }));
stm._tabCache = { ...ptm._tabCache };
stm._activeTabId = ptm._activeTabId;
stm._previewTabId = ptm._previewTabId;
stm._tabBar = tabBar;
stm._tabList = tl;
stm._renderTabs();
}
// Re-init pane-0 TabManager to point to restored DOM
if (this.tabManagers[0]) {
this.tabManagers[0]._tabBar = tabBar;
this.tabManagers[0]._tabList = tl;
this.tabManagers[0]._contentArea = content;
}
// Clean up other tab managers
for (const key of Object.keys(this.tabManagers)) {
if (Number(key) !== 0) delete this.tabManagers[key];
}
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; });
},
_highlightActive() {
this.panes.forEach((p, i) => {
if (p.element) p.element.classList.toggle('pane-active', i === this.activePaneId);
});
},
// ------------------------------------------------------------------
// Persistence — save per-pane tab state
// ------------------------------------------------------------------
_persist() {
if (this.panes.length <= 1) {
localStorage.removeItem('obsigate-panes');
return;
}
const data = {
layout: this.layout,
panes: this.panes.map((p) => {
const tm = this.tabManagers[p.id];
return {
id: p.id,
width: p.width,
activeTab: tm ? tm._activeTabId : null,
previewTab: tm ? tm._previewTabId : null,
tabs: tm ? tm._tabs.map(t => ({
id: t.id, vault: t.vault, path: t.path, name: t.name, icon: t.icon, preview: t.preview
})) : [],
};
}),
};
localStorage.setItem('obsigate-panes', JSON.stringify(data));
},
};
// =============================================================================
// Global helpers
// =============================================================================
function getActiveTabManager() {
if (window.PaneManager && window.PaneManager.isSplit())
return window.PaneManager.getActiveTabManager();
return window._singletonTabManager;
}
function getActiveContentArea() {
if (window.PaneManager && window.PaneManager.isSplit())
return window.PaneManager.getActiveContentArea();
return document.getElementById('content-area');
}
// =============================================================================
// Export + auto-init
// =============================================================================
if (typeof window !== 'undefined') {
window.PaneManager = PaneManager;
window.getActiveTabManager = getActiveTabManager;
window.getActiveContentArea = getActiveContentArea;
document.addEventListener('DOMContentLoaded', () => {
// Keyboard shortcuts
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();
}
if (e.ctrlKey && e.altKey && !e.shiftKey && (e.key === 'w' || e.key === 'W') && PaneManager.isSplit()) {
e.preventDefault();
PaneManager.closeActivePane();
}
});
// Init — wait for DOM + singleton TabManager to be ready
setTimeout(() => {
// Use window.TabManager (set by ui.js init) as the singleton fallback
if (!window._singletonTabManager && window.TabManager)
window._singletonTabManager = window.TabManager;
PaneManager.init();
}, 150);
});
}
export { PaneManager, getActiveTabManager, getActiveContentArea, createPaneTabManager };
export default PaneManager;