fix(reader): render full book in continuous mode with smooth chapter jumping and scroll tracking

This commit is contained in:
Antigravity committed 2026-10-06 14:40:32 +03:00
1 parent 21d94acf4e
commit 0c5951df8d
1 file changed
+128 -43
+128 -43
View File
@@ -147,6 +147,8 @@ export async function openBook(bookId, specificChapterIdx = null, updateHash = t
const readerBookTitle = document.getElementById('reader-book-title');
const readerBookAuthor = document.getElementById('reader-book-author');
const readerChapterSelect = document.getElementById('reader-chapter-select');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
const readerContentBody = document.getElementById('reader-content-body');
if (readerView) readerView.classList.remove('hidden');
@@ -164,16 +166,22 @@ export async function openBook(bookId, specificChapterIdx = null, updateHash = t
if (currentBook && currentBook.id === bookId) {
if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) {
if (specificChapterIdx !== currentChapterIndex) {
if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(specificChapterIdx)) {
currentChapterIndex = specificChapterIdx;
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
if (readerSettings.flowMode === 'continuous') {
const target = document.getElementById(`chapter-anchor-${specificChapterIdx}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${specificChapterIdx}"]`));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
scrollToElement(target, true);
} else {
renderContinuousBook(specificChapterIdx);
}
} else {
renderChapter(specificChapterIdx, 0, 0, 0);
renderChapter(specificChapterIdx, null, null, null);
}
}
}
@@ -264,8 +272,7 @@ export function buildChapterDOM(chapterIdx, knownWordsMap, isContinuous = false)
const chapter = currentBook.chapters[chapterIdx] || { title: '', content: '' };
const frag = document.createDocumentFragment();
if (chapter.title || isContinuous) {
if (isContinuous || chapterIdx > 0) {
if (chapterIdx > 0 || isContinuous) {
const divider = document.createElement('div');
divider.className = 'reader-chapter-divider';
divider.id = `chapter-anchor-${chapterIdx}`;
@@ -282,15 +289,14 @@ export function buildChapterDOM(chapterIdx, knownWordsMap, isContinuous = false)
divider.appendChild(badge);
divider.appendChild(titleEl);
frag.appendChild(divider);
} else if (chapter.title) {
} else {
const h = document.createElement('h2');
h.textContent = chapter.title;
h.textContent = chapter.title || `Глава 1`;
h.setAttribute('data-block-idx', '0');
h.setAttribute('data-chapter-idx', chapterIdx.toString());
h.id = `chapter-anchor-${chapterIdx}`;
h.setAttribute('data-chapter-idx', '0');
h.id = `chapter-anchor-0`;
frag.appendChild(h);
}
}
const blocks = parseContentBlocks(chapter.content);
@@ -481,7 +487,7 @@ export function buildChapterFooterDOM(chapterIdx) {
nextBtn.onclick = () => {
if (readerSettings.flowMode === 'continuous') {
const target = document.getElementById(`chapter-anchor-${nextCh}`);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (target) scrollToElement(target, true);
} else if (readerSettings.autoLoadNext) {
appendNextChapter(nextCh, true);
} else {
@@ -500,6 +506,28 @@ export function buildChapterFooterDOM(chapterIdx) {
return footer;
}
export function scrollToElement(targetEl, smooth = true) {
const readerScrollArea = document.getElementById('reader-scroll-area');
if (!targetEl || !readerScrollArea) return;
const containerTop = readerScrollArea.getBoundingClientRect().top;
const elTop = targetEl.getBoundingClientRect().top;
const targetScroll = readerScrollArea.scrollTop + (elTop - containerTop - 10);
readerScrollArea.scrollTo({
top: Math.max(0, targetScroll),
behavior: smooth ? 'smooth' : 'auto'
});
}
export function updateChapterSelectIcons() {
const readerChapterSelect = document.getElementById('reader-chapter-select');
if (!readerChapterSelect || !readerChapterSelect.options || !currentBook || !currentBook.chapters) return;
for (let i = 0; i < readerChapterSelect.options.length; i++) {
const rawTitle = currentBook.chapters[i]?.title || `Глава ${i + 1}`;
const icon = i < currentChapterIndex ? '✔ ' : (i === currentChapterIndex ? '▶ ' : '○ ');
readerChapterSelect.options[i].textContent = `${icon}${rawTitle}`;
}
}
export function appendNextChapter(nextCh, smoothScroll = false) {
if (!currentBook || !currentBook.chapters || nextCh >= currentBook.chapters.length) return;
const readerContentBody = document.getElementById('reader-content-body');
@@ -511,7 +539,7 @@ export function appendNextChapter(nextCh, smoothScroll = false) {
if (loadedChaptersSet.has(nextCh)) {
if (smoothScroll) {
const target = document.getElementById(`chapter-anchor-${nextCh}`);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (target) scrollToElement(target, true);
}
return;
}
@@ -543,7 +571,7 @@ export function appendNextChapter(nextCh, smoothScroll = false) {
if (smoothScroll) {
const anchor = document.getElementById(`chapter-anchor-${nextCh}`);
if (anchor) {
anchor.scrollIntoView({ behavior: 'smooth', block: 'start' });
scrollToElement(anchor, true);
}
}
}
@@ -555,10 +583,13 @@ export function renderContinuousBook(targetChapter = 0, restoreScrollTop = null,
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
const readerContentBody = document.getElementById('reader-content-body');
const readerScrollArea = document.getElementById('reader-scroll-area');
currentChapterIndex = Math.max(0, Math.min(targetChapter, currentBook.chapters.length - 1));
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
@@ -572,16 +603,37 @@ export function renderContinuousBook(targetChapter = 0, restoreScrollTop = null,
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
});
loadedChaptersSet.add(currentChapterIndex);
const chFrag = buildChapterDOM(currentChapterIndex, knownWordsMap, false);
readerContentBody.appendChild(chFrag);
const totalChapters = currentBook.chapters.length;
const docFrag = document.createDocumentFragment();
const footer = buildChapterFooterDOM(currentChapterIndex);
if (footer) {
readerContentBody.appendChild(footer);
for (let ch = 0; ch < totalChapters; ch++) {
loadedChaptersSet.add(ch);
const chFrag = buildChapterDOM(ch, knownWordsMap, ch > 0);
docFrag.appendChild(chFrag);
}
const finishFooter = buildChapterFooterDOM(totalChapters - 1);
if (finishFooter) {
docFrag.appendChild(finishFooter);
}
readerContentBody.appendChild(docFrag);
// Apply scroll: either restored from storage/arguments or to target chapter anchor
if (restoreScrollTop !== null || restoreFraction !== null || restoreAnchorIdx !== null) {
applyScroll(restoreAnchorIdx, restoreScrollTop, restoreFraction, currentChapterIndex);
} else if (currentChapterIndex > 0) {
requestAnimationFrame(() => {
const targetAnchor = document.getElementById(`chapter-anchor-${currentChapterIndex}`);
if (targetAnchor) {
scrollToElement(targetAnchor, false);
}
updateReaderProgressBar();
});
} else {
if (readerScrollArea) readerScrollArea.scrollTop = 0;
updateReaderProgressBar();
}
const posToSave = (restoreFraction !== null && restoreFraction > 0) ? restoreFraction : 0;
api.books.saveProgress(currentBook.id, { current_chapter: currentChapterIndex, current_position: posToSave })
@@ -604,14 +656,7 @@ export function renderChapter(index, restoreScrollTop = null, restoreFraction =
currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1));
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
// Refresh status icons in chapter select
if (readerChapterSelect && readerChapterSelect.options) {
for (let i = 0; i < readerChapterSelect.options.length; i++) {
const rawTitle = currentBook.chapters[i]?.title || `Глава ${i + 1}`;
const icon = i < currentChapterIndex ? '✔ ' : (i === currentChapterIndex ? '▶ ' : '○ ');
readerChapterSelect.options[i].textContent = `${icon}${rawTitle}`;
}
}
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
@@ -1245,13 +1290,13 @@ export function getActiveVisibleChapter() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerContentBody = document.getElementById('reader-content-body');
if (!readerScrollArea || !readerContentBody) return currentChapterIndex;
const dividers = readerContentBody.querySelectorAll('[data-chapter-idx]');
if (!dividers || dividers.length === 0) return currentChapterIndex;
const anchors = readerContentBody.querySelectorAll('[id^="chapter-anchor-"]');
if (!anchors || anchors.length === 0) return currentChapterIndex;
const containerTop = readerScrollArea.getBoundingClientRect().top;
let activeIdx = currentChapterIndex;
let activeIdx = 0;
for (let el of dividers) {
for (let el of anchors) {
const rect = el.getBoundingClientRect();
if (rect.top <= containerTop + 140) {
const ch = parseInt(el.getAttribute('data-chapter-idx'), 10);
@@ -1313,6 +1358,7 @@ export function handleReaderScroll() {
if (activeCh !== null && activeCh !== undefined && activeCh !== currentChapterIndex) {
currentChapterIndex = activeCh;
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
@@ -1323,8 +1369,8 @@ export function handleReaderScroll() {
}
}
// 2. Infinite scroll: auto-append next chapter in continuous mode or chapters mode with autoload
if ((readerSettings.flowMode === 'continuous' || (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext)) && !isAppendingChapter) {
// 2. Infinite scroll: auto-append next chapter in chapters mode with autoload
if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext && !isAppendingChapter) {
const distanceToBottom = readerScrollArea.scrollHeight - (readerScrollArea.scrollTop + readerScrollArea.clientHeight);
if (distanceToBottom < 700) {
const loadedArr = Array.from(loadedChaptersSet);
@@ -1674,7 +1720,11 @@ export function initReader() {
saveReaderSettings();
updateReaderSettingsUI();
if (currentBook) {
renderChapter(currentChapterIndex, 0, 0, 0);
if (readerSettings.flowMode === 'continuous') {
renderContinuousBook(currentChapterIndex);
} else {
renderChapter(currentChapterIndex);
}
}
}
}
@@ -1876,15 +1926,28 @@ export function initReader() {
readerChapterSelect.addEventListener('change', () => {
const ch = parseInt(readerChapterSelect.value, 10);
if (!currentBook) return;
if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(ch)) {
if (readerSettings.flowMode === 'continuous') {
currentChapterIndex = ch;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const target = document.getElementById(`chapter-anchor-${ch}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${ch}"]`));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
scrollToElement(target, true);
} else {
renderContinuousBook(ch);
}
const desiredHash = `#reader/book/${currentBook.id}/ch/${ch}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
return;
}
}
window.location.hash = `#reader/book/${currentBook.id}/ch/${ch}`;
});
}
@@ -1893,15 +1956,26 @@ export function initReader() {
readerPrevChBtn.addEventListener('click', () => {
if (currentBook && currentChapterIndex > 0) {
const prev = currentChapterIndex - 1;
if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(prev)) {
if (readerSettings.flowMode === 'continuous') {
currentChapterIndex = prev;
if (readerChapterSelect) readerChapterSelect.value = prev;
updateChapterSelectIcons();
readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const target = document.getElementById(`chapter-anchor-${prev}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${prev}"]`));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
scrollToElement(target, true);
} else {
renderContinuousBook(prev);
}
const desiredHash = `#reader/book/${currentBook.id}/ch/${prev}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
return;
}
window.location.hash = `#reader/book/${currentBook.id}/ch/${prev}`;
}
@@ -1912,15 +1986,26 @@ export function initReader() {
readerNextChBtn.addEventListener('click', () => {
if (currentBook && currentBook.chapters && currentChapterIndex < currentBook.chapters.length - 1) {
const next = currentChapterIndex + 1;
if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(next)) {
if (readerSettings.flowMode === 'continuous') {
currentChapterIndex = next;
if (readerChapterSelect) readerChapterSelect.value = next;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const target = document.getElementById(`chapter-anchor-${next}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${next}"]`));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
scrollToElement(target, true);
} else {
renderContinuousBook(next);
}
const desiredHash = `#reader/book/${currentBook.id}/ch/${next}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
return;
}
if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext) {
appendNextChapter(next, true);