/** * 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 = ` `; 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 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; 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 = '
Chargement...
'; 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 = `
Erreur: ${escapeHtml(err.message)}
`; } } 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 = ''; 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 = '
Déplacer vers...▸
'; } } let closePaneHtml = ''; if (isSplit) { closePaneHtml = `
Fermer le panneau
`; } menu.innerHTML = `
Diviser à droite
Diviser en bas
Fermer
Fermer les autres
Fermer à droite
Fermer tout
${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) => `
Panneau ${p.id + 1}
` ).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;