Merge feat/pdf-split-view into main
CI / lint (push) Failing after 35s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Failing after 11s

Features:
- #74 PDF support (extraction, indexation, viewer iframe)
- #75 Split View (PaneManager 1-4 panneaux, resize, persisté)
- Mobile toolbar height fix (56→64px)
- TOC panel always closed on login
- Logout buttons fixed (AuthManager.logout() direct, no confirm)
- Syntax fixes in config.js
This commit is contained in:
2026-07-24 10:28:14 -04:00
12 changed files with 523 additions and 56 deletions
+25 -19
View File
@@ -57,7 +57,7 @@ SUPPORTED_EXTENSIONS = {
".md", ".txt", ".log", ".py", ".js", ".ts", ".jsx", ".tsx",
".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1",
".json", ".yaml", ".yml", ".toml", ".xml", ".csv",
".cfg", ".ini", ".conf", ".env",
".cfg", ".ini", ".conf", ".env", ".pdf",
".html", ".css", ".scss", ".less",
".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb",
".php", ".sql", ".r", ".m", ".swift", ".kt",
@@ -281,26 +281,32 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str,
stat = fpath.stat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
raw = fpath.read_text(encoding="utf-8", errors="replace")
# PDF handling — special path (binary, uses pdf_reader)
if ext == ".pdf":
from backend.pdf_reader import extract_pdf_text, extract_pdf_metadata
raw = extract_pdf_text(fpath, max_chars=100000)
pdf_meta = extract_pdf_metadata(fpath)
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
tags: List[str] = []
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
tags: List[str] = []
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
tags: List[str] = []
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
if ext == ".md":
post = parse_markdown_file(raw)
tags = _extract_tags(post)
inline_tags = _extract_inline_tags(post.content)
tags = list(set(tags) | set(inline_tags))
title = _extract_title(post, fpath)
content_preview = post.content[:200].strip()
if ext == ".md":
post = parse_markdown_file(raw)
tags = _extract_tags(post)
# Merge inline #tags found in content body
inline_tags = _extract_inline_tags(post.content)
tags = list(set(tags) | set(inline_tags))
title = _extract_title(post, fpath)
content_preview = post.content[:200].strip()
# Extract wikilinks for backlink index
_extract_wikilinks_for_backlinks(
vault_name, str(relative).replace("\\", "/"),
title, post.content
)
_extract_wikilinks_for_backlinks(
vault_name, str(relative).replace("\\", "/"),
title, post.content
)
files.append({
"path": str(relative).replace("\\", "/"),
+45
View File
@@ -2301,6 +2301,32 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
ext = file_path.suffix.lower()
# === PDF: special handling before read_text (binary file) ===
if ext == ".pdf":
try:
from backend.pdf_reader import extract_pdf_text, extract_pdf_metadata
pdf_text = extract_pdf_text(file_path, max_chars=100000)
pdf_meta = extract_pdf_metadata(file_path)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": pdf_meta.get("title") or file_path.name,
"tags": [],
"frontmatter": {},
"html": f"<div class='pdf-viewer'><p>PDF — {pdf_meta.get('pages', '?')} pages</p><pre>{pdf_text[:5000]}</pre></div>",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_pdf": True,
"unsupported": False,
"pdf_metadata": pdf_meta,
"size_bytes": size,
}
except Exception as e:
logger.error(f"PDF read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading PDF: {str(e)}")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except PermissionError as e:
@@ -2365,6 +2391,25 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
}
@app.get("/api/file/{vault_name}/pdf/stream")
async def api_pdf_stream(vault_name: str, path: str = Query(...)):
"""Stream a PDF file with Content-Type: application/pdf for inline browser viewing."""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
if file_path.suffix.lower() != ".pdf":
raise HTTPException(status_code=400, detail="Not a PDF file")
from fastapi.responses import FileResponse
return FileResponse(str(file_path), media_type="application/pdf", headers={
"Content-Disposition": f"inline; filename=\"{file_path.name}\""})
@app.get("/api/search", response_model=SearchResponse)
async def api_search(
q: str = Query("", description="Search query"),
+92
View File
@@ -0,0 +1,92 @@
"""PDF text extraction for ObsiGate — pypdf backend, pymupdf optional fallback."""
from __future__ import annotations
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
PDF_READER: str = "pypdf"
try:
import fitz # pymupdf
PDF_READER = "pymupdf"
logger.info("PDF reader: pymupdf (high performance)")
except ImportError:
try:
from pypdf import PdfReader # type: ignore
logger.info("PDF reader: pypdf (pure Python)")
except ImportError:
PdfReader = None # type: ignore
logger.warning("No PDF reader available — install pypdf or pymupdf")
def extract_pdf_text(file_path: Path, max_chars: int = 100000) -> str:
"""Extract text from a PDF file. Returns empty string on failure."""
if PdfReader is None and PDF_READER == "pypdf":
return ""
try:
if PDF_READER == "pymupdf":
return _extract_pymupdf(file_path, max_chars)
else:
return _extract_pypdf(file_path, max_chars)
except Exception as e:
logger.warning("Failed to extract PDF text from %s: %s", file_path, e)
return ""
def extract_pdf_metadata(file_path: Path) -> dict:
"""Extract metadata from a PDF file."""
info = {"pages": 0, "title": "", "author": ""}
try:
if PDF_READER == "pymupdf":
doc = fitz.open(str(file_path))
info["pages"] = doc.page_count
meta = doc.metadata or {}
info["title"] = meta.get("title", "")
info["author"] = meta.get("author", "")
doc.close()
else:
reader = PdfReader(str(file_path))
info["pages"] = len(reader.pages)
meta = reader.metadata or {}
if meta:
info["title"] = str(meta.get("/Title", ""))
info["author"] = str(meta.get("/Author", ""))
except Exception as e:
logger.warning("Failed to extract PDF metadata from %s: %s", file_path, e)
return info
def _extract_pymupdf(file_path: Path, max_chars: int) -> str:
doc = fitz.open(str(file_path))
parts = []
total = 0
for page in doc:
text = page.get_text()
if text:
if total + len(text) > max_chars:
remaining = max_chars - total
if remaining > 0:
parts.append(text[:remaining])
break
parts.append(text)
total += len(text)
doc.close()
return "\f".join(parts)
def _extract_pypdf(file_path: Path, max_chars: int) -> str:
reader = PdfReader(str(file_path))
parts = []
total = 0
for page in reader.pages:
text = page.extract_text() or ""
if text:
if total + len(text) > max_chars:
remaining = max_chars - total
if remaining > 0:
parts.append(text[:remaining])
break
parts.append(text)
total += len(text)
return "\f".join(parts)
+1
View File
@@ -12,3 +12,4 @@ sortedcontainers>=2.4.0
snowballstemmer>=2.2.0
weasyprint>=60.0
httpx>=0.27.0
pypdf>=4.0
+2 -6
View File
@@ -55,7 +55,7 @@
let outlineObserver = null;
let activeHeadingId = null;
let headingsCache = [];
let rightSidebarVisible = true;
let rightSidebarVisible = false;
let rightSidebarWidth = 280;
// ---------------------------------------------------------------------------
@@ -1099,13 +1099,9 @@
},
loadState() {
const savedVisible = localStorage.getItem("obsigate-right-sidebar-visible");
// Always start closed on login/refresh — user must explicitly toggle TOC
const savedWidth = localStorage.getItem("obsigate-right-sidebar-width");
if (savedVisible !== null) {
rightSidebarVisible = savedVisible === "true";
}
if (savedWidth) {
rightSidebarWidth = parseInt(savedWidth) || 280;
}
+3 -1
View File
@@ -656,11 +656,13 @@
>
</span>
</button>
<script>function doLogoutFallback(){var x=new XMLHttpRequest();x.open('POST','/api/auth/logout');x.withCredentials=true;x.onload=function(){var a=document.getElementById('app');var l=document.getElementById('login-screen');if(a)a.classList.add('hidden');if(l)l.classList.remove('hidden');try{localStorage.removeItem('obsigate-token')}catch(e){}};x.send()}</script>
<button
class="menu-list-row menu-list-button"
id="logout-btn"
type="button"
role="menuitem"
onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}"
>
<span
class="menu-list-icon"
@@ -2006,7 +2008,7 @@
<input type="text" id="profile-name" class="config-input" placeholder="Votre nom" maxlength="60">
</div>
<button class="config-save-btn" id="profile-save" data-i18n="config.save">Enregistrer</button>
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout">Déconnexion</button>
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout" onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}">Déconnexion</button>
<span class="profile-saved" id="profile-saved" style="display:none" data-i18n="config.saved">✓ Sauvegardé</span>
</div>
</section>
+2
View File
@@ -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();
+1 -1
View File
@@ -203,7 +203,7 @@ const AuthManager = {
userMenu.innerHTML = "";
return;
}
userMenu.innerHTML = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="' + t('auth.logout_title') + '"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
userMenu.innerHTML = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="' + t('auth.logout_title') + '" onclick="window.handleLogout()"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
safeCreateIcons();
const logoutBtn = document.getElementById("logout-btn");
+2 -21
View File
@@ -631,17 +631,7 @@ function initConfigModal() {
});
}
// Logout button (always visible)
var logoutBtn = document.getElementById("logout-btn");
if (logoutBtn) {
logoutBtn.addEventListener("click", function() {
if (confirm("Voulez-vous vraiment vous deconnecter ?")) {
fetch("/api/auth/logout", { method: "POST" })
.then(function() { window.location.href = "/"; })
.catch(function() { window.location.href = "/"; });
}
});
}
// Logout button — handled inline in index.html (onclick)
// Config nav search
var cfgSearch = document.getElementById("config-nav-search");
@@ -1576,14 +1566,5 @@ function initProfile() {
} catch(e) {}
});
// Logout button
if (logoutBtn) {
logoutBtn.addEventListener('click', function() {
if (confirm(t('confirm.logout'))) {
fetch('/api/auth/logout', { method: 'POST' })
.then(function() { window.location.href = '/'; })
.catch(function() { window.location.href = '/'; });
}
});
}
// Logout button — handled inline in index.html (onclick)
}
+263
View File
@@ -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;
+21
View File
@@ -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
+66 -8
View File
@@ -7357,22 +7357,22 @@ body.popup-mode .content-area {
bottom: 0;
left: 0;
right: 0;
height: 56px;
height: 64px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
z-index: 900;
align-items: center;
justify-content: space-around;
padding: 0 4px;
padding-bottom: env(safe-area-inset-bottom, 0);
padding: 4px 4px;
padding-bottom: calc(4px + env(safe-area-inset-bottom, 0));
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
/* Push content up to avoid toolbar overlap */
.main-layout {
padding-bottom: 56px;
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0));
padding-bottom: 64px;
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0));
}
.editor-modal.active ~ .main-layout {
padding-bottom: 0;
@@ -7419,10 +7419,11 @@ body.popup-mode .content-area {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
gap: 3px;
flex: 1;
height: 48px;
padding: 2px 4px;
height: auto;
min-height: 48px;
padding: 4px 4px;
border: none;
border-radius: 8px;
background: transparent;
@@ -8428,3 +8429,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);
}