feat(reader): continuous reading mode, seamless infinite scroll, and end-of-chapter nav cards
This commit is contained in:
+451
-54
@@ -113,6 +113,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const readerMarginsGroup = document.getElementById('reader-margins-group');
|
||||
const readerThemeGroup = document.getElementById('reader-theme-group');
|
||||
const readerAlignGroup = document.getElementById('reader-align-group');
|
||||
const readerModeGroup = document.getElementById('reader-mode-group');
|
||||
const readerAutoloadGroup = document.getElementById('reader-autoload-group');
|
||||
const settingAutoloadRow = document.getElementById('setting-autoload-row');
|
||||
|
||||
const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn');
|
||||
const readerTrainBtn = document.getElementById('reader-train-btn');
|
||||
@@ -143,11 +146,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
lineHeight: 1.65,
|
||||
marginWidth: 'normal',
|
||||
theme: 'dark',
|
||||
textAlign: 'left'
|
||||
textAlign: 'left',
|
||||
flowMode: 'chapters', // 'chapters' | 'continuous'
|
||||
autoLoadNext: true // boolean: seamlessly load next chapter on scroll
|
||||
};
|
||||
let readerSettings = { ...DEFAULT_READER_SETTINGS };
|
||||
let currentBook = null;
|
||||
let currentChapterIndex = 0;
|
||||
const loadedChaptersSet = new Set();
|
||||
let isAppendingChapter = false;
|
||||
let isHighlightEnabled = false;
|
||||
let popupCurrentData = null;
|
||||
|
||||
@@ -2127,7 +2134,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (currentBook && currentBook.id === bookId) {
|
||||
if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) {
|
||||
if (specificChapterIdx !== currentChapterIndex) {
|
||||
renderChapter(specificChapterIdx, 0, 0, 0);
|
||||
if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(specificChapterIdx)) {
|
||||
currentChapterIndex = specificChapterIdx;
|
||||
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
|
||||
const target = document.getElementById(`chapter-anchor-${specificChapterIdx}`)
|
||||
|| readerContentBody.querySelector(`[data-chapter-idx="${specificChapterIdx}"]`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
} else {
|
||||
renderChapter(specificChapterIdx, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updateHash) {
|
||||
@@ -2213,40 +2230,38 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function renderChapter(index, restoreScrollTop = null, restoreFraction = null, restoreAnchorIdx = null) {
|
||||
if (!currentBook || !currentBook.chapters) return;
|
||||
currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1));
|
||||
readerChapterSelect.value = currentChapterIndex;
|
||||
function buildChapterDOM(chapterIdx, knownWordsMap, isContinuous = false) {
|
||||
const chapter = currentBook.chapters[chapterIdx] || { title: '', content: '' };
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
// Refresh status icons in chapter select
|
||||
if (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 (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());
|
||||
|
||||
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}`;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
readerPrevChBtn.disabled = currentChapterIndex <= 0;
|
||||
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
|
||||
|
||||
const chapter = currentBook.chapters[currentChapterIndex] || { title: '', content: '' };
|
||||
readerContentBody.innerHTML = '';
|
||||
|
||||
if (chapter.title) {
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = chapter.title;
|
||||
h.setAttribute('data-block-idx', '0');
|
||||
readerContentBody.appendChild(h);
|
||||
}
|
||||
|
||||
// Map existing user vocabulary for status marking
|
||||
const knownWordsMap = new Map();
|
||||
allCards.forEach(c => {
|
||||
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
|
||||
});
|
||||
|
||||
// Parse content into structured blocks (code, headings, lists, quotes, paragraphs)
|
||||
const blocks = parseContentBlocks(chapter.content);
|
||||
|
||||
blocks.forEach((block, blockIdx) => {
|
||||
@@ -2346,7 +2361,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
} else if (block.type === 'hr') {
|
||||
blockEl = document.createElement('hr');
|
||||
} else {
|
||||
// paragraph
|
||||
blockEl = document.createElement('p');
|
||||
if (block.align) {
|
||||
blockEl.style.textAlign = block.align;
|
||||
@@ -2357,14 +2371,242 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (blockEl) {
|
||||
blockEl.setAttribute('data-block-idx', domIdx.toString());
|
||||
readerContentBody.appendChild(blockEl);
|
||||
blockEl.setAttribute('data-chapter-idx', chapterIdx.toString());
|
||||
frag.appendChild(blockEl);
|
||||
}
|
||||
});
|
||||
|
||||
function applyScroll() {
|
||||
if (!readerScrollArea) return;
|
||||
return frag;
|
||||
}
|
||||
|
||||
function buildChapterFooterDOM(chapterIdx) {
|
||||
if (!currentBook || !currentBook.chapters) return null;
|
||||
const totalCh = currentBook.chapters.length;
|
||||
const isLastChapter = (chapterIdx >= totalCh - 1);
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'reader-chapter-footer';
|
||||
footer.id = `reader-chapter-footer-${chapterIdx}`;
|
||||
footer.setAttribute('data-footer-for-ch', chapterIdx.toString());
|
||||
|
||||
if (isLastChapter) {
|
||||
footer.classList.add('reader-book-finished');
|
||||
footer.innerHTML = `
|
||||
<div class="chapter-footer-status">
|
||||
<span class="status-check">🎉</span>
|
||||
<strong style="color: var(--primary); font-size: 16px;">Книга прочитана!</strong>
|
||||
</div>
|
||||
<p class="chapter-footer-hint">Поздравляем! Вы завершили чтение «${escapeHtml(currentBook.title)}».</p>
|
||||
<div class="chapter-footer-nav">
|
||||
<button type="button" class="btn btn-trainer btn-sm reader-footer-nav-btn" id="footer-train-btn-${chapterIdx}">
|
||||
🎓 Тренировать слова книги
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline btn-sm reader-footer-nav-btn" id="footer-shelf-btn-${chapterIdx}">
|
||||
📚 К библиотеке
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
const trainBtn = footer.querySelector(`#footer-train-btn-${chapterIdx}`);
|
||||
if (trainBtn) {
|
||||
trainBtn.onclick = () => {
|
||||
const tag = slugify(currentBook.title);
|
||||
window.location.hash = `#srs/${encodeURIComponent(tag)}`;
|
||||
};
|
||||
}
|
||||
const shelfBtn = footer.querySelector(`#footer-shelf-btn-${chapterIdx}`);
|
||||
if (shelfBtn) {
|
||||
shelfBtn.onclick = () => {
|
||||
window.location.hash = '#reader';
|
||||
};
|
||||
}
|
||||
return footer;
|
||||
}
|
||||
|
||||
const nextCh = chapterIdx + 1;
|
||||
const nextTitle = currentBook.chapters[nextCh]?.title || `Глава ${nextCh + 1}`;
|
||||
|
||||
footer.innerHTML = `
|
||||
<div class="chapter-footer-status">
|
||||
<span class="status-check">✔</span>
|
||||
<span>Глава ${chapterIdx + 1} завершена</span>
|
||||
</div>
|
||||
<div class="chapter-footer-nav">
|
||||
${chapterIdx > 0 ? `
|
||||
<button type="button" class="btn btn-outline btn-sm reader-footer-nav-btn prev" id="footer-prev-btn-${chapterIdx}">
|
||||
◀ Назад
|
||||
</button>` : ''}
|
||||
<button type="button" class="btn btn-primary btn-sm reader-footer-nav-btn next" id="footer-next-btn-${chapterIdx}">
|
||||
Следующая: ${escapeHtml(nextTitle)} ▶
|
||||
</button>
|
||||
</div>
|
||||
<div class="chapter-footer-hint">
|
||||
${readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext
|
||||
? '⚡ Автоподгрузка включена: следующая глава подгрузится автоматически при скролле вниз'
|
||||
: '💡 Нажмите кнопку выше для перехода к следующей главе'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const nextBtn = footer.querySelector(`#footer-next-btn-${chapterIdx}`);
|
||||
if (nextBtn) {
|
||||
nextBtn.onclick = () => {
|
||||
if (readerSettings.flowMode === 'continuous') {
|
||||
const target = document.getElementById(`chapter-anchor-${nextCh}`);
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else if (readerSettings.autoLoadNext) {
|
||||
appendNextChapter(nextCh, true);
|
||||
} else {
|
||||
window.location.hash = `#reader/book/${currentBook.id}/ch/${nextCh}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const prevBtn = footer.querySelector(`#footer-prev-btn-${chapterIdx}`);
|
||||
if (prevBtn) {
|
||||
prevBtn.onclick = () => {
|
||||
window.location.hash = `#reader/book/${currentBook.id}/ch/${chapterIdx - 1}`;
|
||||
};
|
||||
}
|
||||
|
||||
return footer;
|
||||
}
|
||||
|
||||
function appendNextChapter(nextCh, smoothScroll = false) {
|
||||
if (!currentBook || !currentBook.chapters || nextCh >= currentBook.chapters.length) return;
|
||||
if (loadedChaptersSet.has(nextCh)) {
|
||||
if (smoothScroll) {
|
||||
const target = document.getElementById(`chapter-anchor-${nextCh}`);
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const oldFooter = readerContentBody.querySelector(`.reader-chapter-footer[data-footer-for-ch="${nextCh - 1}"]`);
|
||||
if (oldFooter) {
|
||||
oldFooter.remove();
|
||||
}
|
||||
|
||||
const knownWordsMap = new Map();
|
||||
allCards.forEach(c => {
|
||||
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
|
||||
});
|
||||
|
||||
const chFrag = buildChapterDOM(nextCh, knownWordsMap, true);
|
||||
readerContentBody.appendChild(chFrag);
|
||||
|
||||
const newFooter = buildChapterFooterDOM(nextCh);
|
||||
if (newFooter) {
|
||||
readerContentBody.appendChild(newFooter);
|
||||
}
|
||||
|
||||
loadedChaptersSet.add(nextCh);
|
||||
|
||||
readerPrevChBtn.disabled = false;
|
||||
readerNextChBtn.disabled = nextCh >= currentBook.chapters.length - 1;
|
||||
|
||||
if (smoothScroll) {
|
||||
const anchor = document.getElementById(`chapter-anchor-${nextCh}`);
|
||||
if (anchor) {
|
||||
anchor.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderContinuousBook(targetChapter = 0, restoreScrollTop = null, restoreFraction = null, restoreAnchorIdx = null) {
|
||||
if (!currentBook || !currentBook.chapters) return;
|
||||
|
||||
currentChapterIndex = Math.max(0, Math.min(targetChapter, currentBook.chapters.length - 1));
|
||||
readerChapterSelect.value = currentChapterIndex;
|
||||
|
||||
readerPrevChBtn.disabled = currentChapterIndex <= 0;
|
||||
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
|
||||
|
||||
readerContentBody.innerHTML = '';
|
||||
loadedChaptersSet.clear();
|
||||
|
||||
const knownWordsMap = new Map();
|
||||
allCards.forEach(c => {
|
||||
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
|
||||
});
|
||||
|
||||
currentBook.chapters.forEach((_, idx) => {
|
||||
loadedChaptersSet.add(idx);
|
||||
const chFrag = buildChapterDOM(idx, knownWordsMap, idx > 0);
|
||||
readerContentBody.appendChild(chFrag);
|
||||
});
|
||||
|
||||
const finalFooter = buildChapterFooterDOM(currentBook.chapters.length - 1);
|
||||
if (finalFooter) {
|
||||
readerContentBody.appendChild(finalFooter);
|
||||
}
|
||||
|
||||
applyScroll(restoreAnchorIdx, restoreScrollTop, restoreFraction, currentChapterIndex);
|
||||
|
||||
const posToSave = (restoreFraction !== null && restoreFraction > 0) ? restoreFraction : 0;
|
||||
fetch(`/api/books/${currentBook.id}/progress`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_chapter: currentChapterIndex, current_position: posToSave }),
|
||||
}).catch(err => console.log('Progress save error:', err));
|
||||
}
|
||||
|
||||
function renderChapter(index, restoreScrollTop = null, restoreFraction = null, restoreAnchorIdx = null) {
|
||||
if (!currentBook || !currentBook.chapters) return;
|
||||
|
||||
if (readerSettings.flowMode === 'continuous') {
|
||||
renderContinuousBook(index, restoreScrollTop, restoreFraction, restoreAnchorIdx);
|
||||
return;
|
||||
}
|
||||
|
||||
currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1));
|
||||
readerChapterSelect.value = currentChapterIndex;
|
||||
|
||||
// Refresh status icons in chapter select
|
||||
if (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}`;
|
||||
}
|
||||
}
|
||||
|
||||
readerPrevChBtn.disabled = currentChapterIndex <= 0;
|
||||
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
|
||||
|
||||
readerContentBody.innerHTML = '';
|
||||
loadedChaptersSet.clear();
|
||||
loadedChaptersSet.add(currentChapterIndex);
|
||||
|
||||
const knownWordsMap = new Map();
|
||||
allCards.forEach(c => {
|
||||
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
|
||||
});
|
||||
|
||||
const chFrag = buildChapterDOM(currentChapterIndex, knownWordsMap, false);
|
||||
readerContentBody.appendChild(chFrag);
|
||||
|
||||
const footer = buildChapterFooterDOM(currentChapterIndex);
|
||||
if (footer) {
|
||||
readerContentBody.appendChild(footer);
|
||||
}
|
||||
|
||||
applyScroll(restoreAnchorIdx, restoreScrollTop, restoreFraction, currentChapterIndex);
|
||||
|
||||
// Save progress to server
|
||||
const posToSave = (restoreFraction !== null && restoreFraction > 0) ? restoreFraction : 0;
|
||||
fetch(`/api/books/${currentBook.id}/progress`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_chapter: currentChapterIndex, current_position: posToSave }),
|
||||
}).catch(err => console.log('Progress save error:', err));
|
||||
}
|
||||
|
||||
function applyScroll(restoreAnchorIdx = null, restoreScrollTop = null, restoreFraction = null, chIdx = 0) {
|
||||
if (!readerScrollArea) return;
|
||||
|
||||
function doScroll() {
|
||||
if (restoreAnchorIdx !== null && restoreAnchorIdx !== undefined) {
|
||||
const el = readerContentBody.querySelector(`[data-block-idx="${restoreAnchorIdx}"]`);
|
||||
const el = readerContentBody.querySelector(`[data-chapter-idx="${chIdx}"][data-block-idx="${restoreAnchorIdx}"]`)
|
||||
|| readerContentBody.querySelector(`[data-block-idx="${restoreAnchorIdx}"]`);
|
||||
if (el) {
|
||||
const containerTop = readerScrollArea.getBoundingClientRect().top;
|
||||
const elTop = el.getBoundingClientRect().top;
|
||||
@@ -2378,6 +2620,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
} else if (restoreFraction !== null && restoreFraction > 0) {
|
||||
const maxScroll = Math.max(0, readerScrollArea.scrollHeight - readerScrollArea.clientHeight);
|
||||
readerScrollArea.scrollTop = Math.round(maxScroll * restoreFraction);
|
||||
} else if (chIdx > 0) {
|
||||
const anchor = document.getElementById(`chapter-anchor-${chIdx}`);
|
||||
if (anchor) {
|
||||
const containerTop = readerScrollArea.getBoundingClientRect().top;
|
||||
const elTop = anchor.getBoundingClientRect().top;
|
||||
readerScrollArea.scrollTop += (elTop - containerTop - 10);
|
||||
} else {
|
||||
readerScrollArea.scrollTop = 0;
|
||||
}
|
||||
} else {
|
||||
readerScrollArea.scrollTop = 0;
|
||||
}
|
||||
@@ -2385,17 +2636,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
applyScroll();
|
||||
setTimeout(applyScroll, 100);
|
||||
doScroll();
|
||||
setTimeout(doScroll, 100);
|
||||
});
|
||||
|
||||
// Save progress to server
|
||||
const posToSave = (restoreFraction !== null && restoreFraction > 0) ? restoreFraction : 0;
|
||||
fetch(`/api/books/${currentBook.id}/progress`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_chapter: currentChapterIndex, current_position: posToSave }),
|
||||
}).catch(err => console.log('Progress save error:', err));
|
||||
}
|
||||
|
||||
// Parse text into structured semantic blocks
|
||||
@@ -3054,20 +3297,39 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Chapter Navigation & Reader Toolbar (URL Hash Driven)
|
||||
// Chapter Navigation & Reader Toolbar (URL Hash & Continuous Scroll Driven)
|
||||
if (readerChapterSelect) {
|
||||
readerChapterSelect.addEventListener('change', () => {
|
||||
const ch = parseInt(readerChapterSelect.value, 10);
|
||||
if (currentBook) {
|
||||
window.location.hash = `#reader/book/${currentBook.id}/ch/${ch}`;
|
||||
if (!currentBook) return;
|
||||
if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(ch)) {
|
||||
currentChapterIndex = ch;
|
||||
const target = document.getElementById(`chapter-anchor-${ch}`)
|
||||
|| readerContentBody.querySelector(`[data-chapter-idx="${ch}"]`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.location.hash = `#reader/book/${currentBook.id}/ch/${ch}`;
|
||||
});
|
||||
}
|
||||
|
||||
if (readerPrevChBtn) {
|
||||
readerPrevChBtn.addEventListener('click', () => {
|
||||
if (currentBook && currentChapterIndex > 0) {
|
||||
window.location.hash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex - 1}`;
|
||||
const prev = currentChapterIndex - 1;
|
||||
if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(prev)) {
|
||||
currentChapterIndex = prev;
|
||||
if (readerChapterSelect) readerChapterSelect.value = prev;
|
||||
const target = document.getElementById(`chapter-anchor-${prev}`)
|
||||
|| readerContentBody.querySelector(`[data-chapter-idx="${prev}"]`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.location.hash = `#reader/book/${currentBook.id}/ch/${prev}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3075,7 +3337,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (readerNextChBtn) {
|
||||
readerNextChBtn.addEventListener('click', () => {
|
||||
if (currentBook && currentBook.chapters && currentChapterIndex < currentBook.chapters.length - 1) {
|
||||
window.location.hash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex + 1}`;
|
||||
const next = currentChapterIndex + 1;
|
||||
if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(next)) {
|
||||
currentChapterIndex = next;
|
||||
if (readerChapterSelect) readerChapterSelect.value = next;
|
||||
const target = document.getElementById(`chapter-anchor-${next}`)
|
||||
|| readerContentBody.querySelector(`[data-chapter-idx="${next}"]`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext) {
|
||||
appendNextChapter(next, true);
|
||||
} else {
|
||||
window.location.hash = `#reader/book/${currentBook.id}/ch/${next}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3112,8 +3389,42 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getActiveVisibleChapter() {
|
||||
if (!readerScrollArea || !readerContentBody) return currentChapterIndex;
|
||||
const dividers = readerContentBody.querySelectorAll('[data-chapter-idx]');
|
||||
if (!dividers || dividers.length === 0) return currentChapterIndex;
|
||||
|
||||
const containerTop = readerScrollArea.getBoundingClientRect().top;
|
||||
let activeIdx = currentChapterIndex;
|
||||
|
||||
for (let el of dividers) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top <= containerTop + 140) {
|
||||
const ch = parseInt(el.getAttribute('data-chapter-idx'), 10);
|
||||
if (!isNaN(ch)) activeIdx = ch;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return activeIdx;
|
||||
}
|
||||
|
||||
function updateReaderProgressBar() {
|
||||
const { pct, fraction } = getReaderScrollMetrics();
|
||||
|
||||
if (readerSettings.flowMode === 'continuous') {
|
||||
if (readerChapterProgressFill) {
|
||||
readerChapterProgressFill.style.width = `${pct}%`;
|
||||
}
|
||||
if (readerScrollPctBadge) {
|
||||
readerScrollPctBadge.textContent = `${pct}%`;
|
||||
const totalCh = currentBook && currentBook.chapters ? currentBook.chapters.length : 1;
|
||||
readerScrollPctBadge.title = `Вся книга: ${pct}% • Глава ${currentChapterIndex + 1} из ${totalCh}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Chapters mode
|
||||
if (readerChapterProgressFill) {
|
||||
readerChapterProgressFill.style.width = `${pct}%`;
|
||||
}
|
||||
@@ -3136,10 +3447,47 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!currentBook || !readerScrollArea) return;
|
||||
updateReaderProgressBar();
|
||||
|
||||
// 1. Detect active visible chapter in continuous or multi-chapter view
|
||||
if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.size > 1) {
|
||||
const activeCh = getActiveVisibleChapter();
|
||||
if (activeCh !== null && activeCh !== undefined && activeCh !== currentChapterIndex) {
|
||||
currentChapterIndex = activeCh;
|
||||
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
|
||||
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 (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
|
||||
|
||||
const desiredHash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex}`;
|
||||
if (window.location.hash !== desiredHash) {
|
||||
history.replaceState(null, '', desiredHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Infinite scroll: auto-append next chapter in chapters mode
|
||||
if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext && !isAppendingChapter) {
|
||||
const distanceToBottom = readerScrollArea.scrollHeight - (readerScrollArea.scrollTop + readerScrollArea.clientHeight);
|
||||
if (distanceToBottom < 450) {
|
||||
const loadedArr = Array.from(loadedChaptersSet);
|
||||
const highestCh = loadedArr.length > 0 ? Math.max(...loadedArr) : currentChapterIndex;
|
||||
if (currentBook && currentBook.chapters && highestCh < currentBook.chapters.length - 1) {
|
||||
isAppendingChapter = true;
|
||||
appendNextChapter(highestCh + 1, false);
|
||||
setTimeout(() => { isAppendingChapter = false; }, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { scrollTop, fraction } = getReaderScrollMetrics();
|
||||
const anchorIdx = getActiveAnchorIndex();
|
||||
|
||||
// 1. Instant local persistence
|
||||
// 3. Instant local persistence
|
||||
try {
|
||||
const key = `notes_book_${currentBook.id}_pos`;
|
||||
localStorage.setItem(key, JSON.stringify({
|
||||
@@ -3153,7 +3501,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
localStorage.setItem('notes_active_view', 'reader-view');
|
||||
} catch (e) {}
|
||||
|
||||
// 2. Debounced server sync (every 800ms)
|
||||
// 4. Debounced server sync (every 800ms)
|
||||
clearTimeout(readerScrollSaveTimer);
|
||||
readerScrollSaveTimer = setTimeout(() => {
|
||||
if (!currentBook) return;
|
||||
@@ -3281,6 +3629,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
btn.classList.toggle('active', btn.dataset.align === readerSettings.textAlign);
|
||||
});
|
||||
}
|
||||
|
||||
if (readerModeGroup) {
|
||||
readerModeGroup.querySelectorAll('.seg-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.flow === readerSettings.flowMode);
|
||||
});
|
||||
}
|
||||
|
||||
if (settingAutoloadRow) {
|
||||
settingAutoloadRow.style.display = (readerSettings.flowMode === 'chapters') ? '' : 'none';
|
||||
}
|
||||
|
||||
if (readerAutoloadGroup) {
|
||||
readerAutoloadGroup.querySelectorAll('.seg-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', (btn.dataset.autoload === 'true') === readerSettings.autoLoadNext);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function positionSettingsPanel() {
|
||||
@@ -3406,6 +3770,39 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (readerModeGroup) {
|
||||
readerModeGroup.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.seg-btn');
|
||||
if (btn && btn.dataset.flow) {
|
||||
if (readerSettings.flowMode !== btn.dataset.flow) {
|
||||
readerSettings.flowMode = btn.dataset.flow;
|
||||
saveReaderSettings();
|
||||
updateReaderSettingsUI();
|
||||
if (currentBook) {
|
||||
renderChapter(currentChapterIndex, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (readerAutoloadGroup) {
|
||||
readerAutoloadGroup.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.seg-btn');
|
||||
if (btn && btn.dataset.autoload !== undefined) {
|
||||
readerSettings.autoLoadNext = (btn.dataset.autoload === 'true');
|
||||
saveReaderSettings();
|
||||
updateReaderSettingsUI();
|
||||
const footerHint = readerContentBody.querySelector('.chapter-footer-hint');
|
||||
if (footerHint) {
|
||||
footerHint.textContent = (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext)
|
||||
? '⚡ Автоподгрузка включена: следующая глава подгрузится автоматически при скролле вниз'
|
||||
: '💡 Нажмите кнопку выше для перехода к следующей главе';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden')) {
|
||||
if (!readerSettingsPanel.contains(e.target) && e.target !== readerSettingsBtn && !readerSettingsBtn.contains(e.target)) {
|
||||
|
||||
Reference in New Issue
Block a user