CI / lint (push) Failing after 9s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Successful in 18s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled
Three fixes for drag-and-drop in split view (#75): 1. Tree drag-to-split: edge zone detection now runs BEFORE the tree/tab dispatch, so dragging a sidebar file to the right/bottom edge of a single pane correctly creates a split and opens the file in the new pane (was: tree check returned early before edge zone logic). 2. Cross-pane transfer consistency: transferTab() now tracks pane count before the transfer — if closePane() fires inside transferTabTo() (source pane becomes empty), the DOM is rebuilt and the original targetTm reference is stale. After close, re-lookup the tab manager that actually holds the transferred tab before _renderTabs()+activate. 3. dragdrop.js flicker: onDragLeave() now checks relatedTarget instead of unconditionally clearing highlights on every child boundary.
1429 lines
56 KiB
JavaScript
1429 lines
56 KiB
JavaScript
/**
|
||
* PaneManager + PaneTabManager — Multi-pane split view for ObsiGate (v2.0.0-dev)
|
||
*
|
||
* Architecture:
|
||
* Single-pane (default): uses existing singleton TabManager + #content-area / #tab-bar.
|
||
* Multi-pane: builds .pane-grid inside .content-wrapper. Each pane gets its OWN
|
||
* independent PaneTabManager instance with its own tabs, tab bar, and content area.
|
||
* Drag & drop tabs between panes.
|
||
* Persisted to localStorage.
|
||
*/
|
||
import { state } from './state.js';
|
||
import { api } from './auth.js';
|
||
import { escapeHtml, getFileIcon, safeCreateIcons } from './utils.js';
|
||
|
||
const PANE_MAX = 4;
|
||
|
||
// =============================================================================
|
||
// PaneTabManager factory — one per pane
|
||
// =============================================================================
|
||
function createPaneTabManager(paneId) {
|
||
const DEFAULT_BAR = document.getElementById('tab-bar');
|
||
const DEFAULT_LIST = document.getElementById('tab-list');
|
||
const DEFAULT_AREA = document.getElementById('content-area');
|
||
|
||
const tm = {
|
||
paneId,
|
||
_tabs: [],
|
||
_activeTabId: null,
|
||
_previewTabId: null,
|
||
_tabCache: {},
|
||
_dirtyTabs: new Set(),
|
||
_tabBar: paneId === 0 ? DEFAULT_BAR : null,
|
||
_tabList: paneId === 0 ? DEFAULT_LIST : null,
|
||
_contentArea: paneId === 0 ? DEFAULT_AREA : null,
|
||
|
||
// ------------------------------------------------------------------
|
||
// Init — bind to DOM
|
||
// ------------------------------------------------------------------
|
||
init(tabBar, tabList, contentArea) {
|
||
if (tabBar) this._tabBar = tabBar;
|
||
if (tabList) this._tabList = tabList;
|
||
if (contentArea) this._contentArea = contentArea;
|
||
if (!this._tabBar)
|
||
this._tabBar = document.getElementById('tab-bar-' + paneId);
|
||
if (!this._tabList)
|
||
this._tabList = document.getElementById('tab-list-' + paneId);
|
||
if (!this._contentArea)
|
||
this._contentArea = document.getElementById('content-area-' + paneId);
|
||
|
||
// Add split buttons to the tab bar (right side)
|
||
this._ensureSplitButtons();
|
||
},
|
||
|
||
_ensureSplitButtons() {
|
||
if (!this._tabBar) return;
|
||
// Remove existing split buttons if any
|
||
const existing = this._tabBar.querySelector('.tab-split-buttons');
|
||
if (existing) existing.remove();
|
||
|
||
const btns = document.createElement('div');
|
||
btns.className = 'tab-split-buttons';
|
||
btns.innerHTML = `
|
||
<button class="tab-split-btn" title="Diviser à droite (Ctrl+Alt+\\)" data-action="split-right">
|
||
<i data-lucide="panel-right" style="width:13px;height:13px"></i>
|
||
</button>
|
||
<button class="tab-split-btn" title="Diviser en bas (Ctrl+Alt+Shift+\\)" data-action="split-down">
|
||
<i data-lucide="panel-bottom" style="width:13px;height:13px"></i>
|
||
</button>
|
||
`;
|
||
btns.querySelector('[data-action="split-right"]').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
if (window.PaneManager && window.PaneManager._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;
|
||
if (this._activeTabId && this._tabCache[this._activeTabId])
|
||
this._saveCurrentTabState();
|
||
|
||
this._activeTabId = tabId;
|
||
this._renderTabs();
|
||
|
||
const cache = this._tabCache[tabId];
|
||
if (!cache) return;
|
||
|
||
state.currentVault = cache.vault;
|
||
state.currentPath = cache.path;
|
||
// syncActiveFileTreeItem is imported below to avoid circular deps
|
||
if (typeof syncActiveFileTreeItem !== 'undefined')
|
||
syncActiveFileTreeItem(cache.vault, cache.path);
|
||
|
||
const area = this._contentArea || DEFAULT_AREA;
|
||
if (cache.data) {
|
||
this._restoreTabContent(cache, area);
|
||
} else {
|
||
area.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Chargement...</div>';
|
||
try {
|
||
const data = await api(`/api/file/${encodeURIComponent(cache.vault)}?path=${encodeURIComponent(cache.path)}`);
|
||
cache.data = data;
|
||
cache.title = data.title;
|
||
this._renderFileInPane(cache.data, area);
|
||
if (cache.sourceView) await this._toggleSourceView(cache, area);
|
||
if (cache.scrollTop) area.scrollTop = cache.scrollTop;
|
||
} catch (err) {
|
||
area.innerHTML = `<div style="padding:40px;text-align:center;color:var(--text-error)">Erreur: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
}
|
||
if (history.pushState)
|
||
history.pushState(null, '', `#/file/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`);
|
||
const db = document.getElementById('dashboard-home');
|
||
if (db) db.style.display = 'none';
|
||
},
|
||
|
||
close(tabId) {
|
||
const idx = this._tabs.findIndex(t => t.id === tabId);
|
||
if (idx === -1) return;
|
||
this._tabs.splice(idx, 1);
|
||
delete this._tabCache[tabId];
|
||
this._dirtyTabs.delete(tabId);
|
||
if (this._tabs.length === 0) {
|
||
this._activeTabId = null;
|
||
// If this pane has no more tabs and isn't the last pane, close it
|
||
if (window.PaneManager && window.PaneManager.isSplit() && window.PaneManager.panes.length > 1) {
|
||
this._renderTabs();
|
||
window.PaneManager.closePane(this.paneId);
|
||
return;
|
||
}
|
||
this._showDashboard();
|
||
if (this._tabBar) this._tabBar.hidden = true;
|
||
} else if (this._activeTabId === tabId) {
|
||
const newIdx = Math.min(idx, this._tabs.length - 1);
|
||
this.activate(this._tabs[newIdx].id);
|
||
}
|
||
this._renderTabs();
|
||
},
|
||
|
||
closeAll() {
|
||
this._tabs = [];
|
||
this._tabCache = {};
|
||
this._dirtyTabs.clear();
|
||
this._activeTabId = null;
|
||
this._showDashboard();
|
||
if (this._tabBar) this._tabBar.hidden = true;
|
||
},
|
||
|
||
closeRight(tabId) {
|
||
const idx = this._tabs.findIndex(t => t.id === tabId);
|
||
if (idx === -1) return;
|
||
const toClose = this._tabs.slice(idx + 1);
|
||
for (const tab of toClose) { delete this._tabCache[tab.id]; this._dirtyTabs.delete(tab.id); }
|
||
this._tabs = this._tabs.slice(0, idx + 1);
|
||
if (!this._tabs.find(t => t.id === this._activeTabId)) this.activate(tabId);
|
||
this._renderTabs();
|
||
},
|
||
|
||
closeOthers(tabId) {
|
||
const tab = this._tabs.find(t => t.id === tabId);
|
||
if (!tab) return;
|
||
for (const t of this._tabs) { if (t.id !== tabId) { delete this._tabCache[t.id]; this._dirtyTabs.delete(t.id); } }
|
||
this._tabs = [tab];
|
||
this.activate(tabId);
|
||
this._renderTabs();
|
||
},
|
||
|
||
moveTab(fromIdx, toIdx) {
|
||
if (fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return;
|
||
const tab = this._tabs.splice(fromIdx, 1)[0];
|
||
this._tabs.splice(toIdx, 0, tab);
|
||
this._renderTabs();
|
||
},
|
||
|
||
// ------------------------------------------------------------------
|
||
// Transfer a tab to another pane's TabManager
|
||
// ------------------------------------------------------------------
|
||
transferTabTo(tabId, targetTm) {
|
||
const idx = this._tabs.findIndex(t => t.id === tabId);
|
||
if (idx === -1) return false;
|
||
const tab = this._tabs[idx];
|
||
const cache = this._tabCache[tabId];
|
||
// Remove from source
|
||
this._tabs.splice(idx, 1);
|
||
if (this._previewTabId === tabId) this._previewTabId = null;
|
||
delete this._tabCache[tabId];
|
||
this._dirtyTabs.delete(tabId);
|
||
// Add to target
|
||
targetTm._tabs.push({ ...tab, preview: false });
|
||
targetTm._tabCache[tabId] = cache || { vault: tab.vault, path: tab.path, title: tab.name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: tab.icon };
|
||
// If source is now empty and not the last pane, close it
|
||
if (this._tabs.length === 0) {
|
||
this._activeTabId = null;
|
||
if (window.PaneManager && window.PaneManager.isSplit() && window.PaneManager.panes.length > 1) {
|
||
this._renderTabs();
|
||
window.PaneManager.closePane(this.paneId);
|
||
return true;
|
||
}
|
||
this._showDashboard();
|
||
if (this._tabBar) this._tabBar.hidden = true;
|
||
} else if (this._activeTabId === tabId) {
|
||
const newIdx = Math.min(idx, this._tabs.length - 1);
|
||
this.activate(this._tabs[newIdx].id);
|
||
}
|
||
this._renderTabs();
|
||
return true;
|
||
},
|
||
|
||
// ------------------------------------------------------------------
|
||
// Internal rendering
|
||
// ------------------------------------------------------------------
|
||
_renderFileInPane(data, area) {
|
||
// Set temporary override so getContentArea() returns this pane's area
|
||
const prev = window._activePaneContentArea;
|
||
window._activePaneContentArea = area;
|
||
// Use dynamic import to call viewer.js renderFile in the right context
|
||
import('./viewer.js').then(m => {
|
||
m.renderFile(data);
|
||
window._activePaneContentArea = prev;
|
||
}).catch(() => {
|
||
window._activePaneContentArea = prev;
|
||
});
|
||
},
|
||
|
||
_saveCurrentTabState() {
|
||
const cache = this._tabCache[this._activeTabId];
|
||
if (!cache) return;
|
||
const area = this._contentArea || DEFAULT_AREA;
|
||
const rendered = document.getElementById('file-rendered-content');
|
||
cache.scrollTop = area ? area.scrollTop : 0;
|
||
cache.sourceView = rendered ? rendered.style.display === 'none' : false;
|
||
},
|
||
|
||
_restoreTabContent(cache, area) {
|
||
this._renderFileInPane(cache.data, area);
|
||
if (cache.sourceView) this._restoreSourceView(cache, area);
|
||
if (cache.scrollTop && area) area.scrollTop = cache.scrollTop;
|
||
},
|
||
|
||
async _toggleSourceView(cache, area) {
|
||
const rendered = document.getElementById('file-rendered-content');
|
||
const raw = document.getElementById('file-raw-content');
|
||
if (!rendered || !raw) return;
|
||
if (!cache.rawSource) {
|
||
const rawData = await api(`/api/file/${encodeURIComponent(cache.vault)}/raw?path=${encodeURIComponent(cache.path)}`);
|
||
cache.rawSource = rawData.raw;
|
||
}
|
||
raw.textContent = cache.rawSource;
|
||
rendered.style.display = 'none';
|
||
raw.style.display = 'block';
|
||
},
|
||
|
||
_restoreSourceView(cache, area) {
|
||
requestAnimationFrame(() => {
|
||
const rendered = document.getElementById('file-rendered-content');
|
||
const raw = document.getElementById('file-raw-content');
|
||
if (rendered && raw && cache.rawSource) {
|
||
raw.textContent = cache.rawSource;
|
||
rendered.style.display = 'none';
|
||
raw.style.display = 'block';
|
||
}
|
||
});
|
||
},
|
||
|
||
_showDashboard() {
|
||
const area = this._contentArea || DEFAULT_AREA;
|
||
let dashboard = document.getElementById('dashboard-home');
|
||
if (!dashboard) {
|
||
// Dashboard was destroyed — rebuild
|
||
area.innerHTML = '';
|
||
if (typeof showWelcome !== 'undefined') showWelcome();
|
||
return;
|
||
}
|
||
area.innerHTML = '';
|
||
dashboard.style.display = '';
|
||
area.appendChild(dashboard);
|
||
if (typeof DashboardStatsWidget !== 'undefined') DashboardStatsWidget.load();
|
||
if (typeof DashboardConflictsWidget !== 'undefined') DashboardConflictsWidget.load();
|
||
if (history.pushState) history.pushState(null, '', '#');
|
||
},
|
||
|
||
// ------------------------------------------------------------------
|
||
// Render tab bar
|
||
// ------------------------------------------------------------------
|
||
_renderTabs() {
|
||
if (!this._tabList) return;
|
||
this._tabList.innerHTML = '';
|
||
if (this._tabs.length === 0) {
|
||
if (this._tabBar) this._tabBar.hidden = true;
|
||
return;
|
||
}
|
||
if (this._tabBar) this._tabBar.hidden = false;
|
||
|
||
this._tabs.forEach((tab, idx) => {
|
||
const el = document.createElement('div');
|
||
el.className = 'tab-item' + (tab.id === this._activeTabId ? ' active' : '') + (tab.preview ? ' preview' : '');
|
||
el.draggable = true;
|
||
el.dataset.tabId = tab.id;
|
||
el.dataset.index = idx;
|
||
el.dataset.paneId = String(this.paneId);
|
||
|
||
const iconEl = document.createElement('i');
|
||
iconEl.setAttribute('data-lucide', tab.icon);
|
||
iconEl.className = 'tab-icon';
|
||
iconEl.style.width = '14px';
|
||
iconEl.style.height = '14px';
|
||
el.appendChild(iconEl);
|
||
|
||
const nameEl = document.createElement('span');
|
||
nameEl.className = 'tab-name';
|
||
nameEl.textContent = tab.name;
|
||
nameEl.title = `${tab.vault}/${tab.path}`;
|
||
el.appendChild(nameEl);
|
||
|
||
const closeEl = document.createElement('span');
|
||
closeEl.className = 'tab-close';
|
||
closeEl.innerHTML = '<i data-lucide="x" style="width:12px;height:12px"></i>';
|
||
closeEl.addEventListener('click', (e) => { e.stopPropagation(); this.close(tab.id); });
|
||
el.appendChild(closeEl);
|
||
|
||
el.addEventListener('click', () => {
|
||
// Set this pane as active in PaneManager
|
||
if (window.PaneManager) window.PaneManager.setActivePane(this.paneId);
|
||
this.activate(tab.id);
|
||
});
|
||
el.addEventListener('dblclick', (e) => { e.preventDefault(); this.close(tab.id); });
|
||
el.addEventListener('mousedown', (e) => {
|
||
if (e.button === 1) { e.preventDefault(); this.close(tab.id); }
|
||
});
|
||
el.addEventListener('contextmenu', (e) => {
|
||
e.preventDefault();
|
||
this._showTabContextMenu(e.clientX, e.clientY, tab.id);
|
||
});
|
||
|
||
// Drag within same pane (+ cross-pane via container drop zone)
|
||
el.addEventListener('dragstart', (e) => {
|
||
const payload = JSON.stringify({
|
||
paneId: this.paneId, tabId: tab.id, index: idx
|
||
});
|
||
// text/plain is universally supported across all browsers for drag-drop
|
||
e.dataTransfer.setData('text/plain', payload);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
el.classList.add('dragging');
|
||
});
|
||
el.addEventListener('dragend', () => {
|
||
el.classList.remove('dragging');
|
||
document.querySelectorAll('.tab-drop-indicator').forEach(d => d.remove());
|
||
});
|
||
el.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
const rect = el.getBoundingClientRect();
|
||
const mid = rect.left + rect.width / 2;
|
||
document.querySelectorAll('.tab-drop-indicator').forEach(d => d.remove());
|
||
const indicator = document.createElement('div');
|
||
indicator.className = 'tab-drop-indicator';
|
||
if (e.clientX < mid) el.before(indicator);
|
||
else el.after(indicator);
|
||
});
|
||
el.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation(); // Prevent container handler from double-firing
|
||
document.querySelectorAll('.tab-drop-indicator').forEach(d => d.remove());
|
||
const raw = e.dataTransfer.getData('text/plain');
|
||
if (!raw) return;
|
||
try {
|
||
const dragData = JSON.parse(raw);
|
||
// Same pane — reorder
|
||
if (dragData.paneId === this.paneId) {
|
||
const rect = el.getBoundingClientRect();
|
||
const mid = rect.left + rect.width / 2;
|
||
const toIdx = e.clientX < mid ? idx : idx + 1;
|
||
if (dragData.index !== toIdx && dragData.index !== toIdx - 1)
|
||
this.moveTab(dragData.index, toIdx);
|
||
} else {
|
||
// Cross-pane transfer
|
||
if (window.PaneManager) {
|
||
window.PaneManager.transferTab(dragData.paneId, dragData.tabId, this.paneId);
|
||
}
|
||
}
|
||
} catch (e2) { /* ignore */ }
|
||
});
|
||
|
||
this._tabList.appendChild(el);
|
||
});
|
||
safeCreateIcons();
|
||
},
|
||
|
||
_showTabContextMenu(x, y, tabId) {
|
||
const existing = document.getElementById('tab-context-menu');
|
||
if (existing) existing.remove();
|
||
const menu = document.createElement('div');
|
||
menu.id = 'tab-context-menu';
|
||
menu.className = 'context-menu active';
|
||
menu.style.left = x + 'px';
|
||
menu.style.top = y + 'px';
|
||
const pm = window.PaneManager;
|
||
const isSplit = pm && pm.isSplit();
|
||
|
||
// Build "Déplacer vers..." submenu items
|
||
let moveToHtml = '';
|
||
if (isSplit) {
|
||
const otherPanes = pm.panes.filter(p => p.id !== this.paneId);
|
||
if (otherPanes.length > 0) {
|
||
moveToHtml = '<div class="context-menu-separator"></div><div class="context-menu-item context-menu-sub" data-action="moveTo"><i data-lucide="arrow-right-left" class="icon"></i><span>Déplacer vers...</span><span class="context-menu-arrow">▸</span></div>';
|
||
}
|
||
}
|
||
|
||
let closePaneHtml = '';
|
||
if (isSplit) {
|
||
closePaneHtml = `<div class="context-menu-separator"></div><div class="context-menu-item" data-action="closePane"><i data-lucide="panel-right-close" class="icon"></i><span>Fermer le panneau</span></div>`;
|
||
}
|
||
menu.innerHTML = `
|
||
<div class="context-menu-item" data-action="splitRight"><i data-lucide="layout-panel-right" class="icon"></i><span>Diviser à droite</span></div>
|
||
<div class="context-menu-item" data-action="splitDown"><i data-lucide="layout-panel-bottom" class="icon"></i><span>Diviser en bas</span></div>
|
||
<div class="context-menu-separator"></div>
|
||
<div class="context-menu-item" data-action="close"><i data-lucide="x" class="icon"></i><span>Fermer</span></div>
|
||
<div class="context-menu-item" data-action="closeOthers"><i data-lucide="x-circle" class="icon"></i><span>Fermer les autres</span></div>
|
||
<div class="context-menu-item" data-action="closeRight"><i data-lucide="arrow-right-circle" class="icon"></i><span>Fermer à droite</span></div>
|
||
<div class="context-menu-separator"></div>
|
||
<div class="context-menu-item" data-action="closeAll"><i data-lucide="trash-2" class="icon"></i><span>Fermer tout</span></div>
|
||
${moveToHtml}
|
||
${closePaneHtml}
|
||
`;
|
||
document.body.appendChild(menu);
|
||
safeCreateIcons();
|
||
|
||
// "Déplacer vers..." flyout submenu
|
||
const moveToEl = menu.querySelector('[data-action="moveTo"]');
|
||
if (moveToEl && isSplit) {
|
||
const otherPanes = pm.panes.filter(p => p.id !== this.paneId);
|
||
let submenu = null;
|
||
|
||
moveToEl.addEventListener('mouseenter', () => {
|
||
if (submenu) submenu.remove();
|
||
submenu = document.createElement('div');
|
||
submenu.className = 'context-menu active context-submenu';
|
||
const parentRect = moveToEl.getBoundingClientRect();
|
||
submenu.style.left = (parentRect.right + 4) + 'px';
|
||
submenu.style.top = parentRect.top + 'px';
|
||
submenu.innerHTML = otherPanes.map((p, i) =>
|
||
`<div class="context-menu-item" data-pane="${p.id}"><i data-lucide="panel-right" class="icon"></i><span>Panneau ${p.id + 1}</span></div>`
|
||
).join('');
|
||
document.body.appendChild(submenu);
|
||
safeCreateIcons();
|
||
submenu.addEventListener('click', (se) => {
|
||
const targetPane = parseInt(se.target.closest('.context-menu-item')?.dataset.pane);
|
||
if (!isNaN(targetPane)) {
|
||
pm.transferTab(this.paneId, tabId, targetPane);
|
||
menu.remove(); submenu.remove();
|
||
}
|
||
});
|
||
});
|
||
|
||
moveToEl.addEventListener('mouseleave', (e) => {
|
||
setTimeout(() => {
|
||
if (submenu && !submenu.matches(':hover')) { submenu.remove(); submenu = null; }
|
||
}, 100);
|
||
});
|
||
}
|
||
|
||
menu.addEventListener('click', (e) => {
|
||
const action = e.target.closest('.context-menu-item')?.dataset.action;
|
||
if (action === 'moveTo') return; // handled by hover
|
||
if (action === 'splitRight') {
|
||
if (pm && pm._hasAnyTab()) {
|
||
pm.splitRight();
|
||
// Transfer this specific tab to the new pane
|
||
const newPaneId = pm.panes.length - 1;
|
||
pm.transferTab(this.paneId, tabId, newPaneId);
|
||
menu.remove(); return;
|
||
}
|
||
}
|
||
else if (action === 'splitDown') {
|
||
if (pm && pm._hasAnyTab()) {
|
||
pm.splitDown();
|
||
const newPaneId = pm.panes.length - 1;
|
||
pm.transferTab(this.paneId, tabId, newPaneId);
|
||
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);
|
||
// Copy active tab to the new pane
|
||
this._copyActiveTabToNewPane();
|
||
} 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);
|
||
this._copyActiveTabToNewPane();
|
||
} 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);
|
||
|
||
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];
|
||
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();
|
||
// Transfer active tab to new pane
|
||
const tm = PaneManager.getActiveTabManager();
|
||
if (tm && tm._activeTabId) {
|
||
setTimeout(() => {
|
||
const newPaneId = PaneManager.panes.length - 1;
|
||
PaneManager.transferTab(PaneManager.activePaneId === newPaneId ? 0 : PaneManager.activePaneId, tm._activeTabId, newPaneId);
|
||
}, 200);
|
||
}
|
||
}
|
||
}
|
||
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;
|