Files
librodrome/public/pdfjs/viewer.html
Yvv 1af00cc64c
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Admin : déroulant sommaire PDF par chapitre, transitions pages, URL GrateWizard
- Ajout API /api/admin/pdf-outline (extraction sommaire PDF côté serveur via pdfjs-dist)
- Déroulant <select> dans chaque ligne de chapitre admin avec les 61 titres/sous-titres du PDF
- Sauvegarde des associations chapitre→page PDF via config YAML
- Transition douce (fondu 1s/1.2s) pour le changement de pages dans le viewer PDF
- Correction des numéros de pages réels dans chapterPages (extraits du sommaire PDF)
- URL GrateWizard prod → gratewizard.axiom-team.fr

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:29:54 +01:00

608 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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%;
position: relative;
}
.page-slot.empty {
visibility: hidden;
}
/* Page turn transition */
.page-slot canvas {
transition: opacity 1s ease-out, transform 1s ease-out;
}
.page-slot canvas.entering {
opacity: 0;
transform: translateX(var(--enter-dir, 20px));
transition: none;
}
.page-slot canvas.leaving {
opacity: 0;
transform: translateX(var(--leave-dir, -20px));
position: absolute;
transition: opacity 1.2s ease-in, transform 1.2s ease-in;
}
/* 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()">&#10005;</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()">&#9776;</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>&#9664; Précédent</button>
<span class="nav-info" id="pageInfo"></span>
<button class="nav-btn" id="nextBtn" onclick="nextSpread()" disabled>Suivant &#9654;</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
let isAnimating = false;
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, animate = true) {
if (index < 0 || index >= spreads.length) return;
if (isAnimating) return;
const prevIndex = currentSpread;
const direction = index > prevIndex ? 1 : -1; // 1 = forward, -1 = back
currentSpread = index;
const pages = spreads[index];
const slotLeft = document.getElementById('slotLeft');
const slotRight = document.getElementById('slotRight');
// Collect old canvases for fade-out
const oldCanvases = [...slotLeft.querySelectorAll('canvas'), ...slotRight.querySelectorAll('canvas')];
const shouldAnimate = animate && oldCanvases.length > 0;
if (shouldAnimate) {
isAnimating = true;
// Fade out old canvases with directional slide
oldCanvases.forEach(c => {
c.style.setProperty('--leave-dir', `${-direction * 20}px`);
c.classList.add('leaving');
});
// Wait for fade-out to mostly complete
await new Promise(r => setTimeout(r, 500));
}
slotLeft.innerHTML = '';
slotRight.innerHTML = '';
slotLeft.className = 'page-slot';
slotRight.className = 'page-slot';
if (pages.length === 1) {
const canvas = await renderPageCanvas(pages[0]);
if (shouldAnimate) {
canvas.style.setProperty('--enter-dir', `${direction * 20}px`);
canvas.classList.add('entering');
}
slotLeft.appendChild(canvas);
slotRight.className = 'page-slot empty';
if (shouldAnimate) {
// Double rAF ensures the browser has painted the initial state before transitioning
requestAnimationFrame(() => requestAnimationFrame(() => canvas.classList.remove('entering')));
}
} else {
const [left, right] = await Promise.all([
renderPageCanvas(pages[0]),
renderPageCanvas(pages[1]),
]);
if (shouldAnimate) {
left.style.setProperty('--enter-dir', `${direction * 20}px`);
right.style.setProperty('--enter-dir', `${direction * 20}px`);
left.classList.add('entering');
right.classList.add('entering');
}
slotLeft.appendChild(left);
slotRight.appendChild(right);
if (shouldAnimate) {
requestAnimationFrame(() => requestAnimationFrame(() => {
left.classList.remove('entering');
right.classList.remove('entering');
}));
}
}
if (shouldAnimate) setTimeout(() => { isAnimating = false; }, 1100);
else isAnimating = false;
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, false);
}, 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, false);
}
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, false);
}, 300);
};
</script>
</body>
</html>