From ed4c1dcf07f7b70b7ef57dbf2426805c09c563e1 Mon Sep 17 00:00:00 2001 From: bruno Date: Mon, 27 Jul 2026 21:37:04 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20split=20view=20pro=20=E2=80=94=20TabMan?= =?UTF-8?q?ager=20ind=C3=A9pendant=20par=20panneau=20avec=20drag=20&=20dro?= =?UTF-8?q?p=20cross-pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaneTabManager factory: chaque panneau a sa propre instance avec ses propres onglets - Drag & drop d'onglets entre panneaux (application/obsigate-tab dataTransfer) - Split copie l'onglet actif dans le nouveau panneau - TabManager singleton: délégation automatique vers le panneau actif en mode split - Persistance complète par panneau (tabs, activeTab, previewTab) - viewer.js: getContentArea() priorise window._activePaneContentArea - 307 tests verts, déployé sur 192.168.30.101:2020 --- frontend/js/pane-manager.js | 974 +++++++++++++++++++++++++++--------- frontend/js/ui.js | 88 +--- frontend/js/viewer.js | 8 +- tests/test_pane_manager.py | 14 +- 4 files changed, 783 insertions(+), 301 deletions(-) diff --git a/frontend/js/pane-manager.js b/frontend/js/pane-manager.js index 2509e1b..a7de1c8 100644 --- a/frontend/js/pane-manager.js +++ b/frontend/js/pane-manager.js @@ -1,29 +1,473 @@ /** - * PaneManager — Multi-pane split view for ObsiGate (v2.0.0-dev) - * Supports 1–4 panes in CSS grid, split right/down, resize, drag tabs between panes. - * Integrates with existing TabManager singleton. + * PaneManager + PaneTabManager — Multi-pane split view for ObsiGate (v2.0.0-dev) * * Architecture: - * Single-pane (default): uses existing #tab-bar, #content-area — zero DOM changes. - * Multi-pane: builds a .pane-grid inside .content-wrapper, each pane gets its - * own tab-bar-N and content-area-N DOM elements. - * TabManager remains the singleton source of truth for open tabs. - * PaneManager tracks per-pane active tab and delegates rendering. + * Single-pane (default): uses existing singleton TabManager + #content-area / #tab-bar. + * Multi-pane: builds .pane-grid inside .content-wrapper. Each pane gets its OWN + * independent PaneTabManager instance with its own tabs, tab bar, and content area. + * Drag & drop tabs between panes. + * Persisted to localStorage. */ import { state } from './state.js'; +import { api } from './auth.js'; +import { escapeHtml, getFileIcon, safeCreateIcons } from './utils.js'; const PANE_MAX = 4; +// ============================================================================= +// PaneTabManager factory — one per pane +// ============================================================================= +function createPaneTabManager(paneId) { + const DEFAULT_BAR = document.getElementById('tab-bar'); + const DEFAULT_LIST = document.getElementById('tab-list'); + const DEFAULT_AREA = document.getElementById('content-area'); + + const tm = { + paneId, + _tabs: [], + _activeTabId: null, + _previewTabId: null, + _tabCache: {}, + _dirtyTabs: new Set(), + _tabBar: paneId === 0 ? DEFAULT_BAR : null, + _tabList: paneId === 0 ? DEFAULT_LIST : null, + _contentArea: paneId === 0 ? DEFAULT_AREA : null, + + // ------------------------------------------------------------------ + // Init — bind to DOM + // ------------------------------------------------------------------ + init(tabBar, tabList, contentArea) { + if (tabBar) this._tabBar = tabBar; + if (tabList) this._tabList = tabList; + if (contentArea) this._contentArea = contentArea; + if (!this._tabBar) + this._tabBar = document.getElementById('tab-bar-' + paneId); + if (!this._tabList) + this._tabList = document.getElementById('tab-list-' + paneId); + if (!this._contentArea) + this._contentArea = document.getElementById('content-area-' + paneId); + }, + + getContentArea() { + return this._contentArea || DEFAULT_AREA; + }, + getTabBar() { return this._tabBar; }, + getTabList() { return this._tabList; }, + + // ------------------------------------------------------------------ + // Open / Activate / Close + // ------------------------------------------------------------------ + async openPreview(vault, path) { + const tabId = `${vault}::${path}`; + const existing = this._tabs.find(t => t.id === tabId && !t.preview); + if (existing) { this.activate(tabId); return; } + if (this._previewTabId && this._previewTabId !== tabId) + this.close(this._previewTabId); + const previewExisting = this._tabs.find(t => t.id === tabId && t.preview); + if (previewExisting) { this.activate(tabId); return; } + const name = path.split('/').pop().replace(/\.md$/i, ''); + const icon = getFileIcon(name + '.md'); + this._tabs.push({ id: tabId, vault, path, name, icon, preview: true }); + this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; + this._previewTabId = tabId; + this._renderTabs(); + this.activate(tabId); + }, + + async openPersistent(vault, path) { + const tabId = `${vault}::${path}`; + const previewTab = this._tabs.find(t => t.id === tabId && t.preview); + if (previewTab) { + previewTab.preview = false; + if (this._previewTabId === tabId) this._previewTabId = null; + this._renderTabs(); + this.activate(tabId); + return; + } + const existing = this._tabs.find(t => t.id === tabId && !t.preview); + if (existing) { this.activate(tabId); return; } + this.open(vault, path); + }, + + async open(vault, path) { + const tabId = `${vault}::${path}`; + const existing = this._tabs.find(t => t.id === tabId); + if (existing) { + if (existing.preview) { + existing.preview = false; + if (this._previewTabId === tabId) this._previewTabId = null; + this._renderTabs(); + } + this.activate(tabId); + return; + } + const name = path.split('/').pop().replace(/\.md$/i, ''); + const icon = getFileIcon(name + '.md'); + this._tabs.push({ id: tabId, vault, path, name, icon }); + this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; + this._renderTabs(); + this.activate(tabId); + }, + + async activate(tabId) { + if (this._activeTabId === tabId && this._tabs.length > 0) return; + if (this._activeTabId && this._tabCache[this._activeTabId]) + this._saveCurrentTabState(); + + this._activeTabId = tabId; + this._renderTabs(); + + const cache = this._tabCache[tabId]; + if (!cache) return; + + state.currentVault = cache.vault; + state.currentPath = cache.path; + // syncActiveFileTreeItem is imported below to avoid circular deps + if (typeof syncActiveFileTreeItem !== 'undefined') + syncActiveFileTreeItem(cache.vault, cache.path); + + const area = this._contentArea || DEFAULT_AREA; + if (cache.data) { + this._restoreTabContent(cache, area); + } else { + area.innerHTML = '
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; + this._tabs.splice(idx, 1); + delete this._tabCache[tabId]; + this._dirtyTabs.delete(tabId); + if (this._tabs.length === 0) { + this._activeTabId = null; + 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 }; + // Activate in source what's left, or show dashboard + if (this._tabs.length === 0) { + this._activeTabId = null; + 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; + try { + // Dynamic import to avoid circular dep — renderFile is in viewer.js + import('./viewer.js').then(m => { + m.renderFile(data); + window._activePaneContentArea = prev; + }); + } catch (e) { + 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 + el.addEventListener('dragstart', (e) => { + e.dataTransfer.setData('application/obsigate-tab', JSON.stringify({ + paneId: this.paneId, tabId: tab.id, index: idx + })); + 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(); + document.querySelectorAll('.tab-drop-indicator').forEach(d => d.remove()); + try { + const raw = e.dataTransfer.getData('application/obsigate-tab'); + if (!raw) return; + const dragData = JSON.parse(raw); + // Same pane — reorder + if (dragData.paneId === this.paneId) { + const rect = el.getBoundingClientRect(); + const mid = rect.left + rect.width / 2; + const toIdx = e.clientX < mid ? idx : idx + 1; + if (dragData.index !== toIdx && dragData.index !== toIdx - 1) + this.moveTab(dragData.index, toIdx); + } else { + // Cross-pane transfer + if (window.PaneManager) { + window.PaneManager.transferTab(dragData.paneId, dragData.tabId, this.paneId); + } + } + } catch (e2) { /* ignore */ } + }); + + this._tabList.appendChild(el); + }); + safeCreateIcons(); + }, + + _showTabContextMenu(x, y, tabId) { + const existing = document.getElementById('tab-context-menu'); + if (existing) existing.remove(); + const menu = document.createElement('div'); + menu.id = 'tab-context-menu'; + menu.className = 'context-menu active'; + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + const isSplit = window.PaneManager && window.PaneManager.isSplit(); + let closePaneHtml = ''; + if (isSplit) { + closePaneHtml = `
Fermer le panneau
`; + } + menu.innerHTML = ` +
Diviser à droite
+
Diviser en bas
+
+
Fermer
+
Fermer les autres
+
Fermer à droite
+
+
Fermer tout
+ ${closePaneHtml} + `; + document.body.appendChild(menu); + safeCreateIcons(); + + menu.addEventListener('click', (e) => { + const action = e.target.closest('.context-menu-item')?.dataset.action; + if (action === 'splitRight') { if (window.PaneManager) { window.PaneManager.splitRight(); menu.remove(); return; } } + else if (action === 'splitDown') { if (window.PaneManager) { window.PaneManager.splitDown(); menu.remove(); return; } } + else if (action === 'closePane') { if (window.PaneManager) { window.PaneManager.closeActivePane(); menu.remove(); return; } } + else if (action === 'close') this.close(tabId); + else if (action === 'closeOthers') this.closeOthers(tabId); + else if (action === 'closeRight') this.closeRight(tabId); + else if (action === 'closeAll') this.closeAll(); + menu.remove(); + }); + const closeMenu = () => menu.remove(); + document.addEventListener('click', closeMenu, { once: true }); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape') menu.remove(); }, { once: true }); + }, + }; + return tm; +} + +// ============================================================================= +// PaneManager — orchestrates panes and their TabManagers +// ============================================================================= const PaneManager = { panes: [], + tabManagers: {}, // { paneId: PaneTabManager instance } activePaneId: 0, - _paneCounter: 1, layout: 'horizontal', + _paneCounter: 1, - // ------------------------------------------------------------------ - // Init / Restore - // ------------------------------------------------------------------ init() { + // Create default pane-0 TabManager (will bind to existing DOM) + this.tabManagers[0] = createPaneTabManager(0); + this.tabManagers[0].init(); + const saved = localStorage.getItem('obsigate-panes'); if (saved) { try { @@ -31,218 +475,110 @@ const PaneManager = { if (data.panes && data.panes.length >= 2) { this.layout = data.layout || 'horizontal'; this._paneCounter = data.panes.length; - this._buildGrid(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.panes[i]) { - this.panes[i].activeTab = p.activeTab; - this.panes[i].width = p.width; + 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); } }); - this.activePaneId = data.panes.length > 0 ? this.panes[0].id : 0; - this._applyResize(); - this._highlightActive(); - return; } } catch (e) { /* ignore corrupt state */ } } }, - // ------------------------------------------------------------------ - // DOM Helpers — return the active pane's DOM elements - // ------------------------------------------------------------------ - getActiveContentArea() { - if (this.panes.length < 2) return document.getElementById('content-area'); - return document.getElementById('content-area-' + this.activePaneId) - || document.getElementById('content-area-0'); - }, - - getActiveTabBar() { - if (this.panes.length < 2) return document.getElementById('tab-bar'); - return document.getElementById('tab-bar-' + this.activePaneId) - || document.getElementById('tab-bar-0'); - }, - - getActiveTabList() { - if (this.panes.length < 2) return document.getElementById('tab-list'); - return document.getElementById('tab-list-' + this.activePaneId) - || document.getElementById('tab-list-0'); - }, - - // ------------------------------------------------------------------ - // Build / Destroy pane grid - // ------------------------------------------------------------------ - _buildGrid(n) { - const wrapper = document.querySelector('.content-wrapper'); - const origTabBar = document.getElementById('tab-bar'); - const origContent = document.getElementById('content-area'); - if (!wrapper || !origContent || n < 2) return; - - // Save dashboard before clearing - const dashboard = document.getElementById('dashboard-home'); - - // Create grid container - const grid = document.createElement('div'); - grid.className = 'pane-grid'; - if (n === 2) { - grid.style.gridTemplateColumns = this.layout === 'horizontal' - ? '1fr 4px 1fr' : '1fr'; - if (this.layout === 'vertical') { - grid.style.gridTemplateRows = '1fr 4px 1fr'; - } - } else if (n === 3) { - grid.style.gridTemplateColumns = '1fr 4px 1fr'; - grid.style.gridTemplateRows = '1fr 4px 1fr'; - // 3 panes: first two in top row, third spans bottom row - } - if (n === 4) { - grid.style.gridTemplateColumns = '1fr 4px 1fr'; - grid.style.gridTemplateRows = '1fr 4px 1fr'; - } - - wrapper.innerHTML = ''; - wrapper.appendChild(grid); - - this.panes = []; - - for (let i = 0; i < Math.min(n, PANE_MAX); i++) { - const pane = this._createPane(i, grid, i === 0 ? origTabBar : null, i === 0 ? origContent : null); - if (dashboard && i === 0 && pane) { - // Re-attach dashboard to first pane's content area - const contentArea = document.getElementById('content-area-0') || origContent; - if (contentArea && !contentArea.querySelector('#dashboard-home')) { - contentArea.innerHTML = ''; - contentArea.appendChild(dashboard); - dashboard.style.display = ''; - } - } - this.panes.push(pane); - if (i < n - 1) { - this._addResizeHandle(grid, i, i + 1); - } - } - this.activePaneId = 0; + setActivePane(paneId) { + if (this.activePaneId === paneId) return; + this.activePaneId = paneId; this._highlightActive(); - }, - - _createPane(idx, grid, reuseTabBar, reuseContent) { - const container = document.createElement('div'); - container.className = 'pane-container'; - container.dataset.pane = idx; - - if (reuseTabBar && reuseContent) { - // Pane 0 reuses existing DOM - container.appendChild(reuseTabBar); - container.appendChild(reuseContent); - reuseContent.id = 'content-area-0'; - reuseTabBar.id = 'tab-bar-0'; - // Rename tab-list too - const tl = reuseTabBar.querySelector('.tab-list') || reuseTabBar.querySelector('#tab-list'); - if (tl) tl.id = 'tab-list-0'; - } else { - const tabBar = document.createElement('div'); - tabBar.className = 'tab-bar pane-tab-bar'; - tabBar.id = 'tab-bar-' + idx; - tabBar.innerHTML = '
'; - const content = document.createElement('main'); - content.className = 'content-area pane-content'; - content.id = 'content-area-' + idx; - content.setAttribute('aria-label', 'Contenu panneau ' + (idx + 1)); - container.appendChild(tabBar); - container.appendChild(content); - } - - container.addEventListener('click', () => { - if (this.activePaneId !== idx) { - this.activePaneId = idx; - this._highlightActive(); - // Sync global state with this pane's active tab - const pane = this.panes[idx]; - if (pane && pane.activeTab) { - const parts = pane.activeTab.split('::'); - if (parts.length === 2) { - state.currentVault = parts[0]; - state.currentPath = parts[1]; - } - } - // Re-render if needed — delegate to TabManager - if (typeof window.TabManager !== 'undefined' && window.TabManager.activatePaneTab) { - window.TabManager.activatePaneTab(idx); - } + // 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; } - }); - - grid.appendChild(container); - return { id: idx, element: container, width: null, activeTab: null }; + } + this._persist(); }, - _addResizeHandle(grid, leftIdx, rightIdx) { - const handle = document.createElement('div'); - handle.className = 'pane-resize-handle'; - if (this.layout === 'horizontal') handle.style.cursor = 'col-resize'; - else handle.style.cursor = 'row-resize'; - handle.addEventListener('mousedown', (e) => this._onResizeStart(e, leftIdx, rightIdx)); - handle.addEventListener('dblclick', () => this._resetResize(leftIdx, rightIdx)); - grid.appendChild(handle); + 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(); - } + if (this.panes.length === 0) this._buildGrid(2); + else this._addPane(); this._persist(); }, splitDown() { if (this.panes.length >= PANE_MAX) return; this.layout = 'vertical'; - if (this.panes.length === 0) { - this._buildGrid(2); - } else { - this._addPane(); - } - this._persist(); - }, - - _addPane() { - const grid = document.querySelector('.pane-grid'); - if (!grid) return; - const n = this.panes.length + 1; - if (n === 3) { - grid.style.gridTemplateColumns = '1fr 4px 1fr'; - grid.style.gridTemplateRows = '1fr 4px 1fr'; - } - if (n === 4) { - grid.style.gridTemplateColumns = '1fr 4px 1fr'; - grid.style.gridTemplateRows = '1fr 4px 1fr'; - } - const handle = document.createElement('div'); - handle.className = 'pane-resize-handle'; - handle.style.cursor = this.layout === 'horizontal' ? 'col-resize' : 'row-resize'; - handle.addEventListener('mousedown', (e) => this._onResizeStart(e, n - 2, n - 1)); - handle.addEventListener('dblclick', () => this._resetResize(n - 2, n - 1)); - grid.appendChild(handle); - const pane = this._createPane(n - 1, grid, null, null); - pane.activeTab = this.panes.length > 0 ? this.panes[this.activePaneId].activeTab : null; - this.panes.push(pane); - this.activePaneId = n - 1; - this._highlightActive(); + if (this.panes.length === 0) this._buildGrid(2); + else this._addPane(); this._persist(); }, // ------------------------------------------------------------------ - // Close / collapse + // 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(); @@ -266,9 +602,164 @@ const PaneManager = { 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); + 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(); + }, + + _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) => { + // Don't capture clicks on tab items (they have their own handler) + if (e.target.closest('.tab-item')) return; + this.setActivePane(idx); + }); + + grid.appendChild(container); + return { id: idx, element: container, width: null }; + }, + + _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() { @@ -277,21 +768,27 @@ const PaneManager = { const wrapper = grid.parentElement; const pane0 = this.panes[0]; wrapper.innerHTML = ''; - const tabBar = (pane0 && pane0.element.querySelector('.tab-bar')) - || document.getElementById('tab-bar-0'); - const content = (pane0 && pane0.element.querySelector('.content-area')) - || document.getElementById('content-area-0'); - if (tabBar) { - tabBar.id = 'tab-bar'; - wrapper.appendChild(tabBar); + let tabBar = null, content = null; + if (pane0 && pane0.element) { + tabBar = pane0.element.querySelector('.tab-bar'); + content = pane0.element.querySelector('.content-area'); } - if (content) { - content.id = 'content-area'; - wrapper.appendChild(content); - } - // Restore tab-list ID + 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'; + // 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(); @@ -306,7 +803,6 @@ const PaneManager = { 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; @@ -321,13 +817,11 @@ const PaneManager = { 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); }, @@ -339,14 +833,9 @@ const PaneManager = { }, _applyResize() { - this.panes.forEach(p => { - if (p.width && p.element) p.element.style.flexBasis = p.width; - }); + this.panes.forEach(p => { if (p.width && p.element) p.element.style.flexBasis = p.width; }); }, - // ------------------------------------------------------------------ - // Highlight - // ------------------------------------------------------------------ _highlightActive() { this.panes.forEach((p, i) => { if (p.element) p.element.classList.toggle('pane-active', i === this.activePaneId); @@ -354,7 +843,7 @@ const PaneManager = { }, // ------------------------------------------------------------------ - // Persistence + // Persistence — save per-pane tab state // ------------------------------------------------------------------ _persist() { if (this.panes.length <= 1) { @@ -363,30 +852,48 @@ const PaneManager = { } const data = { layout: this.layout, - panes: this.panes.map(p => ({ id: p.id, activeTab: p.activeTab, width: p.width })), + 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)); }, - - // ------------------------------------------------------------------ - // Queries - // ------------------------------------------------------------------ - isSplit() { - return this.panes.length >= 2; - }, - - getPaneCount() { - return Math.max(this.panes.length, 1); - }, }; -// ------------------------------------------------------------------ +// ============================================================================= +// 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 — Ctrl+Alt+\ for split right, Ctrl+Alt+Shift+\ for split down + // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.altKey && !e.shiftKey && e.key === '\\') { e.preventDefault(); @@ -396,15 +903,20 @@ if (typeof window !== 'undefined') { e.preventDefault(); PaneManager.splitDown(); } - // Ctrl+Alt+W — close active pane if (e.ctrlKey && e.altKey && !e.shiftKey && (e.key === 'w' || e.key === 'W') && PaneManager.isSplit()) { e.preventDefault(); PaneManager.closeActivePane(); } }); - // Init if saved state exists - setTimeout(() => PaneManager.init(), 100); + // Init — wait for DOM + singleton TabManager to be ready + setTimeout(() => { + // Store singleton TabManager reference + if (typeof TabManager !== 'undefined') + window._singletonTabManager = TabManager; + PaneManager.init(); + }, 150); }); } +export { PaneManager, getActiveTabManager, getActiveContentArea, createPaneTabManager }; export default PaneManager; diff --git a/frontend/js/ui.js b/frontend/js/ui.js index f76086e..b30262f 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -1877,70 +1877,19 @@ export const TabManager = { _tabList: null, _dirtyTabs: new Set(), - // Expose on window for PaneManager cross-module communication - _expose() { - window.TabManager = this; - }, - - // Pane-aware helpers - _getContentArea() { - if (window.PaneManager && window.PaneManager.isSplit()) { - return window.PaneManager.getActiveContentArea() || document.getElementById('content-area'); - } - return document.getElementById('content-area'); - }, - _getTabBar() { - if (window.PaneManager && window.PaneManager.isSplit()) { - return window.PaneManager.getActiveTabBar() || document.getElementById('tab-bar'); - } - return document.getElementById('tab-bar'); - }, - _getTabList() { - if (window.PaneManager && window.PaneManager.isSplit()) { - return window.PaneManager.getActiveTabList() || document.getElementById('tab-list'); - } - return document.getElementById('tab-list'); - }, - init() { - this._tabBar = this._getTabBar(); - this._tabList = this._getTabList(); - this._expose(); - }, - - /** Called by PaneManager when pane focus changes — updates DOM references */ - activatePaneTab(paneId) { - // Update DOM references to the newly active pane - this._tabBar = this._getTabBar(); - this._tabList = this._getTabList(); - // If this pane has a saved active tab, activate it - const pane = window.PaneManager && window.PaneManager.panes - ? window.PaneManager.panes.find(p => p.id === paneId) - : null; - if (pane && pane.activeTab && pane.activeTab !== this._activeTabId) { - this.activate(pane.activeTab); - } - if (!pane || !pane.activeTab) { - // New pane with no tab — show dashboard - this._showDashboard(); - } - }, - - /** Track which pane the active tab belongs to */ - _updatePaneActiveTab() { - if (window.PaneManager && window.PaneManager.isSplit() && this._activeTabId) { - const pane = window.PaneManager.panes.find(p => p.id === window.PaneManager.activePaneId); - if (pane) { - pane.activeTab = this._activeTabId; - window.PaneManager._persist(); - } - } + this._tabBar = document.getElementById("tab-bar"); + this._tabList = document.getElementById("tab-list"); }, /** Open a file as a preview tab (single-click). * Replaces any existing preview tab. If the file is already * open as a persistent tab, just activates it. */ async openPreview(vault, path) { + // Delegate to active pane's TabManager when split + if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { + return window.getActiveTabManager().openPreview(vault, path); + } const tabId = `${vault}::${path}`; // If already open as persistent tab, just activate it @@ -1977,6 +1926,10 @@ export const TabManager = { /** Convert a preview tab to a persistent tab (double-click). * If already persistent, opens a new duplicate (same file, different tab). */ async openPersistent(vault, path) { + // Delegate to active pane's TabManager when split + if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { + return window.getActiveTabManager().openPersistent(vault, path); + } const tabId = `${vault}::${path}`; // If it's already a preview tab, convert it to persistent @@ -2041,7 +1994,7 @@ export const TabManager = { this._activeTabId = tabId; this._renderTabs(); - this._updatePaneActiveTab(); + // Load tab content const cache = this._tabCache[tabId]; if (!cache) return; @@ -2051,7 +2004,7 @@ export const TabManager = { state.currentPath = cache.path; syncActiveFileTreeItem(cache.vault, cache.path); - const area = this._getContentArea(); + const area = document.getElementById("content-area"); if (cache.data) { // Use cached data @@ -2089,6 +2042,10 @@ export const TabManager = { /** Close a tab */ close(tabId) { + // Delegate to active pane's TabManager when split + if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { + return window.getActiveTabManager().close(tabId); + } const idx = this._tabs.findIndex(t => t.id === tabId); if (idx === -1) return; @@ -2111,6 +2068,9 @@ export const TabManager = { /** Close all tabs */ closeAll() { + if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { + return window.getActiveTabManager().closeAll(); + } this._tabs = []; this._tabCache = {}; this._dirtyTabs.clear(); @@ -2121,6 +2081,9 @@ export const TabManager = { /** Close tabs to the right */ closeRight(tabId) { + if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { + return window.getActiveTabManager().closeRight(tabId); + } const idx = this._tabs.findIndex(t => t.id === tabId); if (idx === -1) return; const toClose = this._tabs.slice(idx + 1); @@ -2137,6 +2100,9 @@ export const TabManager = { /** Close other tabs */ closeOthers(tabId) { + if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { + return window.getActiveTabManager().closeOthers(tabId); + } const tab = this._tabs.find(t => t.id === tabId); if (!tab) return; for (const t of this._tabs) { @@ -2163,7 +2129,7 @@ export const TabManager = { const cache = this._tabCache[this._activeTabId]; if (!cache) return; - const area = this._getContentArea(); + const area = document.getElementById("content-area"); const rendered = document.getElementById("file-rendered-content"); cache.scrollTop = area.scrollTop; @@ -2208,7 +2174,7 @@ export const TabManager = { }, _showDashboard() { - const area = this._getContentArea(); + const area = document.getElementById("content-area"); // Save dashboard DOM before clearing (it may have been removed from DOM by renderFile) let dashboard = document.getElementById("dashboard-home"); if (!dashboard) { diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 187fef3..a98c6f8 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -12,10 +12,12 @@ import { openShareDialog } from './config.js'; import { cacheViewedFile, getCachedFile } from './offline.js'; import { t } from './i18n.js'; -// Pane-aware content area helper +// Pane-aware content area helper — delegates to PaneManager when split function getContentArea() { - if (window.PaneManager && window.PaneManager.isSplit()) { - return window.PaneManager.getActiveContentArea(); + // Override set by PaneTabManager._renderFileInPane for cross-module rendering + if (window._activePaneContentArea) return window._activePaneContentArea; + if (window.PaneManager && window.PaneManager.isSplit() && window.getActiveContentArea) { + return window.getActiveContentArea(); } return document.getElementById('content-area'); } diff --git a/tests/test_pane_manager.py b/tests/test_pane_manager.py index bd1c357..7fa72c1 100644 --- a/tests/test_pane_manager.py +++ b/tests/test_pane_manager.py @@ -76,14 +76,16 @@ class TestPaneManagerStaticAssets: # Only the fallback inside getContentArea should remain assert len(direct_refs) <= 2, f"Too many direct content-area refs: {direct_refs}" - def test_ui_tab_manager_has_pane_helpers(self): - """TabManager in ui.js has _getContentArea, _getTabBar, _getTabList.""" + def test_ui_tab_manager_has_delegation(self): + """TabManager in ui.js delegates to getActiveTabManager() when split.""" path = PROJECT_ROOT / "frontend" / "js" / "ui.js" content = path.read_text() - assert "_getContentArea()" in content - assert "_getTabBar()" in content - assert "_getTabList()" in content - assert "activatePaneTab(" in content + assert "getActiveTabManager" in content + assert "PaneManager.isSplit()" in content + # Must have delegation in entry-point methods + assert "window.getActiveTabManager().openPreview" in content + assert "window.getActiveTabManager().openPersistent" in content + assert "window.getActiveTabManager().close(" in content def test_ui_context_menu_has_split_actions(self): """Tab context menu includes 'Diviser à droite' and 'Diviser en bas'."""