feat: PDF support + Split View (P1 roadmap items)
#74 — Support PDF complet: - backend/pdf_reader.py — pymupdf/pypdf text extraction - .pdf added to SUPPORTED_EXTENSIONS in indexer.py - PDF-aware file view API endpoint (metadata, text preview) - Streaming endpoint /api/file/{vault}/pdf/stream - Frontend PDF viewer with iframe, toolbar, download - CSS for PDF viewer container #75 — Split View (PaneManager): - New module frontend/js/pane-manager.js — ES module - 1-4 pane CSS grid with resize handles - Split right/down via keyboard (Ctrl+Alt+\) or PaneManager API - Persistence in localStorage (obsigate-panes) - Collapse to single pane, per-pane active state 285 tests passent
This commit is contained in:
@@ -18,6 +18,7 @@ import { initCommandPalette } from './palette.js';
|
||||
import { initMobileToolbar } from './mobile-toolbar.js';
|
||||
import { initDragDrop } from './dragdrop.js';
|
||||
import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
|
||||
import PaneManager from './pane-manager.js';
|
||||
|
||||
// =========================================================================
|
||||
// Initialization — mirrors the original app.js init() ordering
|
||||
@@ -58,6 +59,7 @@ async function init() {
|
||||
UI.FindInPageManager.init();
|
||||
UI.ContextMenuManager.init();
|
||||
UI.TabManager.init();
|
||||
PaneManager.init();
|
||||
initCommandPalette();
|
||||
initMobileToolbar();
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const PANE_MAX = 4;
|
||||
|
||||
const PaneManager = {
|
||||
panes: [],
|
||||
activePaneId: 0,
|
||||
_paneCounter: 1,
|
||||
layout: 'horizontal',
|
||||
|
||||
init() {
|
||||
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.forEach((p, i) => {
|
||||
this.panes[i].activeTab = p.activeTab;
|
||||
this.panes[i].width = p.width;
|
||||
});
|
||||
this.activePaneId = data.panes[0].id;
|
||||
this._applyResize();
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
},
|
||||
|
||||
_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;
|
||||
|
||||
// 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 (n >= 3) grid.style.gridTemplateColumns = '1fr 4px 1fr';
|
||||
if (n === 4) grid.style.gridTemplateRows = '1fr 4px 1fr';
|
||||
|
||||
wrapper.innerHTML = '';
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
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);
|
||||
this.panes.push(pane);
|
||||
if (i < n - 1) {
|
||||
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, i, i + 1));
|
||||
handle.addEventListener('dblclick', () => this._resetResize(i, i + 1));
|
||||
grid.appendChild(handle);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
// Pane 0 reuses existing DOM
|
||||
container.appendChild(reuseTabBar);
|
||||
container.appendChild(reuseContent);
|
||||
reuseContent.id = 'content-area-0';
|
||||
} else {
|
||||
const tabBar = document.createElement('div');
|
||||
tabBar.className = 'tab-bar pane-tab-bar';
|
||||
tabBar.id = 'tab-bar-' + idx;
|
||||
tabBar.innerHTML = '<div class="tab-list" id="tab-list-' + idx + '"></div>';
|
||||
const content = document.createElement('main');
|
||||
content.className = 'content-area pane-content';
|
||||
content.id = 'content-area-' + idx;
|
||||
container.appendChild(tabBar);
|
||||
container.appendChild(content);
|
||||
}
|
||||
|
||||
container.addEventListener('click', () => {
|
||||
this.activePaneId = idx;
|
||||
this._highlightActive();
|
||||
});
|
||||
|
||||
grid.appendChild(container);
|
||||
return { id: idx, element: container, width: 100 / this.panes.length + '%' };
|
||||
},
|
||||
|
||||
_highlightActive() {
|
||||
this.panes.forEach((p, i) => {
|
||||
p.element.classList.toggle('pane-active', i === this.activePaneId);
|
||||
});
|
||||
},
|
||||
|
||||
splitRight() {
|
||||
if (this.panes.length >= PANE_MAX) return;
|
||||
if (this.panes.length === 0) {
|
||||
// First split: promote single pane to grid
|
||||
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;
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'pane-resize-handle';
|
||||
handle.addEventListener('mousedown', (e) => this._onResizeStart(e, n - 2, n - 1));
|
||||
grid.appendChild(handle);
|
||||
const pane = this._createPane(n - 1, grid, null, null);
|
||||
this.panes.push(pane);
|
||||
this.activePaneId = n - 1;
|
||||
this._highlightActive();
|
||||
if (n <= 2) grid.style.gridTemplateColumns = '1fr 4px 1fr';
|
||||
else if (n === 4) grid.style.gridTemplateRows = '1fr 4px 1fr';
|
||||
},
|
||||
|
||||
closePane(paneId) {
|
||||
if (this.panes.length <= 1) return;
|
||||
const idx = this.panes.findIndex(p => p.id === paneId);
|
||||
if (idx < 0) return;
|
||||
// Remove pane and its resize handles
|
||||
this.panes[idx].element.remove();
|
||||
const handles = document.querySelectorAll('.pane-resize-handle');
|
||||
if (handles[idx]) handles[idx].remove();
|
||||
else if (handles[idx - 1]) handles[idx - 1].remove();
|
||||
this.panes.splice(idx, 1);
|
||||
if (this.panes.length <= 1) {
|
||||
this._collapseToSingle();
|
||||
} else {
|
||||
this.activePaneId = Math.min(this.activePaneId, this.panes.length - 1);
|
||||
this._highlightActive();
|
||||
}
|
||||
this._persist();
|
||||
},
|
||||
|
||||
_collapseToSingle() {
|
||||
const grid = document.querySelector('.pane-grid');
|
||||
if (!grid) return;
|
||||
const wrapper = grid.parentElement;
|
||||
const pane0 = this.panes[0];
|
||||
wrapper.innerHTML = '';
|
||||
const tabBar = pane0.element.querySelector('.tab-bar') || document.getElementById('tab-bar-0');
|
||||
const content = pane0.element.querySelector('.content-area') || document.getElementById('content-area-0');
|
||||
if (tabBar) wrapper.appendChild(tabBar);
|
||||
if (content) wrapper.appendChild(content);
|
||||
this.panes = [];
|
||||
this.activePaneId = 0;
|
||||
},
|
||||
|
||||
_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) {
|
||||
this.panes[leftIdx].element.style.flexBasis = '50%';
|
||||
this.panes[rightIdx].element.style.flexBasis = '50%';
|
||||
this._persist();
|
||||
},
|
||||
|
||||
_applyResize() {
|
||||
this.panes.forEach(p => {
|
||||
if (p.width) p.element.style.flexBasis = p.width;
|
||||
});
|
||||
},
|
||||
|
||||
_persist() {
|
||||
if (this.panes.length <= 1) {
|
||||
localStorage.removeItem('obsigate-panes');
|
||||
return;
|
||||
}
|
||||
const data = {
|
||||
layout: this.layout,
|
||||
panes: this.panes.map(p => ({ id: p.id, activeTab: p.activeTab, width: p.width })),
|
||||
};
|
||||
localStorage.setItem('obsigate-panes', JSON.stringify(data));
|
||||
},
|
||||
|
||||
getActiveContentArea() {
|
||||
if (this.panes.length < 2) return document.getElementById('content-area');
|
||||
return document.getElementById('content-area-' + this.activePaneId) || document.getElementById('content-area-0');
|
||||
},
|
||||
|
||||
isSplit() {
|
||||
return this.panes.length >= 2;
|
||||
},
|
||||
};
|
||||
|
||||
// Export and auto-init
|
||||
if (typeof window !== 'undefined') {
|
||||
window.PaneManager = PaneManager;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Register keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.altKey && !e.shiftKey && e.key === '\\') {
|
||||
e.preventDefault();
|
||||
PaneManager.splitRight();
|
||||
}
|
||||
if (e.ctrlKey && e.altKey && e.shiftKey && e.key === '\\') {
|
||||
e.preventDefault();
|
||||
PaneManager.splitDown();
|
||||
}
|
||||
});
|
||||
// Initialize if saved state exists
|
||||
setTimeout(() => PaneManager.init(), 500);
|
||||
});
|
||||
}
|
||||
|
||||
export default PaneManager;
|
||||
@@ -402,6 +402,27 @@ async function renderBacklinksPanel(vault, path, container) {
|
||||
export function renderFile(data) {
|
||||
const area = document.getElementById("content-area");
|
||||
|
||||
// Handle PDF files — render in iframe
|
||||
if (data.is_pdf) {
|
||||
const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf/stream?path=${encodeURIComponent(data.path)}`;
|
||||
const pages = data.pdf_metadata && data.pdf_metadata.pages ? data.pdf_metadata.pages : '?';
|
||||
area.innerHTML = `
|
||||
<div class="pdf-viewer-container">
|
||||
<div class="pdf-toolbar">
|
||||
<span class="pdf-info">PDF — ${pages} pages</span>
|
||||
<button class="btn-action" onclick="window.open('${pdfUrl}', '_blank')">
|
||||
<i data-lucide="external-link" style="width:14px;height:14px"></i> Plein écran
|
||||
</button>
|
||||
<button class="btn-action" onclick="window.open('/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}', '_blank')">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
|
||||
</button>
|
||||
</div>
|
||||
<iframe src="${pdfUrl}" class="pdf-iframe" title="${escapeHtml(data.title)}"></iframe>
|
||||
</div>`;
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle unsupported (binary) files
|
||||
if (data.unsupported) {
|
||||
const sizeStr = data.size_bytes
|
||||
|
||||
@@ -8428,3 +8428,60 @@ body.popup-mode .content-area {
|
||||
font-size: 0.62rem; color: var(--text-muted); opacity: 0.5;
|
||||
}
|
||||
.about-copy code { font-family: var(--mono); font-size: 0.58rem; }
|
||||
|
||||
/* ── PDF Viewer ── */
|
||||
.pdf-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.pdf-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--surface1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pdf-info {
|
||||
flex: 1;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.pdf-iframe {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
border: none;
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
/* ── PaneManager — Split View ── */
|
||||
.pane-grid {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pane-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.pane-container.pane-active {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
z-index: 1;
|
||||
}
|
||||
.pane-tab-bar { flex-shrink: 0; }
|
||||
.pane-content { flex: 1; overflow-y: auto; }
|
||||
.pane-resize-handle {
|
||||
background: var(--border);
|
||||
transition: background 150ms;
|
||||
min-width: 4px;
|
||||
min-height: 4px;
|
||||
z-index: 2;
|
||||
}
|
||||
.pane-resize-handle:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user