CI / lint (push) Failing after 9s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Successful in 12s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled
C1b: Sous-menu « Déplacer vers... » (flyout hover) — liste les autres panneaux
D2: Drag depuis l'arborescence vers panneau — format 'tree:{vault,path}'
E3: Rendu paresseux — display:none sur contenu des panneaux inactifs
E4: Verrouillage éditeur — même fichier déjà ouvert → focus le panneau existant
G3: Bouton reset (🔄 Réinitialiser les panneaux) dans la palette
ROADMAP: section 75 marquée ✅ Complété (reste I2/I3 tests)
1227 lines
48 KiB
JavaScript
1227 lines
48 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);
|
|
|
|
// 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.splitRight();
|
|
});
|
|
btns.querySelector('[data-action="split-down"]').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
if (window.PaneManager) 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 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;
|
|
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 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.splitRight(); menu.remove(); return; } }
|
|
else if (action === 'splitDown') { if (pm) { 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;
|
|
// Hide previous active pane content (lazy rendering)
|
|
if (this.panes[this.activePaneId] && this.panes[this.activePaneId].element) {
|
|
const oldContent = this.panes[this.activePaneId].element.querySelector('.content-area');
|
|
if (oldContent) oldContent.style.display = 'none';
|
|
}
|
|
this.activePaneId = paneId;
|
|
// Show new active pane content
|
|
if (this.panes[paneId] && this.panes[paneId].element) {
|
|
const newContent = this.panes[paneId].element.querySelector('.content-area');
|
|
if (newContent) newContent.style.display = '';
|
|
}
|
|
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;
|
|
|
|
// Handle tree drag (file from sidebar)
|
|
if (raw.startsWith('tree:')) {
|
|
try {
|
|
const treeData = JSON.parse(raw.substring(5));
|
|
if (treeData.vault && treeData.path) {
|
|
console.log('[PaneManager] tree drop → opening in pane', paneId);
|
|
const tm = window.PaneManager.getTabManager(paneId);
|
|
if (tm) {
|
|
window.PaneManager.setActivePane(paneId);
|
|
tm.openPreview(treeData.vault, treeData.path);
|
|
}
|
|
}
|
|
} catch (e2) { console.debug('[PaneManager] tree drop parse error:', e2); }
|
|
return;
|
|
}
|
|
|
|
// Handle 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; }
|
|
|
|
// 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();
|
|
}
|
|
// 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;
|