feat(reader): continuous reading mode, seamless infinite scroll, and end-of-chapter nav cards

This commit is contained in:
Antigravity committed 2026-10-06 09:33:06 +03:00
1 parent 4d40b13bce
commit 0d05da7b9e
4 files changed
+616 -49

No files matched your search

+6
View File
@@ -146,6 +146,12 @@
- Сохранение не только пикселей `scrollTop`, но и индекса первого видимого абзаца (`anchor_paragraph_idx`).
- Устойчивость к изменению шрифта `Aa` и ориентации экрана: восстановление скролла к конкретному предложению/абзацу, а не слепым пикселям.
- Фиксация скролл-контейнера `#reader-scroll-area` с аппаратным ускорением на мобильных.
- [x] **6.5. Сквозное чтение и бесшовная автоподгрузка (Continuous Book View & Seamless Infinite Append)**:
- Режим «Вся книга целиком» (`flowMode: 'continuous'`): объединение всех глав книги в один непрерывный свиток со стильными разделителями глав без необходимости перелистывания.
- Режим «По главам» с автоподгрузкой (`autoLoadNext: true`): бесшовное присоединение следующей главы снизу при приближении к концу текущей главы.
- Навигационный блок завершения главы: эргономичные кнопки «◀ Назад» и «Следующая: [Название] ▶» прямо под текстом (без необходимости тянуться наверх).
- Настройка режима чтения («По главам» / «Вся книга») и переключатель автоподгрузки добавлены в панель настроек `Aa` с автосохранением в `localStorage`.
- Карточка завершения книги (`🎉 Книга прочитана!`) с быстрыми ссылками на тренировку слов и возврат в библиотеку.
---
+146
View File
@@ -3875,5 +3875,151 @@ body {
vertical-align: middle;
}
/* ==========================================================================
Continuous Reading & Chapter End Navigation Card
========================================================================== */
/* Chapter Divider (Between chapters in continuous / autoload mode) */
.reader-chapter-divider {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 56px 0 36px;
padding: 24px 16px;
border-top: 1px dashed var(--border-color);
border-bottom: 1px dashed var(--border-color);
background: rgba(255, 255, 255, 0.02);
border-radius: var(--radius-md);
text-align: center;
scroll-margin-top: 60px;
}
.reader-chapter-divider .divider-badge {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--primary);
background: var(--bg-input);
padding: 4px 12px;
border-radius: 9999px;
border: 1px solid var(--border-color);
margin-bottom: 10px;
display: inline-block;
}
.reader-chapter-divider .divider-title {
font-size: 22px !important;
font-weight: 700;
color: var(--text-main);
margin: 0 !important;
border-bottom: none !important;
padding-bottom: 0 !important;
text-align: center;
}
/* Chapter End Navigation Card */
.reader-chapter-footer {
margin: 48px 0 64px;
padding: 24px 20px;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 14px;
transition: var(--transition);
}
.reader-chapter-footer .chapter-footer-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-muted);
}
.reader-chapter-footer .chapter-footer-status .status-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.15);
color: var(--success);
font-size: 13px;
font-weight: 700;
}
.reader-chapter-footer .chapter-footer-nav {
display: flex;
gap: 12px;
width: 100%;
max-width: 540px;
justify-content: center;
flex-wrap: wrap;
}
.reader-chapter-footer .reader-footer-nav-btn {
flex: 1;
min-width: 150px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
padding: 10px 18px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: var(--transition);
}
.reader-chapter-footer .chapter-footer-hint {
font-size: 12px;
color: var(--text-dim);
line-height: 1.4;
max-width: 480px;
}
/* Book Completion Card */
.reader-chapter-footer.reader-book-finished {
background: linear-gradient(180deg, rgba(56, 189, 248, 0.08) 0%, rgba(20, 24, 32, 0.95) 100%);
border-color: rgba(56, 189, 248, 0.35);
}
.reader-chapter-footer.reader-book-finished .chapter-footer-hint {
color: var(--text-muted);
font-size: 13px;
}
/* Reading Theme Adaptations */
.reader-scroll-area.reader-theme-sepia .reader-chapter-divider,
.reader-scroll-area.reader-theme-light .reader-chapter-divider {
background: rgba(0, 0, 0, 0.025);
border-color: rgba(0, 0, 0, 0.12);
}
.reader-scroll-area.reader-theme-sepia .reader-chapter-footer,
.reader-scroll-area.reader-theme-light .reader-chapter-footer {
background: rgba(0, 0, 0, 0.03);
border-color: rgba(0, 0, 0, 0.12);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
.reader-scroll-area.reader-theme-oled .reader-chapter-divider {
background: #080808;
border-color: #222;
}
.reader-scroll-area.reader-theme-oled .reader-chapter-footer {
background: #0c0c0c;
border-color: #222;
}
+20 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Notes — Персональный тренажер и словарь английского языка</title>
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
<link rel="stylesheet" href="/static/css/style.css?v=3.5">
<link rel="stylesheet" href="/static/css/style.css?v=3.6">
<!-- PWA meta and icons -->
<link rel="manifest" href="/manifest.json">
@@ -965,6 +965,24 @@
<button id="reader-settings-close-btn" class="popup-close-btn">✕</button>
</div>
<div class="reader-settings-body">
<!-- Reading Flow Mode -->
<div class="setting-row">
<span class="setting-label">Режим чтения</span>
<div class="setting-segmented-control" id="reader-mode-group">
<button type="button" class="seg-btn active" data-flow="chapters">📖 По главам</button>
<button type="button" class="seg-btn" data-flow="continuous">📜 Вся книга</button>
</div>
</div>
<!-- Autoload next chapter toggle (active in chapters mode) -->
<div class="setting-row" id="setting-autoload-row">
<span class="setting-label">Переход между главами</span>
<div class="setting-segmented-control" id="reader-autoload-group">
<button type="button" class="seg-btn active" data-autoload="true">⚡ Автоподгрузка</button>
<button type="button" class="seg-btn" data-autoload="false">🔘 По кнопке</button>
</div>
</div>
<!-- Font Family -->
<div class="setting-row">
<span class="setting-label">Шрифт</span>
@@ -1056,7 +1074,7 @@
<!-- Audio Element for native streaming -->
<audio id="global-audio-player" preload="none"></audio>
<script src="/static/js/app.js?v=3.5"></script>
<script src="/static/js/app.js?v=3.6"></script>
</body>
</html>
+444 -47
View File
@@ -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,9 +2134,19 @@ document.addEventListener('DOMContentLoaded', () => {
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;
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) {
const desiredHash = `#reader/book/${bookId}/ch/${currentChapterIndex}`;
if (window.location.hash !== desiredHash) {
@@ -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());
readerPrevChBtn.disabled = currentChapterIndex <= 0;
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const badge = document.createElement('span');
badge.className = 'divider-badge';
badge.textContent = `Глава ${chapterIdx + 1} из ${currentBook.chapters.length}`;
const chapter = currentBook.chapters[currentChapterIndex] || { title: '', content: '' };
readerContentBody.innerHTML = '';
const titleEl = document.createElement('h2');
titleEl.className = 'divider-title';
titleEl.textContent = chapter.title || `Глава ${chapterIdx + 1}`;
if (chapter.title) {
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');
readerContentBody.appendChild(h);
h.setAttribute('data-chapter-idx', chapterIdx.toString());
h.id = `chapter-anchor-${chapterIdx}`;
frag.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() {
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)) {