From 0d05da7b9e98445fb4ccb5fd72861ae262baed31 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Tue, 6 Oct 2026 09:33:06 +0300 Subject: [PATCH] feat(reader): continuous reading mode, seamless infinite scroll, and end-of-chapter nav cards --- TODO.md | 6 + app/static/css/style.css | 146 +++++++++++ app/static/index.html | 22 +- app/static/js/app.js | 505 ++++++++++++++++++++++++++++++++++----- 4 files changed, 623 insertions(+), 56 deletions(-) diff --git a/TODO.md b/TODO.md index 3a38e13..fb49cee 100644 --- a/TODO.md +++ b/TODO.md @@ -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`. + - Карточка завершения книги (`🎉 Книга прочитана!`) с быстрыми ссылками на тренировку слов и возврат в библиотеку. --- diff --git a/app/static/css/style.css b/app/static/css/style.css index 7b3a54e..5189539 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; +} diff --git a/app/static/index.html b/app/static/index.html index dbfe853..2a5d3b0 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -5,7 +5,7 @@ Notes — Персональный тренажер и словарь английского языка - + @@ -965,6 +965,24 @@
+ +
+ Режим чтения +
+ + +
+
+ + +
+ Переход между главами +
+ + +
+
+
Шрифт @@ -1056,7 +1074,7 @@ - + diff --git a/app/static/js/app.js b/app/static/js/app.js index 30a0243..6be9008 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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 = ` + + + + `; + 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 = ` + + + + `; + + 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)) {