mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Enhance chapter navigation and add chapter panel functionality
This commit is contained in:
@@ -46,13 +46,24 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 1rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-toolbar__group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar__group--end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.reader-toolbar__title {
|
.reader-toolbar__title {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -63,7 +74,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-toolbar button {
|
.reader-toolbar button,
|
||||||
|
.reader-chapter-panel button {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(30, 41, 59, 0.8);
|
background: rgba(30, 41, 59, 0.8);
|
||||||
@@ -77,17 +89,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reader-toolbar button:hover,
|
.reader-toolbar button:hover,
|
||||||
.reader-toolbar button:focus-visible {
|
.reader-toolbar button:focus-visible,
|
||||||
|
.reader-chapter-panel button:hover,
|
||||||
|
.reader-chapter-panel button:focus-visible {
|
||||||
background: rgba(56, 189, 248, 0.2);
|
background: rgba(56, 189, 248, 0.2);
|
||||||
border-color: rgba(56, 189, 248, 0.4);
|
border-color: rgba(56, 189, 248, 0.4);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-toolbar button:disabled {
|
.reader-toolbar button:disabled,
|
||||||
|
.reader-chapter-panel button:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-toolbar__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.4rem;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar__icon svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.reader-toolbar select {
|
.reader-toolbar select {
|
||||||
flex: 1 1 30%;
|
flex: 1 1 30%;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
@@ -211,24 +242,26 @@
|
|||||||
|
|
||||||
.reader-chapter-links {
|
.reader-chapter-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin: 0.25rem 0;
|
margin: 0;
|
||||||
max-height: 12.5rem;
|
max-height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.25rem 0;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-chapter-links button {
|
.reader-chapter-links button {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(30, 41, 59, 0.7);
|
background: rgba(30, 41, 59, 0.7);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: 999px;
|
border-radius: 10px;
|
||||||
padding: 0.35rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease;
|
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-chapter-links button:hover,
|
.reader-chapter-links button:hover,
|
||||||
@@ -254,6 +287,68 @@
|
|||||||
color: rgba(226, 232, 240, 0.6);
|
color: rgba(226, 232, 240, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
border: 0;
|
||||||
|
clip: rect(1px, 1px, 1px, 1px);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-chapter-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
width: min(320px, 85vw);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px 0 0 16px;
|
||||||
|
box-shadow: -6px 0 24px rgba(15, 23, 42, 0.45);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(110%);
|
||||||
|
transition: transform 0.24s ease;
|
||||||
|
z-index: 20;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-chapter-panel.is-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-chapter-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-chapter-panel__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-chapter-panel__body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-chapter-panel__body > .reader-chapter-links {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.reader-status {
|
.reader-status {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: rgba(226, 232, 240, 0.75);
|
color: rgba(226, 232, 240, 0.75);
|
||||||
@@ -272,10 +367,27 @@
|
|||||||
>
|
>
|
||||||
<div class="reader-shell" data-role="reader-shell">
|
<div class="reader-shell" data-role="reader-shell">
|
||||||
<div class="reader-toolbar">
|
<div class="reader-toolbar">
|
||||||
|
<div class="reader-toolbar__group">
|
||||||
<button type="button" data-action="prev">Previous</button>
|
<button type="button" data-action="prev">Previous</button>
|
||||||
<select data-role="reader-chapter" aria-label="Select chapter"></select>
|
<select data-role="reader-chapter" aria-label="Select chapter"></select>
|
||||||
|
</div>
|
||||||
<div class="reader-toolbar__title" data-role="reader-title">{{ display_title or job.original_filename }}</div>
|
<div class="reader-toolbar__title" data-role="reader-title">{{ display_title or job.original_filename }}</div>
|
||||||
|
<div class="reader-toolbar__group reader-toolbar__group--end">
|
||||||
<button type="button" data-action="next">Next</button>
|
<button type="button" data-action="next">Next</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="reader-toolbar__icon"
|
||||||
|
data-action="toggle-chapters"
|
||||||
|
aria-label="Toggle chapter list"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="reader-chapter-panel"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M4 7h16v2H4zm0 5h16v2H4zm0 5h16v2H4z" />
|
||||||
|
</svg>
|
||||||
|
<span class="visually-hidden">Toggle chapter list</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="reader-container">
|
<div id="reader-container">
|
||||||
<div id="reader-view" class="reader-view" tabindex="0" aria-label="Book reader"></div>
|
<div id="reader-view" class="reader-view" tabindex="0" aria-label="Book reader"></div>
|
||||||
@@ -305,10 +417,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="reader-chapter-links" data-role="chapter-links" aria-label="Chapters"></div>
|
|
||||||
<div class="reader-status" data-role="reader-status" aria-live="polite">Loading EPUB…</div>
|
<div class="reader-status" data-role="reader-status" aria-live="polite">Loading EPUB…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<aside class="reader-chapter-panel" data-role="chapter-panel" id="reader-chapter-panel" aria-hidden="true">
|
||||||
|
<div class="reader-chapter-panel__header">
|
||||||
|
<span class="reader-chapter-panel__title">Chapters</span>
|
||||||
|
<button type="button" class="reader-toolbar__icon" data-action="close-chapter-panel" aria-label="Close chapter list">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M6.2 5.2 5.2 6.2 11 12l-5.8 5.8 1 1L12 13l5.8 5.8 1-1L13 12l5.8-5.8-1-1L12 11 6.2 5.2z" />
|
||||||
|
</svg>
|
||||||
|
<span class="visually-hidden">Close chapter list</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="reader-chapter-panel__body">
|
||||||
|
<div class="reader-chapter-links" data-role="chapter-links" aria-label="Chapters"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
<script type="application/json" id="reader-data">{{ {'chapters': chapters, 'title': display_title or job.original_filename, 'chapterTimings': chapter_timings}|tojson }}</script>
|
<script type="application/json" id="reader-data">{{ {'chapters': chapters, 'title': display_title or job.original_filename, 'chapterTimings': chapter_timings}|tojson }}</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/epubjs@0.3.93/dist/epub.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/epubjs@0.3.93/dist/epub.min.js"></script>
|
||||||
@@ -326,6 +451,9 @@
|
|||||||
const playerToggleBtn = document.querySelector('[data-action="player-toggle"]');
|
const playerToggleBtn = document.querySelector('[data-action="player-toggle"]');
|
||||||
const playbackRateSelect = document.querySelector('[data-role="playback-rate"]');
|
const playbackRateSelect = document.querySelector('[data-role="playback-rate"]');
|
||||||
const chapterLinksEl = document.querySelector('[data-role="chapter-links"]');
|
const chapterLinksEl = document.querySelector('[data-role="chapter-links"]');
|
||||||
|
const chapterPanel = document.querySelector('[data-role="chapter-panel"]');
|
||||||
|
const chapterPanelToggleBtn = document.querySelector('[data-action="toggle-chapters"]');
|
||||||
|
const chapterPanelCloseBtn = document.querySelector('[data-action="close-chapter-panel"]');
|
||||||
const bodyDataset = document.body.dataset;
|
const bodyDataset = document.body.dataset;
|
||||||
const assetBase = bodyDataset.assetBase || '';
|
const assetBase = bodyDataset.assetBase || '';
|
||||||
const epubUrl = bodyDataset.epubUrl || '';
|
const epubUrl = bodyDataset.epubUrl || '';
|
||||||
@@ -770,6 +898,75 @@
|
|||||||
let scheduledSync = 0;
|
let scheduledSync = 0;
|
||||||
|
|
||||||
let chapterLinkButtons = [];
|
let chapterLinkButtons = [];
|
||||||
|
let chapterPanelOpen = false;
|
||||||
|
let chapterPanelRestoreFocus = null;
|
||||||
|
|
||||||
|
const focusFirstChapterButton = () => {
|
||||||
|
if (!chapterPanelOpen || !chapterLinkButtons.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstInteractive = chapterLinkButtons.find((button) => button instanceof HTMLButtonElement && !button.disabled);
|
||||||
|
if (firstInteractive) {
|
||||||
|
try {
|
||||||
|
firstInteractive.focus({ preventScroll: true });
|
||||||
|
} catch (error) {
|
||||||
|
firstInteractive.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setChapterPanelOpen = (open) => {
|
||||||
|
if (!chapterPanel) {
|
||||||
|
chapterPanelOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chapterPanelOpen = Boolean(open);
|
||||||
|
chapterPanel.classList.toggle('is-open', chapterPanelOpen);
|
||||||
|
chapterPanel.setAttribute('aria-hidden', chapterPanelOpen ? 'false' : 'true');
|
||||||
|
if (chapterPanelToggleBtn) {
|
||||||
|
chapterPanelToggleBtn.setAttribute('aria-expanded', chapterPanelOpen ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (!chapterPanelOpen && chapterPanelRestoreFocus && typeof chapterPanelRestoreFocus.focus === 'function') {
|
||||||
|
try {
|
||||||
|
chapterPanelRestoreFocus.focus({ preventScroll: true });
|
||||||
|
} catch (error) {
|
||||||
|
// ignore focus restoration failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!chapterPanelOpen) {
|
||||||
|
chapterPanelRestoreFocus = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openChapterPanel = () => {
|
||||||
|
if (!chapterPanel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!chapterPanelOpen) {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (activeElement instanceof HTMLElement && activeElement !== document.body) {
|
||||||
|
chapterPanelRestoreFocus = activeElement;
|
||||||
|
} else {
|
||||||
|
chapterPanelRestoreFocus = chapterPanelToggleBtn instanceof HTMLElement ? chapterPanelToggleBtn : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setChapterPanelOpen(true);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
focusFirstChapterButton();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeChapterPanel = () => {
|
||||||
|
setChapterPanelOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleChapterPanel = () => {
|
||||||
|
if (chapterPanelOpen) {
|
||||||
|
closeChapterPanel();
|
||||||
|
} else {
|
||||||
|
openChapterPanel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateChapterLinkState = () => {
|
const updateChapterLinkState = () => {
|
||||||
if (!chapterLinkButtons.length) {
|
if (!chapterLinkButtons.length) {
|
||||||
@@ -859,6 +1056,9 @@
|
|||||||
chapterLinkButtons.push(button);
|
chapterLinkButtons.push(button);
|
||||||
});
|
});
|
||||||
updateChapterLinkState();
|
updateChapterLinkState();
|
||||||
|
if (chapterPanelOpen) {
|
||||||
|
focusFirstChapterButton();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateStatus = () => {
|
const updateStatus = () => {
|
||||||
@@ -914,6 +1114,12 @@
|
|||||||
const chapter = chapters[currentChapterIndex];
|
const chapter = chapters[currentChapterIndex];
|
||||||
titleEl.textContent = chapter?.title ? `${chapter.title} · ${baseTitle}` : baseTitle;
|
titleEl.textContent = chapter?.title ? `${chapter.title} · ${baseTitle}` : baseTitle;
|
||||||
}
|
}
|
||||||
|
if (chapterPanelToggleBtn) {
|
||||||
|
chapterPanelToggleBtn.disabled = !hasChapters;
|
||||||
|
if (!hasChapters && chapterPanelOpen) {
|
||||||
|
closeChapterPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
updatePlaybackControls();
|
updatePlaybackControls();
|
||||||
updateChapterLinkState();
|
updateChapterLinkState();
|
||||||
};
|
};
|
||||||
@@ -1677,6 +1883,50 @@
|
|||||||
updatePlaybackControls();
|
updatePlaybackControls();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
chapterPanelToggleBtn?.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleChapterPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
chapterPanelCloseBtn?.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
closeChapterPanel();
|
||||||
|
if (chapterPanelToggleBtn) {
|
||||||
|
try {
|
||||||
|
chapterPanelToggleBtn.focus({ preventScroll: true });
|
||||||
|
} catch (error) {
|
||||||
|
chapterPanelToggleBtn.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChapterPanelDismiss = (event) => {
|
||||||
|
if (!chapterPanelOpen || !chapterPanel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chapterPanel.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chapterPanelToggleBtn && chapterPanelToggleBtn.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeChapterPanel();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleChapterPanelDismiss);
|
||||||
|
document.addEventListener('touchstart', handleChapterPanelDismiss);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && chapterPanelOpen) {
|
||||||
|
event.preventDefault();
|
||||||
|
closeChapterPanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
chapterLinksEl?.addEventListener('click', (event) => {
|
chapterLinksEl?.addEventListener('click', (event) => {
|
||||||
const target = event.target instanceof Element ? event.target.closest('button[data-chapter-index]') : null;
|
const target = event.target instanceof Element ? event.target.closest('button[data-chapter-index]') : null;
|
||||||
if (!(target instanceof HTMLButtonElement)) {
|
if (!(target instanceof HTMLButtonElement)) {
|
||||||
@@ -1688,7 +1938,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const shouldResume = audioEl ? !audioEl.paused : false;
|
const shouldResume = audioEl ? !audioEl.paused : false;
|
||||||
navigateToChapter(idx, { preservePlayback: true, forcePlay: shouldResume });
|
navigateToChapter(idx, { preservePlayback: true, forcePlay: shouldResume }).finally(() => {
|
||||||
|
if (chapterPanelOpen) {
|
||||||
|
closeChapterPanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
updatePlaybackControls();
|
updatePlaybackControls();
|
||||||
|
|||||||
Reference in New Issue
Block a user