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:
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>
|
||||
Reference in New Issue
Block a user