fix(reader): render full book in continuous mode with smooth chapter jumping and scroll tracking
This commit is contained in:
+147
-62
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user