bf7d528917
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
225 lines
6.9 KiB
JavaScript
225 lines
6.9 KiB
JavaScript
// /app/static/js/reader.js
|
|
'use strict';
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const { startPage } = window.readerConfig || { startPage: 1 };
|
|
|
|
// Elements
|
|
// #readerContent is the actual scroll container (overflow-y: auto)
|
|
const readerContent = document.getElementById('readerContent');
|
|
const pageJumpSelect = document.getElementById('pageJumpSelect');
|
|
const floatingIndicator = document.getElementById('floatingPageIndicator');
|
|
const currentPageNum = document.getElementById('currentPageNum');
|
|
const readerNav = document.getElementById('readerNav');
|
|
const readerHeader = document.querySelector('.reader-header');
|
|
const toggleNavBtn = document.getElementById('toggleNavBtn');
|
|
const closeNavBtn = document.getElementById('closeNavBtn');
|
|
const navThumbs = document.querySelectorAll('.reader-nav-thumb');
|
|
const scrollToTopBtn = document.getElementById('scrollToTopBtn');
|
|
const scrollToBottomBtn = document.getElementById('scrollToBottomBtn');
|
|
const pages = document.querySelectorAll('.reader-page');
|
|
const progressBar = document.getElementById('readerProgressBar');
|
|
|
|
let currentPage = startPage;
|
|
let isNavOpen = false;
|
|
let headerHideTimeout = null;
|
|
|
|
// -------------------------
|
|
// Scroll to starting page
|
|
// -------------------------
|
|
if (startPage > 1) {
|
|
const targetPage = document.getElementById('page-' + startPage);
|
|
if (targetPage) {
|
|
setTimeout(() => targetPage.scrollIntoView({ behavior: 'auto' }), 100);
|
|
}
|
|
}
|
|
|
|
// -------------------------
|
|
// Progress bar
|
|
// -------------------------
|
|
function updateProgressBar() {
|
|
if (!progressBar || !readerContent) return;
|
|
const scrollTop = readerContent.scrollTop;
|
|
const scrollHeight = readerContent.scrollHeight - readerContent.clientHeight;
|
|
const pct = scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0;
|
|
progressBar.style.width = `${Math.min(pct, 100)}%`;
|
|
}
|
|
|
|
// -------------------------
|
|
// Auto-hide header
|
|
// -------------------------
|
|
function showHeader() {
|
|
if (!readerHeader) return;
|
|
readerHeader.classList.remove('hidden');
|
|
clearTimeout(headerHideTimeout);
|
|
}
|
|
|
|
function scheduleHideHeader() {
|
|
clearTimeout(headerHideTimeout);
|
|
headerHideTimeout = setTimeout(() => {
|
|
if (readerHeader) readerHeader.classList.add('hidden');
|
|
}, 2500);
|
|
}
|
|
|
|
// -------------------------
|
|
// Current page tracking
|
|
// -------------------------
|
|
function updateCurrentPage() {
|
|
if (!readerContent) return;
|
|
const scrollTop = readerContent.scrollTop;
|
|
const viewportCenter = scrollTop + (readerContent.clientHeight / 3);
|
|
|
|
let newCurrentPage = currentPage;
|
|
pages.forEach(page => {
|
|
const pageTop = page.offsetTop;
|
|
const pageBottom = pageTop + page.offsetHeight;
|
|
if (viewportCenter >= pageTop && viewportCenter < pageBottom) {
|
|
newCurrentPage = parseInt(page.dataset.page);
|
|
}
|
|
});
|
|
|
|
if (newCurrentPage !== currentPage) {
|
|
currentPage = newCurrentPage;
|
|
|
|
// Update floating indicator
|
|
if (currentPageNum) currentPageNum.textContent = currentPage;
|
|
|
|
// Update jump select
|
|
if (pageJumpSelect) pageJumpSelect.value = currentPage;
|
|
|
|
// Update active nav thumb + scroll it into view
|
|
navThumbs.forEach(thumb => {
|
|
const isActive = parseInt(thumb.dataset.page) === currentPage;
|
|
thumb.classList.toggle('active', isActive);
|
|
if (isActive) {
|
|
thumb.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
}
|
|
});
|
|
|
|
// Update URL without reload
|
|
const url = new URL(window.location);
|
|
url.searchParams.set('page', currentPage);
|
|
history.replaceState({}, '', url);
|
|
}
|
|
}
|
|
|
|
// -------------------------
|
|
// Scroll handler
|
|
// -------------------------
|
|
let scrollTimeout = null;
|
|
if (readerContent) {
|
|
readerContent.addEventListener('scroll', () => {
|
|
updateProgressBar();
|
|
showHeader();
|
|
scheduleHideHeader();
|
|
|
|
if (scrollTimeout) return;
|
|
scrollTimeout = setTimeout(() => {
|
|
updateCurrentPage();
|
|
scrollTimeout = null;
|
|
}, 50);
|
|
});
|
|
}
|
|
|
|
// Show header on mouse move
|
|
document.addEventListener('mousemove', () => {
|
|
showHeader();
|
|
scheduleHideHeader();
|
|
});
|
|
|
|
// -------------------------
|
|
// Jump to page
|
|
// -------------------------
|
|
if (pageJumpSelect) {
|
|
pageJumpSelect.addEventListener('change', (e) => {
|
|
const pageNum = parseInt(e.target.value);
|
|
const targetPage = document.getElementById('page-' + pageNum);
|
|
if (targetPage) targetPage.scrollIntoView({ behavior: 'smooth' });
|
|
});
|
|
}
|
|
|
|
// -------------------------
|
|
// Sidebar toggle
|
|
// -------------------------
|
|
if (toggleNavBtn) {
|
|
toggleNavBtn.addEventListener('click', () => {
|
|
isNavOpen = !isNavOpen;
|
|
readerNav.classList.toggle('open', isNavOpen);
|
|
});
|
|
}
|
|
|
|
if (closeNavBtn) {
|
|
closeNavBtn.addEventListener('click', () => {
|
|
isNavOpen = false;
|
|
readerNav.classList.remove('open');
|
|
});
|
|
}
|
|
|
|
// Click thumbnail to jump
|
|
navThumbs.forEach(thumb => {
|
|
thumb.addEventListener('click', () => {
|
|
const pageNum = parseInt(thumb.dataset.page);
|
|
const targetPage = document.getElementById('page-' + pageNum);
|
|
if (targetPage) targetPage.scrollIntoView({ behavior: 'smooth' });
|
|
if (window.innerWidth < 768) {
|
|
isNavOpen = false;
|
|
readerNav.classList.remove('open');
|
|
}
|
|
});
|
|
});
|
|
|
|
// -------------------------
|
|
// Quick nav buttons
|
|
// -------------------------
|
|
if (scrollToTopBtn) {
|
|
scrollToTopBtn.addEventListener('click', () => {
|
|
readerContent.scrollTo({ top: 0, behavior: 'smooth' });
|
|
});
|
|
}
|
|
|
|
if (scrollToBottomBtn) {
|
|
scrollToBottomBtn.addEventListener('click', () => {
|
|
readerContent.scrollTo({ top: readerContent.scrollHeight, behavior: 'smooth' });
|
|
});
|
|
}
|
|
|
|
// -------------------------
|
|
// Keyboard navigation
|
|
// -------------------------
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
|
if (!readerContent) return;
|
|
showHeader();
|
|
|
|
switch (e.key) {
|
|
case 'Home':
|
|
e.preventDefault();
|
|
readerContent.scrollTo({ top: 0, behavior: 'smooth' });
|
|
break;
|
|
case 'End':
|
|
e.preventDefault();
|
|
readerContent.scrollTo({ top: readerContent.scrollHeight, behavior: 'smooth' });
|
|
break;
|
|
case 'PageUp':
|
|
e.preventDefault();
|
|
readerContent.scrollBy({ top: -readerContent.clientHeight * 0.9, behavior: 'smooth' });
|
|
break;
|
|
case 'PageDown':
|
|
case ' ':
|
|
e.preventDefault();
|
|
readerContent.scrollBy({ top: readerContent.clientHeight * 0.9, behavior: 'smooth' });
|
|
break;
|
|
case 'n':
|
|
isNavOpen = !isNavOpen;
|
|
readerNav.classList.toggle('open', isNavOpen);
|
|
break;
|
|
}
|
|
});
|
|
|
|
// -------------------------
|
|
// Initial state
|
|
// -------------------------
|
|
updateCurrentPage();
|
|
updateProgressBar();
|
|
});
|