feat(reader): continuous reading mode, seamless infinite scroll, and end-of-chapter nav cards
This commit is contained in:
1 parent
4d40b13bce
commit
0d05da7b9e
4 files changed
+616
-49
No files matched your search
@@ -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`.
|
||||
- Карточка завершения книги (`🎉 Книга прочитана!`) с быстрыми ссылками на тренировку слов и возврат в библиотеку.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)) {
|
||||
|
||||
Reference in new issue
Block a user