Files
ObsiGate/frontend/js/pane-manager.js
T
bruno 2add24a9c1
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
feat: onglet Accueil, menus contextuels de navigation et racine vault #158
- clic sur le titre: la page d'accueil s'ouvre dans un onglet Accueil (icone Maison) epingle en tete de barre, reactivable d'un clic (onglet propre au panneau actif en split)
- page de navigation: menus contextuels des repertoires et des fichiers identiques a ceux de la sidebar, clic sur la racine d'un vault qui ouvre la vue, icone dossier a la couleur de l'arbre de navigation
- BUG-101: bloc « Keyboard shortcuts for tabs » duplique dans ui.js (un seul Ctrl+W fermait deux onglets)
- BUG-102: le menu contextuel se refermait 10 ms apres son ouverture (scroll de reflow des icones lucide)
2026-10-02 14:05:01 -04:00

1447 lines
56 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 + 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, detachInlineEditor } 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);
// Add split buttons to the tab bar (right side)
this._ensureSplitButtons();
},
_ensureSplitButtons() {
if (!this._tabBar) return;
// Remove existing split buttons if any
const existing = this._tabBar.querySelector('.tab-split-buttons');
if (existing) existing.remove();
const btns = document.createElement('div');
btns.className = 'tab-split-buttons';
btns.innerHTML = `
<button class="tab-split-btn" title="Diviser à droite (Ctrl+Alt+\\)" data-action="split-right">
<i data-lucide="panel-right" style="width:13px;height:13px"></i>
</button>
<button class="tab-split-btn" title="Diviser en bas (Ctrl+Alt+Shift+\\)" data-action="split-down">
<i data-lucide="panel-bottom" style="width:13px;height:13px"></i>
</button>
`;
btns.querySelector('[data-action="split-right"]').addEventListener('click', (e) => {
e.stopPropagation();
if (window.PaneManager && window.PaneManager._hasAnyTab()) window.PaneManager.splitRight();
});
btns.querySelector('[data-action="split-down"]').addEventListener('click', (e) => {
e.stopPropagation();
if (window.PaneManager && window.PaneManager._hasAnyTab()) window.PaneManager.splitDown();
});
this._tabBar.appendChild(btns);
if (typeof safeCreateIcons === 'function') safeCreateIcons();
},
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 openHome(name = "Home") {
const tabId = "home";
const idx = this._tabs.findIndex((x) => x.id === tabId);
if (idx > 0) {
const [tab] = this._tabs.splice(idx, 1);
this._tabs.unshift(tab);
} else if (idx === -1) {
this._tabs.unshift({ id: tabId, vault: null, path: "", name, icon: "home", home: true });
this._tabCache[tabId] = { home: true, vault: null, path: "", title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "home" };
}
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;
}
// 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 });
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;
// #93 — an inline edition session owns this pane's content area: release
// it before the load placeholder / read view wipes it off the DOM.
detachInlineEditor();
if (this._activeTabId && this._tabCache[this._activeTabId])
this._saveCurrentTabState();
this._activeTabId = tabId;
this._renderTabs();
const cache = this._tabCache[tabId];
if (!cache) return;
// Onglet Accueil (#158) — la page d'accueil vit dans l'onglet
if (cache.home) {
state.currentVault = null;
state.currentPath = null;
this._showDashboard();
if (history.pushState) history.pushState(null, '', '#');
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;
const closingTab = this._tabs[idx];
this._tabs.splice(idx, 1);
delete this._tabCache[tabId];
this._dirtyTabs.delete(tabId);
if (window.NowPlaying && closingTab) {
window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path);
}
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 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>`;
}
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>
${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 === 'moveTo') return; // handled by hover
if (action === 'splitRight') {
if (pm && pm._hasAnyTab()) {
pm.splitRight();
menu.remove(); return;
}
}
else if (action === 'splitDown') {
if (pm && pm._hasAnyTab()) {
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(); };
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;
// Track state BEFORE transfer (closePane may rebuild DOM)
const paneCountBefore = this.panes.length;
const ok = fromTm.transferTabTo(tabId, toTm);
if (!ok) return false;
// If source pane was auto-closed, DOM was rebuilt — re-lookup target
let actualTm = toTm;
if (this.panes.length < paneCountBefore) {
// Find which pane manager now holds the tab
for (const [pid, tm] of Object.entries(this.tabManagers)) {
if (tm._tabs.find(t => t.id === tabId)) {
actualTm = tm;
console.log('[PaneManager] source closed, tab now in pane', pid);
break;
}
}
}
actualTm._renderTabs();
actualTm.activate(tabId);
this._persist();
return ok;
},
isSplit() { return this.panes.length >= 2; },
// ------------------------------------------------------------------
// Split actions
// ------------------------------------------------------------------
splitRight() {
if (this.panes.length >= PANE_MAX) return;
// Don't split if no tab is open anywhere
if (!this._hasAnyTab()) 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;
if (!this._hasAnyTab()) return;
this.layout = 'vertical';
if (this.panes.length === 0) {
this._buildGrid(2);
} else {
this._addPane();
}
this._persist();
},
/** Check if any pane has at least one tab */
_hasAnyTab() {
if (!this.isSplit()) {
// Check singleton in single-pane mode
if (window._singletonTabManager && window._singletonTabManager._tabs.length > 0)
return true;
return false;
}
for (const tm of Object.values(this.tabManagers)) {
if (tm._tabs && tm._tabs.length > 0) return true;
}
return false;
},
/** Copy the active tab to the newly created last pane */
_copyActiveTabToNewPane() {
const newPaneId = this.panes.length - 1;
const srcTm = this.tabManagers[this.activePaneId];
const dstTm = this.tabManagers[newPaneId];
if (!srcTm || !dstTm || !srcTm._activeTabId) return;
const cache = srcTm._tabCache[srcTm._activeTabId];
if (cache) {
setTimeout(() => {
dstTm.open(cache.vault, cache.path);
}, 150);
}
},
// ------------------------------------------------------------------
// 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();
this.panes.splice(idx, 1);
if (this.panes.length <= 1) {
this._collapseToSingle();
} else {
// Rebuild grid positions and handles for remaining panes
const grid = document.querySelector('.pane-grid');
if (grid) {
const n = this.panes.length;
// Re-apply grid template
this._applyGridTemplate(grid, n);
// Re-position panes
const positions = this._computePanePositions(n);
this.panes.forEach((p, i) => {
const pos = positions[i];
p.id = i;
p.element.dataset.pane = i;
p.element.style.gridRow = pos.row + (pos.rowSpan ? ' / span ' + pos.rowSpan : '');
p.element.style.gridColumn = pos.col + (pos.colSpan ? ' / span ' + pos.colSpan : '');
// Update pane content IDs
const tb = p.element.querySelector('.tab-bar');
const ca = p.element.querySelector('.content-area');
if (tb) tb.id = 'tab-bar-' + i;
if (ca) ca.id = 'content-area-' + i;
const tl = p.element.querySelector('.tab-list');
if (tl) tl.id = 'tab-list-' + i;
// Update tab manager paneId
if (this.tabManagers[paneId] && i >= idx) {
// Move tab manager to new index
const oldTm = this.tabManagers[paneId];
delete this.tabManagers[paneId];
oldTm.paneId = i;
this.tabManagers[i] = oldTm;
}
});
// Rebuild handles
this._rebuildResizeHandles(grid, n);
}
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';
this._applyGridTemplate(grid, n);
// #110 — keep playing media alive across a pane-grid rebuild.
if (window.NowPlaying) window.NowPlaying.handleRender(wrapper, null);
wrapper.innerHTML = '';
wrapper.appendChild(grid);
this.panes = [];
// Keep pane-0 TabManager (already created in init)
for (const key of Object.keys(this.tabManagers)) {
if (Number(key) >= n) delete this.tabManagers[key];
}
// Build panes + resize handles with explicit grid placement
const positions = this._computePanePositions(n);
for (let i = 0; i < n; i++) {
const pos = positions[i];
const isFirst = (i === 0);
const pane = this._createPaneAt(i, grid, pos, isFirst ? origTabBar : null, isFirst ? 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);
this._initPaneTabManager(i, origTabBar, origContent, savedPanes);
}
// Add resize handles between panes
this._addAllResizeHandles(grid, n, positions);
this.activePaneId = 0;
this._highlightActive();
// Migrate tabs from singleton
if (!savedPanes && window._singletonTabManager && window._singletonTabManager._tabs.length > 0) {
const stm = window._singletonTabManager;
const ptm = this.tabManagers[0];
if (ptm && ptm._tabs.length === 0) {
ptm._tabs = stm._tabs.map(t => ({ ...t }));
ptm._tabCache = { ...stm._tabCache };
ptm._activeTabId = stm._activeTabId;
ptm._previewTabId = stm._previewTabId;
stm._tabs = []; stm._tabCache = {};
stm._activeTabId = null; stm._previewTabId = null;
setTimeout(() => {
ptm._renderTabs();
if (ptm._activeTabId) ptm.activate(ptm._activeTabId);
}, 100);
}
}
},
/** Compute explicit grid-row/grid-column for each pane */
_computePanePositions(n) {
if (n === 2) {
if (this.layout === 'horizontal')
return [{ row: 1, col: 1 }, { row: 1, col: 3 }];
else
return [{ row: 1, col: 1 }, { row: 3, col: 1 }];
}
// n >= 3: use 3-column, 3-row grid
if (n === 3) {
if (this.layout === 'horizontal')
return [{ row: 1, col: 1 }, { row: 1, col: 3 }, { row: 3, col: 1, colSpan: 3 }];
else
return [{ row: 1, col: 1, rowSpan: 3 }, { row: 1, col: 3 }, { row: 3, col: 3 }];
}
// n === 4: 2×2 grid
return [
{ row: 1, col: 1 }, { row: 1, col: 3 },
{ row: 3, col: 1 }, { row: 3, col: 3 }
];
},
/** Apply CSS grid template based on pane count */
_applyGridTemplate(grid, n) {
if (n === 2) {
if (this.layout === 'horizontal')
grid.style.gridTemplateColumns = '1fr 4px 1fr';
else {
grid.style.gridTemplateColumns = '1fr';
grid.style.gridTemplateRows = '1fr 4px 1fr';
}
} else {
// 3+ panes: 3-col, 3-row grid
grid.style.gridTemplateColumns = '1fr 4px 1fr';
grid.style.gridTemplateRows = '1fr 4px 1fr';
}
},
/** Create a pane with explicit grid placement */
_createPaneAt(idx, grid, pos, reuseTabBar, reuseContent) {
const container = document.createElement('div');
container.className = 'pane-container';
container.dataset.pane = idx;
container.style.gridRow = pos.row + (pos.rowSpan ? ' / span ' + pos.rowSpan : '');
container.style.gridColumn = pos.col + (pos.colSpan ? ' / span ' + pos.colSpan : '');
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);
});
this._setupPaneDropZone(container, idx);
grid.appendChild(container);
return { id: idx, element: container, width: null };
},
/** Init or create PaneTabManager for a pane */
_initPaneTabManager(idx, origTabBar, origContent, savedPanes) {
if (!this.tabManagers[idx]) {
this.tabManagers[idx] = createPaneTabManager(idx);
}
const tabBar = document.getElementById('tab-bar-' + idx) || origTabBar;
const tabList = document.getElementById('tab-list-' + idx) || (tabBar ? tabBar.querySelector('.tab-list') : null);
const contentArea = document.getElementById('content-area-' + idx) || origContent;
this.tabManagers[idx].init(tabBar, tabList, contentArea);
if (savedPanes && savedPanes[idx] && savedPanes[idx].tabs) {
const tm = this.tabManagers[idx];
tm._tabs = savedPanes[idx].tabs.map(t => ({ ...t }));
tm._activeTabId = savedPanes[idx].activeTab;
tm._previewTabId = savedPanes[idx].previewTab || null;
savedPanes[idx].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);
}
},
/** Add all resize handles between panes */
_addAllResizeHandles(grid, n, positions) {
if (n === 2) {
this._addResizeHandleAt(grid, 1, 2, 1);
} else if (n === 3) {
if (this.layout === 'horizontal') {
this._addResizeHandleAt(grid, 1, 2, 1); // between pane 0 & 1
this._addResizeHandleAt(grid, 2, 1, 3); // between top row & bottom
} else {
this._addResizeHandleAt(grid, 2, 1, 1); // between left & right col
this._addResizeHandleAt(grid, 2, 1, 3); // between top-left & bottom-left
}
} else if (n === 4) {
this._addResizeHandleAt(grid, 1, 2, 1); // col 2 (between pane 0&1 and 2&3)
this._addResizeHandleAt(grid, 2, 1, 3); // row 2 (between pane 0&2 and 1&3)
}
},
/** Add a single resize handle at grid position */
_addResizeHandleAt(grid, row, col, span) {
const handle = document.createElement('div');
handle.className = 'pane-resize-handle';
handle.style.gridRow = span > 1 ? row + ' / span ' + span : String(row);
handle.style.gridColumn = span > 1 ? col + ' / span ' + span : String(col);
handle.style.cursor = (span > 1 && col === 1) ? 'row-resize' : 'col-resize';
handle.addEventListener('mousedown', (e) => this._onResizeStart(e, 0, 1)); // simplified
handle.addEventListener('dblclick', () => this._resetResize(0, 1));
grid.appendChild(handle);
},
/** 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;
// --- Determine drop zone ---
const rect = container.getBoundingClientRect();
const relX = e.clientX - rect.left;
const relY = e.clientY - rect.top;
const isSingle = !this.isSplit();
const onRightEdge = isSingle && relX > rect.width * 0.72;
const onBottomEdge = isSingle && relY > rect.height * 0.72;
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,
'onRightEdge', onRightEdge, 'onBottomEdge', onBottomEdge);
// --- Tree drag (file from sidebar) ---
if (raw.startsWith('tree:')) {
try {
const treeData = JSON.parse(raw.substring(5));
if (!treeData.vault || !treeData.path) return;
console.log('[PaneManager] tree drop vault=', treeData.vault, 'path=', treeData.path);
if (onRightEdge) {
console.log('[PaneManager] → tree splitRight');
this.splitRight();
setTimeout(() => {
const newPaneId = this.panes.length - 1;
const tm = this.getTabManager(newPaneId);
if (tm) {
this.setActivePane(newPaneId);
tm.openPreview(treeData.vault, treeData.path);
}
}, 200);
} else if (onBottomEdge) {
console.log('[PaneManager] → tree splitDown');
this.splitDown();
setTimeout(() => {
const newPaneId = this.panes.length - 1;
const tm = this.getTabManager(newPaneId);
if (tm) {
this.setActivePane(newPaneId);
tm.openPreview(treeData.vault, treeData.path);
}
}, 200);
} else {
console.log('[PaneManager] → tree open in pane', paneId);
const tm = this.getTabManager(paneId);
if (tm) {
this.setActivePane(paneId);
tm.openPreview(treeData.vault, treeData.path);
}
}
} catch (e2) { console.debug('[PaneManager] tree drop parse error:', e2); }
return;
}
// --- 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; }
if (onRightEdge) {
console.log('[PaneManager] → splitRight');
this.splitRight();
setTimeout(() => {
const newPaneId = this.panes.length - 1;
console.log('[PaneManager] transferring tab', dragData.tabId, 'to pane', newPaneId);
this.transferTab(dragData.paneId, dragData.tabId, newPaneId);
}, 200);
} else if (onBottomEdge) {
console.log('[PaneManager] → splitDown');
this.splitDown();
setTimeout(() => {
const newPaneId = this.panes.length - 1;
this.transferTab(dragData.paneId, dragData.tabId, newPaneId);
}, 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 > PANE_MAX) return;
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'; }
// Compute position for the new pane
const positions = this._computePanePositions(n);
const newPos = positions[n - 1];
const pane = this._createPaneAt(n - 1, grid, newPos, 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 active 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);
}
}
// Rebuild resize handles
this._rebuildResizeHandles(grid, n);
this.activePaneId = n - 1;
this._highlightActive();
this._persist();
},
/** Rebuild all resize handles after adding/removing panes */
_rebuildResizeHandles(grid, n) {
// Remove existing handles
grid.querySelectorAll('.pane-resize-handle').forEach(h => h.remove());
this._addAllResizeHandles(grid, n, this._computePanePositions(n));
},
_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];
// #110 — keep playing media alive across a pane collapse.
if (window.NowPlaying) window.NowPlaying.handleRender(wrapper, null);
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();
if (PaneManager._hasAnyTab()) {
PaneManager.splitRight();
}
}
if (e.ctrlKey && e.altKey && e.shiftKey && e.key === '\\') {
e.preventDefault();
if (PaneManager._hasAnyTab()) {
PaneManager.splitDown();
}
}
// Close active pane
if (e.ctrlKey && e.altKey && !e.shiftKey && (e.key === 'w' || e.key === 'W') && PaneManager.isSplit()) {
e.preventDefault();
PaneManager.closeActivePane();
}
// Close all other panes
if (e.ctrlKey && e.shiftKey && e.altKey && (e.key === 'w' || e.key === 'W') && PaneManager.isSplit()) {
e.preventDefault();
PaneManager.closeOtherPanes();
}
// Navigate between panes
if (e.ctrlKey && e.altKey && PaneManager.isSplit()) {
if (e.key === 'ArrowRight') {
e.preventDefault();
const next = (PaneManager.activePaneId + 1) % PaneManager.panes.length;
PaneManager.setActivePane(next);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prev = (PaneManager.activePaneId - 1 + PaneManager.panes.length) % PaneManager.panes.length;
PaneManager.setActivePane(prev);
} else if (e.key === 'ArrowDown' && PaneManager.layout === 'vertical') {
e.preventDefault();
const next = (PaneManager.activePaneId + 1) % PaneManager.panes.length;
PaneManager.setActivePane(next);
} else if (e.key === 'ArrowUp' && PaneManager.layout === 'vertical') {
e.preventDefault();
const prev = (PaneManager.activePaneId - 1 + PaneManager.panes.length) % PaneManager.panes.length;
PaneManager.setActivePane(prev);
}
}
});
// 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;