Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Viewer PDF.js mode livre : double page côte à côte, navigation prev/next visuelle et clavier - Panneau signets (outline) avec tout déplier/replier, highlight du spread courant - Page 1 = couverture seule, puis paires 2-3, 4-5, etc. - Navigation clavier : flèches, espace, Home/End - Redimensionnement auto des canvas au resize - Fix hydratation SSR : bookData.init() sans await dans ChapterHeader et SongBadges - BookPdfReader : iframe vers /pdfjs/viewer.html au lieu du viewer natif - Script postinstall pour copier pdf.min.mjs depuis node_modules Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
547 lines
14 KiB
HTML
547 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>PDF Viewer</title>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
html, body {
|
||
height: 100%;
|
||
overflow: hidden;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: #1a1a1a;
|
||
color: #e0e0e0;
|
||
}
|
||
|
||
.viewer-layout {
|
||
display: flex;
|
||
height: 100%;
|
||
}
|
||
|
||
/* Outline panel */
|
||
.outline-panel {
|
||
width: 260px;
|
||
min-width: 260px;
|
||
background: #111;
|
||
border-right: 1px solid #2a2a2a;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
transition: width 0.25s, min-width 0.25s;
|
||
}
|
||
|
||
.outline-panel.collapsed {
|
||
width: 0;
|
||
min-width: 0;
|
||
border-right: none;
|
||
}
|
||
|
||
.outline-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.6rem 0.75rem;
|
||
border-bottom: 1px solid #2a2a2a;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.outline-title {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: #888;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.outline-actions { display: flex; gap: 0.25rem; }
|
||
|
||
.outline-btn {
|
||
background: none;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 4px;
|
||
color: #777;
|
||
cursor: pointer;
|
||
padding: 0.2rem 0.4rem;
|
||
font-size: 0.65rem;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.outline-btn:hover { border-color: #555; color: #ccc; }
|
||
|
||
.outline-tree {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 0.4rem;
|
||
}
|
||
|
||
.outline-tree::-webkit-scrollbar { width: 4px; }
|
||
.outline-tree::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
|
||
|
||
.outline-node { user-select: none; }
|
||
|
||
.outline-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 0.2rem;
|
||
padding: 0.3rem 0.4rem;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
color: #aaa;
|
||
font-size: 0.75rem;
|
||
line-height: 1.3;
|
||
transition: background 0.1s;
|
||
}
|
||
|
||
.outline-item:hover { background: #1e1e1e; color: #fff; }
|
||
.outline-item.active { background: #1a1207; color: #e8a040; }
|
||
|
||
.outline-item .toggle {
|
||
flex-shrink: 0;
|
||
width: 14px; height: 14px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.6rem; color: #555; cursor: pointer;
|
||
transition: transform 0.15s; margin-top: 1px;
|
||
}
|
||
|
||
.outline-item .toggle.open { transform: rotate(90deg); }
|
||
.outline-item .toggle.empty { visibility: hidden; }
|
||
.outline-item .label { flex: 1; min-width: 0; }
|
||
|
||
.outline-children { padding-left: 0.85rem; overflow: hidden; }
|
||
.outline-children.hidden { display: none; }
|
||
|
||
.outline-empty {
|
||
padding: 1.5rem 1rem;
|
||
text-align: center;
|
||
color: #444;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
/* Main area */
|
||
.main-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Book spread */
|
||
.book-spread {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 2px;
|
||
padding: 1rem;
|
||
background: #2a2a2a;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.book-spread canvas {
|
||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||
background: white;
|
||
display: block;
|
||
}
|
||
|
||
.page-slot {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
max-height: 100%;
|
||
}
|
||
|
||
.page-slot.empty {
|
||
visibility: hidden;
|
||
}
|
||
|
||
/* Navigation bar */
|
||
.nav-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 1rem;
|
||
padding: 0.6rem 1rem;
|
||
background: #141414;
|
||
border-top: 1px solid #2a2a2a;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.nav-btn {
|
||
background: #1e1e1e;
|
||
border: 1px solid #333;
|
||
border-radius: 6px;
|
||
color: #aaa;
|
||
cursor: pointer;
|
||
padding: 0.4rem 0.8rem;
|
||
font-size: 0.85rem;
|
||
transition: all 0.15s;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
}
|
||
|
||
.nav-btn:hover:not(:disabled) { background: #2a2a2a; color: #fff; border-color: #555; }
|
||
.nav-btn:disabled { opacity: 0.25; cursor: default; }
|
||
|
||
.nav-info {
|
||
font-size: 0.75rem;
|
||
color: #777;
|
||
font-variant-numeric: tabular-nums;
|
||
min-width: 6rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.sidebar-btn {
|
||
position: absolute;
|
||
left: 0; top: 0; bottom: 0;
|
||
width: 2rem;
|
||
background: transparent;
|
||
border: none;
|
||
color: #555;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
transition: all 0.15s;
|
||
z-index: 5;
|
||
}
|
||
|
||
.sidebar-btn:hover { background: rgba(255,255,255,0.03); color: #aaa; }
|
||
.sidebar-btn.hidden { display: none; }
|
||
|
||
/* Loading */
|
||
.loading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
color: #555;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.outline-panel { width: 200px; min-width: 200px; }
|
||
.book-spread { padding: 0.5rem; }
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.outline-panel { position: absolute; z-index: 20; height: 100%; }
|
||
.outline-panel.collapsed { display: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="viewer-layout">
|
||
<div class="outline-panel" id="outlinePanel">
|
||
<div class="outline-header">
|
||
<span class="outline-title">Sommaire</span>
|
||
<div class="outline-actions">
|
||
<button class="outline-btn" onclick="expandAll()">Déplier</button>
|
||
<button class="outline-btn" onclick="collapseAll()">Replier</button>
|
||
<button class="outline-btn" onclick="toggleSidebar()">✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="outline-tree" id="outlineTree">
|
||
<div class="loading">Chargement…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="main-area">
|
||
<div class="book-spread" id="bookSpread">
|
||
<button class="sidebar-btn hidden" id="sidebarBtn" onclick="toggleSidebar()">☰</button>
|
||
<div class="loading" id="loadingMsg">Chargement du PDF…</div>
|
||
<div class="page-slot" id="slotLeft"></div>
|
||
<div class="page-slot" id="slotRight"></div>
|
||
</div>
|
||
|
||
<div class="nav-bar">
|
||
<button class="nav-btn" id="prevBtn" onclick="prevSpread()" disabled>◀ Précédent</button>
|
||
<span class="nav-info" id="pageInfo"></span>
|
||
<button class="nav-btn" id="nextBtn" onclick="nextSpread()" disabled>Suivant ▶</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="module">
|
||
import * as pdfjsLib from '/pdfjs/pdf.min.mjs';
|
||
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.mjs';
|
||
|
||
const params = new URLSearchParams(location.search);
|
||
const hash = location.hash.slice(1);
|
||
const hashParams = new URLSearchParams(hash);
|
||
|
||
const fileUrl = params.get('file') || '/pdf/une-economie-du-don.pdf';
|
||
const targetPage = parseInt(hashParams.get('page') || params.get('page') || '1', 10);
|
||
|
||
let pdfDoc = null;
|
||
let currentSpread = 0; // index into spreads[]
|
||
let spreads = []; // [[1], [2,3], [4,5], ...] — page 1 alone (cover), then pairs
|
||
let pageCanvasCache = new Map();
|
||
let outlinePageMap = []; // [{item, pageNum}] for highlighting
|
||
|
||
function buildSpreads(numPages) {
|
||
spreads = [];
|
||
// Page 1 = couverture seule
|
||
spreads.push([1]);
|
||
// Ensuite par paires : 2-3, 4-5, etc.
|
||
for (let i = 2; i <= numPages; i += 2) {
|
||
if (i + 1 <= numPages) {
|
||
spreads.push([i, i + 1]);
|
||
} else {
|
||
spreads.push([i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
function getSpreadForPage(pageNum) {
|
||
return spreads.findIndex(s => s.includes(pageNum));
|
||
}
|
||
|
||
async function renderPageCanvas(pageNum) {
|
||
if (pageCanvasCache.has(pageNum)) return pageCanvasCache.get(pageNum);
|
||
|
||
const page = await pdfDoc.getPage(pageNum);
|
||
const spread = document.getElementById('bookSpread');
|
||
const availH = spread.clientHeight - 32;
|
||
const availW = (spread.clientWidth - 40) / 2;
|
||
|
||
const rawViewport = page.getViewport({ scale: 1 });
|
||
const scaleH = availH / rawViewport.height;
|
||
const scaleW = availW / rawViewport.width;
|
||
const scale = Math.min(scaleH, scaleW, 2.5);
|
||
|
||
const viewport = page.getViewport({ scale });
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = viewport.width;
|
||
canvas.height = viewport.height;
|
||
canvas.dataset.page = pageNum;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||
|
||
pageCanvasCache.set(pageNum, canvas);
|
||
return canvas;
|
||
}
|
||
|
||
async function showSpread(index) {
|
||
if (index < 0 || index >= spreads.length) return;
|
||
currentSpread = index;
|
||
|
||
const pages = spreads[index];
|
||
const slotLeft = document.getElementById('slotLeft');
|
||
const slotRight = document.getElementById('slotRight');
|
||
|
||
slotLeft.innerHTML = '';
|
||
slotRight.innerHTML = '';
|
||
slotLeft.className = 'page-slot';
|
||
slotRight.className = 'page-slot';
|
||
|
||
if (pages.length === 1) {
|
||
// Single page (cover or last odd page) — centered
|
||
const canvas = await renderPageCanvas(pages[0]);
|
||
slotLeft.appendChild(canvas);
|
||
slotRight.className = 'page-slot empty';
|
||
} else {
|
||
const [left, right] = await Promise.all([
|
||
renderPageCanvas(pages[0]),
|
||
renderPageCanvas(pages[1]),
|
||
]);
|
||
slotLeft.appendChild(left);
|
||
slotRight.appendChild(right);
|
||
}
|
||
|
||
updateNav();
|
||
highlightOutline();
|
||
}
|
||
|
||
function updateNav() {
|
||
const pages = spreads[currentSpread];
|
||
const info = document.getElementById('pageInfo');
|
||
const prevBtn = document.getElementById('prevBtn');
|
||
const nextBtn = document.getElementById('nextBtn');
|
||
|
||
if (pages.length === 1) {
|
||
info.textContent = `Page ${pages[0]} / ${pdfDoc.numPages}`;
|
||
} else {
|
||
info.textContent = `Pages ${pages[0]}–${pages[1]} / ${pdfDoc.numPages}`;
|
||
}
|
||
|
||
prevBtn.disabled = currentSpread <= 0;
|
||
nextBtn.disabled = currentSpread >= spreads.length - 1;
|
||
}
|
||
|
||
function prevSpread() { showSpread(currentSpread - 1); }
|
||
function nextSpread() { showSpread(currentSpread + 1); }
|
||
|
||
function goToPage(pageNum) {
|
||
const idx = getSpreadForPage(pageNum);
|
||
if (idx >= 0) showSpread(idx);
|
||
}
|
||
|
||
// Keyboard navigation
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
prevSpread();
|
||
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ') {
|
||
e.preventDefault();
|
||
nextSpread();
|
||
} else if (e.key === 'Home') {
|
||
e.preventDefault();
|
||
showSpread(0);
|
||
} else if (e.key === 'End') {
|
||
e.preventDefault();
|
||
showSpread(spreads.length - 1);
|
||
}
|
||
});
|
||
|
||
// Resize handling
|
||
let resizeTimer;
|
||
window.addEventListener('resize', () => {
|
||
clearTimeout(resizeTimer);
|
||
resizeTimer = setTimeout(() => {
|
||
pageCanvasCache.clear();
|
||
showSpread(currentSpread);
|
||
}, 200);
|
||
});
|
||
|
||
// Outline
|
||
async function renderOutline() {
|
||
const outlineTree = document.getElementById('outlineTree');
|
||
const outline = await pdfDoc.getOutline();
|
||
|
||
if (!outline || outline.length === 0) {
|
||
outlineTree.innerHTML = '<div class="outline-empty">Aucun signet dans ce PDF</div>';
|
||
return;
|
||
}
|
||
|
||
outlineTree.innerHTML = '';
|
||
outlinePageMap = [];
|
||
const tree = await buildOutlineTree(outline);
|
||
outlineTree.appendChild(tree);
|
||
}
|
||
|
||
async function buildOutlineTree(items) {
|
||
const frag = document.createDocumentFragment();
|
||
|
||
for (const item of items) {
|
||
const node = document.createElement('div');
|
||
node.className = 'outline-node';
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'outline-item';
|
||
|
||
// Resolve page number for this item
|
||
let pageNum = null;
|
||
try {
|
||
let dest = item.dest;
|
||
if (typeof dest === 'string') dest = await pdfDoc.getDestination(dest);
|
||
if (dest) {
|
||
const ref = dest[0];
|
||
pageNum = (await pdfDoc.getPageIndex(ref)) + 1;
|
||
}
|
||
} catch {}
|
||
|
||
const toggle = document.createElement('span');
|
||
toggle.className = 'toggle' + (item.items && item.items.length > 0 ? ' open' : ' empty');
|
||
toggle.textContent = '▶';
|
||
if (item.items && item.items.length > 0) {
|
||
toggle.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const children = node.querySelector('.outline-children');
|
||
if (children) {
|
||
const isHidden = children.classList.toggle('hidden');
|
||
toggle.classList.toggle('open', !isHidden);
|
||
}
|
||
});
|
||
}
|
||
row.appendChild(toggle);
|
||
|
||
const label = document.createElement('span');
|
||
label.className = 'label';
|
||
label.textContent = item.title;
|
||
row.appendChild(label);
|
||
|
||
if (pageNum !== null) {
|
||
const pg = pageNum;
|
||
row.addEventListener('click', () => goToPage(pg));
|
||
outlinePageMap.push({ row, pageNum: pg });
|
||
}
|
||
|
||
node.appendChild(row);
|
||
|
||
if (item.items && item.items.length > 0) {
|
||
const children = document.createElement('div');
|
||
children.className = 'outline-children';
|
||
children.appendChild(await buildOutlineTree(item.items));
|
||
node.appendChild(children);
|
||
}
|
||
|
||
frag.appendChild(node);
|
||
}
|
||
|
||
return frag;
|
||
}
|
||
|
||
function highlightOutline() {
|
||
const pages = spreads[currentSpread];
|
||
for (const { row, pageNum } of outlinePageMap) {
|
||
row.classList.toggle('active', pages.includes(pageNum));
|
||
}
|
||
}
|
||
|
||
// Load
|
||
async function init() {
|
||
const loadingTask = pdfjsLib.getDocument(fileUrl);
|
||
pdfDoc = await loadingTask.promise;
|
||
|
||
buildSpreads(pdfDoc.numPages);
|
||
await renderOutline();
|
||
|
||
document.getElementById('loadingMsg').remove();
|
||
|
||
const startSpread = getSpreadForPage(targetPage);
|
||
await showSpread(startSpread >= 0 ? startSpread : 0);
|
||
}
|
||
|
||
init().catch(err => {
|
||
document.getElementById('loadingMsg').textContent = `Erreur : ${err.message}`;
|
||
document.getElementById('loadingMsg').style.color = '#c44';
|
||
console.error(err);
|
||
});
|
||
|
||
// Expose globals
|
||
window.prevSpread = prevSpread;
|
||
window.nextSpread = nextSpread;
|
||
|
||
window.expandAll = function() {
|
||
const tree = document.getElementById('outlineTree');
|
||
tree.querySelectorAll('.outline-children.hidden').forEach(el => el.classList.remove('hidden'));
|
||
tree.querySelectorAll('.toggle:not(.empty)').forEach(el => el.classList.add('open'));
|
||
};
|
||
|
||
window.collapseAll = function() {
|
||
const tree = document.getElementById('outlineTree');
|
||
tree.querySelectorAll('.outline-children').forEach(el => el.classList.add('hidden'));
|
||
tree.querySelectorAll('.toggle:not(.empty)').forEach(el => el.classList.remove('open'));
|
||
};
|
||
|
||
window.toggleSidebar = function() {
|
||
const panel = document.getElementById('outlinePanel');
|
||
const btn = document.getElementById('sidebarBtn');
|
||
panel.classList.toggle('collapsed');
|
||
btn.classList.toggle('hidden', !panel.classList.contains('collapsed'));
|
||
// Recalc on sidebar toggle
|
||
setTimeout(() => {
|
||
pageCanvasCache.clear();
|
||
showSpread(currentSpread);
|
||
}, 300);
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>
|