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

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