From 0c5951df8dda961c4e0fc69cd2564a233917af89 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Tue, 6 Oct 2026 14:40:32 +0300 Subject: [PATCH] fix(reader): render full book in continuous mode with smooth chapter jumping and scroll tracking --- app/static/js/modules/reader.js | 209 ++++++++++++++++++++++---------- 1 file changed, 147 insertions(+), 62 deletions(-) diff --git a/app/static/js/modules/reader.js b/app/static/js/modules/reader.js index 651d850..a5a0d03 100644 --- a/app/static/js/modules/reader.js +++ b/app/static/js/modules/reader.js @@ -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; + 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,32 +272,30 @@ 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) { - const divider = document.createElement('div'); - divider.className = 'reader-chapter-divider'; - divider.id = `chapter-anchor-${chapterIdx}`; - divider.setAttribute('data-chapter-idx', chapterIdx.toString()); + if (chapterIdx > 0 || isContinuous) { + const divider = document.createElement('div'); + divider.className = 'reader-chapter-divider'; + divider.id = `chapter-anchor-${chapterIdx}`; + divider.setAttribute('data-chapter-idx', chapterIdx.toString()); - const badge = document.createElement('span'); - badge.className = 'divider-badge'; - badge.textContent = `Глава ${chapterIdx + 1} из ${currentBook.chapters.length}`; + const badge = document.createElement('span'); + badge.className = 'divider-badge'; + badge.textContent = `Глава ${chapterIdx + 1} из ${currentBook.chapters.length}`; - const titleEl = document.createElement('h2'); - titleEl.className = 'divider-title'; - titleEl.textContent = chapter.title || `Глава ${chapterIdx + 1}`; + const titleEl = document.createElement('h2'); + titleEl.className = 'divider-title'; + titleEl.textContent = chapter.title || `Глава ${chapterIdx + 1}`; - divider.appendChild(badge); - divider.appendChild(titleEl); - frag.appendChild(divider); - } else if (chapter.title) { - const h = document.createElement('h2'); - h.textContent = chapter.title; - h.setAttribute('data-block-idx', '0'); - h.setAttribute('data-chapter-idx', chapterIdx.toString()); - h.id = `chapter-anchor-${chapterIdx}`; - frag.appendChild(h); - } + divider.appendChild(badge); + divider.appendChild(titleEl); + frag.appendChild(divider); + } else { + const h = document.createElement('h2'); + h.textContent = chapter.title || `Глава 1`; + h.setAttribute('data-block-idx', '0'); + 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); } - 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; 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' }); - return; + 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);