Viewer PDF.js mode livre avec signets, fix hydratation SSR
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -18,6 +18,10 @@ logs
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# PDF.js (copié depuis node_modules par postinstall)
|
||||
public/pdfjs/pdf.min.mjs
|
||||
public/pdfjs/pdf.worker.min.mjs
|
||||
|
||||
# Sources originales (PDF, JPG — pas servies par l'appli)
|
||||
sources/
|
||||
|
||||
|
||||
@@ -50,10 +50,9 @@ const isOpen = computed({
|
||||
|
||||
const pdfUrl = computed(() => {
|
||||
const base = bookData.getPdfUrl()
|
||||
const fragments: string[] = []
|
||||
if (props.page) fragments.push(`page=${props.page}`)
|
||||
fragments.push('pagemode=bookmarks')
|
||||
return `${base}#${fragments.join('&')}`
|
||||
const viewerBase = `/pdfjs/viewer.html?file=${encodeURIComponent(base)}`
|
||||
if (props.page) return `${viewerBase}#page=${props.page}`
|
||||
return viewerBase
|
||||
})
|
||||
|
||||
function close() {
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- Songs + PDF actions row -->
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<!-- Associated songs badges -->
|
||||
<div v-if="songs.length > 0" class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
@@ -26,18 +27,16 @@
|
||||
<div class="i-lucide-music h-3 w-3" />
|
||||
{{ song.title }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- PDF button -->
|
||||
<div v-if="pdfPage" class="mt-3">
|
||||
<button class="pdf-btn" @click="showPdf = true">
|
||||
<button v-if="pdfPage" class="pdf-btn" @click="showPdf = true">
|
||||
<div class="i-lucide-book-open h-3.5 w-3.5" />
|
||||
<span>Lire dans le PDF</span>
|
||||
<span class="pdf-page-badge">p.{{ pdfPage }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<BookPdfReader v-model="showPdf" :page="pdfPage" />
|
||||
<LazyBookPdfReader v-if="showPdf" v-model="showPdf" :page="pdfPage" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -55,7 +54,7 @@ const props = defineProps<{
|
||||
const bookData = useBookData()
|
||||
const { loadAndPlay } = useAudioPlayer()
|
||||
|
||||
await bookData.init()
|
||||
bookData.init()
|
||||
|
||||
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
|
||||
const pdfPage = computed(() => bookData.getChapterPage(props.chapterSlug))
|
||||
|
||||
159
app/components/book/ChapterToc.vue
Normal file
159
app/components/book/ChapterToc.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="chapter-toc" ref="tocRoot">
|
||||
<button class="toc-toggle" @click="open = !open" :aria-expanded="open">
|
||||
<div class="i-lucide-list h-4 w-4" />
|
||||
<span>Sommaire</span>
|
||||
<div class="i-lucide-chevron-down h-3.5 w-3.5 toc-chevron" :class="{ 'toc-chevron--open': open }" />
|
||||
</button>
|
||||
|
||||
<Transition name="toc-dropdown">
|
||||
<div v-if="open" class="toc-panel">
|
||||
<nav>
|
||||
<a
|
||||
v-for="item in headings"
|
||||
:key="item.id"
|
||||
:href="`#${item.id}`"
|
||||
class="toc-item"
|
||||
:class="{ 'toc-item--h2': item.level === 2 }"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface TocHeading {
|
||||
id: string
|
||||
text: string
|
||||
level: number
|
||||
}
|
||||
|
||||
const open = ref(false)
|
||||
const headings = ref<TocHeading[]>([])
|
||||
const tocRoot = ref<HTMLElement>()
|
||||
|
||||
function extractHeadings() {
|
||||
const article = document.querySelector('.prose-lyrics')
|
||||
if (!article) return
|
||||
|
||||
const nodes = article.querySelectorAll('h1, h2')
|
||||
headings.value = Array.from(nodes)
|
||||
.filter(el => el.id)
|
||||
.map(el => ({
|
||||
id: el.id,
|
||||
text: el.textContent?.trim() ?? '',
|
||||
level: parseInt(el.tagName[1]),
|
||||
}))
|
||||
}
|
||||
|
||||
// Close on click outside
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (tocRoot.value && !tocRoot.value.contains(e.target as Node)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Wait for content to render
|
||||
nextTick(() => {
|
||||
extractHeadings()
|
||||
// Retry after a short delay in case content loads async
|
||||
setTimeout(extractHeadings, 500)
|
||||
})
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chapter-toc {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.toc-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(20 8% 10%);
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
color: hsl(20 8% 60%);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toc-toggle:hover {
|
||||
border-color: hsl(20 8% 30%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toc-chevron {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toc-chevron--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toc-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 8%);
|
||||
border: 1px solid hsl(20 8% 16%);
|
||||
box-shadow: 0 8px 32px hsl(0 0% 0% / 0.4);
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8rem;
|
||||
color: hsl(20 8% 65%);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.toc-item:hover {
|
||||
background: hsl(20 8% 12%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toc-item--h2 {
|
||||
padding-left: 1.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 50%);
|
||||
}
|
||||
|
||||
/* Dropdown transition */
|
||||
.toc-dropdown-enter-active {
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.toc-dropdown-leave-active {
|
||||
transition: all 0.15s ease-in;
|
||||
}
|
||||
.toc-dropdown-enter-from,
|
||||
.toc-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
@@ -17,7 +17,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const bookData = useBookData()
|
||||
await bookData.init()
|
||||
bookData.init()
|
||||
|
||||
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="font-display text-2xl font-bold text-white">Chapitres</h1>
|
||||
<h1 class="font-display text-2xl font-bold text-white">Livre & chapitres</h1>
|
||||
<AdminSaveButton :saving="saving" :saved="saved" @save="saveOrder" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
"postinstall": "nuxt prepare && sh scripts/copy-pdfjs.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/content": "^3.11.2",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@vueuse/nuxt": "^14.2.1",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"nuxt": "^4.3.1",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"vue": "^3.5.28",
|
||||
"vue-router": "^4.6.4",
|
||||
"yaml": "^2.8.2"
|
||||
|
||||
141
pnpm-lock.yaml
generated
141
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
||||
nuxt:
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1(@parcel/watcher@2.5.6)(@types/node@25.2.3)(@vue/compiler-sfc@3.5.28)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2))(ioredis@5.9.3)(magicast@0.5.2)(rollup@4.57.1)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))(yaml@2.8.2)
|
||||
pdfjs-dist:
|
||||
specifier: ^5.4.624
|
||||
version: 5.4.624
|
||||
vue:
|
||||
specifier: ^3.5.28
|
||||
version: 3.5.28(typescript@5.9.3)
|
||||
@@ -598,6 +601,81 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.95':
|
||||
resolution: {integrity: sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.95':
|
||||
resolution: {integrity: sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.95':
|
||||
resolution: {integrity: sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
|
||||
resolution: {integrity: sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.95':
|
||||
resolution: {integrity: sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.95':
|
||||
resolution: {integrity: sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
|
||||
resolution: {integrity: sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.95':
|
||||
resolution: {integrity: sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.95':
|
||||
resolution: {integrity: sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.95':
|
||||
resolution: {integrity: sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.95':
|
||||
resolution: {integrity: sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.95':
|
||||
resolution: {integrity: sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.1':
|
||||
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
||||
|
||||
@@ -3336,6 +3414,9 @@ packages:
|
||||
node-mock-http@1.0.4:
|
||||
resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==}
|
||||
|
||||
node-readable-to-web-readable-stream@0.4.2:
|
||||
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
|
||||
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
@@ -3496,6 +3577,10 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pdfjs-dist@5.4.624:
|
||||
resolution: {integrity: sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==}
|
||||
engines: {node: '>=20.16.0 || >=22.3.0'}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
@@ -5214,6 +5299,54 @@ snapshots:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.95':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.95
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.95
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.95
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.95
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.95
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.95
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.95
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.95
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.95
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 0.1.95
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.95
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.1':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.8.1
|
||||
@@ -8512,6 +8645,9 @@ snapshots:
|
||||
|
||||
node-mock-http@1.0.4: {}
|
||||
|
||||
node-readable-to-web-readable-stream@0.4.2:
|
||||
optional: true
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
nopt@8.1.0:
|
||||
@@ -8855,6 +8991,11 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdfjs-dist@5.4.624:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.95
|
||||
node-readable-to-web-readable-stream: 0.4.2
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
perfect-debounce@2.1.0: {}
|
||||
|
||||
546
public/pdfjs/viewer.html
Normal file
546
public/pdfjs/viewer.html
Normal file
@@ -0,0 +1,546 @@
|
||||
<!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>
|
||||
12
scripts/copy-pdfjs.sh
Executable file
12
scripts/copy-pdfjs.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# Copie les fichiers PDF.js nécessaires depuis node_modules vers public/pdfjs/
|
||||
set -e
|
||||
|
||||
DEST="public/pdfjs"
|
||||
SRC="node_modules/pdfjs-dist/build"
|
||||
|
||||
mkdir -p "$DEST"
|
||||
cp "$SRC/pdf.min.mjs" "$DEST/pdf.min.mjs"
|
||||
cp "$SRC/pdf.worker.min.mjs" "$DEST/pdf.worker.min.mjs"
|
||||
|
||||
echo "[copy-pdfjs] OK"
|
||||
Reference in New Issue
Block a user